Skip to content

A child's end is readiness on its Process handle: OP_WATCH answers at the published exit, and no close ends another handle's watch - #648

Merged
Japabu merged 12 commits into
mainfrom
wt/toyos-proclife1
Oct 2, 2026
Merged

Japabu merged 12 commits into
mainfrom
wt/toyos-proclife1

Conversation

@Japabu

@Japabu Japabu commented Sep 30, 2026 •

Copy link
Copy Markdown
Collaborator

Stage 1 of issues/kernel/a-childs-end-is-an-event-and-a-parent-takes-its-children-down.md, the kernel's half, as ruled: an OP_WATCH READABLE on a Process handle (WAIT) is answered once the exit is published, closing one handle ends no other handle's watch, and the ABI gains nothing. Nothing under toyos-abi/, toyos/ or userland/libc/ changes.

A process that holds a child's handle now waits for its end in the poller it already waits in, beside its pipes and ports. On main that watch is refused -NotSupported, and the only wait that blocks is a thread parked in SYS_PROCESS_WAIT per child.

init's waiter threads are the other half of the stage and are not in this pull request. The track's stage 1 is cut to them.

What changed, per decision

  • ops::read_watch answers a Process with its watch, and ops::has_data with ProcessObject::finished (kernel/src/object/ops.rs).
    • On inbox: a watch is answered after a look at its object, never by a post #655's contract a post only owes a poll a look, and the ring's own submitter writes the answer after reading has_data again. So finished is the answer of every watch on a process.
    • The watch is what lets a watch on a live child register: inbox::arm refuses -NotSupported a poll with no readiness and no watch.
    • publish_exit stores finished before it posts, as it already did for SYS_PROCESS_WAIT's predicate. The look rests on the same order, and nothing else posts that watch.
    • read_posts_are_readiness keeps a Process in main's false arm, unchanged: the kernel holds the fact the look reads, so a post is not the readiness.
  • ops::close_ends_polls answers false for a Process.
  • ProcessObject's watch is an Arc<Watch>, as a port's is (kernel/src/object/process.rs). WatchRef has no lifetime, so read_watch answers a share. SYS_PROCESS_WAIT and the test kernel's spawn hold arm on the same watch through it.
  • init. close_when_it_ends's doc said a thread was needed because the kernel answered a process's end "to nothing a poll can watch". That paragraph is deleted. The threads are unchanged.
  • The track. Stage 1 is cut to what is left of it, init's waiter threads. Its exit names what the conversion keeps of close_when_it_ends, and what reads each:
    • a restart row that ends is started again on the ports it kept;
    • a row without one has its acceptors closed, so a client's next connect is Gone;
    • a swap's expected end leaves them open for the binary after it. src/metalswap.rs's judge reads this one, on the T14 through toyos-metal --swap.
    • No test reads the first two. git grep over tests/ and src/ for init's "started again" and "its ports are closed", for ServerGone and for sshd's "no network on this machine" finds none. netd_gone_mid_bind closes a port of its own and never reaches init.
  • Filed: issues/kernel/a-watch-outlives-the-close-of-the-process-handle-it-was-made-through.md. A close that may end no other handle's watch cannot end its own handle's either, so that watch is kept until the child ends and answered -NotFound then. Measured; see "Not sure of". It names issues/kernel/a-close-of-one-handle-ends-every-rings-poll-on-its-object.md, whose first exit would keep the very poll this one's exit ends: one change answers both.

What the merges changed

The branch was one commit, 60ec86df3, on 649ea51d4 (#641).

Tests

  • process_lifecycle, a member of the T14's shared boot, gains four arms. Every child is held on its stdin, so an end is ordered by the test and by no clock; every blocking wait is u64::MAX, bounded by the harness's hang ceiling.
    • each_end_completes_its_own_watch: three children in one Poller, each watched once under its index, released in the order 1, 2, 0. Before each release a non-blocking submit finds nothing. After it a blocking wait answers exactly that child's token, and try_wait reads its code.
    • a_watch_on_an_ended_child_completes_at_once: a child already waited for and a held one, watched together. One non-blocking submit answers the ended child's token alone.
    • a_kill_completes_a_watch: a raw ring of one watch on a held child. Nothing completes; the child is killed; the one completion's result word is READABLE, and try_wait reads 137. Poller hands out a completion's token and never its result, so this arm reads the completion itself (watch_result_across).
    • closing_one_handle_ends_no_other_watch: the child's handle is watched and a duplicate of it closed. A non-blocking submit finds nothing; after the release the watch answers and try_wait reads 17.
  • Tiers.
    • No type holds these: each is what a wait hands back at run time.
    • No host test reaches them: ops::read_watch, has_data and close_ends_polls, and inbox::arm that reads them, name the kernel and compile in no host crate. kernel-loom compiles polls.rs against a fake look.
    • So the four arms run on the T14, in a shared boot that already exists. No QEMU guest test is added.
  • What the arms see that reading cannot: which ring, token and result word the kernel hands back when a registration, a publish, a kill and a close run in the order the test sets.

Gates at 209891cac

One script, from the clean committed head; the tree is clean after (gates-results-209891cac.txt).

gate exit
cargo run -- --ci host 0 "Host: 67 step(s), all green"
cargo run -- --build-only 0
cargo test --test toyos-build, the QEMU suite 0 "26 passed, 26 total"; process_lifecycle is not among them
cargo test -p toyos-proclife 0 48 passed

High-risk checks

This is the kernel's object and wait path. Every run below is one script that checks its patch, applies it, runs, reverses it and shows the tree clean. The patches, scripts and result files: #648 (comment) for 7bf868ef4, #648 (comment) for 209891cac.

process_lifecycle in one QEMU guest

process_lifecycle has no QEMU registration, so a harness patch that is in no commit (local-qemu.patch) boots tests/testcases with 8 vCPUs and runs that one member. A guest exit code is the image having built and booted. Exit is cargo test --test toyos-build -- zz_local_process_lifecycle's.

The branch's kernel diff at 209891cac is byte for byte its kernel diff at 7bf868ef4 (git diff origin/main...209891cac -- kernel against git diff c59e09ed6...7bf868ef4 -- kernel, cmp exit 0), and #651 changes nothing under kernel/src/object/. So the head and the whole-change control are measured at 209891cac, and m1 to m5 stand from 7bf868ef4.

tree at exit the guest
the head 209891cac 0 exit 0, all eleven arms' lines
negative control, nc-whole: kernel/ and userland/init as on origin/main a93067fc2, the test kept 209891cac 1 exit 101 at the first arm: "child 0 is held, and its watch completed" (:270). The watch is refused -NotSupported, whose completion the first submit hands out
m1: close_ends_polls answers true for a Process 7bf868ef4 1 exit 101 at the last arm: "closing a second handle answered the first's watch (0)"
m2: has_data answers false for a Process 7bf868ef4 1 no exit: the five arms before the first watch arm print, and the harness's ceiling ends the run, "timed out after 960s" on a host it scaled 8.00x
m3: read_watch answers None for a Process 7bf868ef4 1 exit 101 at the first arm: "child 0 is held, and its watch completed"
m4: has_data answers true for a Process 7bf868ef4 1 exit 101 at the first arm, the same line: a held child reads as ended
m5: publish_exit cancels its watch's polls before it posts 7bf868ef4 1 exit 101 at the kill arm: "the killed child's watch", left -1, right 1
  • m2's red is a hang, a completion verdict.
  • m5 is the mutation the kill arm's raw ring exists for: an end answered -NotFound.
  • nc-whole is byte for byte git diff 209891cac a93067fc2 -- kernel userland/init.

Oracles

  • pidfd_open(2): "A PID file descriptor can be monitored using poll(2), select(2), and epoll(7). When the process that it refers to terminates, these interfaces indicate the file descriptor as readable."
    • Readable at the end: the first arm and the kill arm, which reads the word.
    • Readable at once for a process already ended: the second arm.
    • Closing one of two descriptors ends no interest held through the other: the last arm. epoll(7) Q6/A6 removes an interest only once every descriptor of its open file description is closed.
  • The T14, below.

The T14

The T14 booted four images of 209891cac once each, run by the orchestrator (the boots and judges): each image's sha256 checked before it was flashed, each toyos-metal exit 0; process exit 0 (PASS process_tree, 3 passed, 0 failed, 2 boots, the four arms' lines in order), spawn_child_ends_first exit 0, and the control's process_lifecycle exit 1 (exited 101 on the T14, child 0 is held, and its watch completed at process_lifecycle.rs:270:35). They were staged, each by cargo test --test toyos-build -- --metal --metal-readback <dir> <filter>, exit 2 (staged) each, the tree clean after; /Users/jan/.claude/jobs/2280e09e/tmp/scratchpad/orch/648-r2/metal/request.txt lists the boots, the judges in order and what each must read and exit.

boot tree members image sha256
process / proctreecase head process_tree f04f5c2fa200a9de5815c5b806487a2636a28fd186f60fd3d93f4ee428e175ef
process / shared head process_lifecycle, std_process 0886f151467517c8e0cb713bdfa877d18c8f0ea67e030eb9587b856a72e6045e
spawn_child_ends_first / shared-debug head spawn_child_ends_first 14dea088cca20e67e91bc87b54aa47011348d60e1406e18bd3625f78995bbde5
process_lifecycle / shared head and nc-whole process_lifecycle 782f6b19cabc3449fbd842e61ffd9788efc122c6a5a13aeb49c83dec41820cad
  • The first three are the rows the diff reaches: the new arms, and the two other callers of ProcessObject::watch(), whose type changed. They are SYS_PROCESS_WAIT, which std_process and process_tree run, and the test kernel's spawn hold, which spawn_child_ends_first runs.
  • The fourth is the negative control as a mutated image. Its judge must exit 1 on test_rs_process_lifecycle exited 101 on the T14.

Not sure of

  • The T14. The four arms' only committed home is the T14's shared boot. The mutations other than the whole-change control (m1 to m5) are QEMU's alone, at 7bf868ef4.
  • A watch outlives the close of its own handle. Measured at 81e68535c in one QEMU guest with a scratch arm that is in no commit: a watch on a duplicate of a held child's handle answers nothing at the submit after the duplicate's close, and answers token 7 with result -1 once the child ends. Filed. No committed arm closes a watched handle.
  • Three arms read tokens, not results. Only the kill arm reads a completion's result word. A refusal's token arriving when an end is expected would pass the other three; issues/kernel/a-close-of-one-handle-ends-every-rings-poll-on-its-object.md's exit, a completion's result reaching Poller's caller, is what would let them read it.
  • m2's wait is not shown parked. Its guest is "still talking" at the ceiling because on a shared boot the kernel itself prints on a cadence (GUEST_QUIET's doc, tests/common/qemu.rs), which says nothing of the test's thread. By reading, the submitter parks: the look arms a new poll on a watch nothing posts again.
  • The track's stage 1 text is mine. I cut it to what is left and wrote that remainder's exit; the owner's ruling above it is untouched. That no test reads a restart or a close to Gone rests on a git grep, not on a run.

Net lines

git diff --shortstat origin/main...209891cac: 6 files, +178 −24.

  • Production +16 −13: the three match arms and their docs, and the watch's Arc (kernel +16 −9); one paragraph of init's doc deleted (−4).
  • Tests +124 −1: the four arms and the raw ring.
  • Issues +38 −10.

🤖 Generated with Claude Code

https://claude.ai/code/session_013UDZQ6fSKw14e4w2TKTRfm

Stage 1 of issues/kernel/a-childs-end-is-an-event-and-a-parent-takes-its-children-down.md,
as ruled: an OP_WATCH READABLE on a Process completes once the exit is
published, closing one handle ends no other's watch, and the ABI gains
nothing.

- ProcessObject's watch becomes an Arc<Watch>, as a port's is, so
  ops::read_watch can answer a share of it; SYS_PROCESS_WAIT arms on the
  same watch and the exit's publish is still its one post.
- ops::read_watch answers a Process with that watch and ops::has_data with
  ProcessObject::finished, so a registration on a child already gone
  completes in the submit that makes it and one before the end is
  completed by the publish.
- ops::close_ends_polls answers false for a Process: a process is ended by
  its own end, never by one holder letting a handle go.

process_lifecycle gains four arms: three held children in one poller, let
go one at a time, each completion naming the child just released and its
code there for try_wait; a watch on a child already gone completing at once
beside a held one that does not; a kill completing a watch; and a second
handle closed while the first is watched, a non-blocking submit finding
nothing until the child is let go.

init's service waiters still park a thread each on SYS_PROCESS_WAIT; the
sentence giving the reason, that no poll could watch a process's end, is
no longer true and goes. Moving them onto init's poller is the rest of
stage 1.

