From a35358bc467a71385f8700fe9c54c0414cc0e252 Mon Sep 17 00:00:00 2001 From: japabu Date: Wed, 11 Nov 2026 02:00:45 +0200 Subject: [PATCH 1/3] The shell reads its input a byte a read, so what is typed after a line is its program's The shell read standard input through std's `Stdin`, which reads ahead up to 64 bytes. A line typed while the shell was still coming back to its prompt was read together with the one before it, and when that one started a program reading the same input (a nested shell, `cat`), the program never saw its line: it sat in the outer shell's buffer until the program ended. `libc_children`'s `tty_interrupt` stalled on exactly that, about one run in seven, at "the nested line held". The probe typed `cat` early by a defect of its own: - After `echo pipeline-ended` it waited for no line to be held, which was already so before `echo`'s line was handed over, so it waited for nothing. - Its next wait, for the nested shell's line, was then satisfied by `echo`'s stale notice whenever the shell had not yet ended that line. - It typed `cat` at once, both lines were in the pipe when the shell came back to read, and the shell took both. The nested shell blocked in its one read, as the kernel's exit record of it said. The shell now reads the descriptor itself, one byte a read, and no longer sets std's raw mode, which it does not read through. The probe counts a notice at a time and waits for each `echo` line handed and ended. It also types the nested shell's first line with the line that starts it, in one write: the shell reading ahead stalls that step every run instead of one in seven. Co-Authored-By: Claude Fable 5.2 --- system/shell/src/main.rs | 24 ++++++---- .../guest/rust-tests/src/bin/tty_interrupt.rs | 46 ++++++++++--------- 2 files changed, 38 insertions(+), 32 deletions(-) diff --git a/system/shell/src/main.rs b/system/shell/src/main.rs index 1373dae68fe..d110cb662d9 100644 --- a/system/shell/src/main.rs +++ b/system/shell/src/main.rs @@ -1,6 +1,6 @@ use std::env; use std::fs::{self, File, OpenOptions}; -use std::io::{self, Read, Write}; +use std::io::{self, Write}; use std::path::Path; use std::os::toyos::process::{CommandExt, ExitStatusExt}; use std::process::{Child, Command, ExitStatus, Stdio}; @@ -130,7 +130,6 @@ fn main() { let _ = env::set_current_dir(home); } let mut history = load_history(); - std::os::toyos::io::set_stdin_raw(true); loop { let cwd = env::current_dir().map(|p| p.display().to_string()).unwrap_or_else(|_| "?".into()); @@ -1013,17 +1012,22 @@ fn save_history(history: &[String]) { // --- Readline helpers --- +/// The next byte of standard input, or `None` at its end. +/// +/// **Read from the descriptor itself, a byte a read**: std's `Stdin` reads +/// ahead of what it is asked for, and what is typed after a line is the input +/// of the program that line starts, which reads the same pipe. fn read_byte() -> Option { - let mut buf = [0u8; 1]; - if let Err(e) = io::stdin().lock().read_exact(&mut buf) { - // UnexpectedEof is the ordinary end of input; anything else is a - // device error or a revoked fd, and would otherwise be silent. - if e.kind() != io::ErrorKind::UnexpectedEof { - eprintln!("shell: stdin fd=0: {:?}", e.kind()); + let mut byte = [0u8; 1]; + match toyos::files::read(0, &mut byte) { + Ok(1) => Some(byte[0]), + // A closed descriptor is at its end, as std reads one. + Ok(_) | Err(toyos::files::Error::Fd(toyos::fd::Error::BadFd)) => None, + Err(e) => { + eprintln!("shell: stdin fd=0: {e:?}"); + None } - return None; } - Some(buf[0]) } fn read_char() -> Option { diff --git a/tests/guest/rust-tests/src/bin/tty_interrupt.rs b/tests/guest/rust-tests/src/bin/tty_interrupt.rs index 0efac30368e..30f912fa708 100644 --- a/tests/guest/rust-tests/src/bin/tty_interrupt.rs +++ b/tests/guest/rust-tests/src/bin/tty_interrupt.rs @@ -9,7 +9,8 @@ //! the line is left. //! - A shell nested on the line is not ended: Ctrl+C reaches the line it runs //! while it runs one, and, at its prompt, the nested shell itself, which -//! reads it and runs on. +//! reads it and runs on. Its first line is typed with the line that starts +//! it, before it exists: what follows a line is the input of what it runs. //! - Under the nested shell, a program that reads each interrupt survives //! three Ctrl+C, each pressed once it has read the one before, and one that //! listens and never reads dies on the second: each Ctrl+C is one quit of @@ -63,30 +64,28 @@ fn hear(mut stdout: ChildStdout) { } } -/// Serve the surface until `lines` clients hold a foreground line. +/// Serve the surface until `lines` clients hold a foreground line, counted a +/// notice at a time, so a line handed and ended is seen held in between. fn until_lines(host: &mut Host, held: &mut usize, lines: usize) { let poller = Poller::new(Host::POLL_HANDLES); while *held != lines { - while let Some(notice) = host.poll() { - match notice { - Notice::Foreground { held: true, .. } => *held += 1, - Notice::Foreground { held: false, .. } => *held -= 1, - Notice::Dropped { client, why } => panic!("the surface dropped client {client}: {why}"), - Notice::LayoutChanged | Notice::Grabbed { .. } | Notice::Released { .. } => {} + match host.poll() { + Some(Notice::Foreground { held: true, .. }) => *held += 1, + Some(Notice::Foreground { held: false, .. }) => *held -= 1, + Some(Notice::Dropped { client, why }) => panic!("the surface dropped client {client}: {why}"), + Some(Notice::LayoutChanged | Notice::Grabbed { .. } | Notice::Released { .. }) => {} + None => { + poller.watch(&host.acceptor_handle(), READABLE, 0); + for client in host.client_handles() { + poller.watch(&client, READABLE, 1); + } + let mut accept = false; + poller.wait(1, u64::MAX, |token| accept |= token == 0); + if accept { + host.accept(); + } } } - if *held == lines { - return; - } - poller.watch(&host.acceptor_handle(), READABLE, 0); - for client in host.client_handles() { - poller.watch(&client, READABLE, 1); - } - let mut accept = false; - poller.wait(1, u64::MAX, |token| accept |= token == 0); - if accept { - host.accept(); - } } } @@ -160,14 +159,16 @@ fn main() { step("the prompt back"); shell.type_line("echo pipeline-ended"); shell.until_said("pipeline-ended"); + // `echo`'s line, handed and ended: its notices are not the next line's. + until_lines(&mut host, &mut held, 1); until_lines(&mut host, &mut held, 0); println!("tty_interrupt: one Ctrl+C ended a pipeline whose stage started a child, and the prompt came back"); step("the nested shell held"); - shell.type_line("shell"); + // One write: `cat` is in the pipe before the nested shell exists to read it. + shell.type_line("shell\rcat"); until_lines(&mut host, &mut held, 1); step("the nested line held"); - shell.type_line("cat"); until_lines(&mut host, &mut held, 2); step("the nested line ended"); assert!(host.interrupt(), "the nested shell's line was not the foreground"); @@ -176,6 +177,7 @@ fn main() { step("the nested shell alive"); shell.type_line("echo nested-alive"); shell.until_said("nested-alive"); + until_lines(&mut host, &mut held, 2); until_lines(&mut host, &mut held, 1); step("the reader armed"); From ecb718f5f2cd0d51119b97b25cc1c8119b9eff4f Mon Sep 17 00:00:00 2001 From: japabu Date: Wed, 11 Nov 2026 02:29:51 +0200 Subject: [PATCH 2/3] issues: tty_interrupt's stall is closed, and signal_quit's is its own record The closed record asked whether `signal_quit`'s one stall, "driving the child that reloads", was the same defect. It is not: `signal_quit` runs no shell and each of its children has pipes of its own. What its code allows is a signal raised between a child's `armed` and the call it blocks in, which libc runs on its own thread and the call then never sees. That is read from the code and not reproduced, and the new record says so. Co-Authored-By: Claude Fable 5.2 --- ...upt-can-stall-at-the-nested-shells-line.md | 52 ------------------- ...can-take-their-signal-before-they-block.md | 35 +++++++++++++ 2 files changed, 35 insertions(+), 52 deletions(-) delete mode 100644 issues/libc-childrens-tty-interrupt-can-stall-at-the-nested-shells-line.md create mode 100644 issues/signal-quits-children-can-take-their-signal-before-they-block.md diff --git a/issues/libc-childrens-tty-interrupt-can-stall-at-the-nested-shells-line.md b/issues/libc-childrens-tty-interrupt-can-stall-at-the-nested-shells-line.md deleted file mode 100644 index 226bfeda286..00000000000 --- a/issues/libc-childrens-tty-interrupt-can-stall-at-the-nested-shells-line.md +++ /dev/null @@ -1,52 +0,0 @@ ---- -status: open -kind: defect -severity: [hang] -opened: 2026-11-01 ---- - -# libc_children's tty_interrupt can stall at the nested shell's line - -`cargo test --test toyos-build -- libc_children` on the 2-CPU QEMU x86-64 boot -sometimes ends with `tty_interrupt: stalled at the nested line held`. -1. The probe types `shell` into its shell, and the nested prompt prints. -2. It then types `cat` into the nested shell. That line never becomes a held - foreground line. -3. The probe's own 120 s hang ceiling ends the run with exit 2. - -**Evidence.** These are local QEMU runs, with logs in -[`pr-1111/r2/tty`](https://github.com/ToyOSOrg/evidence/tree/8b3306f1ba244133a2c28e8d7c09fcc48c473c8e/pr-1111/r2/tty): - -| revision | runs | stalled | -|---|---|---| -| main `24a538c88` | 4 | 1 | -| `f5ae75f6b` (#1111's step 1 on that main) | 6 | 0 | -| `dd9bf974d` (#1111's head) | 3 | 2 | - -One of the head's two failures was this stall. The other was -`signal_quit: stalled driving the child that reloads`. - -It reproduces on main, so it does not come with #1111. Its cause is unknown. -The kept serial log covers only boot, so nothing yet says which side lost the -line: the nested shell's stdin read, the surface's report of its foreground -line, or a wake between them. - -One more, 2026-11-04, at the nightly's shape (`--jobs 1`, `TOYOS_HOST_CORES=4`) -on the dev host, in #1166's head arm: `libc_children` ended -`tty_interrupt ended Some(2)` after 207 s, `stalled at the nested line held`, -the same stall. Nothing else in that run was red; it did not wait for host -CPUs; host load was not recorded. Evidence: -https://github.com/ToyOSOrg/evidence/tree/a95c798b496d939b4bdb3c7fe0a3cfc2e6d9bfb6/pr-1166/r1 - -Another sighting, 2026-11-05, at #1189 (the guest runner, run 42018194698, and -local QEMU), with main `15c26bfbc` stalling the same way in 1 of 7 local runs -beside it. The kernel's exit record of the killed nested shell names its side: -its main thread made one `SYS_READ` (`1=1`) and spent the 119 s in it, after -the prompt's one write, while the probe had written `cat` (`0=4`). So the -nested shell's stdin read sat blocked with the line written: the read or its -wake lost it, not the surface. Evidence: -https://github.com/ToyOSOrg/evidence/tree/43e52cb13749d79b5ca967e0688065fb2eefd103/pr-1189/tty - -**Exit:** find what the nested shell waits on when it stalls, fix it, and -show `libc_children` green across repeated runs. `signal_quit`'s stall may be -the same defect. diff --git a/issues/signal-quits-children-can-take-their-signal-before-they-block.md b/issues/signal-quits-children-can-take-their-signal-before-they-block.md new file mode 100644 index 00000000000..d5c9070f178 --- /dev/null +++ b/issues/signal-quits-children-can-take-their-signal-before-they-block.md @@ -0,0 +1,35 @@ +--- +status: open +kind: defect +severity: [hang] +opened: 2026-11-11 +--- + +# `signal_quit`'s children can take their signal before they block + +`libc_children`'s `signal_quit` ended `stalled driving the child that +reloads` once, in 1 of 3 local QEMU runs at `dd9bf974d` (#1111's head), on +the 2-CPU x86-64 boot. Its log is in +[`pr-1111/r2/tty`](https://github.com/ToyOSOrg/evidence/tree/8b3306f1ba244133a2c28e8d7c09fcc48c473c8e/pr-1111/r2/tty). + +It is not `tty_interrupt`'s stall, which was a shell reading ahead on a pipe +it shares with the program it starts: `signal_quit` runs no shell, and each +child has pipes of its own. + +**Read from the code, not reproduced.** The `reloads` child says `armed` and +then calls `pause`; the parent sends `SIGHUP` as soon as it has read +`armed`. libc runs a raised signal at once: in a blocking call of a thread +that leaves it unblocked, or else on libc's own thread +(`sdk/libc/src/sigreq.rs`). A `SIGHUP` raised before the child has entered +`pause` runs its handler on libc's own thread, and the `pause` that follows +waits for a handler that has already run. The child never says `reloaded`. +The `reads` child has the same window between `armed` and its `read`, and +`reloads` a second one between testing `ended` and `pause`. + +Unmeasured: whether this is what the one sighting was. Nothing kept from it +says where the child was. + +**Exit**: each of the two children blocks without a window a signal can fall +in, or the parent sends only once the child is in its call, as +`libc_read_restarts` orders its own by the roster; a run that holds the +child short of its call for a control shows the stall before and not after. From 57b5d38f72633533c882b10f8d1f2102de90765c Mon Sep 17 00:00:00 2001 From: japabu Date: Wed, 11 Nov 2026 03:32:28 +0200 Subject: [PATCH 3/3] issues: signal_quit's stall was seen again, and a control reproduces it A second sighting, in this branch's own control run: `signal_quit`, which runs no shell, ended `stalled driving the child that reloads` on a quiet guest runner. A control that holds that child 200 ms short of its `pause` after it says `armed` stalls the row the same way on main, so the record states the cause and no longer calls it unreproduced. Co-Authored-By: Claude Fable 5.2 --- ...can-take-their-signal-before-they-block.md | 54 +++++++++++-------- 1 file changed, 33 insertions(+), 21 deletions(-) diff --git a/issues/signal-quits-children-can-take-their-signal-before-they-block.md b/issues/signal-quits-children-can-take-their-signal-before-they-block.md index d5c9070f178..9ad3067b581 100644 --- a/issues/signal-quits-children-can-take-their-signal-before-they-block.md +++ b/issues/signal-quits-children-can-take-their-signal-before-they-block.md @@ -7,29 +7,41 @@ opened: 2026-11-11 # `signal_quit`'s children can take their signal before they block -`libc_children`'s `signal_quit` ended `stalled driving the child that -reloads` once, in 1 of 3 local QEMU runs at `dd9bf974d` (#1111's head), on -the 2-CPU x86-64 boot. Its log is in -[`pr-1111/r2/tty`](https://github.com/ToyOSOrg/evidence/tree/8b3306f1ba244133a2c28e8d7c09fcc48c473c8e/pr-1111/r2/tty). +`libc_children`'s `signal_quit` sometimes ends `stalled driving the child +that reloads`, by its own 120 s ceiling, on the 2-CPU QEMU x86-64 boot. +Seen twice: + +| when | where | revision | +|---|---|---| +| by 2026-11-01 | local QEMU, 1 of 3 runs, [`pr-1111/r2/tty`](https://github.com/ToyOSOrg/evidence/tree/8b3306f1ba244133a2c28e8d7c09fcc48c473c8e/pr-1111/r2/tty) | `dd9bf974d` (#1111's head) | +| 2026-11-11 | the guest runner, run 42931710932, host quiet | `9ad2edd7d` (#1209's branch and a control in the shell) | It is not `tty_interrupt`'s stall, which was a shell reading ahead on a pipe it shares with the program it starts: `signal_quit` runs no shell, and each child has pipes of its own. -**Read from the code, not reproduced.** The `reloads` child says `armed` and -then calls `pause`; the parent sends `SIGHUP` as soon as it has read -`armed`. libc runs a raised signal at once: in a blocking call of a thread -that leaves it unblocked, or else on libc's own thread -(`sdk/libc/src/sigreq.rs`). A `SIGHUP` raised before the child has entered -`pause` runs its handler on libc's own thread, and the `pause` that follows -waits for a handler that has already run. The child never says `reloaded`. -The `reads` child has the same window between `armed` and its `read`, and -`reloads` a second one between testing `ended` and `pause`. - -Unmeasured: whether this is what the one sighting was. Nothing kept from it -says where the child was. - -**Exit**: each of the two children blocks without a window a signal can fall -in, or the parent sends only once the child is in its call, as -`libc_read_restarts` orders its own by the roster; a run that holds the -child short of its call for a control shows the stall before and not after. +**The cause.** The `reloads` child says `armed` and then calls `pause`; the +parent sends `SIGHUP` as soon as it has read `armed`. libc runs a raised +signal at once: in a blocking call of a thread that leaves it unblocked, or +else on libc's own thread (`sdk/libc/src/sigreq.rs`). A `SIGHUP` raised +before the child has entered `pause` runs its handler on libc's own thread, +and the `pause` that follows waits for a handler that has already run. The +child never says `reloaded`. + +A control holds the child 200 ms short of its `pause` after `armed`, on main +`9349705ce`: the row stalls the same way, run 42937578767. Its patch and log +are in +[`pr-1209/r1`](https://github.com/ToyOSOrg/evidence/tree/9eaaac0994e1468a9135af6ce22188462f8567ac/r1). + +Read from the code and not measured: the `reads` child has the same window +between `armed` and its `read`, and `reloads` a second one between testing +`ended` and `pause`. + +A C program cannot close the window for `pause` or `read`: only the kernel's +roster says a thread is parked, which is why `libc_read_restarts` is a Rust +program. + +**Exit**: no child of `signal_quit` blocks in a call a signal can fall +short of. Each either holds its signal blocked until a call that unblocks it +(`sigsuspend`, `ppoll`), or its claim moves to a program that can order the +signal after the block; the control above then passes.