Skip to content

signal_quit's reloading child waits blocked, and a read across an interrupt moves to libc_read_restarts - #1214

Merged
Japabu merged 3 commits into
mainfrom
signal-quit-waits-blocked
Nov 11, 2026
Merged

Japabu merged 3 commits into
mainfrom
signal-quit-waits-blocked

Conversation

@Japabu

@Japabu Japabu commented Nov 11, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

libc_children's signal_quit stalled twice at "the child that reloads"
(issues/signal-quits-children-can-take-their-signal-before-they-block.md).

The reloads child said armed and then called 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 its own
thread. A SIGHUP raised before the child had entered pause ran its
handler there, and the pause waited for a handler that had already run.

That the two sightings went through this window is inferred: it is the only
wait on that path, and the control below shows the window is enough.

Resulting behavior

  • reloads waits blocked. It blocks SIGHUP and SIGTERM before it
    says armed and waits in sigsuspend both times. Neither signal can run
    before the child waits for it. libc's pause is sigsuspend with the
    thread's own mask, so nothing pause showed is lost.
  • reads is deleted from signal_quit, and its arm moves. read
    cannot unblock a signal atomically, so its window cannot be closed in C.
    • The composition it ran, a read across an interrupt answering EINTR
      with SIGINT handled without SA_RESTART, is now a child of
      libc_read_restarts. Its parent sends the interrupt once the child has
      said armed and the roster shows its main thread parked. This is the
      orchestrator's decision, to keep stage 6.2's exit arm literal.
    • A read the interrupt does not end fails by name: the child's handler
      says it ran, and the parent then writes the child one byte, which only a
      read still parked takes.
    • libc_read_restarts already held a handler waking a parked read, for
      SIGCHLD.
    • The ppolls child holds something else: a pending quit taken at a
      call's entry, not a quit waking a parked call.
  • pause is no longer called by any test.

Checks

libc_children on the guest runner (QEMU x86-64, 2 CPUs). The control holds
the reloads child 200 ms short of its wait after it says armed.

arm revision result
this branch ead7b3f4c run 42953449011, green
control for the new arm: the child handles SIGINT with SA_RESTART 2be1bb7a2 run 42953815079, red in 7 s: an interrupt did not end the child's read with EINTR: it answered 1 (errno 0), the handler run 1 times
control before: main 9349705ce plus the hold a42c5fab8 run 42937578767, red: stalled driving the child that reloads
control after: d9c7f606f plus the hold 9c8e9a94e run 42945978144, green
d9c7f606f, before the review d9c7f606f run 42945464142, green

No host check ran: the change is two guest programs and issue files. The
guest runner compiles both programs and no host suite builds either;
tests/common/libcchildren.rs changed in a comment alone.

Evidence: r3 https://github.com/ToyOSOrg/evidence/tree/59dc9df432e4d007e6628cc415196f58003c8e00/r3, r2 https://github.com/ToyOSOrg/evidence/tree/59dc9df432e4d007e6628cc415196f58003c8e00/r2, r1 https://github.com/ToyOSOrg/evidence/tree/59dc9df432e4d007e6628cc415196f58003c8e00/r1

Round 2's control, before the arm failed by name (run 42949792797,
277825f76): the row said nothing for 900 s after quit_tree and the
driver's silence guard ended the suite, leaving a scratch directory in the
runner's /tmp. Whether the row's own bound had expired is not in the log,
because the row reports failures only at its end.

Records

  • Closes
    issues/signal-quits-children-can-take-their-signal-before-they-block.md.
    Its rule is one clause in signal_quit's header.
  • New, not fixed here:
    issues/a-program-that-stalls-in-libc-children-is-not-named-and-the-driver-ends-the-suite.md,
    the harness defect round 2's control met. Its exit is the review's
    proposal, not a decision.
  • issues/a-childs-end-is-an-event-and-a-parent-takes-its-children-down.md,
    stage 6.2: the exit's reload arm reads sigsuspend, and the "Met by"
    sentence says what holds each arm since this PR. The review found this
    record, which my search for the closed slug had missed.

