fbcond: perform the display handoff open off the console loop

fbcond blocked indefinitely in open_display_v2() while handing the console
over to the real graphics driver: boot stopped at "fbcond: Performing
handoff" and never reached the "Opened new display" that follows a
successful open.

fbcond is single-threaded and owns the console, so while it waited there it
stopped draining console writers. getty, login and the login shell all
blocked on their writes, which made the failure look like a slow or hung
shell -- the stall point just tracked whichever process next wrote to the
console. If the graphics driver was itself waiting on the console, the two
deadlocked outright and the console never came back.

The wait comes from the scheme-open handshake: inputd signals the handoff as
soon as the driver registers its path, but the driver may not be serving its
scheme yet, and O_NONBLOCK does not bound the open (it governs later reads).

Split the two halves in inputd's ConsumerHandle: display_path_v2() only does
an fpath on our own handle and returns promptly, while open_display_path_v2()
carries the blocking open. fbcond now resolves the path inline and hands the
open to a worker thread, then picks the result up from the event loop via
poll_pending_handoff(). Console output produced meanwhile is buffered in
pending_writes (already the behaviour while the display is unmapped) and
flushed once the new display is mapped.
This commit is contained in:
Red Bear OS
2026-07-20 18:10:59 +09:00
parent ad40fffd80
commit 54e89a337a
4 changed files with 138 additions and 19 deletions
+38
View File
@@ -22,6 +22,44 @@ impl Display {
Ok(display)
}
/// Resolve the display path to hand to a worker performing the blocking open.
pub fn display_path(&self) -> io::Result<String> {
self.input_handle.display_path_v2()
}
/// Install a display from a file opened elsewhere (see `display_path`).
///
/// Returns whether the display is now mapped. Both steps here talk to a
/// driver that has already answered our open, so they do not reintroduce the
/// unbounded wait that opening it does.
pub fn install_display_file(&mut self, display_file: std::fs::File) -> bool {
let new_display_handle = match V2GraphicsHandle::from_file(display_file) {
Ok(handle) => handle,
Err(err) => {
log::debug!("fbcond: display not ready at handoff (from_file: {err}); will retry");
return false;
}
};
log::info!("fbcond: Opened new display");
match V2DisplayMap::new(new_display_handle) {
Ok(map) => {
log::info!(
"fbcond: Mapped new display with size {}x{}",
map.buffer.buffer().size().0,
map.buffer.buffer().size().1,
);
self.map = Some(map);
true
}
Err(err) => {
log::error!("fbcond: failed to map new display: {err}");
false
}
}
}
/// Re-open the display after a handoff.
pub fn reopen_for_handoff(&mut self) {
let display_file = match self.input_handle.open_display_v2() {
+7
View File
@@ -84,6 +84,13 @@ fn daemon(daemon: daemon::SchemeDaemon) -> ! {
for event in event_queue {
let event = event.expect("fbcond: failed to read event from event queue");
// Pick up any display handoff whose (blocking) open has completed on its
// worker. Cheap and non-blocking; done per event so the swap lands as
// soon as the driver starts serving, without the console ever waiting on
// it. Output produced meanwhile is buffered and flushed on install.
for vt in scheme.vts.values_mut() {
vt.poll_pending_handoff();
}
if let Some(fd) = serial_fd {
if event.user_data == VtIndex::SERIAL_SENTINEL {
let mut buf = [0u8; 256];
+70 -19
View File
@@ -29,6 +29,9 @@ pub struct TextScreen {
/// VT). Mirroring makes the same console — boot log and login prompt —
/// visible on serial too.
serial_mirror: Option<std::fs::File>,
/// In-flight handoff: a worker performing the (blocking) open of the new
/// display. See `handle_handoff`.
pending_handoff: Option<std::sync::mpsc::Receiver<std::io::Result<std::fs::File>>>,
}
impl TextScreen {
@@ -48,33 +51,81 @@ impl TextScreen {
scrollback: VecDeque::with_capacity(SCROLLBACK_LINES),
keymap: Keymap::us(),
serial_mirror,
pending_handoff: None,
}
}
/// Pick up a handoff started by `handle_handoff`, if the worker has finished.
///
/// Never blocks. Called from the event loop, so the console keeps being
/// serviced while the open is outstanding; writes made in the meantime are
/// buffered in `pending_writes` and flushed once the display is mapped.
pub fn poll_pending_handoff(&mut self) {
let Some(rx) = &self.pending_handoff else {
return;
};
match rx.try_recv() {
Ok(Ok(display_file)) => {
self.pending_handoff = None;
if self.display.install_display_file(display_file) {
self.flush_pending_writes();
} else {
log::warn!(
"fbcond: handoff display opened but could not be mapped; \
framebuffer console stays on the previous display"
);
}
}
Ok(Err(err)) => {
self.pending_handoff = None;
log::warn!(
"fbcond: handoff open failed ({err}); framebuffer console stays \
on the previous display"
);
}
Err(std::sync::mpsc::TryRecvError::Empty) => {}
Err(std::sync::mpsc::TryRecvError::Disconnected) => {
self.pending_handoff = None;
log::warn!("fbcond: handoff worker exited without a result");
}
}
}
pub fn handle_handoff(&mut self) {
log::info!("fbcond: Performing handoff");
// The handoff is one-shot: inputd signals it the moment the real display
// driver registers its path, but the driver's scheme may not serve the
// v2 open / capability query for a few more milliseconds. Without a
// retry, a transient not-ready driver leaves the framebuffer permanently
// blank (the login prompt is drawn to the framebuffer VT, so the console
// appears dead even though boot continues on the serial mirror). Retry
// briefly — the driver becomes ready well within this bound in practice.
for attempt in 0..250u32 {
self.display.reopen_for_handoff();
if self.display.map.is_some() {
if attempt > 0 {
log::info!("fbcond: handoff completed after {attempt} retries");
}
self.flush_pending_writes();
if self.pending_handoff.is_some() {
log::debug!("fbcond: handoff already in flight; ignoring repeat signal");
return;
}
// Resolving the path only talks to inputd and returns promptly; the
// subsequent open of the graphics driver's scheme is what can block
// indefinitely (O_NONBLOCK does not bound the scheme-open handshake, so
// a driver that has registered its path but is not yet serving leaves us
// waiting on it). fbcond is single-threaded and owns the console, so
// doing that open inline stops us draining console writers -- getty,
// login and the login shell then block on their writes, and if the
// driver is itself waiting on the console the two deadlock and the whole
// console dies right after this message. Hand the open to a worker and
// keep serving the console; writes are buffered until it lands.
let display_path = match self.display.display_path() {
Ok(path) => path,
Err(err) => {
log::warn!("fbcond: could not resolve display path for handoff: {err}");
return;
}
std::thread::sleep(std::time::Duration::from_millis(10));
};
let (tx, rx) = std::sync::mpsc::channel();
match std::thread::Builder::new()
.name("fbcond-handoff".to_owned())
.spawn(move || {
let _ = tx.send(inputd::ConsumerHandle::open_display_path_v2(&display_path));
}) {
Ok(_) => self.pending_handoff = Some(rx),
Err(err) => log::warn!("fbcond: could not spawn handoff worker: {err}"),
}
log::warn!(
"fbcond: display still not ready after handoff retries; \
framebuffer console will remain blank (serial mirror unaffected)"
);
}
pub fn input(&mut self, event: &Event) {
+23
View File
@@ -78,6 +78,17 @@ impl ConsumerHandle {
}
pub fn open_display_v2(&self) -> io::Result<File> {
let display_path_str = self.display_path_v2()?;
Self::open_display_path_v2(&display_path_str)
}
/// Resolve the v2 display path for this consumer handle.
///
/// This only talks to inputd (an `fpath` on our own handle), so it returns
/// promptly. Separated from the open below so that a caller with a
/// single-threaded event loop can resolve the path inline and perform the
/// blocking open elsewhere.
pub fn display_path_v2(&self) -> io::Result<String> {
let mut buffer = [0; 1024];
let fd = self.0.as_raw_fd();
let written = libredox::call::fpath(fd as usize, &mut buffer)?;
@@ -120,6 +131,18 @@ impl ConsumerHandle {
)
})?;
Ok(display_path_str.to_owned())
}
/// Open a v2 display path previously resolved by [`Self::display_path_v2`].
///
/// NOTE: this can block indefinitely. `O_NONBLOCK` governs later reads, not
/// the scheme-open handshake, so if the graphics driver has registered its
/// path but is not yet serving its scheme, the open waits for it. Callers
/// that own a single-threaded event loop (fbcond owns the console) must not
/// call this inline, or they stop servicing their own clients while it
/// waits — which deadlocks if the driver is in turn waiting on them.
pub fn open_display_path_v2(display_path_str: &str) -> io::Result<File> {
let display_file =
libredox::call::open(display_path_str, (O_CLOEXEC | O_NONBLOCK | O_RDWR) as _, 0)
.map(|socket| unsafe { File::from_raw_fd(socket as RawFd) })