Negative control: the change reverted whole, where a watch on a Process is
refused NotSupported and every watch in the first arm completes at the first
submit. Mutations: close_ends_polls answering true for a Process reds the
close arm; has_data answering false reds the already-gone arm. Oracle:
pidfd_open(2), whose descriptor polls readable once the process it refers
to terminates.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016t9wjdQkB8SH7bmfUoiy6L
Japabu and others added 7 commits October 2, 2026 23:10
The branch was at 649ea51 (#641). Three landings since sit under it:
#642 (dc8212c), #659 (c1c5048) and #655 (5daab30).

Two content conflicts, each main deleting what this branch's hunk stood
beside:

- kernel/src/object/ops.rs, close_ends_polls: #655 deleted the log's and
  the keyboard's close actuators, whose two arms this branch's
  `Process(_) => false` sat between. Main's two `false` arms stand and
  the process's is a third.
- tests/toyos-rust-tests/src/bin/process_lifecycle.rs, the imports: #642
  deleted `toyos::AsHandle` with the pid arm, its one user; this branch's
  `toyos::poller` import stands alone.

Everything else merged by itself: #642's deletions in
kernel/src/object/process.rs beside this branch's `Arc<Watch>`, #659's
init changes beside the one doc sentence this branch deletes, and the
`rust` gitlink at main's 95960d6c214.

This commit is the resolution and nothing else. What #655's contract
changes in this branch's own lines is the next commit's.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013UDZQ6fSKw14e4w2TKTRfm
60ec86d was written against a kernel where an object's post completed
every poll on its watch: the publish's post was the answer to a watch on a
child, and has_data's Process arm mattered only to a registration made
after the end. #655 took the answer from the post. A post fires a poll,
which owes it a look, and the ring's own submitter writes the completion
after reading ops::has_data again; an object not ready at the look is
armed again (kernel/src/inbox/mod.rs, Submitter::look).

Nothing this branch added became unnecessary. What each line is for
moved:

- has_data's Process arm, ProcessObject::finished, is what the look of
  every watch on a process reads, not the late registration's alone.
- read_watch's Process arm is what lets a watch on a live child register:
  inbox::arm refuses NotSupported a poll with no readiness and no watch.
- read_posts_are_readiness keeps a Process in main's false arm, with no
  change here: the kernel holds the fact the look reads, so the post is
  not the readiness.
- close_ends_polls answers false for a Process, as before: ops::close
  cancels every poll on the read watch of a kind it answers true for.

publish_exit stores `finished` before it posts, as it already did for
SYS_PROCESS_WAIT's predicate; the look rests on the same order.

The prose that had the post answer goes: read_watch's comment, and the
sentence of process.rs's header in which the publish answered the watch.
The watch's field doc is main's sentence with the reason for its own Arc.

process_lifecycle's first arm watches each child once, not again each
round. On #655's kernel a watch replaces its handle's earlier poll, so
the re-watch made every completion the newest registration's; watched
once, the arm also holds that a standing poll outlives two other
children's ends.

The track's stage 1 is cut to what is left of it: init's waiter threads.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013UDZQ6fSKw14e4w2TKTRfm
… and by no close

The model held a process's watch and its handles and scripted nothing
that armed on one: publish_exit posted Watch::Process to no waiter, so no
law could red on what answers a watch on a child.

Op::Watch is OP_WATCH on a holder's handle to a process, cut where the
kernel's registration is: the poll registered on the object's watch, then
the look inbox::arm owes it, which answers an end published in the
window. L14, checked at every state: a wait or a watch on a process is
answered only with that process's exit published.

a_watch_on_a_process_is_answered_by_its_end_and_by_no_close runs every
ordering of a parent's watch on its child, the child closing its own
`self`, and a kill of the child. L4 holds the watch answered once the end
is published, registered before it or after; L14 holds it unanswered
until then.

mutate-close-ends-a-process-watch is its control, in src/ci.rs's
CONTROLS: the model's close posts the watch of the process its handle
named, as ops::close does for a kind close_ends_polls answers true for,
which is what main answers for a Process. Under it the child's close
answers its parent's watch with the child still running.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013UDZQ6fSKw14e4w2TKTRfm
`Poller` hands a completion's token to its caller and never its result,
and hands out a refusal's token as it does a ready one's. So an end that
answered a watch on a child `-NotFound`, as a watch whose source is gone,
passed every arm: each saw a token at the right time.

The kill arm takes one raw ring of its own (`watch_result_across`),
submits the watch, sees nothing complete, kills the child, and reads the
one completion whole: its result is `READABLE`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013UDZQ6fSKw14e4w2TKTRfm
Main moved during the round: #657 (8f6abbc) and #649 (c59e09e) landed
after b102097 merged 5daab30. No conflict, and no file is changed on
both sides since 5daab30; the `rust` gitlink is the same on both.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013UDZQ6fSKw14e4w2TKTRfm
And says what the kill arm now reads: the completion's result.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013UDZQ6fSKw14e4w2TKTRfm
…hrough

close_ends_polls answers false for a Process so that one holder's close
ends no other's watch, and a close has no way to end only the polls made
through its own handle. A watch whose handle closes is therefore kept
until the child ends, and answered -NotFound then.

Measured at 81e6853 in one QEMU guest, with a scratch arm that is in no
commit (x-scratch-closed-handle.patch, posted on the pull request): a
watch on a duplicate of a held child's handle, the duplicate closed:

  the watch on handle 4102: Ok(0)
  after its close: Ok(0)
  after the child's end: Ok(1), token 7 result -1

The same arm with a pipe made after the close read the same -1: a handle
carries its slot's generation, so the pipe's ends were 16392 and 11 where
the closed handle was 12296, and the look found the closed one stale. A
poll does not move to whatever takes its slot.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013UDZQ6fSKw14e4w2TKTRfm
@Japabu

Japabu commented Oct 2, 2026

Copy link
Copy Markdown
Collaborator Author

Evidence for 7bf868ef4: the patches this round applied, the scripts that applied them, and what each run came to. Every result is the command's own exit code, written by a script that checks its patch, applies it, runs, reverses it and shows the tree clean. Logs: /Users/jan/.claude/jobs/2280e09e/tmp/scratchpad/orch/648-round/.

The gates (gates-results-7bf868ef4.txt)

HEAD 7bf868ef44ca30ed15a449c5ca73ecb5501fa9b0
status before: []
load before: 23:50  up 3 days, 11:34, 3 users, load averages: 34.72 31.76 24.15
RESULT cargo run -- --ci host | EXIT=0
RESULT cargo run -- --build-only | EXIT=0
RESULT cargo test --test toyos-build | EXIT=0
RESULT cargo test -p toyos-proclife | EXIT=0
load after:  0:00  up 3 days, 11:44, 3 users, load averages: 58.92 46.11 35.54
status after: []

process_lifecycle in one QEMU guest, the head and each mutation (local-qemu-results.txt)

HEAD 7bf868ef44ca30ed15a449c5ca73ecb5501fa9b0
RESULT head | HEAD 7bf868ef4 (clean after) | mutation none | load 48.18 44.85 35.51 | cargo test --test toyos-build -- zz_local_process_lifecycle | EXIT=0
  guest: exit Some(0) error None
RESULT nc-whole | HEAD 7bf868ef4 (clean after) | mutation nc-whole | load 30.52 39.53 35.22 | cargo test --test toyos-build -- zz_local_process_lifecycle | EXIT=1
  guest: exit Some(101) error None
  thread 'main' (1) panicked at src/bin/process_lifecycle.rs:272:35:
  child 0 is held, and its watch completed
RESULT m1 | HEAD 7bf868ef4 (clean after) | mutation m1-close-ends-a-process-watch | load 26.06 37.74 34.69 | cargo test --test toyos-build -- zz_local_process_lifecycle | EXIT=1
  guest: exit Some(101) error None
  thread 'main' (1) panicked at src/bin/process_lifecycle.rs:359:31:
  closing a second handle answered the first's watch (0)
RESULT m3 | HEAD 7bf868ef4 (clean after) | mutation m3-a-process-has-no-read-watch | load 22.14 36.31 34.24 | cargo test --test toyos-build -- zz_local_process_lifecycle | EXIT=1
  guest: exit Some(101) error None
  thread 'main' (1) panicked at src/bin/process_lifecycle.rs:272:35:
  child 0 is held, and its watch completed
RESULT m4 | HEAD 7bf868ef4 (clean after) | mutation m4-a-process-is-always-readable | load 22.73 35.77 34.08 | cargo test --test toyos-build -- zz_local_process_lifecycle | EXIT=1
  guest: exit Some(101) error None
  thread 'main' (1) panicked at src/bin/process_lifecycle.rs:272:35:
  child 0 is held, and its watch completed
RESULT m5 | HEAD 7bf868ef4 (clean after) | mutation m5-an-end-answers-its-watches-as-gone | load 38.72 38.42 35.08 | cargo test --test toyos-build -- zz_local_process_lifecycle | EXIT=1
  guest: exit Some(101) error None
  thread 'main' (1) panicked at src/bin/process_lifecycle.rs:309:5:
  assertion `left == right` failed: the killed child's watch
RESULT m2 | HEAD 7bf868ef4 (clean after) | mutation m2-a-process-is-never-readable | load 12.80 17.70 26.82 | cargo test --test toyos-build -- zz_local_process_lifecycle | EXIT=1
  guest: exit None error Some(WaitVerdict("timed out after 960s, with the guest still talking 8s ago (1435 console line(s) while it ran) — it was working and did not finish"))
status after all: []

The model, and the lint control of its new feature (host-mutation-results.txt)

HEAD 7bf868ef44ca30ed15a449c5ca73ecb5501fa9b0
RESULT head | HEAD 7bf868ef4 (clean after) | patch none | cargo test -p toyos-proclife  | build EXIT=0 | run EXIT=0 | red: 
RESULT control | HEAD 7bf868ef4 (clean after) | patch none | cargo test -p toyos-proclife --features mutate-close-ends-a-process-watch | build EXIT=0 | run EXIT=101 | red: interleave::tests::a_watch_on_a_process_is_answered_by_its_end_and_by_no_close 
RESULT h1 | HEAD 7bf868ef4 (clean after) | patch h1-a-watch-registers-without-its-look | cargo test -p toyos-proclife  | build EXIT=0 | run EXIT=101 | red: interleave::tests::a_watch_on_a_process_is_answered_by_its_end_and_by_no_close 
RESULT l1 | HEAD 7bf868ef4 (clean after) | patch l1-a-forget-in-the-control-arm | cargo run -- --clippy | EXIT=1
  6:error: use of a disallowed method `core::mem::forget`
  7-   --> toyos-proclife/src/interleave.rs:397:21
  8-    |
  9:397 |                     core::mem::forget(0u8);
  10-    |                     ^^^^^^^^^^^^^^^^^
  11-    |
  12-    = note: a resource nobody gives back is a leak unless its site says why
  --
  17:error: calls to `std::mem::forget` with a value that implements `Copy` does nothing
  18-   --> toyos-proclife/src/interleave.rs:397:21
  19-    |
  20:397 |                     core::mem::forget(0u8);
  21-    |                     ^^^^^^^^^^^^^^^^^^---^
  22-    |                                       |
  23-    |                                       argument has type `u8`
  --
  25:    = note: `-D forgetting-copy-types` implied by `-D warnings`
  26:    = help: to override `-D warnings` add `#[allow(forgetting_copy_types)]`
  27-help: use `let _ = ...` to ignore the expression or result
  28-    |
  29:397 -                     core::mem::forget(0u8);
  30-397 +                     let _ = 0u8;
  31-    |
  32-
  --
  42:error: use of a disallowed method `core::mem::forget`
  43-   --> toyos-proclife/src/interleave.rs:397:21
  44-    |
  45:397 |                     core::mem::forget(0u8);
  46-    |                     ^^^^^^^^^^^^^^^^^
  47-    |
  48-    = note: a resource nobody gives back is a leak unless its site says why
  --
  53:error: calls to `std::mem::forget` with a value that implements `Copy` does nothing
  54-   --> toyos-proclife/src/interleave.rs:397:21
  55-    |
  56:397 |                     core::mem::forget(0u8);
  57-    |                     ^^^^^^^^^^^^^^^^^^---^
  58-    |                                       |
  59-    |                                       argument has type `u8`
  --
  61:    = note: `-D forgetting-copy-types` implied by `-D warnings`
  62:    = help: to override `-D warnings` add `#[allow(forgetting_copy_types)]`
  63-help: use `let _ = ...` to ignore the expression or result
  64-    |
  65:397 -                     core::mem::forget(0u8);
  66-397 +                     let _ = 0u8;
  67-    |
  68-
status after all: []

The T14 stagings (stage-results.txt; exit 2 is "staged", and no machine was touched)

HEAD 7bf868ef44ca30ed15a449c5ca73ecb5501fa9b0
RESULT cargo test --test toyos-build -- --metal --list process | EXIT=0
RESULT cargo test --test toyos-build -- --metal --list spawn_child_ends_first | EXIT=0
RESULT head-process | HEAD 7bf868ef4 (clean after) | patch none | cargo test --test toyos-build -- --metal --metal-readback /Users/jan/.claude/jobs/2280e09e/tmp/scratchpad/orch/648-round/metal/head-process process | EXIT=2
  sha256 6cca382d3a1257b8b3369966ade2906c1fe1f0e57e987eb3dfb23c1a678631b1  /Users/jan/.claude/jobs/2280e09e/tmp/scratchpad/orch/648-round/metal/head-process/proctreecase/image.img
  sha256 e8d2b0e602492435567ebdfa727419319202dae035ec26f77a48812a157e2feb  /Users/jan/.claude/jobs/2280e09e/tmp/scratchpad/orch/648-round/metal/head-process/shared/image.img
RESULT head-spawn_child_ends_first | HEAD 7bf868ef4 (clean after) | patch none | cargo test --test toyos-build -- --metal --metal-readback /Users/jan/.claude/jobs/2280e09e/tmp/scratchpad/orch/648-round/metal/head-spawn_child_ends_first spawn_child_ends_first | EXIT=2
  sha256 e24c43365ebadebda10a2bcdbeafa48c888f72322270f2f6fe1323250c6454c0  /Users/jan/.claude/jobs/2280e09e/tmp/scratchpad/orch/648-round/metal/head-spawn_child_ends_first/shared-debug/image.img
RESULT nc-process_lifecycle | HEAD 7bf868ef4 (clean after) | patch nc-whole | cargo test --test toyos-build -- --metal --metal-readback /Users/jan/.claude/jobs/2280e09e/tmp/scratchpad/orch/648-round/metal/nc-process_lifecycle process_lifecycle | EXIT=2
  sha256 5de00dcf19e846acbe11dc1094b90f4a576e70517e04f62931a2edaa6f1327a6  /Users/jan/.claude/jobs/2280e09e/tmp/scratchpad/orch/648-round/metal/nc-process_lifecycle/shared/image.img
status after all: []

The closed handle, measured at 81e68535c (scratch-closed-handle-results.txt, and the arm's lines from local-qemu-scratch-closed-handle.log)

RESULT scratch-closed-handle | HEAD 81e68535c (clean after) | mutation x-scratch-closed-handle | load 44.45 32.63 23.76 | cargo test --test toyos-build -- zz_local_process_lifecycle | EXIT=0
  guest: exit Some(0) error None
SCRATCH reuse=false: the watch on handle 4102: Ok(0)
SCRATCH reuse=false: after its close: Ok(0)
SCRATCH reuse=false: after the child's end: Ok(1), token 7 result -1
SCRATCH reuse=true: the watch on handle 12296: Ok(0)
SCRATCH reuse=true: after its close: Ok(0)
SCRATCH reuse=true: the pipe's ends are handles 16392 and 11
SCRATCH reuse=true: 2 s after the child's end: Ok(1)
SCRATCH reuse=true: after a byte into the pipe: Ok(1), token 7 result -1

The patches

nc-whole.patch
diff --git a/kernel/src/object/ops.rs b/kernel/src/object/ops.rs
index d734e5960..8eb9d79f7 100644
--- a/kernel/src/object/ops.rs
+++ b/kernel/src/object/ops.rs
@@ -272,7 +272,6 @@ pub fn read_watch(object: &KObjectRef) -> Option<WatchRef> {
         KObjectRef::PipeRead(r) => pipe::read_watch(r.id()).map(WatchRef::Shared),
         KObjectRef::Connection(c) => pipe::read_watch(c.rx()).map(WatchRef::Shared),
         KObjectRef::Acceptor(a) => Some(WatchRef::Shared(a.watch().clone())),
-        KObjectRef::Process(p) => Some(WatchRef::Shared(p.watch().clone())),
         KObjectRef::Console(_) => Some(WatchRef::Static(&keyboard::WATCH)),
         KObjectRef::Device(d) => match d.class() {
             device_registry::DeviceType::Keyboard => Some(WatchRef::Static(&keyboard::WATCH)),
@@ -291,7 +290,7 @@ pub fn read_watch(object: &KObjectRef) -> Option<WatchRef> {
         KObjectRef::SysCap(_) => Some(WatchRef::Static(&crate::log::user::WATCH)),
         KObjectRef::PipeWrite(_) | KObjectRef::File(_) | KObjectRef::Inbox(_)
         | KObjectRef::Connector(_) | KObjectRef::Namespace(_)
-        | KObjectRef::SharedMem(_) => None,
+        | KObjectRef::SharedMem(_) | KObjectRef::Process(_) => None,
     }
 }
 
@@ -312,14 +311,11 @@ pub fn write_watch(object: &KObjectRef) -> Option<WatchRef> {
 /// Whether closing one handle to this object ends what its watches watch, so
 /// every poll on them — in any ring — is answered as gone. `false` for the log
 /// and the keyboard, which the machine ends on its own and which other handles
-/// share: a console closing is not every console's keyboard going away. `false`
-/// for a process, which only its own end ends: closing one handle to it ends no
-/// other's watch.
+/// share: a console closing is not every console's keyboard going away.
 fn close_ends_polls(object: &KObjectRef) -> bool {
     match object {
         KObjectRef::SysCap(_) => false,
         KObjectRef::Console(_) => false,
-        KObjectRef::Process(_) => false,
         KObjectRef::Device(d) => match d.class() {
             device_registry::DeviceType::Keyboard => false,
             device_registry::DeviceType::Mouse
@@ -332,7 +328,7 @@ fn close_ends_polls(object: &KObjectRef) -> bool {
         KObjectRef::PipeRead(_) | KObjectRef::PipeWrite(_) | KObjectRef::Connection(_)
         | KObjectRef::Acceptor(_) | KObjectRef::File(_) | KObjectRef::Inbox(_)
         | KObjectRef::Connector(_) | KObjectRef::Namespace(_)
-        | KObjectRef::SharedMem(_) => true,
+        | KObjectRef::SharedMem(_) | KObjectRef::Process(_) => true,
     }
 }
 
@@ -759,7 +755,6 @@ pub fn has_data(object: &KObjectRef) -> bool {
         KObjectRef::Connection(c) => pipe::has_data(c.rx()),
         KObjectRef::Console(_) => serial::has_data(),
         KObjectRef::Acceptor(a) => a.has_pending(),
-        KObjectRef::Process(p) => p.finished(),
         KObjectRef::File(_) => true,
         KObjectRef::Device(d) => match d.class() {
             device_registry::DeviceType::Keyboard => keyboard::has_data(),
@@ -778,7 +773,7 @@ pub fn has_data(object: &KObjectRef) -> bool {
         },
         KObjectRef::PipeWrite(_) | KObjectRef::Inbox(_) | KObjectRef::SysCap(_)
         | KObjectRef::Connector(_) | KObjectRef::Namespace(_)
-        | KObjectRef::SharedMem(_) => false,
+        | KObjectRef::SharedMem(_) | KObjectRef::Process(_) => false,
     }
 }
 
diff --git a/kernel/src/object/process.rs b/kernel/src/object/process.rs
index 45ec53263..3b6516311 100644
--- a/kernel/src/object/process.rs
+++ b/kernel/src/object/process.rs
@@ -2,9 +2,8 @@
 //!
 //! The exit code lives on the object, not a table entry: no zombie, no reap,
 //! no orphan adoption. A wait after the fact reads a value; a wait before it
-//! parks and is woken by the publish. An `OP_WATCH` reads the same fact at its
-//! look, so a handle is readable from the publish on. A process nobody holds a
-//! handle to disappears.
+//! parks and is woken by the publish. A process nobody holds a handle to
+//! disappears.
 
 use alloc::sync::Arc;
 use core::sync::atomic::{AtomicBool, Ordering};
@@ -31,8 +30,7 @@ pub struct ProcessObject {
     /// The same fact, without the lock, for a waiter's per-wake predicate.
     finished: AtomicBool,
     /// What `SYS_PROCESS_WAIT` arms on; holding the `Arc` across the park keeps the watch from outliving its subject.
-    /// An `Arc` of its own, as a port's is, for the share `ops::read_watch` answers.
-    watch: Arc<Watch>,
+    watch: Watch,
 }
 
 impl ProcessObject {
@@ -42,7 +40,7 @@ impl ProcessObject {
             pid,
             exit: Lock::new(None),
             finished: AtomicBool::new(false),
-            watch: Arc::new(Watch::new()),
+            watch: Watch::new(),
         })
     }
 
@@ -63,7 +61,7 @@ impl ProcessObject {
         self.exit.lock().as_ref().map(|e| e.stats)
     }
 
-    pub fn watch(&self) -> &Arc<Watch> {
+    pub fn watch(&self) -> &Watch {
         &self.watch
     }
 
diff --git a/userland/init/src/main.rs b/userland/init/src/main.rs
index b2344e0c7..3e55817b7 100644
--- a/userland/init/src/main.rs
+++ b/userland/init/src/main.rs
@@ -693,7 +693,9 @@ impl std::fmt::Display for StartError {
 /// Wait for one start of a service to end, and close its ports if nothing was
 /// expecting it to, or wake the loop to start it again if its row says so.
 ///
-/// It parks in the kernel for the process's life and costs nothing until then.
+/// **A thread, because the kernel answers a process's end to a wait and to
+/// nothing a poll can watch.** It parks in the kernel for the process's life
+/// and costs nothing until then.
 fn close_when_it_ends(kept: &Mutex<Kept>, generation: u64, process: toyos::RawHandle) {
     let _ = toyos_abi::syscall::process_wait(process);
     toyos_abi::syscall::close(process);
m1-close-ends-a-process-watch.patch
--- a/kernel/src/object/ops.rs	2026-10-02 23:17:20
+++ b/kernel/src/object/ops.rs	2026-10-02 23:17:20
@@ -319,7 +319,7 @@
     match object {
         KObjectRef::SysCap(_) => false,
         KObjectRef::Console(_) => false,
-        KObjectRef::Process(_) => false,
+        KObjectRef::Process(_) => true,
         KObjectRef::Device(d) => match d.class() {
             device_registry::DeviceType::Keyboard => false,
             device_registry::DeviceType::Mouse
m2-a-process-is-never-readable.patch
--- a/kernel/src/object/ops.rs	2026-10-02 23:17:20
+++ b/kernel/src/object/ops.rs	2026-10-02 23:17:20
@@ -759,7 +759,7 @@
         KObjectRef::Connection(c) => pipe::has_data(c.rx()),
         KObjectRef::Console(_) => serial::has_data(),
         KObjectRef::Acceptor(a) => a.has_pending(),
-        KObjectRef::Process(p) => p.finished(),
+        KObjectRef::Process(_) => false,
         KObjectRef::File(_) => true,
         KObjectRef::Device(d) => match d.class() {
             device_registry::DeviceType::Keyboard => keyboard::has_data(),
m3-a-process-has-no-read-watch.patch
--- a/kernel/src/object/ops.rs	2026-10-02 23:17:20
+++ b/kernel/src/object/ops.rs	2026-10-02 23:17:20
@@ -272,7 +272,7 @@
         KObjectRef::PipeRead(r) => pipe::read_watch(r.id()).map(WatchRef::Shared),
         KObjectRef::Connection(c) => pipe::read_watch(c.rx()).map(WatchRef::Shared),
         KObjectRef::Acceptor(a) => Some(WatchRef::Shared(a.watch().clone())),
-        KObjectRef::Process(p) => Some(WatchRef::Shared(p.watch().clone())),
+        KObjectRef::Process(_) => None,
         KObjectRef::Console(_) => Some(WatchRef::Static(&keyboard::WATCH)),
         KObjectRef::Device(d) => match d.class() {
             device_registry::DeviceType::Keyboard => Some(WatchRef::Static(&keyboard::WATCH)),
m4-a-process-is-always-readable.patch
--- a/kernel/src/object/ops.rs	2026-10-02 23:17:20
+++ b/kernel/src/object/ops.rs	2026-10-02 23:17:20
@@ -759,7 +759,7 @@
         KObjectRef::Connection(c) => pipe::has_data(c.rx()),
         KObjectRef::Console(_) => serial::has_data(),
         KObjectRef::Acceptor(a) => a.has_pending(),
-        KObjectRef::Process(p) => p.finished(),
+        KObjectRef::Process(_) => true,
         KObjectRef::File(_) => true,
         KObjectRef::Device(d) => match d.class() {
             device_registry::DeviceType::Keyboard => keyboard::has_data(),
m5-an-end-answers-its-watches-as-gone.patch
--- a/kernel/src/object/process.rs	2026-10-02 23:37:16
+++ b/kernel/src/object/process.rs	2026-10-02 23:37:16
@@ -83,6 +83,7 @@
         self.finished.store(true, Ordering::Release);
         // Must come after the store: reap_finished polls this flag, not the lock.
         crate::scheduler::note_reapable();
+        self.watch.cancel_polls();
         self.watch.post();
     }
 }
h1-a-watch-registers-without-its-look.patch
--- a/toyos-proclife/src/interleave.rs	2026-10-02 23:17:20
+++ b/toyos-proclife/src/interleave.rs	2026-10-02 23:17:20
@@ -407,9 +407,6 @@
                 // `inbox::arm` reads the object again once the poll is
                 // registered, so an end published in the window answers it.
                 _ => {
-                    if world.published(*object).is_some() {
-                        world.post(Watch::Process(*object));
-                    }
                     world.leave_kernel(*by);
                     *pc = DONE;
                 }
l1-a-forget-in-the-control-arm.patch
--- a/toyos-proclife/src/interleave.rs	2026-10-02 23:37:16
+++ b/toyos-proclife/src/interleave.rs	2026-10-02 23:37:16
@@ -394,6 +394,7 @@
                 // The mutation this feature stages: the close ends every poll
                 // on the object's watch, another handle's included.
                 if cfg!(feature = "mutate-close-ends-a-process-watch") {
+                    core::mem::forget(0u8);
                     world.post(Watch::Process(*object));
                 }
                 world.leave_kernel(*by);
x-scratch-closed-handle.patch
--- a/tests/toyos-rust-tests/src/bin/process_lifecycle.rs	2026-10-02 23:41:51
+++ b/tests/toyos-rust-tests/src/bin/process_lifecycle.rs	2026-10-02 23:41:51
@@ -35,6 +35,7 @@
 
 use toyos::endow::{Endowments, SYSCAP_LABEL};
 use toyos::poller::{Poller, READABLE};
+use toyos::AsHandle;
 use toyos::process::Process;
 use toyos::syscap::SysCap;
 use toyos_abi::inbox::{
@@ -66,6 +67,7 @@
 }
 
 fn test() {
+    scratch_a_closed_handles_poll();
     reading_the_code_does_not_spend_it();
     a_wait_before_the_exit_is_woken_by_it();
     an_unrelated_wake_does_not_end_the_wait();
@@ -423,3 +425,52 @@
     println!("waited {code}");
     std::process::exit(0);
 }
+
+/// Scratch, in no commit: a watch made through a handle that is then closed.
+fn scratch_a_closed_handles_poll() {
+    for reuse in [false, true] {
+        let (mut child, release) = start(19);
+        let dupe = syscall::dup(RawHandle(child.as_raw_handle())).expect("dup");
+        let (inbox, base) = unsafe { syscall::inbox_setup(1) }.expect("inbox_setup");
+        unsafe {
+            (base.add(SUBMISSIONS_OFF as usize) as *mut Submission).write(Submission {
+                op: OP_WATCH,
+                handle: dupe,
+                op_flags: READABLE,
+                token: 7,
+                ..Submission::default()
+            });
+            AtomicU32::from_ptr(base.add(SUBMISSION_RING_OFF as usize + RING_TAIL_OFF) as *mut u32)
+                .store(1, Ordering::Release);
+        }
+        let first = || unsafe {
+            (base.add(COMPLETION_RING_OFF as usize + core::mem::size_of::<RingHeader>()) as *const Completion)
+                .read_volatile()
+        };
+        println!("SCRATCH reuse={reuse}: the watch on handle {}: {:?}", dupe.0, syscall::inbox_submit(inbox, 1, 0, 0));
+        syscall::close(dupe);
+        println!("SCRATCH reuse={reuse}: after its close: {:?}", syscall::inbox_submit(inbox, 0, 0, 0));
+        let pipe = reuse.then(|| toyos::pipe_pair().expect("pipe"));
+        if let Some((read, write)) = &pipe {
+            println!("SCRATCH reuse=true: the pipe's ends are handles {} and {}", read.as_handle().0, write.as_handle().0);
+        }
+        drop(release);
+        assert_eq!(child.wait().expect("wait").code(), Some(19));
+        match &pipe {
+            None => {
+                let n = syscall::inbox_submit(inbox, 0, 1, u64::MAX);
+                let c = first();
+                println!("SCRATCH reuse=false: after the child's end: {n:?}, token {} result {}", c.token, c.result);
+            }
+            Some((_read, write)) => {
+                let n = syscall::inbox_submit(inbox, 0, 1, 2_000_000_000);
+                println!("SCRATCH reuse=true: 2 s after the child's end: {n:?}");
+                write.write_nonblock(b"x").expect("write");
+                let n = syscall::inbox_submit(inbox, 0, 1, u64::MAX);
+                let c = first();
+                println!("SCRATCH reuse=true: after a byte into the pipe: {n:?}, token {} result {}", c.token, c.result);
+            }
+        }
+        syscall::close(inbox);
+    }
+}
local-qemu.patch, the harness patch that boots tests/testcases with 8 vCPUs and runs one shared-boot member; in no commit
diff --git a/tests/toyos.rs b/tests/toyos.rs
index 7fb33c083..1baa3cd2f 100644
--- a/tests/toyos.rs
+++ b/tests/toyos.rs
@@ -177,6 +177,7 @@ const MACHINE_TESTS: &[&str] = &[
     // no way to turn it back on, so only a machine QEMU reports stopping can
     // be asked. `machine_soft_off_decoded` reads the T14's own decode.
     "machine_shutdown",
+    "zz_local_process_lifecycle",
 ];
 
 /// **The metal profile**: which registrations run on the ThinkPad T14, what
@@ -2246,6 +2247,24 @@ fn run_machine_test(name: &str, test_config: &Path) -> Result<(), String> {
         "iommu_virtio_platform" => common::iommu::iommu_virtio_platform(test_config),
         "nested_nmi_is_loud" => faults::nested_nmi_is_loud(test_config),
         "machine_shutdown" => power::machine_shutdown(test_config),
+        local if local.starts_with("zz_local_") => {
+            let member = &local["zz_local_".len()..];
+            let crate_path = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/toyos-rust-tests");
+            let bin = qemu::build_toyos_bin(qemu::SUITE_ARCH, &crate_path, member);
+            let mut guest = QemuInstance::boot_with_options(
+                test_config,
+                &[],
+                &[(member.to_string(), bin)],
+                BootOptions { smp: 8, ..BootOptions::default() },
+            );
+            let result =
+                guest.run_test(&format!("test_rs_{member}"), Duration::from_secs(120));
+            eprintln!("  [local] exit {:?} error {:?}\n{}", result.exit_code, result.error, result.stdout);
+            match (result.exit_code, result.error) {
+                (Some(0), None) => Ok(()),
+                (code, error) => Err(format!("{member}: exit {code:?}, {error:?}")),
+            }
+        }
         other => Err(format!("unknown machine test {other}")),
     }
 }

The scripts

gates.sh
#!/bin/sh
# The gates at one committed clean head, each result the command's own exit code.
set -u
W=/Users/jan/Dev/jan/toyos-proclife1
D=/Users/jan/.claude/jobs/2280e09e/tmp/scratchpad/orch/648-round
cd "$W" || exit 1
h=$(git rev-parse --short=9 HEAD)
R="$D/gates-results-$h.txt"
rm -f "$D/gates-$h.done"
echo "HEAD $(git rev-parse HEAD)" >> "$R"
echo "status before: [$(git status --porcelain --ignore-submodules=none)]" >> "$R"
echo "load before: $(uptime)" >> "$R"
cargo run -- --ci host > "$D/ci-host-$h.log" 2>&1; echo "RESULT cargo run -- --ci host | EXIT=$?" >> "$R"
cargo run -- --build-only > "$D/build-only-$h.log" 2>&1; echo "RESULT cargo run -- --build-only | EXIT=$?" >> "$R"
cargo test --test toyos-build > "$D/guest-suite-$h.log" 2>&1; echo "RESULT cargo test --test toyos-build | EXIT=$?" >> "$R"
cargo test -p toyos-proclife > "$D/proclife-$h.log" 2>&1; echo "RESULT cargo test -p toyos-proclife | EXIT=$?" >> "$R"
echo "load after: $(uptime)" >> "$R"
echo "status after: [$(git status --porcelain --ignore-submodules=none)]" >> "$R"
touch "$D/gates-$h.done"
local-qemu.sh
#!/bin/sh
# One QEMU guest (tests/testcases, 8 vCPUs) runs the shared-boot member process_lifecycle on the tree as it
# stands plus the named mutation ("none" for none). The harness patch and the mutation are checked, applied,
# run and reversed here, and the tree is shown clean after. A guest exit code in the result is the image
# having built and booted.
# usage: local-qemu.sh <label> <mutation|none> [strict]
#   strict: refuse a tree that is not clean before.
set -u
W=/Users/jan/Dev/jan/toyos-proclife1
D=/Users/jan/.claude/jobs/2280e09e/tmp/scratchpad/orch/648-round
R="$D/local-qemu-results.txt"
cd "$W" || exit 1
label=$1; mutation=$2; strict=${3:-loose}
h=$(git rev-parse --short=9 HEAD)
L="$D/local-qemu-$label.log"
rm -f "$D/local-qemu-$label.done"
if [ "$strict" = strict ] && [ -n "$(git status --porcelain --ignore-submodules=none)" ]; then echo "RESULT $label: TREE NOT CLEAN BEFORE" >> "$R"; touch "$D/local-qemu-$label.done"; exit 1; fi
git apply --check "$D/local-qemu.patch" || { echo "RESULT $label: HARNESS PATCH DOES NOT APPLY" >> "$R"; touch "$D/local-qemu-$label.done"; exit 1; }
if [ "$mutation" != none ]; then git apply --check "$D/mutations/$mutation.patch" || { echo "RESULT $label: MUTATION DOES NOT APPLY" >> "$R"; touch "$D/local-qemu-$label.done"; exit 1; }; fi
git apply "$D/local-qemu.patch"
if [ "$mutation" != none ]; then git apply "$D/mutations/$mutation.patch"; fi
cargo test --test toyos-build -- zz_local_process_lifecycle > "$L" 2>&1; ran=$?
if [ "$mutation" != none ]; then git apply -R "$D/mutations/$mutation.patch"; fi
git apply -R "$D/local-qemu.patch"
clean=dirty; [ -z "$(git status --porcelain --ignore-submodules=none)" ] && clean=clean
echo "RESULT $label | HEAD $h ($clean after) | mutation $mutation | load $(uptime | sed 's/.*load averages: //') | cargo test --test toyos-build -- zz_local_process_lifecycle | EXIT=$ran" >> "$R"
sed -n 's/^  \[local\] /  guest: /p' "$L" >> "$R"
grep -A1 'panicked at' "$L" | sed 's/^/  /' >> "$R"
touch "$D/local-qemu-$label.done"
stage.sh
#!/bin/sh
# Stage the metal rows a filter selects, on the committed head plus the named patch ("none" for none), with
# `--metal --metal-readback`, which builds images and touches no machine. The patch is checked, applied and
# reversed here, and the tree is shown clean after. Each image's sha256 is recorded.
# usage: stage.sh <label> <filter> <patch|none>
set -u
W=/Users/jan/Dev/jan/toyos-proclife1
D=/Users/jan/.claude/jobs/2280e09e/tmp/scratchpad/orch/648-round
R="$D/stage-results.txt"
cd "$W" || exit 1
label=$1; filter=$2; patch=$3
h=$(git rev-parse --short=9 HEAD)
dir="$D/metal/$label"
rm -f "$D/stage-$label.done"
if [ -n "$(git status --porcelain --ignore-submodules=none)" ]; then echo "RESULT $label: TREE NOT CLEAN BEFORE" >> "$R"; touch "$D/stage-$label.done"; exit 1; fi
if [ "$patch" != none ]; then git apply --check "$D/mutations/$patch.patch" || { echo "RESULT $label: PATCH DOES NOT APPLY" >> "$R"; touch "$D/stage-$label.done"; exit 1; }; git apply "$D/mutations/$patch.patch"; fi
mkdir -p "$dir"
cargo test --test toyos-build -- --metal --metal-readback "$dir" "$filter" > "$D/stage-$label.log" 2>&1; ran=$?
if [ "$patch" != none ]; then git apply -R "$D/mutations/$patch.patch"; fi
clean=dirty; [ -z "$(git status --porcelain --ignore-submodules=none)" ] && clean=clean
echo "RESULT $label | HEAD $h ($clean after) | patch $patch | cargo test --test toyos-build -- --metal --metal-readback $dir $filter | EXIT=$ran" >> "$R"
if [ -f "$dir/request.txt" ]; then
  sed -n 's/^  image: //p' "$dir/request.txt" | while read -r image; do
    echo "  sha256 $(shasum -a 256 "$image" | cut -d' ' -f1)  $image" >> "$R"
  done
fi
touch "$D/stage-$label.done"
host-mutation.sh
#!/bin/sh
# One run of the toyos-proclife model on the committed head, under a control feature or a checked patch
# ("none" for either). Shown to build (--no-run), then run; the patch is reversed and the tree shown clean.
# usage: host-mutation.sh <label> <patch|none> <feature|none>
set -u
W=/Users/jan/Dev/jan/toyos-proclife1
D=/Users/jan/.claude/jobs/2280e09e/tmp/scratchpad/orch/648-round
R="$D/host-mutation-results.txt"
cd "$W" || exit 1
label=$1; patch=$2; feature=$3
h=$(git rev-parse --short=9 HEAD)
if [ -n "$(git status --porcelain --ignore-submodules=none)" ]; then echo "RESULT $label: TREE NOT CLEAN BEFORE" >> "$R"; exit 1; fi
if [ "$patch" != none ]; then git apply --check "$D/mutations/$patch.patch" || { echo "RESULT $label: PATCH DOES NOT APPLY" >> "$R"; exit 1; }; git apply "$D/mutations/$patch.patch"; fi
features=""; [ "$feature" != none ] && features="--features $feature"
cargo test -p toyos-proclife $features --no-run > "$D/host-mutation-$label.build.log" 2>&1; built=$?
cargo test -p toyos-proclife $features > "$D/host-mutation-$label.run.log" 2>&1; ran=$?
if [ "$patch" != none ]; then git apply -R "$D/mutations/$patch.patch"; fi
clean=dirty; [ -z "$(git status --porcelain --ignore-submodules=none)" ] && clean=clean
red=$(sed -n 's/^test \(.*\) \.\.\. FAILED$/\1/p' "$D/host-mutation-$label.run.log" | tr '\n' ' ')
echo "RESULT $label | HEAD $h ($clean after) | patch $patch | cargo test -p toyos-proclife $features | build EXIT=$built | run EXIT=$ran | red: $red" >> "$R"
lint-control.sh
#!/bin/sh
# The new cargo feature's arm shown linted: a `mem::forget` planted in it, `cargo run -- --clippy`, the patch
# reversed and the tree shown clean.
set -u
W=/Users/jan/Dev/jan/toyos-proclife1
D=/Users/jan/.claude/jobs/2280e09e/tmp/scratchpad/orch/648-round
R="$D/host-mutation-results.txt"
P="$D/mutations/l1-a-forget-in-the-control-arm.patch"
cd "$W" || exit 1
h=$(git rev-parse --short=9 HEAD)
if [ -n "$(git status --porcelain --ignore-submodules=none)" ]; then echo "RESULT l1: TREE NOT CLEAN BEFORE" >> "$R"; exit 1; fi
git apply --check "$P" || { echo "RESULT l1: PATCH DOES NOT APPLY" >> "$R"; exit 1; }
git apply "$P"
cargo run -- --clippy > "$D/lint-control-l1.log" 2>&1; ran=$?
git apply -R "$P"
clean=dirty; [ -z "$(git status --porcelain --ignore-submodules=none)" ] && clean=clean
echo "RESULT l1 | HEAD $h ($clean after) | patch l1-a-forget-in-the-control-arm | cargo run -- --clippy | EXIT=$ran" >> "$R"
grep -n -A3 'forget' "$D/lint-control-l1.log" | sed 's/^/  /' >> "$R"
phase2.sh
#!/bin/sh
# After the gates, at the same committed clean head: this branch's guest test in one QEMU guest, the T14
# stagings (head, then the negative control as a mutated image), the QEMU mutations, the model's, and the
# lint control of the new feature's arm.
set -u
W=/Users/jan/Dev/jan/toyos-proclife1
D=/Users/jan/.claude/jobs/2280e09e/tmp/scratchpad/orch/648-round
cd "$W" || exit 1
h=$(git rev-parse --short=9 HEAD)
rm -f "$D/phase2-$h.done"
for f in local-qemu-results.txt stage-results.txt host-mutation-results.txt; do echo "HEAD $(git rev-parse HEAD)" >> "$D/$f"; done

sh "$D/local-qemu.sh" head none strict

cargo test --test toyos-build -- --metal --list process > "$D/metal-list-process.log" 2>&1; echo "RESULT cargo test --test toyos-build -- --metal --list process | EXIT=$?" >> "$D/stage-results.txt"
cargo test --test toyos-build -- --metal --list spawn_child_ends_first > "$D/metal-list-spawn_child_ends_first.log" 2>&1; echo "RESULT cargo test --test toyos-build -- --metal --list spawn_child_ends_first | EXIT=$?" >> "$D/stage-results.txt"
sh "$D/stage.sh" head-process process none
sh "$D/stage.sh" head-spawn_child_ends_first spawn_child_ends_first none
sh "$D/stage.sh" nc-process_lifecycle process_lifecycle nc-whole

sh "$D/local-qemu.sh" nc-whole nc-whole strict
sh "$D/local-qemu.sh" m1 m1-close-ends-a-process-watch strict
sh "$D/local-qemu.sh" m3 m3-a-process-has-no-read-watch strict
sh "$D/local-qemu.sh" m4 m4-a-process-is-always-readable strict
sh "$D/local-qemu.sh" m5 m5-an-end-answers-its-watches-as-gone strict

sh "$D/host-mutation.sh" head none none
sh "$D/host-mutation.sh" control none mutate-close-ends-a-process-watch
sh "$D/host-mutation.sh" h1 h1-a-watch-registers-without-its-look none
sh "$D/lint-control.sh"

sh "$D/local-qemu.sh" m2 m2-a-process-is-never-readable strict

for f in local-qemu-results.txt stage-results.txt host-mutation-results.txt; do echo "status after all: [$(git status --porcelain --ignore-submodules=none)]" >> "$D/$f"; done
touch "$D/phase2-$h.done"

The T14 request

metal/request.txt
# T14 run requested for pull request #648, at head 7bf868ef44ca30ed15a449c5ca73ecb5501fa9b0.
#
# Worktree: /Users/jan/Dev/jan/toyos-proclife1, clean at that head. Every `cargo` below runs from it.
# D = /Users/jan/.claude/jobs/2280e09e/tmp/scratchpad/orch/648-round/metal
# Staged by `cargo test --test toyos-build -- --metal --metal-readback $D/<label> <filter>`, exit 2 each
# (staged; no machine touched). Each staging's own request is $D/<label>/request.txt.
# The four images are four boots. Check each sha256 before it is flashed.

## The boots, in this order

1. head, proctreecase: `process_tree`
   image:  /Users/jan/.claude/jobs/2280e09e/tmp/scratchpad/orch/648-round/metal/head-process/proctreecase/image.img
   sha256: 6cca382d3a1257b8b3369966ade2906c1fe1f0e57e987eb3dfb23c1a678631b1
   cargo run --bin toyos-metal -- --image /Users/jan/.claude/jobs/2280e09e/tmp/scratchpad/orch/648-round/metal/head-process/proctreecase/image.img --readback /Users/jan/.claude/jobs/2280e09e/tmp/scratchpad/orch/648-round/metal/head-process/proctreecase --fat32-check

2. head, shared: `process_lifecycle`, `std_process`
   image:  /Users/jan/.claude/jobs/2280e09e/tmp/scratchpad/orch/648-round/metal/head-process/shared/image.img
   sha256: e8d2b0e602492435567ebdfa727419319202dae035ec26f77a48812a157e2feb
   cargo run --bin toyos-metal -- --image /Users/jan/.claude/jobs/2280e09e/tmp/scratchpad/orch/648-round/metal/head-process/shared/image.img --readback /Users/jan/.claude/jobs/2280e09e/tmp/scratchpad/orch/648-round/metal/head-process/shared --fat32-check

3. head, shared-debug (the kernel that carries SYS_DEBUG): `spawn_child_ends_first`
   image:  /Users/jan/.claude/jobs/2280e09e/tmp/scratchpad/orch/648-round/metal/head-spawn_child_ends_first/shared-debug/image.img
   sha256: e24c43365ebadebda10a2bcdbeafa48c888f72322270f2f6fe1323250c6454c0
   cargo run --bin toyos-metal -- --image /Users/jan/.claude/jobs/2280e09e/tmp/scratchpad/orch/648-round/metal/head-spawn_child_ends_first/shared-debug/image.img --readback /Users/jan/.claude/jobs/2280e09e/tmp/scratchpad/orch/648-round/metal/head-spawn_child_ends_first/shared-debug --fat32-check

4. negative control, shared: `process_lifecycle` alone
   The image is the head with /Users/jan/.claude/jobs/2280e09e/tmp/scratchpad/orch/648-round/mutations/nc-whole.patch
   applied: kernel/ and userland/init as on origin/main (c59e09ed6), the test as at the head. The patch was
   applied and reversed around the staging; the worktree is clean at the head.
   image:  /Users/jan/.claude/jobs/2280e09e/tmp/scratchpad/orch/648-round/metal/nc-process_lifecycle/shared/image.img
   sha256: 5de00dcf19e846acbe11dc1094b90f4a576e70517e04f62931a2edaa6f1327a6
   cargo run --bin toyos-metal -- --image /Users/jan/.claude/jobs/2280e09e/tmp/scratchpad/orch/648-round/metal/nc-process_lifecycle/shared/image.img --readback /Users/jan/.claude/jobs/2280e09e/tmp/scratchpad/orch/648-round/metal/nc-process_lifecycle/shared --fat32-check

## The judges, in this order, each over its readbacks and touching no machine

A. cargo test --test toyos-build -- --metal --metal-readback /Users/jan/.claude/jobs/2280e09e/tmp/scratchpad/orch/648-round/metal/head-process process
   Must exit 0, and read:
     "PASS process_tree"
     "[metal] shared: 2 member(s)", with no FAIL line under it
     "[metal] 3 passed, 0 failed, 2 boot(s)"
   head-process/shared/kernel.log must hold `test_rs_process_lifecycle` ending exit=0 and, among its lines,
   the four the watch arms print, in this order:
     "one poller: each end completes the watch of the child that ended, and only it"
     "a watch on a child already gone completes at once"
     "a kill completes a watch, as readable"
     "closing one handle to a child ends no other handle's watch"
   and after them "a process is a handle: the code is read, not claimed".

B. cargo test --test toyos-build -- --metal --metal-readback /Users/jan/.claude/jobs/2280e09e/tmp/scratchpad/orch/648-round/metal/head-spawn_child_ends_first spawn_child_ends_first
   Must exit 0, and read:
     "[metal] shared-debug: 1 member(s)", with no FAIL line under it
     "[metal] 1 passed, 0 failed, 1 boot(s)"

C. cargo test --test toyos-build -- --metal --metal-readback /Users/jan/.claude/jobs/2280e09e/tmp/scratchpad/orch/648-round/metal/nc-process_lifecycle process_lifecycle
   Must exit 1, and read:
     "FAIL test_rs_process_lifecycle: test_rs_process_lifecycle exited 101 on the T14"
     "[metal] 0 passed, 1 failed, 1 boot(s)"
   nc-process_lifecycle/shared/kernel.log must hold the first watch arm's panic, and none of the four lines
   under A:
     "panicked at src/bin/process_lifecycle.rs:272:35:"
     "child 0 is held, and its watch completed"
   The boot itself is healthy: `toyos-metal` exits 0 on boot 4; only the member is red.
   Under QEMU the same patched tree's member exited 101 on those two lines
   (/Users/jan/.claude/jobs/2280e09e/tmp/scratchpad/orch/648-round/local-qemu-nc-whole.log).

## What the judges leave behind

Judging a green `shared` boot offers `boot.shared.*` rows for tests/metal/lenovo-20w0003amz.toml. They are
not this branch's to commit: restore the file, and the worktree is clean at the head again.

Earlier heads of this round

The gates and the same runs at 352d2df07, before the kill arm read its result word and before c59e09ed6 was merged (gates-results-352d2df07.txt, local-qemu-results-352d2df07.txt, host-mutation-results-352d2df07.txt, stage-results-352d2df07.txt); the gates again at 81e68535c (gates-results-81e68535c.txt).

HEAD 352d2df07687ea7a44ff8fe1a6e26d6c55f7f2db
status before: []
load before: 23:17  up 3 days, 11:01, 3 users, load averages: 13.29 16.06 10.57
RESULT cargo run -- --ci host | EXIT=0
RESULT cargo run -- --build-only | EXIT=0
RESULT cargo test --test toyos-build | EXIT=0
RESULT cargo test -p toyos-proclife | EXIT=0
load after: 23:27  up 3 days, 11:11, 3 users, load averages: 38.14 32.04 23.13
status after: []
HEAD 352d2df07687ea7a44ff8fe1a6e26d6c55f7f2db
RESULT head | HEAD 352d2df07 (clean after) | mutation none | load 34.52 31.61 23.19 | cargo test --test toyos-build -- zz_local_process_lifecycle | EXIT=0
  guest: exit Some(0) error None
RESULT nc-whole | HEAD 352d2df07 (clean after) | mutation nc-whole | load 30.34 31.37 24.22 | cargo test --test toyos-build -- zz_local_process_lifecycle | EXIT=1
  guest: exit Some(101) error None
  thread 'main' (1) panicked at src/bin/process_lifecycle.rs:268:35:
  child 0 is held, and its watch completed
RESULT m1 | HEAD 352d2df07 (clean after) | mutation m1-close-ends-a-process-watch | load 28.20 30.86 24.13 | cargo test --test toyos-build -- zz_local_process_lifecycle | EXIT=1
  guest: exit Some(101) error None
  thread 'main' (1) panicked at src/bin/process_lifecycle.rs:324:31:
  closing a second handle answered the first's watch (0)
RESULT m3 | HEAD 352d2df07 (clean after) | mutation m3-a-process-has-no-read-watch | load 25.91 30.29 24.00 | cargo test --test toyos-build -- zz_local_process_lifecycle | EXIT=1
  guest: exit Some(101) error None
  thread 'main' (1) panicked at src/bin/process_lifecycle.rs:268:35:
  child 0 is held, and its watch completed
RESULT m4 | HEAD 352d2df07 (clean after) | mutation m4-a-process-is-always-readable | load 24.31 29.82 23.91 | cargo test --test toyos-build -- zz_local_process_lifecycle | EXIT=1
  guest: exit Some(101) error None
  thread 'main' (1) panicked at src/bin/process_lifecycle.rs:268:35:
  child 0 is held, and its watch completed
RESULT m2 | HEAD 352d2df07 (clean after) | mutation m2-a-process-is-never-readable | load 6.37 15.84 19.34 | cargo test --test toyos-build -- zz_local_process_lifecycle | EXIT=1
  guest: exit None error Some(WaitVerdict("timed out after 300s, with the guest still talking 5s ago (509 console line(s) while it ran) — it was working and did not finish"))
status after all: []
HEAD 352d2df07687ea7a44ff8fe1a6e26d6c55f7f2db
RESULT head | HEAD 352d2df07 (clean after) | patch none | cargo test -p toyos-proclife  | build EXIT=0 | run EXIT=0 | red: 
RESULT control | HEAD 352d2df07 (clean after) | patch none | cargo test -p toyos-proclife --features mutate-close-ends-a-process-watch | build EXIT=0 | run EXIT=101 | red: interleave::tests::a_watch_on_a_process_is_answered_by_its_end_and_by_no_close 
RESULT h1 | HEAD 352d2df07 (clean after) | patch h1-a-watch-registers-without-its-look | cargo test -p toyos-proclife  | build EXIT=0 | run EXIT=101 | red: interleave::tests::a_watch_on_a_process_is_answered_by_its_end_and_by_no_close 
status after all: []
HEAD 81e68535c22711d346f0b7dcfc4fc6523ec69f25
status before: []
load before: 23:38  up 3 days, 11:22, 3 users, load averages: 6.74 12.52 17.39
RESULT cargo run -- --ci host | EXIT=0
RESULT cargo run -- --build-only | EXIT=0
RESULT cargo test --test toyos-build | EXIT=0
RESULT cargo test -p toyos-proclife | EXIT=0
load after: 23:48  up 3 days, 11:32, 3 users, load averages: 47.28 30.63 22.55
status after: []

@Japabu Japabu changed the title A child's end is readiness on its Process handle A child's end is readiness on its Process handle: OP_WATCH answers at the published exit, and no close ends another handle's watch Oct 2, 2026
@Japabu

Japabu commented Oct 2, 2026

Copy link
Copy Markdown
Collaborator Author

T14 at 7bf868ef4, run by the orchestrator from 648-round/metal/request.txt: four boots, each image's sha256 checked against the request before it was flashed, each toyos-metal exit 0; then the three judges from the clean worktree at the head (cargo test --test toyos-build -- --metal --metal-readback <dir> <filter>). The worktree was clean before and after; the boot.shared.* rows a judging offered are not committed.

boot image sha256 members, exit
head, proctreecase 6cca382d3a1257b8b3369966ade2906c1fe1f0e57e987eb3dfb23c1a678631b1 process_tree 0
head, shared e8d2b0e602492435567ebdfa727419319202dae035ec26f77a48812a157e2feb process_lifecycle 0, std_process 0
head, shared-debug e24c43365ebadebda10a2bcdbeafa48c888f72322270f2f6fe1323250c6454c0 spawn_child_ends_first 0
negative control (nc-whole.patch: kernel/ and userland/init as on c59e09ed6), shared 5de00dcf19e846acbe11dc1094b90f4a576e70517e04f62931a2edaa6f1327a6 process_lifecycle 101
  • A, process: exit 0. PASS process_tree; shared: 2 member(s); 3 passed, 0 failed, 2 boot(s). head-process/shared/kernel.log holds the four watch arms' lines in the request's order (:465 "one poller: each end completes the watch of the child that ended, and only it", :495 "a watch on a child already gone completes at once", :510 "a kill completes a watch, as readable", :526 "closing one handle to a child ends no other handle's watch") and after them :572 "a process is a handle: the code is read, not claimed".
  • B, spawn_child_ends_first: exit 0. shared-debug: 1 member(s); 1 passed, 0 failed, 1 boot(s).
  • C, the control, process_lifecycle: exit 1, as required. FAIL test_rs_process_lifecycle: test_rs_process_lifecycle exited 101 on the T14; 0 passed, 1 failed, 1 boot(s). nc-process_lifecycle/shared/kernel.log:433-434: panicked at src/bin/process_lifecycle.rs:272:35: / child 0 is held, and its watch completed; none of A's four lines is in that log. The boot itself is healthy (toyos-metal exit 0).

Readbacks and judge logs: /Users/jan/.claude/jobs/2280e09e/tmp/scratchpad/orch/648-round/ (metal/…, judge-{A,B,C}.log).

@Japabu

Japabu commented Oct 2, 2026

Copy link
Copy Markdown
Collaborator Author

Review of #648 at 7bf868ef4, round 1 (against origin/main a93067fc2; merge base c59e09ed6)

Rulings asked for

  • Stage 1 in two parts: may land so. The kernel half carries its own whole-change control (T14 judge C exit 1, nc-process_lifecycle/shared/kernel.log:433-434) and its hardware reading (judges A and B exit 0). init's half rewrites the supervision whose restart policy and swap ladder issues/isolation/the-supervisor-is-host-tested-and-owns-the-stop.md stage 2 moves into a host-tested crate, so it belongs on a branch of its own. The remainder's exit as written is not right: see the first NOTE.
  • m1–m5 under QEMU through the uncommitted local-qemu.patch: accepted. Nothing in the change targets hardware. The mutations measure the arms' teeth against kernel logic the T14 runs unchanged, each result names its patch and exit, and the T14 read both the head and the whole-change control.
  • The kill arm's result word: closed by m5 at the head (local-qemu-m5.log: exit 101 at process_lifecycle.rs:309, left -1, right 1). The counterfactual needs no run, because Poller::drain hands out completion.token and never reads result (toyos/src/poller.rs:348).
  • The new issue file: a legal record of the branch's own compromise. Its frontmatter is valid, it carries a measured reading, and its exit is one a test can fail. See the second NOTE.
  • The model: it echoes the change and is not its contract. BLOCKER below.
  • git submodule status: no effect. .git/modules/rust/config still holds worktree = ../../../rust, last written Oct 1 19:04, before this round.
  • ABI: a deleted syscall, SYS_DEBUG action or inbox op is simply deleted #651 unmerged: no overlap. Its hunks in toyos-abi/src/inbox.rs, toyos-abi/src/syscall.rs and kernel/src/inbox/mod.rs delete comments and one comment line; nothing the test imports moves.
  • Evidence read: gates-results-7bf868ef4.txt (--ci host 0, its log ending "Host: 68 step(s), all green" with the new control's verdict reached; QEMU suite 0, 26/26; toyos-proclife 0). nc-whole.patch is byte for byte git diff 7bf868ef4 c59e09ed6 -- kernel userland/init. The T14 log head-process/shared/kernel.log:465,495,510,526,572 holds the four arms' lines in order.
  • Net lines: git diff --shortstat origin/main...7bf868ef4 gives 11 files, +244 −25.
    • Production +19 −13: kernel +16 −9, init +1 −3, toyos-proclife/src/lib.rs doc +2 −1. The +6 is the three match arms and the Arc the ruled feature needs: accepted.
    • Tests and gates +195 −2: process_lifecycle +126 −1; the model, its feature and its control row +69 −1, which the BLOCKER deletes.
    • Issues +30 −10.

BLOCKER

  • toyos-proclife/src/interleave.rs:402 — Op::Watch (with :84, :122), L14 (toyos-proclife/src/model.rs:589), a_watch_on_a_process_is_answered_by_its_end_and_by_no_close (interleave.rs:880), the mutate-close-ends-a-process-watch feature (toyos-proclife/Cargo.toml:104-110, interleave.rs:394-398) and its CONTROLS row (src/ci.rs:420) model the change but compile none of it; delete all five (−69) — toyos-proclife depends on toyos-abi alone. Only Op::Watch arms a Watch::Process, and only the model's own publish_exit, its own look and the feature's arm release one; no decision the kernel calls hands one back. So m1–m5 and nc-whole each leave the test green, and the control reds only when the model itself is edited. On high-risk code that is a test that cannot fail on a claim the change makes, and a standing CI step guarding the one arm at kernel/src/object/ops.rs:322, which a reader checks and m1 already measures. pidfd_open(2), epoll(7) and the T14 remain the oracle.

NOTE

  • issues/kernel/a-childs-end-is-an-event-and-a-parent-takes-its-children-down.md:42 — the remainder's exit names only the deletion — a conversion that drops what close_when_it_ends does would still meet it: starting a restart row again on its kept ports, closing the acceptors of a row without one so its next connect is Gone, and leaving them open across a swap's expected end. Name each behaviour and the test that reads it: src/metalswap.rs reads the swap, and a search of tests/ found no test for the other two.
  • issues/kernel/a-watch-outlives-the-close-of-the-process-handle-it-was-made-through.md:24 — name issues/kernel/a-close-of-one-handle-ends-every-rings-poll-on-its-object.md — that issue's first exit alternative ("a poll ends only when the last handle to its source closes") keeps the very poll this exit ends at its own handle's close. One change answers both, and this file should say so.

REMOVE

  • userland/init/src/main.rs:696 — "It parks in the kernel for the process's life and costs nothing until then." — a rewrapped remnant of the deleted reason, and none of the three comment kinds.
  • tests/toyos-rust-tests/src/bin/process_lifecycle.rs:21-23 — "Its arms: … ends no other's watch." — restates the four arms' own doc comments.
  • toyos-proclife/src/lib.rs:120-121 — the OP_WATCH clause, once Op::Watch goes — after that nothing in the crate names it.
  • PR body, "What the merges changed", under the inbox: a watch is answered after a look at its object, never by a post #655 bullet: "At 60ec86df3…", "The old body said…", "The comment and the header sentence…", "Nothing the branch added…" — they describe a head that never landed, which main's record does not need.
  • PR body, "Not sure of": "I expected worse and the measurement refuted it: …" — investigation story.

SEND BACK

Japabu and others added 4 commits October 3, 2026 00:49
Review of #648 at 7bf868e, BLOCKER: `Op::Watch`, L14,
`a_watch_on_a_process_is_answered_by_its_end_and_by_no_close`, the
`mutate-close-ends-a-process-watch` feature and its `CONTROLS` row modelled
the change and compiled none of it. `toyos-proclife` depends on `toyos-abi`
alone: only `Op::Watch` armed a `Watch::Process`, and only the model's own
`publish_exit`, its own look and the feature's arm released one. So the
QEMU mutations m1-m5 and the whole-change control each left that test green,
and its control redded only when the model itself was edited.

What the deleted test claimed is held where the kernel's lines run:
`process_lifecycle`'s four watch arms, their QEMU mutations and the T14.
`toyos-proclife/src/lib.rs` loses the `OP_WATCH` clause of `Watch::Process`'s
doc, which nothing in the crate names any more.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013UDZQ6fSKw14e4w2TKTRfm
Review of #648 at 7bf868e, REMOVE: `close_when_it_ends`'s "It parks in the
kernel for the process's life and costs nothing until then" was a rewrapped
remnant of the deleted reason, and none of the three comment kinds;
`process_lifecycle`'s header listed the four watch arms that their own doc
comments already state.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013UDZQ6fSKw14e4w2TKTRfm
…tch defect names its twin

Review of #648 at 7bf868e, NOTE 1: the remainder's exit named only the
deletion, which a conversion dropping what `close_when_it_ends` decides would
still meet. It now names each behaviour: a `restart` row started again on
the ports it kept; a row without one having its acceptors closed so a
client's next connect is `Gone`; a swap's expected end leaving them open. And
what reads each: `src/metalswap.rs`'s `judge` reads the swap through
`toyos-metal --swap`; `git grep` over `tests/` and `src/` for init's restart
and close lines ("started again", "its ports are closed", `ServerGone`,
"sshd: no network") finds no test of the other two. `netd_gone_mid_bind`
closes a port of its own and never reaches init's supervision.

NOTE 2: `a-watch-outlives-the-close-of-the-process-handle-it-was-made-through`
names `a-close-of-one-handle-ends-every-rings-poll-on-its-object`, whose first
exit alternative would keep the very poll this one's exit ends: one change,
a close ending the polls made through its own handle and no other's, answers
both.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013UDZQ6fSKw14e4w2TKTRfm
#651 deletes retired syscalls, SYS_DEBUG actions and inbox ops. It shares no
file with this branch; nothing `process_lifecycle` imports from
`toyos_abi::inbox` moves.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013UDZQ6fSKw14e4w2TKTRfm
@Japabu

Japabu commented Oct 2, 2026

Copy link
Copy Markdown
Collaborator Author

Evidence at 209891cac (round 2): the whole-change control's patch, the uncommitted harness patch, the scripts and their result files. Every run is one script that checks its patch, applies it, runs, reverses it and shows the tree clean.

nc-whole.patch: byte for byte git diff 209891cac a93067fc2 -- kernel userland/init (cmp exit 0)
diff --git a/kernel/src/object/ops.rs b/kernel/src/object/ops.rs
index d734e5960..8eb9d79f7 100644
--- a/kernel/src/object/ops.rs
+++ b/kernel/src/object/ops.rs
@@ -272,7 +272,6 @@ pub fn read_watch(object: &KObjectRef) -> Option<WatchRef> {
         KObjectRef::PipeRead(r) => pipe::read_watch(r.id()).map(WatchRef::Shared),
         KObjectRef::Connection(c) => pipe::read_watch(c.rx()).map(WatchRef::Shared),
         KObjectRef::Acceptor(a) => Some(WatchRef::Shared(a.watch().clone())),
-        KObjectRef::Process(p) => Some(WatchRef::Shared(p.watch().clone())),
         KObjectRef::Console(_) => Some(WatchRef::Static(&keyboard::WATCH)),
         KObjectRef::Device(d) => match d.class() {
             device_registry::DeviceType::Keyboard => Some(WatchRef::Static(&keyboard::WATCH)),
@@ -291,7 +290,7 @@ pub fn read_watch(object: &KObjectRef) -> Option<WatchRef> {
         KObjectRef::SysCap(_) => Some(WatchRef::Static(&crate::log::user::WATCH)),
         KObjectRef::PipeWrite(_) | KObjectRef::File(_) | KObjectRef::Inbox(_)
         | KObjectRef::Connector(_) | KObjectRef::Namespace(_)
-        | KObjectRef::SharedMem(_) => None,
+        | KObjectRef::SharedMem(_) | KObjectRef::Process(_) => None,
     }
 }
 
@@ -312,14 +311,11 @@ pub fn write_watch(object: &KObjectRef) -> Option<WatchRef> {
 /// Whether closing one handle to this object ends what its watches watch, so
 /// every poll on them — in any ring — is answered as gone. `false` for the log
 /// and the keyboard, which the machine ends on its own and which other handles
-/// share: a console closing is not every console's keyboard going away. `false`
-/// for a process, which only its own end ends: closing one handle to it ends no
-/// other's watch.
+/// share: a console closing is not every console's keyboard going away.
 fn close_ends_polls(object: &KObjectRef) -> bool {
     match object {
         KObjectRef::SysCap(_) => false,
         KObjectRef::Console(_) => false,
-        KObjectRef::Process(_) => false,
         KObjectRef::Device(d) => match d.class() {
             device_registry::DeviceType::Keyboard => false,
             device_registry::DeviceType::Mouse
@@ -332,7 +328,7 @@ fn close_ends_polls(object: &KObjectRef) -> bool {
         KObjectRef::PipeRead(_) | KObjectRef::PipeWrite(_) | KObjectRef::Connection(_)
         | KObjectRef::Acceptor(_) | KObjectRef::File(_) | KObjectRef::Inbox(_)
         | KObjectRef::Connector(_) | KObjectRef::Namespace(_)
-        | KObjectRef::SharedMem(_) => true,
+        | KObjectRef::SharedMem(_) | KObjectRef::Process(_) => true,
     }
 }
 
@@ -759,7 +755,6 @@ pub fn has_data(object: &KObjectRef) -> bool {
         KObjectRef::Connection(c) => pipe::has_data(c.rx()),
         KObjectRef::Console(_) => serial::has_data(),
         KObjectRef::Acceptor(a) => a.has_pending(),
-        KObjectRef::Process(p) => p.finished(),
         KObjectRef::File(_) => true,
         KObjectRef::Device(d) => match d.class() {
             device_registry::DeviceType::Keyboard => keyboard::has_data(),
@@ -778,7 +773,7 @@ pub fn has_data(object: &KObjectRef) -> bool {
         },
         KObjectRef::PipeWrite(_) | KObjectRef::Inbox(_) | KObjectRef::SysCap(_)
         | KObjectRef::Connector(_) | KObjectRef::Namespace(_)
-        | KObjectRef::SharedMem(_) => false,
+        | KObjectRef::SharedMem(_) | KObjectRef::Process(_) => false,
     }
 }
 
diff --git a/kernel/src/object/process.rs b/kernel/src/object/process.rs
index 45ec53263..3b6516311 100644
--- a/kernel/src/object/process.rs
+++ b/kernel/src/object/process.rs
@@ -2,9 +2,8 @@
 //!
 //! The exit code lives on the object, not a table entry: no zombie, no reap,
 //! no orphan adoption. A wait after the fact reads a value; a wait before it
-//! parks and is woken by the publish. An `OP_WATCH` reads the same fact at its
-//! look, so a handle is readable from the publish on. A process nobody holds a
-//! handle to disappears.
+//! parks and is woken by the publish. A process nobody holds a handle to
+//! disappears.
 
 use alloc::sync::Arc;
 use core::sync::atomic::{AtomicBool, Ordering};
@@ -31,8 +30,7 @@ pub struct ProcessObject {
     /// The same fact, without the lock, for a waiter's per-wake predicate.
     finished: AtomicBool,
     /// What `SYS_PROCESS_WAIT` arms on; holding the `Arc` across the park keeps the watch from outliving its subject.
-    /// An `Arc` of its own, as a port's is, for the share `ops::read_watch` answers.
-    watch: Arc<Watch>,
+    watch: Watch,
 }
 
 impl ProcessObject {
@@ -42,7 +40,7 @@ impl ProcessObject {
             pid,
             exit: Lock::new(None),
             finished: AtomicBool::new(false),
-            watch: Arc::new(Watch::new()),
+            watch: Watch::new(),
         })
     }
 
@@ -63,7 +61,7 @@ impl ProcessObject {
         self.exit.lock().as_ref().map(|e| e.stats)
     }
 
-    pub fn watch(&self) -> &Arc<Watch> {
+    pub fn watch(&self) -> &Watch {
         &self.watch
     }
 
diff --git a/userland/init/src/main.rs b/userland/init/src/main.rs
index 1901f662f..3e55817b7 100644
--- a/userland/init/src/main.rs
+++ b/userland/init/src/main.rs
@@ -692,6 +692,10 @@ impl std::fmt::Display for StartError {
 
 /// Wait for one start of a service to end, and close its ports if nothing was
 /// expecting it to, or wake the loop to start it again if its row says so.
+///
+/// **A thread, because the kernel answers a process's end to a wait and to
+/// nothing a poll can watch.** It parks in the kernel for the process's life
+/// and costs nothing until then.
 fn close_when_it_ends(kept: &Mutex<Kept>, generation: u64, process: toyos::RawHandle) {
     let _ = toyos_abi::syscall::process_wait(process);
     toyos_abi::syscall::close(process);
local-qemu.patch: the harness patch in no commit, unchanged from round 1
diff --git a/tests/toyos.rs b/tests/toyos.rs
index 7fb33c083..1baa3cd2f 100644
--- a/tests/toyos.rs
+++ b/tests/toyos.rs
@@ -177,6 +177,7 @@ const MACHINE_TESTS: &[&str] = &[
     // no way to turn it back on, so only a machine QEMU reports stopping can
     // be asked. `machine_soft_off_decoded` reads the T14's own decode.
     "machine_shutdown",
+    "zz_local_process_lifecycle",
 ];
 
 /// **The metal profile**: which registrations run on the ThinkPad T14, what
@@ -2246,6 +2247,24 @@ fn run_machine_test(name: &str, test_config: &Path) -> Result<(), String> {
         "iommu_virtio_platform" => common::iommu::iommu_virtio_platform(test_config),
         "nested_nmi_is_loud" => faults::nested_nmi_is_loud(test_config),
         "machine_shutdown" => power::machine_shutdown(test_config),
+        local if local.starts_with("zz_local_") => {
+            let member = &local["zz_local_".len()..];
+            let crate_path = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/toyos-rust-tests");
+            let bin = qemu::build_toyos_bin(qemu::SUITE_ARCH, &crate_path, member);
+            let mut guest = QemuInstance::boot_with_options(
+                test_config,
+                &[],
+                &[(member.to_string(), bin)],
+                BootOptions { smp: 8, ..BootOptions::default() },
+            );
+            let result =
+                guest.run_test(&format!("test_rs_{member}"), Duration::from_secs(120));
+            eprintln!("  [local] exit {:?} error {:?}\n{}", result.exit_code, result.error, result.stdout);
+            match (result.exit_code, result.error) {
+                (Some(0), None) => Ok(()),
+                (code, error) => Err(format!("{member}: exit {code:?}, {error:?}")),
+            }
+        }
         other => Err(format!("unknown machine test {other}")),
     }
 }
run.sh
#!/bin/sh
# Everything this round owes at one committed clean head, in order: the gates, process_lifecycle in one QEMU
# guest at the head and under the whole-change control, and the T14 stagings. Each result is its command's
# own exit code.
set -u
W=/Users/jan/Dev/jan/toyos-proclife1
D=/Users/jan/.claude/jobs/2280e09e/tmp/scratchpad/orch/648-r2
cd "$W" || exit 1
h=$(git rev-parse --short=9 HEAD)
R="$D/gates-results-$h.txt"
rm -f "$D/run-$h.done"
for f in "$R" "$D/local-qemu-results.txt" "$D/stage-results.txt"; do echo "HEAD $(git rev-parse HEAD)" >> "$f"; done
echo "status before: [$(git status --porcelain --ignore-submodules=none)]" >> "$R"
echo "load before: $(uptime)" >> "$R"
cargo run -- --ci host > "$D/ci-host-$h.log" 2>&1; echo "RESULT cargo run -- --ci host | EXIT=$?" >> "$R"
cargo run -- --build-only > "$D/build-only-$h.log" 2>&1; echo "RESULT cargo run -- --build-only | EXIT=$?" >> "$R"
cargo test --test toyos-build > "$D/guest-suite-$h.log" 2>&1; echo "RESULT cargo test --test toyos-build | EXIT=$?" >> "$R"
cargo test -p toyos-proclife > "$D/proclife-$h.log" 2>&1; echo "RESULT cargo test -p toyos-proclife | EXIT=$?" >> "$R"
echo "load after: $(uptime)" >> "$R"
echo "status after: [$(git status --porcelain --ignore-submodules=none)]" >> "$R"
sh "$D/local-qemu.sh" head none
sh "$D/local-qemu.sh" nc-whole nc-whole
cargo test --test toyos-build -- --metal --list process > "$D/metal-list-process-$h.log" 2>&1; echo "RESULT cargo test --test toyos-build -- --metal --list process | EXIT=$?" >> "$D/stage-results.txt"
cargo test --test toyos-build -- --metal --list spawn_child_ends_first > "$D/metal-list-spawn_child_ends_first-$h.log" 2>&1; echo "RESULT cargo test --test toyos-build -- --metal --list spawn_child_ends_first | EXIT=$?" >> "$D/stage-results.txt"
sh "$D/stage.sh" head-process process none
sh "$D/stage.sh" head-spawn_child_ends_first spawn_child_ends_first none
sh "$D/stage.sh" nc-process_lifecycle process_lifecycle nc-whole
for f in "$D/local-qemu-results.txt" "$D/stage-results.txt"; do echo "status after all: [$(git status --porcelain --ignore-submodules=none)]" >> "$f"; done
touch "$D/run-$h.done"
local-qemu.sh
#!/bin/sh
# One QEMU guest (tests/testcases, 8 vCPUs) runs the shared-boot member process_lifecycle on the committed
# head plus the named patch ("none" for none). The uncommitted harness patch and the named patch are checked,
# applied, run and reversed here, and the tree is shown clean after. A guest exit code in the result is the
# image having built and booted.
# usage: local-qemu.sh <label> <patch|none>
set -u
W=/Users/jan/Dev/jan/toyos-proclife1
D=/Users/jan/.claude/jobs/2280e09e/tmp/scratchpad/orch/648-r2
R="$D/local-qemu-results.txt"
cd "$W" || exit 1
label=$1; mutation=$2
h=$(git rev-parse --short=9 HEAD)
L="$D/local-qemu-$label-$h.log"
if [ -n "$(git status --porcelain --ignore-submodules=none)" ]; then echo "RESULT $label: TREE NOT CLEAN BEFORE" >> "$R"; exit 1; fi
git apply --check "$D/local-qemu.patch" || { echo "RESULT $label: HARNESS PATCH DOES NOT APPLY" >> "$R"; exit 1; }
if [ "$mutation" != none ]; then git apply --check "$D/mutations/$mutation.patch" || { echo "RESULT $label: PATCH DOES NOT APPLY" >> "$R"; exit 1; }; fi
git apply "$D/local-qemu.patch"
if [ "$mutation" != none ]; then git apply "$D/mutations/$mutation.patch"; fi
cargo test --test toyos-build -- zz_local_process_lifecycle > "$L" 2>&1; ran=$?
if [ "$mutation" != none ]; then git apply -R "$D/mutations/$mutation.patch"; fi
git apply -R "$D/local-qemu.patch"
clean=dirty; [ -z "$(git status --porcelain --ignore-submodules=none)" ] && clean=clean
echo "RESULT $label | HEAD $h ($clean after) | patch $mutation | load $(uptime | sed 's/.*load averages: //') | cargo test --test toyos-build -- zz_local_process_lifecycle | EXIT=$ran" >> "$R"
sed -n 's/^  \[local\] /  guest: /p' "$L" >> "$R"
grep -A1 'panicked at' "$L" | sed 's/^/  /' >> "$R"
stage.sh
#!/bin/sh
# Stage the metal rows a filter selects, on the committed head plus the named patch ("none" for none), with
# `--metal --metal-readback`, which builds images and touches no machine. The patch is checked, applied and
# reversed here, and the tree is shown clean after. Each image's sha256 is recorded.
# usage: stage.sh <label> <filter> <patch|none>
set -u
W=/Users/jan/Dev/jan/toyos-proclife1
D=/Users/jan/.claude/jobs/2280e09e/tmp/scratchpad/orch/648-r2
R="$D/stage-results.txt"
cd "$W" || exit 1
label=$1; filter=$2; patch=$3
h=$(git rev-parse --short=9 HEAD)
dir="$D/metal/$label"
if [ -n "$(git status --porcelain --ignore-submodules=none)" ]; then echo "RESULT $label: TREE NOT CLEAN BEFORE" >> "$R"; exit 1; fi
if [ "$patch" != none ]; then git apply --check "$D/mutations/$patch.patch" || { echo "RESULT $label: PATCH DOES NOT APPLY" >> "$R"; exit 1; }; git apply "$D/mutations/$patch.patch"; fi
mkdir -p "$dir"
cargo test --test toyos-build -- --metal --metal-readback "$dir" "$filter" > "$D/stage-$label-$h.log" 2>&1; ran=$?
if [ "$patch" != none ]; then git apply -R "$D/mutations/$patch.patch"; fi
clean=dirty; [ -z "$(git status --porcelain --ignore-submodules=none)" ] && clean=clean
echo "RESULT $label | HEAD $h ($clean after) | patch $patch | cargo test --test toyos-build -- --metal --metal-readback $dir $filter | EXIT=$ran" >> "$R"
if [ -f "$dir/request.txt" ]; then
  sed -n 's/^  image: //p' "$dir/request.txt" | while read -r image; do
    echo "  sha256 $(shasum -a 256 "$image" | cut -d' ' -f1)  $image" >> "$R"
  done
fi
gates-results-209891cac.txt
HEAD 209891caceeddd8c65eb1595946d05b769edc37a
status before: []
load before:  0:51  up 3 days, 12:35, 3 users, load averages: 15.01 23.51 22.89
RESULT cargo run -- --ci host | EXIT=0
RESULT cargo run -- --build-only | EXIT=0
RESULT cargo test --test toyos-build | EXIT=0
RESULT cargo test -p toyos-proclife | EXIT=0
load after:  1:01  up 3 days, 12:45, 3 users, load averages: 44.34 33.73 27.68
status after: []
local-qemu-results.txt
HEAD 209891caceeddd8c65eb1595946d05b769edc37a
RESULT head | HEAD 209891cac (clean after) | patch none | load 36.62 32.90 27.59 | cargo test --test toyos-build -- zz_local_process_lifecycle | EXIT=0
  guest: exit Some(0) error None
RESULT nc-whole | HEAD 209891cac (clean after) | patch nc-whole | load 32.79 32.21 27.46 | cargo test --test toyos-build -- zz_local_process_lifecycle | EXIT=1
  guest: exit Some(101) error None
  thread 'main' (1) panicked at src/bin/process_lifecycle.rs:270:35:
  child 0 is held, and its watch completed
status after all: []
stage-results.txt
HEAD 209891caceeddd8c65eb1595946d05b769edc37a
RESULT cargo test --test toyos-build -- --metal --list process | EXIT=0
RESULT cargo test --test toyos-build -- --metal --list spawn_child_ends_first | EXIT=0
RESULT head-process | HEAD 209891cac (clean after) | patch none | cargo test --test toyos-build -- --metal --metal-readback /Users/jan/.claude/jobs/2280e09e/tmp/scratchpad/orch/648-r2/metal/head-process process | EXIT=2
  sha256 f04f5c2fa200a9de5815c5b806487a2636a28fd186f60fd3d93f4ee428e175ef  /Users/jan/.claude/jobs/2280e09e/tmp/scratchpad/orch/648-r2/metal/head-process/proctreecase/image.img
  sha256 0886f151467517c8e0cb713bdfa877d18c8f0ea67e030eb9587b856a72e6045e  /Users/jan/.claude/jobs/2280e09e/tmp/scratchpad/orch/648-r2/metal/head-process/shared/image.img
RESULT head-spawn_child_ends_first | HEAD 209891cac (clean after) | patch none | cargo test --test toyos-build -- --metal --metal-readback /Users/jan/.claude/jobs/2280e09e/tmp/scratchpad/orch/648-r2/metal/head-spawn_child_ends_first spawn_child_ends_first | EXIT=2
  sha256 14dea088cca20e67e91bc87b54aa47011348d60e1406e18bd3625f78995bbde5  /Users/jan/.claude/jobs/2280e09e/tmp/scratchpad/orch/648-r2/metal/head-spawn_child_ends_first/shared-debug/image.img
RESULT nc-process_lifecycle | HEAD 209891cac (clean after) | patch nc-whole | cargo test --test toyos-build -- --metal --metal-readback /Users/jan/.claude/jobs/2280e09e/tmp/scratchpad/orch/648-r2/metal/nc-process_lifecycle process_lifecycle | EXIT=2
  sha256 782f6b19cabc3449fbd842e61ffd9788efc122c6a5a13aeb49c83dec41820cad  /Users/jan/.claude/jobs/2280e09e/tmp/scratchpad/orch/648-r2/metal/nc-process_lifecycle/shared/image.img
status after all: []
metal/request.txt
# T14 run requested for pull request #648, at head 209891caceeddd8c65eb1595946d05b769edc37a.
#
# Worktree: /Users/jan/Dev/jan/toyos-proclife1, clean at that head. Every `cargo` below runs from it.
# D = /Users/jan/.claude/jobs/2280e09e/tmp/scratchpad/orch/648-r2/metal
# Staged by `cargo test --test toyos-build -- --metal --metal-readback $D/<label> <filter>`, exit 2 each
# (staged; no machine touched). Each staging's own request is $D/<label>/request.txt.
# The four images are four boots. Check each sha256 before it is flashed.

## The boots, in this order

1. head, proctreecase: `process_tree`
   image:  /Users/jan/.claude/jobs/2280e09e/tmp/scratchpad/orch/648-r2/metal/head-process/proctreecase/image.img
   sha256: f04f5c2fa200a9de5815c5b806487a2636a28fd186f60fd3d93f4ee428e175ef
   cargo run --bin toyos-metal -- --image /Users/jan/.claude/jobs/2280e09e/tmp/scratchpad/orch/648-r2/metal/head-process/proctreecase/image.img --readback /Users/jan/.claude/jobs/2280e09e/tmp/scratchpad/orch/648-r2/metal/head-process/proctreecase --fat32-check

2. head, shared: `process_lifecycle`, `std_process`
   image:  /Users/jan/.claude/jobs/2280e09e/tmp/scratchpad/orch/648-r2/metal/head-process/shared/image.img
   sha256: 0886f151467517c8e0cb713bdfa877d18c8f0ea67e030eb9587b856a72e6045e
   cargo run --bin toyos-metal -- --image /Users/jan/.claude/jobs/2280e09e/tmp/scratchpad/orch/648-r2/metal/head-process/shared/image.img --readback /Users/jan/.claude/jobs/2280e09e/tmp/scratchpad/orch/648-r2/metal/head-process/shared --fat32-check

3. head, shared-debug (the kernel that carries SYS_DEBUG): `spawn_child_ends_first`
   image:  /Users/jan/.claude/jobs/2280e09e/tmp/scratchpad/orch/648-r2/metal/head-spawn_child_ends_first/shared-debug/image.img
   sha256: 14dea088cca20e67e91bc87b54aa47011348d60e1406e18bd3625f78995bbde5
   cargo run --bin toyos-metal -- --image /Users/jan/.claude/jobs/2280e09e/tmp/scratchpad/orch/648-r2/metal/head-spawn_child_ends_first/shared-debug/image.img --readback /Users/jan/.claude/jobs/2280e09e/tmp/scratchpad/orch/648-r2/metal/head-spawn_child_ends_first/shared-debug --fat32-check

4. negative control, shared: `process_lifecycle` alone
   The image is the head with /Users/jan/.claude/jobs/2280e09e/tmp/scratchpad/orch/648-r2/mutations/nc-whole.patch
   applied: kernel/ and userland/init as on origin/main (a93067fc2, the head's merge base), the test as at the
   head. The patch was applied and reversed around the staging; the worktree is clean at the head.
   image:  /Users/jan/.claude/jobs/2280e09e/tmp/scratchpad/orch/648-r2/metal/nc-process_lifecycle/shared/image.img
   sha256: 782f6b19cabc3449fbd842e61ffd9788efc122c6a5a13aeb49c83dec41820cad
   cargo run --bin toyos-metal -- --image /Users/jan/.claude/jobs/2280e09e/tmp/scratchpad/orch/648-r2/metal/nc-process_lifecycle/shared/image.img --readback /Users/jan/.claude/jobs/2280e09e/tmp/scratchpad/orch/648-r2/metal/nc-process_lifecycle/shared --fat32-check

## The judges, in this order, each over its readbacks and touching no machine

A. cargo test --test toyos-build -- --metal --metal-readback /Users/jan/.claude/jobs/2280e09e/tmp/scratchpad/orch/648-r2/metal/head-process process
   Must exit 0, and read:
     "PASS process_tree"
     "[metal] shared: 2 member(s)", with no FAIL line under it
     "[metal] 3 passed, 0 failed, 2 boot(s)"
   head-process/shared/kernel.log must hold `test_rs_process_lifecycle` ending exit=0 and, among its lines,
   the four the watch arms print, in this order:
     "one poller: each end completes the watch of the child that ended, and only it"
     "a watch on a child already gone completes at once"
     "a kill completes a watch, as readable"
     "closing one handle to a child ends no other handle's watch"
   and after them "a process is a handle: the code is read, not claimed".

B. cargo test --test toyos-build -- --metal --metal-readback /Users/jan/.claude/jobs/2280e09e/tmp/scratchpad/orch/648-r2/metal/head-spawn_child_ends_first spawn_child_ends_first
   Must exit 0, and read:
     "[metal] shared-debug: 1 member(s)", with no FAIL line under it
     "[metal] 1 passed, 0 failed, 1 boot(s)"

C. cargo test --test toyos-build -- --metal --metal-readback /Users/jan/.claude/jobs/2280e09e/tmp/scratchpad/orch/648-r2/metal/nc-process_lifecycle process_lifecycle
   Must exit 1, and read:
     "FAIL test_rs_process_lifecycle: test_rs_process_lifecycle exited 101 on the T14"
     "[metal] 0 passed, 1 failed, 1 boot(s)"
   nc-process_lifecycle/shared/kernel.log must hold the first watch arm's panic, and none of the four lines
   under A:
     "panicked at src/bin/process_lifecycle.rs:270:35:"
     "child 0 is held, and its watch completed"
   The boot itself is healthy: `toyos-metal` exits 0 on boot 4; only the member is red.
   Under QEMU the same patched tree's member exited 101 on those two lines
   (/Users/jan/.claude/jobs/2280e09e/tmp/scratchpad/orch/648-r2/local-qemu-nc-whole-209891cac.log).

## What the judges leave behind

Judging a green `shared` boot offers `boot.shared.*` rows for tests/metal/lenovo-20w0003amz.toml. They are
not this branch's to commit: restore the file, and the worktree is clean at the head again.

@Japabu

Japabu commented Oct 2, 2026

Copy link
Copy Markdown
Collaborator Author

T14 at 209891cac, run by the orchestrator from 648-r2/metal/request.txt: four boots, each image's sha256 checked against the request before it was flashed, each toyos-metal exit 0; then the three judges from the clean worktree at the head. The worktree was clean before and after; the boot.shared.* rows a judging offered are not committed.

boot image sha256 members, exit
head, proctreecase f04f5c2fa200a9de5815c5b806487a2636a28fd186f60fd3d93f4ee428e175ef process_tree 0
head, shared 0886f151467517c8e0cb713bdfa877d18c8f0ea67e030eb9587b856a72e6045e process_lifecycle 0, std_process 0
head, shared-debug 14dea088cca20e67e91bc87b54aa47011348d60e1406e18bd3625f78995bbde5 spawn_child_ends_first 0
negative control (kernel/ and userland/init as on a93067fc2), shared 782f6b19cabc3449fbd842e61ffd9788efc122c6a5a13aeb49c83dec41820cad process_lifecycle 101
  • A, process: exit 0. PASS process_tree; 3 passed, 0 failed, 2 boot(s); head-process/shared/kernel.log:465,495,510,526 hold the four watch arms' lines in order, and :572 "a process is a handle: the code is read, not claimed".
  • B, spawn_child_ends_first: exit 0. 1 passed, 0 failed, 1 boot(s).
  • C, the control: exit 1, as required. FAIL test_rs_process_lifecycle: test_rs_process_lifecycle exited 101 on the T14; nc-process_lifecycle/shared/kernel.log:433-434: panicked at src/bin/process_lifecycle.rs:270:35: / child 0 is held, and its watch completed. The boot itself is healthy.

Readbacks and judge logs: /Users/jan/.claude/jobs/2280e09e/tmp/scratchpad/orch/648-r2/.

@Japabu

Japabu commented Oct 2, 2026

Copy link
Copy Markdown
Collaborator Author

Review of #648 at 209891cac, round 2 (against origin/main a93067fc2, the merge base)

Round 1's BLOCKER

  • The toyos-proclife model of the watch (Op::Watch, L14, a_watch_on_a_process_is_answered_by_its_end_and_by_no_close, mutate-close-ends-a-process-watch, its CONTROLS row): CLOSED. git diff origin/main 209891cac -- toyos-proclife src/ci.rs is empty. git grep -n 'Op::Watch\|OP_WATCH\|close-ends-a-process-watch' -- toyos-proclife src/ci.rs exits 1. proclife-209891cac.log:54 reads "48 passed". ci-host-209891cac.log:7382 reads "Host: 67 step(s), all green", round 1's 68 less the deleted control row.

Round 1's NOTEs and REMOVEs: all closed at this head.

  • The remainder's exit names the restart, the close to Gone and the swap's open ports, with what reads each (issues/kernel/a-childs-end-is-an-event-and-a-parent-takes-its-children-down.md:40-48).
  • The watch issue names its twin (issues/kernel/a-watch-outlives-the-close-of-the-process-handle-it-was-made-through.md:26-29).
  • init's remnant sentence, process_lifecycle.rs's arm list and the OP_WATCH clause are gone.
  • The five PR-body lines are gone.

Rulings

  • m1–m5 carried from 7bf868ef4: accepted.
    • git diff origin/main...209891cac -- kernel and git diff c59e09ed6...7bf868ef4 -- kernel are byte-identical (I reproduced cmp 0).
    • The test's code is unchanged. Its diff differs from round 1's only in the three header lines round 1 removed.
    • ABI: a deleted syscall, SYS_DEBUG action or inbox op is simply deleted #651's kernel hunks are dispatch.rs's retired-syscall table and one comment in inbox/mod.rs. Neither is on the watch path.
    • The head and the whole-change control were measured at this head.
  • "No test reads the first two": the record stands. A search outside userland/init for init's "started again as", "ports are closed" and "would not start again" finds nothing (rg exit 1).
  • Evidence read at 209891cac.
    • nc-whole.patch is byte for byte git diff 209891cac a93067fc2 -- kernel userland/init (I reproduced cmp 0).
    • QEMU, head: local-qemu-head-209891cac.log:7-21, exit 0 with all eleven arms' lines.
    • QEMU, control: local-qemu-nc-whole-209891cac.log:7,14-15, exit 101 at process_lifecycle.rs:270:35.
    • QEMU suite: guest-suite-209891cac.log:187, "26 passed, 26 total".
    • T14, judge A: judge-A.log:14,24 ("PASS process_tree", "3 passed, 0 failed, 2 boot(s)"), and head-process/shared/kernel.log:465,495,510,526,572 hold the four arms' lines and then the closing line.
    • T14, judge B: judge-B.log:21, "1 passed, 0 failed".
    • T14, judge C: judge-C.log:15,21 ("exited 101 on the T14", "0 passed, 1 failed"), and nc-process_lifecycle/shared/kernel.log:433-434 hold the panic at 270:35.
    • Every boot.txt names LENOVO 20W0003AMZ. The worktree is clean at the head.
  • Net lines: git diff --shortstat origin/main...209891cac gives 6 files, +178 −24.
    • Production +16 −13 (kernel +16 −9, init −4): accepted, as in round 1.
    • Tests +124 −1.
    • Issues +38 −10.

BLOCKER: none.

NOTE: none.

REMOVE

LAND AFTER NAMED CHANGES

@Japabu
Japabu marked this pull request as ready for review October 2, 2026 23:19
@Japabu
Japabu enabled auto-merge October 2, 2026 23:19
@Japabu
Japabu added this pull request to the merge queue Oct 2, 2026
Merged via the queue into main with commit 322085f Oct 2, 2026
6 checks passed
@Japabu
Japabu deleted the wt/toyos-proclife1 branch October 2, 2026 23:58
Japabu added a commit that referenced this pull request Oct 3, 2026
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013UDZQ6fSKw14e4w2TKTRfm
Japabu added a commit that referenced this pull request Oct 3, 2026
…hers) into wt/toyos-resident

One conflict: issues/hardware/the-t14-boots-toyos-unattended.md, which this
branch deletes and main modified. Every hunk of main's side since the merge
base 4f2bea1 is accounted for:

- e9f67e7 (#639) appended one line: "`smp_failed_ap_leaves_no_hole` is
  deleted; issues/build/smp-ap-hole-and-log-reserve-window-red-under-a-loaded-host.md
  records the commit that restores it." It qualified the sentinel's finding 2,
  which named that test as the roster's gate. No file replacing the track
  plans a sentinel or names the test; the gap a sentinel was for (the span
  before clock::init) is stated in kernel/src/deadline.rs's header, and the
  restore the line pointed at stays where it was recorded, in
  smp-ap-hole-and-log-reserve-window-red-under-a-loaded-host.md
  ("`git revert aedcf17` brings it back"). So the file stays deleted and
  the line has nothing left to qualify.

The deleted track's one other citation, from #638:
issues/build/hard-lockup-bound-ms-is-read-by-nothing-but-its-own-assertion.md
named it as owner. Its owner is now
issues/hardware/a-frozen-toyos-waits-for-a-hand-on-the-power-button.md, the
track #638's review named. Its two exits disagreed: that track's first step
gives HARD_LOCKUP_BOUND_MS a reader (the detector's bound on a boot that names
no boot-deadline=), and the issue's exit deleted the constant. The reader
stands: the issue now closes on that step, with the constant's doc saying what
it bounds, and says deleting it is not the exit, since the step would declare
it again.

issues/boot-media/the-loader-does-only-what-must-precede-the-handover.md
merged cleanly; main's two hunks (create_boot_image's per-image GUIDs, and
root_read_ticks) are in it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013UDZQ6fSKw14e4w2TKTRfm
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