🤖 Generated with Claude Code

`libc_children`'s `signal_quit` stalled twice at "the child that
reloads". The child said `armed` and then called `pause`; a `SIGHUP`
raised in between ran its handler on libc's own thread, and the `pause`
waited for a handler that had already run. That it is what the two
sightings went through is inferred: it is the only wait on that path,
and a control that holds the child 200 ms short of its `pause` stalls
the row the same way.

- `reloads` blocks `SIGHUP` and `SIGTERM` before it says `armed` and
  waits in `sigsuspend` both times, so neither signal can run before the
  child waits for it. libc's `pause` is `sigsuspend` with the thread's
  own mask, so nothing `pause` showed is lost.
- `reads` is deleted: `read` cannot unblock a signal atomically, so its
  window cannot be closed in C. `read` answering `EINTR` across a
  handler without `SA_RESTART` is `libc_read_restarts`'s claim, ordered
  by the roster, and a quit's `SIGINT` running its handler and
  interrupting a blocked call is the `ppolls` child's second `ppoll`.

Closes issues/signal-quits-children-can-take-their-signal-before-they-block.md.

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 #1214 at d9c7f606f

SEND BACK on one blocker: an open track's record still says signal_quit holds the two cases this change removes and respells. The fix is right and its evidence holds; the blocker is a markdown edit and needs no run.

Assessment

The change is in the right place: the race is the test's own, and the test now orders the signal after the wait with what C has for it. Net: signal_quit.c +13/−24, one issue record removed, no production code.

The race is closed in both windows, by reading.

  • reloads blocks SIGHUP and SIGTERM before armed(). While the one thread blocks a signal, libc holds it pending (sdk/libc/src/sigreq.rs:12-16).
  • sigsuspend enters its call and takes a pending signal before it waits, each under libc's one lock (sdk/libc/src/signal.rs:361-393, sdk/libc/src/posix_io.rs:1099-1105). A hang-up raised before the call is taken at its entry.
  • The second window closes the same way: end can run only inside sigsuspend, so while (!ended) sigsuspend(&none) cannot test ended, miss the handler and then wait.
  • This is the shape the blocks child already had.

pause and sigsuspend are one call, as claimed: both are poll_until with a mask, pause passing the thread's own (posix_io.rs:1139-1147). After this change no test calls pause; inspection is enough for a one-line wrapper.

The three arms establish the first window.

arm what I found
branch run 42945464142 at d9c7f606f, PASS libc_children, signal_quit's new line present
main plus the hold run 42937578767 at a42c5fab8 (parent 9349705ce), signal_quit: stalled driving the child that reloads, exit 142
branch plus the hold run 42945978144 at 9c8e9a94e (parent d9c7f606f), green
  • Each published patch equals the control commit's diff against its parent.
  • Nothing under tests/guest/libc, sdk/libc or kernel differs between 9349705ce and the branch's base, so the red arm stands for it.
  • In the red arm the handler runs inside the control's own usleep, not on libc's thread. The effect is the same: it has run before pause.
  • The second window has no arm. It is the same mask and the same call, so reading suffices.

Deleting reads loses one composition and nothing libc decides separately.

  • Claim 1 holds with a qualifier. libc_read_restarts asserts read answers EINTR across a handler without SA_RESTART, ordered by the roster (tests/guest/rust-tests/src/bin/libc_read_restarts.rs:90,131). Its signal is SIGCHLD, not a quit's SIGINT.
  • Claim 2 is inaccurate. The ppolls child's second ppoll never blocks: the child loops until sigpending shows the quit, and the descriptor is still ready (tests/guest/libc/signal_quit.c:141-153). It shows a pending quit taken at a call's entry, with its handler run and EINTR answered. It does not show a quit waking a parked call.
  • What makes the deletion sound is something else: libc takes every signal by one signal-blind path (sigreq.rs:349-359), which read enters at posix_io.rs:171. A handler waking a parked read is libc_read_restarts's; a quit becoming its signal is every remaining child's.
  • So no test now runs a read across an interrupt. That is the composition lost, and it is an arm of a track's exit (below).

