Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view

This file was deleted.

Original file line number Diff line number Diff line change
@@ -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.
24 changes: 14 additions & 10 deletions system/shell/src/main.rs
Original file line number Diff line number Diff line change
@@ -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};
Expand Down Expand Up @@ -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());
Expand Down Expand Up @@ -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<u8> {
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<char> {
Expand Down
46 changes: 24 additions & 22 deletions tests/guest/rust-tests/src/bin/tty_interrupt.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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();
}
}
}

Expand Down Expand Up @@ -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");
Expand All @@ -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");
Expand Down