Skip to content

libc: a child's end, an alarm and a quit reach a handler - #1011

Merged
Japabu merged 6 commits into
mainfrom
wt/toyos-proc-signals
Oct 22, 2026
Merged

Japabu merged 6 commits into
mainfrom
wt/toyos-proc-signals

Conversation

@Japabu

@Japabu Japabu commented Oct 22, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

Until now, libc refused every signal handler. A C program could not handle a child's end, an alarm, or Ctrl+C. A quit (#995) killed every C program, because libc never listened to its notice. This PR covers stage 4 (libc imitates SIGCHLD) and libc's half of stage 6 of issues/a-childs-end-is-an-event-and-a-parent-takes-its-children-down.md. It builds on #1001's sigreq.

Resulting behaviour

Which handlers libc keeps. libc keeps a handler for the signals an event raises:

  • SIGCHLD: a child's end;
  • SIGALRM: an alarm coming due;
  • SIGINT, SIGHUP, SIGTERM: the three reasons a quit asks.

A handler for any other signal is still refused EINVAL, because nothing would ever call it. Unsupported operations keep failing honestly. sigaction now keeps an action whole, so it reads back as it was installed.

Where a raised signal runs (sigreq; signal performs it):

  • In a blocking call of a thread that leaves the signal unblocked.
  • Otherwise, on libc's own thread, beside the program.
  • While every thread blocks it, it waits pending. sigpending shows it.

raise of a blocked signal is now pending rather than #1001's ENOSYS. Masks are kept per kernel tid.

libc's own thread replaces the alarm's old keeper. It watches:

  • every unwaited child while a SIGCHLD handler is kept, through copies of their handles;
  • the alarm;
  • the notice, once a quit's signal is handled, ignored or blocked.

Listening to the notice. The call that makes the process listen does so before it returns. Otherwise a quit could kill the process in the gap. If the process holds no notice, that call is refused ENOSYS and nothing changes. Reasons arrive as SIGINT, SIGHUP or SIGTERM:

  • ignored: dropped;
  • blocked: pending;
  • unhandled: the process ends with exit code 128 plus the signal's number.

kill. kill(pid, SIGINT | SIGHUP | SIGTERM) is a quit of that child, and of a group's children for a negative pid. Aimed at the caller's own pid, it raises the signal in the caller.

Interruptible calls.

  • Restart after a handler kept with SA_RESTART, or answer EINTR without it:
    • read of a kernel object
    • recv and accept
    • wait, waitpid, wait4
  • Always answer EINTR: poll, ppoll, pause, sigsuspend, nanosleep, usleep.
  • sleep answers the time left.

Each of these watches one wake pipe beside what it waits for. The pipe holds a byte exactly while some such call has something to look at. With no handler kept, these calls block as they did before.

SIG_IGN on SIGCHLD keeps no ended child for a wait.

New calls: ppoll, pause, sigpending, sigsuspend, sigdelset, sigismember, and the sig_atomic_t type.

Bounds:

  • libc's own thread watches at most 254 unwaited children, beside its control pipe and the notice.
  • A poll takes at most 255 descriptors, leaving room for the wake pipe.

Ordinary Rust behaviour is unchanged. A Rust program links toyos_c only where it names it, so std's behaviour does not change. A Rust program that never touches libc's signals is killed by a quit, as before.

Decisions worth a look

  • Handlers run on libc's own thread when no thread is in a blocking call, as the track rules. A handler there must itself be safe to run beside the program. That is POSIX's async-signal-safety contract, enforced by nobody.
  • The thread registry. Signals are routed by the first thread and the threads libc started. Any other thread is refused a mask with ENOSYS, because nothing tells libc when it ends. libc's own thread is never routed, and takes nothing in a call a handler makes there.
  • What a child inherits. A child starts with every signal at its default and none blocked. A SETSIGMASK naming a signal is refused ENOSYS. Carrying an ignore or a mask across a spawn needs the owner's decision (comment on this PR), and stage 6.2 lists those arms as open.
  • Two takers of the notice. In a Rust program that links libc, libc's copy of toyos and the program's own could both take the notice. Stage 6.3 (std's notice and the ctrlc fork) has to make one owner.
  • Closed issue. issues/an-alarm-reaches-no-sigalrm-handler.md is deleted; its exit is signal_alarm.

Checks and evidence

Review round 2, at bc19cf6b9 (measured head):

  • libc's own thread enters every blocking call with KEEPER_MASK, every signal blocked, so a handler running there (a SIGCHLD handler's waitpid, say) takes no signal a program thread blocks. Host law a_call_on_libcs_own_thread_takes_no_signal.
  • Mutation KEEPER_MASK = 0: that law fails, exit 101, restored.
  • signal_quit's hang ceiling now names the child it stalled on.
  • cargo run -- --ci host: exit 0. libc_children: exit 0.
  • Evidence: https://github.com/ToyOSOrg/evidence/tree/a1112d5b5b8751c1c6830bf890ccd3d5f976382c (sig/r2/)

Review round 1, at 9c6c847ad:

The original run:

Unless a line says otherwise, at code head 61d25840e, on main 81c1386cf. The head 29414c592 adds only the PR number to the track.

  • cargo test -p toyos-libc-copies: host tests of the routing laws, the dispositions and the masks (signal_dispositions.rs, signal_masks.rs, alarm_requests.rs).
  • cargo test --test toyos-build -- libc_children (QEMU x86-64): exit 0. New C cases:
    • signal_sigchld_ignored: 255 children under SIG_IGN, then ECHILD.
    • signal_sigchld_ppoll: one SIGCHLD; EINTR; then the readable pipe; then a block until a live second child writes.
    • signal_alarm: a handler interrupts wait4; a blocked alarm stays pending until its unblock ends the process 142; an ignored one is dropped.
    • signal_quit: EINTR on an interrupt; a hang-up reloads and a terminate ends; an ignoring child runs on; a blocking child ends 130 on sigsuspend; LLVM's restore-and-raise handler ends a busy child 130.
    • New Rust probe libc_read_restarts: a read across a SIGCHLD handler, ordered by the kernel's roster, restarts with SA_RESTART and answers EINTR without it. libccase.toml's test-runner gains roster and dup for it.
  • Negative controls, each a checked patch run through libc_children and restored in the same run. They ran on the work in progress before two later edits: keeping actions whole, and watching copies of children's handles. Neither edit touches a mutated line or the test that reds, so the results stand:
    • mut-libc-never-listens: libc listens only once its own thread's watch is armed. signal_quit's ignoring child is killed (status 0x9). This exposes a real race, so it may not red on every run.
    • mut-libc-never-restarts: libc_read_restarts' SA_RESTART read answers EINTR.
    • mut-libc-keeps-ignored-children: signal_sigchld_ignored's first wait answers the child.
  • cargo run -- --ci host: exit 0.
  • cargo run -- --build-only: exit 0.
  • Updated existing tests:
    • spawn_children_cap: 255th refused, a poll of 255 answers, and one of 256 is EINVAL.
    • spawn_kill_group: SIGUSR1 instead of SIGTERM as the unsent signal.
    • Corpus 213_honest_answers: the refused handlers are now SIGUSR1 and SIGUSR2, and ignoring SIGCHLD is kept.
  • Metal, staged and not run: the T14 shared boot, which carries the corpus cases 207 and 213 that read the kept actions back.

Evidence: https://github.com/ToyOSOrg/evidence/tree/6bdc00b4c9ee09e2acae4753d29e4fb9b4151c9e (sig/)

Stage 7

Stage 7 cannot be added cheaply here. It needs:

  • the shell handing its terminal MANAGE duplicates over the surface port;
  • the terminal's Ctrl+C, close and deadline paths;
  • the shell and terminal listening in Rust (stage 6.3);
  • sshd's tokio fork arm.

It touches system programs, a fork and the surface protocol, so it is a separate PR on top of this one.

Risks

  • osbench (osbench round 2: files, IPC, networking, processes, memory, time, the tail, a workload and boot #989) measures posix_spawn plus waitpid. Each wait now enters an interruptible call: one lock, a wake pipe made once, and one more watched handle. This is unmeasured.
  • Interruption covers a kernel object's read, sockets, waits and sleeps. A read of a file a server serves, or of a console, is not interrupted. Both are stated at read.
  • The negative control for synchronous listening exposes a race, so it is not a guaranteed red.
  • AArch64 runs none of these guest cases until issues/toyos-runs-on-arm64.md stage 8.

🤖 Generated with Claude Code

https://claude.ai/code/session_01PXEk8BBMDT8tXu4AnENB9Q

Japabu and others added 2 commits October 22, 2026 11:49
Stage 4 of the child-process track and libc's half of stage 6.

libc keeps a handler for a signal an event raises: SIGCHLD a child's end,
SIGALRM an alarm come due, and SIGINT, SIGHUP and SIGTERM the three
reasons a quit asks. A handler for any other is still refused EINVAL, as
nothing would call it. sigreq decides, signal performs: a signal raised
runs at once beside the program, in a blocking call of a thread that
leaves it unblocked, else on libc's own thread, and waits pending while
every thread blocks it. A thread is the kernel's tid, so a mask is kept
per thread whoever started it. raise of a blocked signal is pending now,
not ENOSYS.

libc's own thread replaces the alarm's keeper. It watches the unwaited
children while a SIGCHLD handler is kept (a copy of each handle, since a
wait elsewhere closes the table's), the alarm, and the process's notice
once a quit's signal is handled, ignored or blocked. libc listens to the
notice before that call returns, so no quit lands in a window where the
kernel would still kill the process; where the process holds no notice
the call is refused ENOSYS and nothing changes. kill with SIGINT, SIGHUP
or SIGTERM is a quit of the child, and raises the signal in the caller's
own process: libc is the first program that sends a quit, and lifts the
handler refusal in the same change.

read of a kernel object, recv, accept and the wait family restart after a
handler kept with SA_RESTART and answer EINTR without; poll, ppoll,
pause, sigsuspend, nanosleep and usleep answer EINTR, sleep the time
left. Each watches one wake pipe beside what it waits for, which holds a
byte exactly while some such call has something to look at. Under
SIG_IGN an ended child is kept for no wait. ppoll, pause, sigpending,
sigsuspend, sigdelset and sigismember come with it, and sigaction keeps
an action whole, as it reads it back.

Bounds: libc's own thread watches at most 254 children beside its
control pipe and the notice, and a poll at most 255 descriptors beside
the wake pipe.

A spawned child starts with every signal at its default and none
blocked: SETSIGDEF is what it gets, a SETSIGMASK naming a signal is
refused ENOSYS, and the missing inheritance of an ignore or a mask is
recorded. The alarm's issue is closed by signal_alarm.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PXEk8BBMDT8tXu4AnENB9Q
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PXEk8BBMDT8tXu4AnENB9Q
@Japabu

Japabu commented Oct 22, 2026

Copy link
Copy Markdown
Collaborator Author

Review of #1011, round 1 (head 29414c5, code 61d2584, on main 81c1386)

Architecture

The ownership is right. Signals are a libc imitation over native events: a child's end, the alarm and the quit notice. The routing decisions are a pure, host-tested sigreq::Signals. signal.rs performs them. No kernel signal is added, toyos-abi is untouched, and sdk/std is unchanged, so "Rust std is unchanged" holds for the code. kill→quit gets its authority only from the Process handle libc holds for a child it spawned (the kernel's MANAGE check in sys_process_quit). No pid grants anything. Handlers run synchronously, at libc's own Call::take points or on libc's thread, never asynchronously. That is a sound simplification of async-signal-safety: no handler interrupts arbitrary code, and none runs while a libc lock is held (I checked read, recv/accept, poll_until, wait_child and sleep).

The concurrency model has holes, though. A signal can be left pending with nobody to deliver it. Blocking a signal does not exclude a handler already running for it on libc's thread. libc's own thread can enter the routed set. The track's approved exit was also narrowed while the stages were marked done.

Net change: +2131/−517. About +1100 is libc production (mostly signal.rs and sigreq.rs, replacing alarm.rs), and about +600 is tests. That size is proportionate.

Coordinator's question: transport gate sections (§11.1, #1004/#1012)

No finding. A handler runs only at libc's Call::take or on libc's own thread, and never inside toyos code. recv makes each attempt a whole stream::read(.., Wait::Nonblocking) and takes signals between attempts (socket.rs, interruptibly). poll_until takes them outside wait_fds, and toyos has no callback into libc. A handler on libc's thread does not stop the gate holder; it runs beside it. If such a handler calls recv on a socket whose gate another thread of the same holder owns, it waits as a second thread does today. One merge note: S2 rewrites libc's recv to call stream::read(.., Wait::Blocking(None)) (S2 socket.rs:453). Whichever of S2 and #1011 lands second must keep delivery outside the stream call, which is #1011's interruptibly shape.

Blockers

  1. sdk/libc/src/signal.rs:363-371, sigreq.rs:281, posix_io.rs:930 — a pending signal is stranded when a blocking call leaves without taking it.

    • arrive makes a signal pending for any waiter whose mask leaves it unblocked.
    • The waiter may leave without taking it:
      • poll_until returns ready when the wake pipe and a descriptor are both readable;
      • read gets its bytes after its take;
      • ppoll/sigsuspend return to a thread mask that unblocks what the call's mask blocked.
    • Call::drop then only leaves. Nothing delivers that pending signal, because keep routes only new arrivals and never looks at what is pending.
    • It waits until that thread makes another interruptible call. A thread that computes, or reads a console or server file (which are not interruptible), never gets it.
    • Every later arrival of the same signal answers Nothing because it is already pending (sigreq.rs:281). libc reads every reason eagerly, so the kernel's "a second quit kills an unread reason" valve never fires either.
    • Concrete case: SIGTERM handled and SIGINT default, so libc listens. A Ctrl+C lands while poll returns ready. The process is now unkillable by any number of interrupts.
    • POSIX delivers an unblocked pending signal before the call returns.
    • Fix: on leaving a call, deliver what the thread's own mask leaves unblocked on that thread (deliver_pending(tid) after leave). Alternatively, have libc's thread route pending signals whenever a waiter leaves. Add a host law for "a waiter leaving without taking"; the approved 6.2 arm below is its guest check.
  2. signal.rs:214-227 vs signal.rs:468-470 — sigprocmask does not exclude a handler already running on libc's own thread.

    • keep decides Deliver under the lock, drops the lock, and runs the handler.
    • A single-threaded program can call sigprocmask(SIG_BLOCK, {SIGCHLD}) meanwhile. The call returns while the handler is still running beside it.
    • The usual pattern breaks: block, edit the job table, unblock, with the handler editing the same table. The result is a data race and undefined behaviour in exactly the critical section the mask exists to protect.
    • This also contradicts the PR's own rule that "while every thread blocks it, it waits pending".
    • Fix: record the signal and its sa_mask that libc's thread is running. A set_mask that newly blocks one waits until that run ends (except on libc's own thread).
  3. signal.rs:215-219 with sigreq.rs:260-270, 287 — libc's own thread becomes a routed thread once a handler on it changes a mask.

    • set_mask registers any tid. A handler run by keep that calls sigprocmask/pthread_sigmask therefore puts libc's thread into masks.
    • From then on arrive counts libc's thread as "a thread free" (it is never a waiter). A signal that every program thread blocks is still delivered, permanently.
    • raise from a handler there also reads that registered mask.
    • Fix: never route by libc's own tid (it is known at spawn_own). Decide and document what a mask change inside a handler on libc's thread means.
  4. issues/a-childs-end-is-an-event-and-a-parent-takes-its-children-down.md:118-224 — the approved exits were narrowed and the stages marked Done.

    • The PR rewrote stage 6.2's exit to match what was implemented. It dropped these arms:
      • a child spawned ignoring SIGINT runs on;
      • a child spawned blocking it, by mask or SETSIGMASK, shows it pending and ends 130;
      • SETSIGDEF reads killed;
      • a quit that arrives while SIGINT is blocked as ppoll answers a ready descriptor shows in sigpending, and the next ppoll runs the handler and answers EINTR.
    • It also dropped the negative control "the stage reverted whole" and these mutations:
      • handlers run only in blocking calls;
      • EINTR from poll alone;
      • a quit without its reason;
      • watching only from a handler's install;
      • a spawn carrying no ignore, no mask or no SETSIGDEF.
    • Stage 4 lost its "SIGCHLD raised again at every blocking call / at every one that finds nothing ready" mutation.
    • The inheritance arms moved to a new issue that says the decision is "one nobody has made". But the track listed them as 6.2's exit, so dropping them changes an owner-approved stage. It goes back to the owner, as either a scoping decision or a spawn-time design. The implementer cannot decide it.
    • The PR description does not disclose the narrowing. It says the records are updated (the orchestrator owns the description).
    • The dropped ppoll-pending arm is the guest check for blocker 1.
    • Most dropped mutations are one-line patches through the same libc_children boot. Run them, or argue each one away in the PR.
  5. tests/guest/libc/signal_alarm.c:33-36 (and the header's "armed at a second, looked at past two") — a QEMU timing margin is used as an assertion.

    • The blocked arm asserts sigpending names SIGALRM after alarm(1); sleep(2). A guest descheduled for more than one second under host load reds falsely.
    • QEMU's clock is only a hang ceiling. Wait for the condition instead: loop on sigpending with a short sleep until SIGALRM shows, and let the harness bound catch a stall.
    • The ignored arm's sleep(2) is a negative claim and can stay, with its weakness stated.
  6. sigreq.rs:15-17, 199-200, 272-275 — the stale std-thread mask is unrecorded debt.

    • A thread libc did not start is registered when it sets a mask, and is never removed. It therefore:
      • routes as a live thread after it ends (an unblocking stale mask defeats "every thread blocks it");
      • hands its mask to a later thread that reuses the tid;
      • grows masks without bound if tids are not reused.
    • No shipped program reaches this today. The only callers are a Rust program whose std threads call libc's pthread_sigmask, which none in the tree does. So it need not be fixed now.
    • It is a known compromise, though, and root forbids silent debt. File it under issues/ with its evidence, owner and exit (for example: refuse pthread_sigmask on an unknown thread, or route std threads through stage 6.3's single owner). Alternatively, refuse that call ENOSYS now and make the gap honest.
  7. issues/toyos-builds-itself.md:92 — row 20 still reads "not started" although stage 6.1 (kernel: a process is asked to quit, and one that never listens is killed #995), stage 4 and libc's half of 6.2 have now landed. This PR edits the same file, so set it to "in progress".

The stated risks

  • The std-thread mask: blocker 6. Record it; no fix is needed now.
  • Two toyos copies taking the notice: no defect today. In the tree only quit_tree takes the notice, and it does not link libc. toyos::quit::notice() wraps the endowment once per copy, so a Rust program that links libc and also takes the notice would split reasons between the two copies. The track's 6.3 records this ("One owner takes the notice"), and it belongs there.

Tests and evidence

  • Controls: the three mutation logs are real reds for the stated reasons:
    • signal_quit.c:152: status 0x9;
    • libc_read_restarts.rs:130;
    • signal_sigchld_ignored.c:20.
  • Reuse: the later edits leave the mutated lines intact; run's restart moved 3 lines, and the listen line is unchanged. The reuse is acceptable.
  • The racy listen control: acceptable as a supplementary check. The property it guards, listening before act/set_mask returns (signal.rs:115, under the same lock and before act returns), is established by inspection, so the guest arm is coverage, not the oracle. A control that may stay green is no gate, so do not describe it as one. The PR's wording already says it may not red, which is honest.
  • Missing controls: see blocker 4.
  • Logs: the logs record no revision; the PR text supplies it. Put the code head into each log next time.
  • Metal: T14 is pending; the coordinator runs it.
  • Authority: libccase.toml grants test-runner dup and roster. It is a test image only, justified and commented.

Suggestions

  • sdk/libc/src/spawn.rs:577: let _ = child.process.quit(reason) turns every refusal into success. libc always holds MANAGE on its own children, so a refusal is a libc bug. expect it, or return its errno; do not swallow it.
  • spawn.rs kill doc: a quit asks the child and everything below it, unlike POSIX kill. State that in one clause, since the "and everything below it" behaviour is new.
  • kill(0 | -own_group, SIGINT|SIGHUP|SIGTERM) still skips the caller. Raising in self is now cheap, and POSIX includes the caller.
  • signal.rs:409-431: keep reuses one poller and re-watches fresh dups each turn, closing them afterwards. Their polls stay armed on handle numbers that are later reused. Keep each child's copy until it raises or is reaped, which ends the churn and the stale polls.

SEND BACK

Review round 1 of #1011.

A blocking call that leaves without taking a signal it made pending now
delivers what its thread's own mask leaves unblocked before it returns,
and leaves the rest to libc's own thread once a thread in no call leaves
it unblocked (Signals::orphan, host-tested). Before, such a signal stayed
pending until that thread's next interruptible call, and each later
arrival of it was dropped as already pending.

A mask that newly blocks a signal whose handler runs on libc's own thread
waits for that run to end, so a block-edit-unblock section excludes the
handler. libc's own thread routes nothing: a mask a handler there sets
lasts its run. A thread neither the first nor libc's is refused a mask
ENOSYS, since nothing tells libc when it ends.

libc's own thread keeps its copy of each watched child's handle until the
child raises or is reaped. kill raises a quit's signal in the caller for
its own group too, and treats a refused quit or kill of a child it holds
as the bug it is.

signal_alarm waits for sigpending to name the alarm. signal_quit gains the
quit held over a ready ppoll. The track keeps stage 6.2's approved exit,
with what is met and what is open, and stage 4's approved text; row 20 of
the self-hosting record is in progress.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PXEk8BBMDT8tXu4AnENB9Q
A mutation that leaves a child running made the test wait for the
harness's own ceiling; an alarm well above the children's run ends it with
SIGALRM's 142 instead.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PXEk8BBMDT8tXu4AnENB9Q
Japabu added a commit to ToyOSOrg/evidence that referenced this pull request Oct 22, 2026
…ew round 1; libc_children EXIT=0; six mutations and the stage reverted whole EXIT=1 each; cargo run -- --ci host EXIT=0
@Japabu

Japabu commented Oct 22, 2026

Copy link
Copy Markdown
Collaborator Author

Round 1 is addressed at 9c6c847, except blocker 4's spawn-inheritance arms, which need a decision (end of this comment).

Blockers

  1. Stranded pending signal. Fixed.

    • Call::drop now delivers, on the leaving thread, whatever that thread's own mask leaves unblocked.
    • What it still blocks goes to libc's own thread once a thread that is in no call leaves it unblocked: Signals::orphan. The keeper drains orphans on every turn, and a leaving call kicks it.
    • New host law: a_signal_a_leaving_call_did_not_take_goes_to_libcs_own_thread. The guest arm is the restored ppoll-held quit in signal_quit.
  2. A block did not exclude a running handler. Fixed.

    • The keeper publishes which handlers it is running (RUNNING) under the lock that decided them.
    • A set_mask that newly blocks one of them waits on a futex (RAN) until that run ends.
  3. libc's own thread entered the routed set. Fixed.

    • The keeper's tid is never routed.
    • A mask a handler sets on that thread is kept apart (keeper_mask), applies to raise there, and is cleared after each run.
  4. Narrowed exits. Stage 4's approved text and stage 6.2's approved exit are restored word for word. Each now has a status:

    • stage 4: done;
    • stage 6.2: in progress, with what is met and what is open.

    I removed the issue I had moved the arms to. Ran through libc_children, each a checked patch restored in the same run (logs in sig/r1/):

    • Handlers run only in blocking calls: signal_quit reaches its hang ceiling (142). I added an alarm(120) ceiling to the test so that a child left running fails here instead of at the harness's ceiling.
    • EINTR from poll alone: libc_read_restarts.rs:131, plus signal_quit at its ceiling.
    • A quit without its reason: the reload's pause takes the terminate.
    • Watching only from a handler's install: the ignoring child is killed (0x9).
    • SIGCHLD raised again at every blocking call: signal_sigchld_ppoll.c:72.
    • SIGCHLD raised again at every blocking call that finds nothing ready: signal_sigchld_ppoll.c:58.
    • The stage reverted whole (all of sdk/libc back to 81c1386): reds at the build. signal_alarm.c cannot compile without sig_atomic_t, and the stage's other new calls go with it. A parent can then only kill.
  5. signal_alarm's timing margin. Fixed. The blocked arm loops on sigpending. The ignored arm keeps its one-second look, with its weakness stated in the header.

  6. Masks of threads libc did not start. A mask set by a thread other than the first or one libc started is now refused ENOSYS. Routing therefore counts no thread whose end libc cannot see.

  7. Row 20. Now "in progress".

Suggestions taken

  • A refused quit or kill of a child is now expected.
  • kill's documentation says a quit reaches the child's subtree.
  • kill(0 | -own_group) raises the signal in the caller too.
  • The keeper keeps each watched child's handle copy until the child raises or is reaped.

Merge note. Whichever of S2 (#1012) and this lands second keeps interruptibly's shape: delivery stays outside the stream call.

Reruns at 9c6c847

The decision the inheritance arms need. The arms are: a child spawned ignoring SIGINT runs on; one spawned blocking it (by its caller's mask or SETSIGMASK) shows it in sigpending and ends 130 on unblocking; one whose SETSIGDEF names it reads killed. Their spawn mutations go with them.

They are not impossible, but they need a spawn to carry signal state. That changes the kernel/program boundary, and it touches the track's ruling that "the kernel grows no … signal". The core difficulty: the child has to be listening to its notice from its first instruction, or a quit kills it before its libc runs. Two shapes would work:

  • A (recommended). SpawnArgs gains two reason sets for the child's notice:

    • ignored: the kernel drops those reasons for that process but still asks its children;
    • held: the child starts as a listener.

    The child's libc reads both sets once from its notice, to set SIG_IGN and its mask. This honours nohup-style ignores for any program, Rust included. The cost: the notice gains a per-reason drop, which is signal-shaped kernel state.

  • B. The kernel only gains "start the child listening". Parent libc and child libc carry the ignore set and the mask in the environment. No kernel signal state, but a string convention between libc copies, and a non-libc child does not honour the ignore.

I recommend A, which needs the owner's word because of the ruling. Until then, #1011 refuses a SETSIGMASK that names a signal with ENOSYS. A child starts with every signal at its default and none blocked, which is what SETSIGDEF asks for. Stage 6.2 lists these arms as open.

@Japabu

Japabu commented Oct 22, 2026

Copy link
Copy Markdown
Collaborator Author

Review of #1011, round 2 (head 9c6c847)

I checked each round-1 blocker against the code at 9c6c847 and the evidence in sig/r1/. Five are resolved, one is resolved only in part, and one evidence claim in the restored record is false.

Round-1 blockers

  1. Stranded pending signal: resolved.
    • Call::drop delivers, on the leaving thread, what its own mask leaves unblocked.
    • Signals::orphan and has_orphan hand what it still blocks to libc's thread, and the drop kicks it.
    • The new host law covers both the hand-over and "every thread blocks it". The guest ppolls arm covers the ppoll-held quit.
  2. A block excluded no running handler: resolved.
    • RUNNING is published inside the same critical section that decides the delivery.
    • set_mask computes newly under the lock and then waits on RAN with the load, check, wait order. The keeper clears RUNNING before it bumps and wakes RAN, so no wake is lost.
    • The keeper's own set_mask returns early, so it cannot wait on itself.
  3. libc's thread routed: only partly resolved. See blocker 1 below.
  4. Narrowed exits: the text is resolved, but one evidence claim is false.
    • Stage 4's text and 6.2's exit read word for word as approved, apart from rewrapping.
    • Stage 6.2 is now "In progress", and the inheritance arms are listed as open with the owner (item 60, which I don't decide).
    • The six new mutation logs are at 9c6c847, and each reds for its stated reason (two at the test's ceiling, 142).
    • The reverted-stage claim is false. See blocker 2 below.
  5. signal_alarm's margin: resolved. The blocked arm loops on sigpending, and the ignored arm's weakness is stated.
  6. Foreign-thread masks: resolved.
    • Signals::set_mask answers false for a tid it did not register, and the change surfaces as ENOSYS.
    • thread_started is the only way a tid is registered, and the host laws are updated.
  7. Row 20: resolved.

The round-1 suggestions were taken as described: kill now expects, its docs state the subtree, kill(0 | -own_group) raises in self, and the keeper keeps each copy.

Blockers

  1. sdk/libc/src/signal.rs, Call::enter: libc's own thread still takes signals that every program thread blocks.
    • On the keeper, Call::enter takes mask from state.signals.mask(tid). That is 0, because the keeper is never registered.
    • It then registers the keeper as a waiter, and Call::take(0) takes any pending signal.
    • Example: a program blocks SIGTERM in its only thread, and the quit arrives and goes pending. A child then ends, and the canonical SIGCHLD handler runs on the keeper: while (waitpid(-1, &s, WNOHANG) > 0).
    • wait_child always enters a Call and checks take() first, so the handler takes the pending SIGTERM there. Its handler, or its default end, runs despite the block. The same happens for any read, poll or sleep a keeper-run handler makes while a handler is kept.
    • This is the rest of round-1 blocker 3: the keeper's tid is still routed, through waiters.
    • Fix: on the keeper, enter with mask u64::MAX, so it takes nothing and does not count as a waiter that takes. What it would have taken stays pending for the keeper's next turn or for a program thread.
    • Add one host law: a waiter whose mask blocks everything takes nothing, and orphaned is unchanged.
  2. Track 6.2 "Met by libc: a child's end, an alarm and a quit reach a handler #1011", and the PR reply: the reverted-stage control is claimed as behavioural, but it only reds at the build.
    • The record says "with the stage's libc reverted whole the same cases red". sig/r1/stage-reverted-whole.log shows signal_alarm.c:16:17: error: unknown type name 'sig_atomic_t' after 2 s.
    • No case ran, so nothing shows "a parent can only kill".
    • The mutations carry the behavioural controls, so state what was observed. Either:
      • write that the reverted stage fails to build its cases, so the six mutations are the arm-level controls; or
      • make the control behavioural: revert sdk/libc/src but keep the stage's header declarations, and accept link reds only where a case calls a function the stage adds.
    • Whether a build red satisfies the approved "stage reverted whole" control is the orchestrator's call to raise. The record must not describe it as the same cases red.

The alarm(120) hang ceiling in signal_quit

It is acceptable. The root wait rule governs product waits: a wait must expose a stall, and a timeout alone does not end anyone's work. A test ending itself at a ceiling is the test reporting its own stall as a failure, and nothing in it has state to keep. Testing policy also allows QEMU's clock as a hang ceiling, and 120 s sits below the harness's 300 s bound.

As written, though, it adds little over the harness bound. A bare exit 142 does not say which arm stalled; the two mutation logs that red at the ceiling show only "ended Some(142)". It also arms an alarm in the parent under test, which starts libc's own thread there.

Suggestion: say which step stalled, as tty_interrupt's ceiling does (a SIGALRM handler that writes the current role with write(2), then _exit). Otherwise drop it and let the harness bound report the stall.

SEND BACK

A handler run on libc's own thread entered each blocking call it made with
that thread's mask, which no thread registers and so read as empty: the
call took any pending signal, one every program thread blocks among them.
A SIGCHLD handler's waitpid ran a blocked SIGTERM's handler or its default
end. The thread now enters a call blocking every signal, so what it would
have taken stays pending for its next turn or a thread that unblocks it.

signal_quit's hang ceiling names the child it stalled on, and the track
records that the stage reverted whole runs no case: its cases do not build,
so the mutations are the arm controls.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PXEk8BBMDT8tXu4AnENB9Q
@Japabu

Japabu commented Oct 22, 2026

Copy link
Copy Markdown
Collaborator Author

Round 2 answered at bc19cf6b9.

Blocker 1: libc's thread took signals the program blocks. Fixed. On libc's own thread, Call::enter now uses sigreq::KEEPER_MASK (every signal), whatever mask the call names. So a call a handler makes there takes nothing and is no waiter a signal goes pending for. What it would have taken stays pending for its next turn or for a thread that unblocks it. Host law a_call_on_libcs_own_thread_takes_no_signal: with SIGTERM pending and blocked by the only program thread, libc's thread in a call takes nothing, wants no wake, leaves has_orphan unchanged, and SIGCHLD is still delivered to it. Mutation KEEPER_MASK = 0 fails that law (exit 101), and was restored.

Blocker 2: the reverted-stage claim. The record now says what was observed: with the stage's libc reverted whole, the cases do not build, so that control runs no case, and the mutations are the arm-level controls. The coordinator decided a truthful record suffices. Making the revert behavioural was not cheap: three of the six cases call functions the stage adds, and they would fail at the link.

Suggestion, the ceiling: taken. A SIGALRM handler writes signal_quit: stalled driving the child that <role> with write(2) and then _exit(142).

Checks at bc19cf6b9: cargo run -- --ci host exit 0, and libc_children exit 0. Evidence: https://github.com/ToyOSOrg/evidence/tree/a1112d5b5b8751c1c6830bf890ccd3d5f976382c (sig/r2/).

Japabu added a commit that referenced this pull request Oct 22, 2026
Stage 7.1 of the child-process track, stacked on #1011.

An interactive shell hands its surface, the terminal's or the console's, a
duplicate carrying MANAGE (and TRANSFER, which the move takes) of each
process of the line it waits for, and clears it once the line has ended
(toyos::surface::Foreground, MSG_FOREGROUND). The surface turns Ctrl+C
into an interrupt of the line set most recently (Host::interrupt), and
passes the key on as its byte when no line is held. A quit reaches each
process's subtree and kills what never listens, so a pipeline whose stage
started a child ends whole. A -c shell is itself a process of the line
that started it and hands nothing; a shell nested on a line is asked only
at its own prompt, which is why only the most recent line is asked: asking
every line would quit a nested line's processes twice, and the second
quit kills a listener that has not read the first.

The shell and the terminal listen. An interactive shell does not end on
an interrupt; a -c shell ends once what it runs has, with its status, and
ends a line it spawned past an interrupt itself, since a child spawned
after the quit read its tree was not asked. A hang-up or a terminate ends
either once its line has. Closing a terminal's window, or a hang-up or
terminate of the terminal, asks the shell's tree to hang up and waits,
saying every ten seconds what is still outstanding: no timer ends a
process, by the root wait rule.

One copy of toyos takes a process's notice, the descriptor table's owner,
and every copy asks it (toyos_quit_notice), so a Rust program that links
libc listens to one notice.

The track records the owner's 2026-10-22 session ruling, the rule that no
timer ends a process, and restates stage 5 and stage 7 by them.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PXEk8BBMDT8tXu4AnENB9Q
@Japabu

Japabu commented Oct 22, 2026

Copy link
Copy Markdown
Collaborator Author

Review of #1011, round 3 (head bc19cf6)

This round is limited to the round-2 findings and the code added to fix them. The only change since 9c6c847 is bc19cf6 (+58/−7): the keeper mask, one host law, the record text and the signal_quit ceiling.

Round-2 findings

  1. libc's own thread took signals the program blocks: fixed.
    • On the keeper, Call::enter (sdk/libc/src/signal.rs:370) now enters with KEEPER_MASK = u64::MAX, whatever mask the caller passed.
      • take(u64::MAX) takes nothing.
      • Call::drop already skips deliver_pending on the keeper.
      • A waitpid that a SIGCHLD handler makes on the keeper still waits for children (children is unchanged), so the canonical handler loop works and no longer consumes a blocked SIGTERM.
    • The host law a_call_on_libcs_own_thread_takes_no_signal checks the four properties I asked for: nothing is taken, no wake is wanted, has_orphan is unchanged, and SIGCHLD is still delivered to the keeper.
    • r2/mut-keeper-takes.log reds at signal_dispositions.rs:224 (wake_wanted) with exit 101 and is restored. The log's head.log is bc19cf6.
    • The law sets the mask itself rather than going through on_keeper. The three-line match that wires it in is plain on inspection, so that is sufficient.
  2. Reverted-stage control: fixed. Track 6.2 now says the reverted stage does not build its cases (sig_atomic_t), so that control runs no case and the mutations are the arm-level controls. The PR body (line 75) says the same. The reply calls a truthful record sufficient and attributes that decision to the coordinator; I have not checked that attribution.
  3. Ceiling suggestion: taken.
    • stalled writes the role through write(2) and then calls _exit(142). Both are async-signal-safe.
    • step is a volatile pointer that start sets.
    • r2/guest-children.log exits 0 at bc19cf6, so the case still passes with the handler installed.

--ci host at bc19cf6 exits 0 with 93 steps green. The tree status after the run is clean.

Blocker

  1. sdk/libc/src/sigreq.rs:94-101: the new constant was inserted between index's doc comment and index itself.
    • Rust attaches both doc paragraphs to KEEPER_MASK. The constant is documented as "The index of signal signum in a table of [SIGNALS], or EINVAL for a number that names none", which is false. index is left with no documentation.
    • The same hunk is in shell, terminal: Ctrl+C ends the foreground line, and a close hangs up #1017's head (4984b6d), which was rebased onto this one.
    • Fix: move KEEPER_MASK and its comment above index's doc comment, or below index.
    • While moving it, shorten its comment, for example: "The mask of a blocking call on libc's own thread: every signal, so the call takes none and no signal waits for it." The comment in Call::enter then repeats the consequence and can drop to one line or go.

Suggestion

SEND BACK: only for the misplaced doc comment. A move and a compile are enough. No guest or metal run needs repeating.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PXEk8BBMDT8tXu4AnENB9Q
Japabu added a commit that referenced this pull request Oct 22, 2026
Stage 7.1 of the child-process track, stacked on #1011.

An interactive shell hands its surface, the terminal's or the console's, a
duplicate carrying MANAGE (and TRANSFER, which the move takes) of each
process of the line it waits for, and clears it once the line has ended
(toyos::surface::Foreground, MSG_FOREGROUND). The surface turns Ctrl+C
into an interrupt of the line set most recently (Host::interrupt), and
passes the key on as its byte when no line is held. A quit reaches each
process's subtree and kills what never listens, so a pipeline whose stage
started a child ends whole. A -c shell is itself a process of the line
that started it and hands nothing; a shell nested on a line is asked only
at its own prompt, which is why only the most recent line is asked: asking
every line would quit a nested line's processes twice, and the second
quit kills a listener that has not read the first.

The shell and the terminal listen. An interactive shell does not end on
an interrupt; a -c shell ends once what it runs has, with its status, and
ends a line it spawned past an interrupt itself, since a child spawned
after the quit read its tree was not asked. A hang-up or a terminate ends
either once its line has. Closing a terminal's window, or a hang-up or
terminate of the terminal, asks the shell's tree to hang up and waits,
saying every ten seconds what is still outstanding: no timer ends a
process, by the root wait rule.

One copy of toyos takes a process's notice, the descriptor table's owner,
and every copy asks it (toyos_quit_notice), so a Rust program that links
libc listens to one notice.

The track records the owner's 2026-10-22 session ruling, the rule that no
timer ends a process, and restates stage 5 and stage 7 by them.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PXEk8BBMDT8tXu4AnENB9Q
@Japabu
Japabu marked this pull request as ready for review October 22, 2026 19:53
@Japabu
Japabu added this pull request to the merge queue Oct 22, 2026
Merged via the queue into main with commit bd1fdc3 Oct 22, 2026
8 checks passed
@Japabu
Japabu deleted the wt/toyos-proc-signals branch October 22, 2026 20:36
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