The record is closed by the procedure, as far as its own file goes.

  • The file is deleted and the commit message carries the story.
  • git grep for the slug at d9c7f606f and at origin/main finds no citation.
  • Its rule is in the program's header, for the child it applies to.

BLOCKER

  1. issues/a-childs-end-is-an-event-and-a-parent-takes-its-children-down.md:213-216,231-234 — stage 6.2, in progress, names both cases as its exit and as met in signal_quit — the record is false after this change and is not updated with it.
    • Its exit lists "a C child's read of an empty pipe, SIGINT handled without SA_RESTART, answers EINTR on an interrupt" and "a C child in pause whose SIGHUP handler reloads".
    • Its Met by libc: a child's end, an alarm and a quit reach a handler #1011 sentence places "the read answering EINTR, the reload in pause" in signal_quit.
    • After this change signal_quit has no read arm and no pause. Whoever closes 6.2 would rely on a "met" that no test holds.
    • Root requires issue records directly affected by a change to stay accurate. The PR body's "nothing outside issues/ cited it" is true of the slug and misses this record, which describes the cases.
    • Needed: the record says what holds each arm since this change. The reload waits in sigsuspend with both signals blocked. The read arm is either dropped from the exit, naming what holds its parts and that no test runs a read across an interrupt, or re-homed (suggestion 2). Which of the two is a change to a briefed stage's exit, so the record must show it; I leave who signs it to the orchestrator.

SUGGESTION

  1. tests/guest/libc/signal_quit.c:4-5,14-16 — the rule sits in the reloads bullet, where "Both wait blocked until then" is hard to parse. The closing paragraph still reads as if armed alone orders the signal, which is the misconception the record was about. One clause there serves the next child: a child that waits for its signal holds it blocked until the call that unblocks it.
  2. To keep the exit arm literal, add a third raiser to libc_read_restarts: an interrupt sent once the roster shows the main thread parked in the read. That is one arm in an existing program and no new boot.

For the description

  • Replace the ppolls sentence, in the body and in whichever commit corrects the record, with what the sources show: the take is signal-blind, libc_read_restarts holds a handler waking a parked read, and ppolls holds a pending quit taken at a call's entry.
  • "No coverage is lost" should read: one composition is no longer run, a read across an interrupt, and pause is no longer called.
  • Verified as stated: the three runs and their revisions, the inference about the two sightings labeled as one, and that no host suite builds the C program.

SEND BACK

…s, and stage 6.2's record says where each arm is

`signal_quit` lost its `read` child, and with it the one test of a `read`
answering `EINTR` on an interrupt, which stage 6.2 of
issues/a-childs-end-is-an-event-and-a-parent-takes-its-children-down.md
names in its exit. By the orchestrator's decision the arm stays literal:
`libc_read_restarts` gains a child that handles `SIGINT` without
`SA_RESTART` and reads its empty standard input, and its parent sends
the interrupt once the child has said `armed` and the roster shows its
main thread parked. The read answers `EINTR` and the child ends 0.

The record says what holds each arm since this change: the reload waits
in `sigsuspend` with both signals blocked, `pause` is called by no test,
and the `read` arm is `libc_read_restarts`'s.

`signal_quit`'s header states the rule once, where it describes what
`armed` orders.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
@Japabu Japabu changed the title signal_quit's reloading child waits blocked, and its reading child goes signal_quit's reloading child waits blocked, and a read across an interrupt moves to libc_read_restarts Nov 11, 2026
@Japabu

Japabu commented Nov 11, 2026

Copy link
Copy Markdown
Collaborator Author

Review r2 of #1214 at 5a55e56f2

SEND BACK on one blocker in the new arm: when its claim fails, the arm hangs instead of failing, and the assertion written for that failure cannot run. Round 1's record blocker is resolved. The fix is a few lines in the test and two short runs.

