Skip to content

The shell reads its input a byte a read, so what is typed after a line is its program's - #1209

Merged
Japabu merged 3 commits into
mainfrom
shell-reads-its-line-alone
Nov 11, 2026
Merged

Japabu merged 3 commits into
mainfrom
shell-reads-its-line-alone

Conversation

@Japabu

@Japabu Japabu commented Nov 11, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

libc_children's tty_interrupt stalled at "the nested line held" about one
run in seven
(issues/libc-childrens-tty-interrupt-can-stall-at-the-nested-shells-line.md).
Two defects made it, one the product's and one the probe's.

The shell read ahead. It read standard input through std's Stdin, whose
buffer takes up to 64 bytes a read. A line typed while the shell was still
coming back to its prompt was read together with the one before it. 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.

The probe typed early.

  1. After echo pipeline-ended it waited for no line to be held. That was
    already so before echo's line was handed over, so it waited for nothing.
  2. Its next wait, for the nested shell's line, was satisfied by echo's
    stale notice whenever the shell had not yet ended that line.
  3. It typed cat at once. Both lines were in the pipe when the shell came
    back to read, and the shell took both.
  4. The nested shell blocked in its one read, which is what the kernel's exit
    record of it said in The loader is the program's, and the kernel parses no ELF #1189's sighting.

Resulting behavior

  • The shell reads the descriptor itself, one byte a read. What is typed
    after a line stays in the pipe for what that line runs. It 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.
  • The probe types the nested shell's first line with the line that starts
    it, in one write. A shell that reads ahead stalls that step every run.

Checks

All guest runs are libc_children on the guest runner (QEMU x86-64, 2 CPUs).

arm revision result
this branch a35358bc4 run 42929766063, green
control: main, the shell ending its line at the surface 100 ms late a31d949e0 run 42930386515, red: stalled at the nested line held
control: the new probe against main's shell 648b06b31 run 42930897486, red: the same stall
this branch, the shell ending its line 100 ms late 9ad2edd7d run 42931710932: tty_interrupt green. The run is red for signal_quit, see below
session_lifecycle a_usb_key_reaches_the_console, which type at a terminal's and the console's shell ecb718f5f run 42936374586, green, 2 passed
cargo run -- --ci host, macOS a35358bc4 109 of 110 green; the red is issues/the-llvm-environment-test-breaks-git-on-macos.md

The first control widens the window the stall needs on main's own code: the
probe's stale notice, then both lines read at once. The second is the new
step's negative control. In both the shell had said /> shell and the nested
prompt, and nothing of cat.

The fourth arm is red, and not for this change. signal_quit, a C
program of the same row that runs no shell, ended stalled driving the child that reloads. That is its second sighting, recorded in the new issue below.
tty_interrupt passed in that run, which is what the arm asks. The host was
quiet (0.02 CPUs of other work).

The head, 57b5d38f7, is a35358bc4 plus the two issue files. The shell
rows ran at ecb718f5f, which differs from the head in the new record's
text alone.

Evidence: https://github.com/ToyOSOrg/evidence/tree/9eaaac0994e1468a9135af6ce22188462f8567ac/r1

Records

  • Closes
    issues/libc-childrens-tty-interrupt-can-stall-at-the-nested-shells-line.md.
    Nothing outside issues/ cited it.
  • New: issues/signal-quits-children-can-take-their-signal-before-they-block.md.
    The closed record asked whether signal_quit's stall was the same defect.
    It is not: signal_quit runs no shell. Its reloads child can take its
    SIGHUP between saying armed and entering pause. A control that holds
    the child 200 ms short of its pause stalls the row the same way on main
    (run 42937578767, a42c5fab8). It is recorded and not fixed here: a C
    program cannot order a signal after a block, so the fix is a choice about
    what those children claim.

Risks

  • Other programs still read standard input through std's buffer, as programs
    do; only a shell hands its input on.
  • A shell on a pipe now makes one read per byte.

🤖 Generated with Claude Code

Japabu and others added 3 commits November 11, 2026 02:00
…e 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 <noreply@anthropic.com>
… 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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
@Japabu

Japabu commented Nov 11, 2026

Copy link
Copy Markdown
Collaborator Author

Review r1 of #1209 at 57b5d38f7

Verdict: LAND. No blocker. Four optional suggestions, all on the new record and its evidence, none on the code.

Architecture and fit

