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..9ad3067b581 --- /dev/null +++ b/issues/signal-quits-children-can-take-their-signal-before-they-block.md @@ -0,0 +1,47 @@ +--- +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` 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. + +**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. 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");