Scope: d9c7f606f..5a55e56f2 only, +97/−13, no production code.

Assessment

The arm is ordered, with no fixed delay.

  • The parent sends the interrupt only after it has read armed and the roster shows the child's main thread blocked (libc_read_restarts.rs:145-156).
  • sigaction returns only once libc listens to the notice (sdk/libc/src/signal.rs:148-166), so an interrupt after armed is never a kill.
  • After armed the main thread makes no blocking call but the read, and a main thread inside the call takes the signal there (sdk/libc/src/sigreq.rs:291-304).
  • One window is left, by reading. read's entry takes libc's futex lock before it registers the call (signal.rs:361-377), and libc's own thread, which this sigaction just started, takes the same lock as it starts (signal.rs:481,512,524). A main thread asleep on that lock reads BLOCKED before it has entered.
    • To hit it, the parent's sample must land inside a hold of a few instructions, and libc's own thread must then take the interrupt before the main thread gets the lock. My estimate, unmeasured: far below what any run will show.
    • Its result is a hang, never a false pass. The SIGCHLD arms read the roster the same way, so this is not new and I ask for nothing beyond the blocker, whose fix gives it a name.

The control establishes that the arm is sensitive; it does not establish that it says so.

  • Head: run 42949786423 checks out 5a55e56f2, PASS libc_children, both libc_read_restarts lines present.
  • Control: 277825f76 has parent 5a55e56f2 and its diff equals the published patch, one flag in interrupted().
  • The inference that the third read hung is sound: only the new arm runs the patched function, and the first two arms were green at the head minutes earlier on the same runner. It is labeled as an inference.
  • What the control also shows is the blocker.

BLOCKER

  1. tests/guest/rust-tests/src/bin/libc_read_restarts.rs:116-129,156-160 — the arm's failure is a hang, and the child's assertion cannot see the defect it describes.
    • Every way the claim fails leaves the child's read parked: the handler restarting it, as in the control, or the handler run on libc's own thread. The assertion at line 128 runs only once the read returns, and the parent waits in waitpid at line 159 with nothing to end it.
    • Consequence, from run 42949792797: no line names the program or the arm, the suite is ended after 900 s, and a second step reds on a leaked scratch directory.
    • The arm this one replaces failed the same defect in 120 s as signal_quit: stalled driving the child that reads (base signal_quit.c:108-110,180-184). The arm beside it fails it at once as an assertion: its byte is written once the handler has said it ran, so a restarted read answers the byte (lines 186-193, 203-204).
    • Root's testing policy asks each test for a meaningful failure. Here the move lost one the test had.
    • Needed: a read that is not interrupted fails the arm by an assertion that names it. The implementer chooses how. The sibling's way is about six lines: on_interrupt says it ran on the child's stdout, and the parent reads that and writes one byte to the child's stdin before waitpid. An interrupted read has already answered EINTR; a restarted one, or one whose handler ran elsewhere, answers the byte and line 128 fires. A hang ceiling in the child, as signal_quit's alarm, is the other way.
    • Evidence then: the row at the new head, and the SA_RESTART control again. The control should red in the row's normal time with the child's own sentence, which replaces the inference about which read hung.

Verified, no change needed

item what I found
stage 6.2, reload arm true: reloads blocks SIGHUP and SIGTERM before armed and waits in sigsuspend both times (signal_quit.c:49-66)
stage 6.2, "pause, which no test calls now" true: no call under tests/
stage 6.2, read arm true: libc_read_restarts is a Rust program calling libc's read, in the same boot, ordered by the roster. The exit still says "a C child's read"; the Since #1214 sentence states the departure, which is enough
stage 6.2, mutation text "the reload's wait" true of #1011's pause and of today's sigsuspend
signal_quit.c:13-16 true of every child that waits for its signal (reloads, blocks, ppolls), and the right length
PR body, ppolls sentence and coverage statement match round 1's findings
PR body, "no host check ran" holds: host clippy excludes guest packages (build/clippy.rs:70, build/hostws.rs:118), and the runner compiled both programs

SUGGESTION

  1. tests/common/libcchildren.rs:13-14 — the header's line for libc_read_restarts names the SIGCHLD handler alone. A clause for the interrupt keeps the inventory whole.

For the orchestrator

The 900 s is a harness defect and needs a record. None exists under issues/ that I could find.

  • What the 300 s bounds: one program's wait on the host, not the row (tests/common/libcchildren.rs:30-31,54). run_test scales it by the host's boot time, up to 8×, and ends the wait only once the guest has also been silent 15 s, or at twice the scaled bound if it keeps talking (tests/common/qemu.rs:291-295,503-539,1904). It kills nothing in the guest.
  • Why the row stayed silent, by reading:
    • The row prints failures only after its last program (libcchildren.rs:54-66), so a stall says nothing when it happens.
    • The guest runner reads one run line at a time and waits for that job (tests/guest/test-runner/src/main.rs:127-131,323). After a stall the next run is never read, so every later program spends its own full bound in silence.
    • A hang at any of the 23 programs but the last costs at least two scaled bounds before the row speaks, and far more for an early one.
  • The driver's comment says the guest harness ends a silent guest long before its 900 s (build/ci.rs:230-232). For this row that is false.
  • Which mechanism this run hit is not in the log: two bounds scaled above 1.5×, or one wait held to its backstop by a talking guest. The log prints no scale and keeps no serial capture.
  • Consequence: the red loses its name and its console window, and the killed suite leaks its scratch directory, which reds a second step.
  • Cheapest fix, a proposal for the record and not for this PR: the row stops at the first program that ends with no exit code, since that boot runs nothing more. Other rows that call run_test repeatedly on one boot may share this.

One sentence of the PR body states an inference as an observation. "The row's own 300 s bound did not end the hung job" is not shown by the log: the bound may have expired without a line. Suggested: the row said nothing for 900 s after quit_tree and the driver's silence guard ended the suite; whether the row's own bound had expired is not in the log, because the row reports failures only at its end.

SEND BACK

…and the row's silent stall is recorded

The new arm of `libc_read_restarts` failed as a hang: a read the
interrupt did not end stayed parked, the child's assertion never ran,
and the parent waited in `waitpid`. The child's handler now says it ran
on the child's standard output, and the parent, having read that, writes
the child one byte before it waits. An interrupted read has answered
`EINTR` already; a restarted one, or one whose handler ran elsewhere,
answers the byte, and the child's assertion names the arm.

`tests/common/libcchildren.rs` names the interrupt in its line for the
program.

New record, not fixed here: a program that stalls in `libc_children` is
not named when it happens, later programs each spend their bound in
silence, and the driver's 900 s guard ends the suite and leaks its
scratch directory. Seen in run 42949792797, this PR's own control.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
@Japabu

Japabu commented Nov 11, 2026

Copy link
Copy Markdown
Collaborator Author

Review r3 of #1214 at ead7b3f4c

LAND. Round 2's blocker is resolved and the new commit adds no blocker. Two optional wording fixes to the new record are below.

Scope: 5a55e56f2..ead7b3f4c only, +65/−6: 20 net lines in one guest test, a 42-line record, one header clause. No production code.

Round 2's blocker: resolved

Both failures round 2 named now end in the child's own assertion (tests/guest/rust-tests/src/bin/libc_read_restarts.rs:112-116,133-136,166-176).

  • Passing path: libc sets EINTR after the handler returns and returns without looking at the pipe again (sdk/libc/src/posix_io.rs:179-182). The parent's byte cannot race it.
  • Restarted read, or handler run on libc's own thread: the read is still parked, takes the byte, and line 133 fires.
  • Child ended without its handler's word: the parent's read answers 0 and line 169 fires.
  • The byte's write is refused if the child has already ended. libc raises no SIGPIPE, so ignoring the result is right, and line 170 says why.