The fix is in the right place and is the right shape.

  • Owner. The shell is the only program in the tree that both reads its standard input and starts children on it. Not reading past its line is the shell's obligation, not std's or the kernel's: std's Stdin is a BufReader (64 bytes here) on every platform.
  • Independent support. POSIX's sh (STDIN section) requires exactly this: the shell "shall not read ahead in such a manner that any characters intended to be read by the invoked command are consumed by the shell".
  • No cheaper representation exists in userland. On a pipe, any read longer than one byte can cross a CR and cannot be put back. The alternatives are a kernel peek/unread (a kernel addition for something userland can do) or a channel that keeps line boundaries (a different design, and the shell's editor needs raw keys for history and completion anyway).
  • Cost. Typed keys already arrived one per read, and the old path paid an fstat plus a read per fill, so interactive input is now cheaper. Only a paste changes: one pipe read per byte, beside the write and flush per character the echo already makes. Acceptable.
  • By input kind.
    • Pipe and terminal: correct; what follows a line stays for its program.
    • File: the offset is left exactly after the line, which is the same rule.
    • Script on stdin: not a feature today, before or after. readline ends a line on CR alone and drops LF, so an LF-terminated script never runs a line. Unrelated to this change.
  • Raw mode. STDIN_RAW is a per-process static read only by std's Stdin::read, which the shell no longer calls (no io::stdin() is left in system/shell). Removing the call changes nothing for children.
  • Errors. Unchanged in effect: a closed descriptor is the silent end, as std's read_stdin maps it; anything else is said and ends the loop.

Net: production +14/−10 (system/shell), test +24/−22, issues +47/−52. No new mechanism, no manual change owed.

Diagnosis: established

Each arm ran once at the stated revision (checked against the runs and the control commits: a31d949e0 and a42c5fab8 are one hunk on 9349705ce, 648b06b31 carries the branch's probe byte for byte, 9ad2edd7d is the late hunk on a35358bc4).

arm what it shows
main, line end 100 ms late: red at "the nested line held" The stall is governed by the window between echo's output and its line's end. The old until_lines returned on echo's stale held: true, cat was typed early, and the 64-byte fill took both lines. The shell said /> shell, the nested prompt, and nothing of cat.
new probe on main's shell: red, same step The one-write step detects read-ahead without any timing.
branch, line end 100 ms late: tty_interrupt green The fix holds under the same widening.
branch: green —

This is consistent with #1189's kernel record of the nested shell sitting in its first read. The closed record's exit asked for "green across repeated runs"; that was written with the cause unknown. A deterministic reproduction and its removal are stronger than a run count against a 1-in-7 rate, so I accept the close. No citation of the closed slug remains at the head or on origin/main.

Revisions. 57b5d38f7 differs from a35358bc4 in the two issue files only, and from ecb718f5f in the new record only. The guest evidence stands for the head. Host CI: 1 of 110 red, the recorded macOS llvm test.

The probe

Clear claim, deterministic failure.

  • host.poll() yields one notice per MSG_FOREGROUND frame, so counting one at a time sees every line handed and ended in order.
  • The added waits after each echo consume that line's two notices. This also closes the same stale-notice hazard before the reader step, which the old probe had and nobody had hit.
  • The combined write adds no boot, and its claim (what follows a line is its program's) is one only real processes sharing a pipe can show.

Other programs with the same defect

None found. I searched system/, apps/, lib/ and sdk/ for code that both spawns and reads standard input: only system/shell. The terminal, console, sshserver and libc's system/popen start a shell on a pipe they write or pass on, and the last two use -c, which reads no input. toybox spawns nothing. The PR's risk note is accurate; nothing to record.

Findings

No BLOCKER.

SUGGESTION

  1. Evidence r1/control-signal_quit-child-short-of-its-pause.patch — the published patch is a diff against a newer main, so it also "deletes" issues: what the kernel still holds, by responsibility #1208's three issue files and two of its hunks. It does not apply to 9349705ce, the base the record names. The control itself is only the last hunk (three lines in signal_quit.c, which is what a42c5fab8 actually is). The record's exit depends on re-running this control, so append the clean hunk and repoint the link.
  2. issues/signal-quits-children-can-take-their-signal-before-they-block.md:24 — "The cause." is stated as fact. The control shows the window is sufficient to produce the line; neither sighting was observed going through it. It is the only wait the code has on that path, so the inference is strong, but one clause should say it is inferred.
  3. issues/signal-quits-children-can-take-their-signal-before-they-block.md:4 — severity: [hang] was inherited from the closed record. The new record says the race is the test's own: libc does what sigreq.rs's header says, and pause has this window on any POSIX system. The flag puts a racy test into the query for product hangs. Consider dropping it.
  4. Recommended fix for that record, since you asked. It stays in C and loses no coverage:
    • reloads: block SIGHUP and SIGTERM before armed(), and wait in sigsuspend(&none) both times. libc's pause is sigsuspend with the thread's own mask (sdk/libc/src/posix_io.rs:1139-1147, the same poll_until call), so nothing pause showed is lost, and both windows close. This keeps what only this child claims: a hang-up and a terminate arriving as SIGHUP and SIGTERM.
    • reads: delete it. read cannot unblock a signal atomically. Its two halves are held elsewhere: read answering EINTR across a handler without SA_RESTART is libc_read_restarts's claim, ordered by the roster; a quit's SIGINT running its handler and interrupting a blocked call is the ppolls child's second ppoll. If a quit interrupting a read specifically is wanted, it is one more raiser in libc_read_restarts, not a new program.
    • The record's enumeration of windows (reads, reloads twice) is complete: ignores, blocks, computes and ppolls have none.

The rest of the new record checks out: both sightings and their revisions, the control's run and base, the description of libc's routing against sigreq.rs's header, and why libc_read_restarts is a Rust program. Recording it rather than fixing it here is within the rules: its independence is established (no shell, pipes of its own, reproduced on main).

LAND

@Japabu
Japabu marked this pull request as ready for review November 11, 2026 01:45
@Japabu
Japabu added this pull request to the merge queue Nov 11, 2026
Merged via the queue into main with commit ea7a389 Nov 11, 2026
10 checks passed
@Japabu
Japabu deleted the shell-reads-its-line-alone branch November 11, 2026 02:04
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant