Repository navigation
libc: a child's end, an alarm and a quit reach a handler - #1011
Conversation
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
Review of #1011, round 1 (head 29414c5, code 61d2584, on main 81c1386)ArchitectureThe 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 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 Coordinator's question: transport gate sections (§11.1, #1004/#1012)No finding. A handler runs only at libc's Blockers
The stated risks
Tests and evidence
Suggestions
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
…ew round 1; libc_children EXIT=0; six mutations and the stage reverted whole EXIT=1 each; cargo run -- --ci host EXIT=0
|
Round 1 is addressed at 9c6c847, except blocker 4's spawn-inheritance arms, which need a decision (end of this comment). Blockers
Suggestions taken
Merge note. Whichever of S2 (#1012) and this lands second keeps Reruns at 9c6c847
The decision the inheritance arms need. The arms are: a child spawned ignoring 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:
I recommend A, which needs the owner's word because of the ruling. Until then, #1011 refuses a |
Review of #1011, round 2 (head 9c6c847)I checked each round-1 blocker against the code at 9c6c847 and the evidence in Round-1 blockers
The round-1 suggestions were taken as described: Blockers
The
|
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
|
Round 2 answered at Blocker 1: libc's thread took signals the program blocks. Fixed. On libc's own thread, 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 Checks at |
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
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 Round-2 findings
Blocker
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
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
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 ofissues/a-childs-end-is-an-event-and-a-parent-takes-its-children-down.md. It builds on #1001'ssigreq.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.sigactionnow keeps an action whole, so it reads back as it was installed.Where a raised signal runs (
sigreq;signalperforms it):sigpendingshows it.raiseof a blocked signal is now pending rather than #1001'sENOSYS. Masks are kept per kernel tid.libc's own thread replaces the alarm's old keeper. It watches:
SIGCHLDhandler is kept, through copies of their handles;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
ENOSYSand nothing changes. Reasons arrive asSIGINT,SIGHUPorSIGTERM: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.
SA_RESTART, or answerEINTRwithout it:readof a kernel objectrecvandacceptwait,waitpid,wait4EINTR:poll,ppoll,pause,sigsuspend,nanosleep,usleep.sleepanswers 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_IGNonSIGCHLDkeeps no ended child for a wait.New calls:
ppoll,pause,sigpending,sigsuspend,sigdelset,sigismember, and thesig_atomic_ttype.Bounds:
polltakes at most 255 descriptors, leaving room for the wake pipe.Ordinary Rust behaviour is unchanged. A Rust program links
toyos_conly 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
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.SETSIGMASKnaming a signal is refusedENOSYS. 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.toyosand the program's own could both take the notice. Stage 6.3 (std's notice and the ctrlc fork) has to make one owner.issues/an-alarm-reaches-no-sigalrm-handler.mdis deleted; its exit issignal_alarm.Checks and evidence
Review round 2, at
bc19cf6b9(measured head):KEEPER_MASK, every signal blocked, so a handler running there (a SIGCHLD handler'swaitpid, say) takes no signal a program thread blocks. Host lawa_call_on_libcs_own_thread_takes_no_signal.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.sig/r2/)Review round 1, at
9c6c847ad:libc_children: exit 0.cargo run -- --ci host: exit 0.sig_atomic_tis undeclared), so it ran no case and is not a behavioural control. The track says so.sig/r1/)The original run:
Unless a line says otherwise, at code head
61d25840e, on main81c1386cf. The head29414c592adds 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 underSIG_IGN, thenECHILD.signal_sigchld_ppoll: oneSIGCHLD;EINTR; then the readable pipe; then a block until a live second child writes.signal_alarm: a handler interruptswait4; a blocked alarm stays pending until its unblock ends the process 142; an ignored one is dropped.signal_quit:EINTRon an interrupt; a hang-up reloads and a terminate ends; an ignoring child runs on; a blocking child ends 130 onsigsuspend; LLVM's restore-and-raise handler ends a busy child 130.libc_read_restarts: areadacross aSIGCHLDhandler, ordered by the kernel's roster, restarts withSA_RESTARTand answersEINTRwithout it.libccase.toml's test-runner gainsrosteranddupfor it.libc_childrenand 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_RESTARTread answersEINTR.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.spawn_children_cap: 255th refused, a poll of 255 answers, and one of 256 isEINVAL.spawn_kill_group:SIGUSR1instead ofSIGTERMas the unsent signal.213_honest_answers: the refused handlers are nowSIGUSR1andSIGUSR2, and ignoringSIGCHLDis kept.Evidence: https://github.com/ToyOSOrg/evidence/tree/6bdc00b4c9ee09e2acae4753d29e4fb9b4151c9e (
sig/)Stage 7
Stage 7 cannot be added cheaply here. It needs:
MANAGEduplicates over the surface port;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) measuresposix_spawnpluswaitpid. Each wait now enters an interruptible call: one lock, a wake pipe made once, and one more watched handle. This is unmeasured.read, sockets, waits and sleeps. A read of a file a server serves, or of a console, is not interrupted. Both are stated atread.issues/toyos-runs-on-arm64.mdstage 8.🤖 Generated with Claude Code
https://claude.ai/code/session_01PXEk8BBMDT8tXu4AnENB9Q