The evidence establishes it.

claim what I checked
head green run 42953449011, headSha ead7b3f4c, PASS libc_children (7s), both libc_read_restarts lines present
control is one flag 2be1bb7a2 has parent ead7b3f4c; its diff is byte-identical to the published patch
control reds by name run 42953815079, headSha 2be1bb7a2, FAIL libc_children (7s), libc_read_restarts ended Some(101) with the child's sentence at line 134 and the parent's at line 177
no stall tty_interrupt ran after it in the control

The two questions asked

The wait on the handler's word has a failure timeout and needs no second one.

  • When it hangs: the child stays alive and its handler never runs, so the interrupt was lost or never woke the read. The parent then waits at line 168.
  • Its timeout is the row's per-program bound (tests/common/libcchildren.rs:32,56), which reports libc_read_restarts ended None with the stall verdict.
  • That is this suite's stated ceiling for every unbounded guest wait (tests/guest/rust-tests/src/roster.rs:51-52). The same ceiling stands behind this program's other waits: the armed read, the roster poll, waitpid, and the SIGCHLD arms' identical wait for their handler's word at line 206.
  • What is weak is the ceiling, not this wait. For this program, second to last, the row names the stall only if two scaled bounds fit inside the driver's 900 s; otherwise the red is unnamed, as in round 2's control. The new record owns that, and its exit fixes it for all 23 programs at once.
  • A private ceiling here, as signal_quit's alarm(120), would be a second mechanism for one program. I do not ask for it.

The handler is safe as written.

  • It does an atomic add and one write to a pipe. write is on POSIX's async-signal-safe list.
  • ToyOS's libc never runs a handler asynchronously. It runs at a blocking call's take point with libc's lock released, or on libc's own thread (sdk/libc/src/signal.rs:301-329,386-406,548-553). No interrupted code holds a lock the handler could need.
  • The child's stdout is a pipe, so the write goes straight to the descriptor (sdk/toyos/src/log/stdio.rs:325).
  • errno needs no save: the passing path sets it after the handler returns, and on the failing path it is only printed.
  • The green head run exercises the handler inside the parked read; the control exercises it with a restart.

Records and prose

item finding
new record, format status: open, kind: tooling, opened: 2026-11-11, no severity: valid per issues/README.md. No other record covers the subject.
new record, observation matches run 42949792797: last line quit_tree's at 03:02:03, ended at 03:17:04 after 900 s, second step red on the leaked scratch directory, no serial artifact
new record, mechanism 18 C programs plus 5 Rust is 23; the bound, backstop and build/ci.rs comment are as described. Labelled as read, not run.
new record, exit labelled as the review's proposal, not decided
tests/common/libcchildren.rs:13-15 accurate
PR body, corrected sentence matches the log, and no longer claims the row's bound did not expire
PR body, checks table and "no host check ran" revisions and runs match; libcchildren.rs changed in a comment alone across the whole PR

SUGGESTION

  1. issues/a-program-that-stalls-in-libc-children-is-not-named-and-the-driver-ends-the-suite.md:10-11 — the lead sentence is unconditional and the mechanism below it is not.
    • A hang in the last program is named at its own bound. One in the next to last, the case seen, reaches the guard only when the bound is scaled past about 1.45×.
    • Suggested: "When one of them hangs, the row says nothing until its last program's bound has also expired, and the driver's 900 s silence guard usually ends the whole suite first."
  2. Same file, line 24 — the bound is also scaled by the guest's vCPU oversubscription, not the host's boot time alone (tests/common/qemu.rs budget_smp).

LAND

@Japabu
Japabu marked this pull request as ready for review November 11, 2026 03:47
@Japabu
Japabu added this pull request to the merge queue Nov 11, 2026
Merged via the queue into main with commit 1191cef Nov 11, 2026
11 checks passed
@Japabu
Japabu deleted the signal-quit-waits-blocked branch November 11, 2026 04:17
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