Skip to content

A process holds its stdio stream state once, with its descriptor table - #1226

Merged
Japabu merged 6 commits into
mainfrom
sdk-stdio-one-owner
Nov 11, 2026
Merged

Japabu merged 6 commits into
mainfrom
sdk-stdio-one-owner

Conversation

@Japabu

@Japabu Japabu commented Nov 11, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

std, a program's own copy of toyos and libc each linked their own copy of log::stdio's statics: what descriptors 1 and 2 were found to name, the ring's mapping, the partial line of each stream and the pid. A line said in pieces through two runtimes became two records, a ring was mapped and its handle duplicated once per copy and stream, and a redirect was seen only by the copy that made it (libc's dup2 forgot only libc's answer, std's dup2 forgot nobody's). The line's hold spun, so a panic raised under it waited on itself.

Resulting behaviour

  • The stream state lives once per process, in the copy that owns the descriptor table (the fd-table feature: std's in a Rust program, libc's in a C one). That copy defines toyos_stdio_* beside toyos_fd_*, and every copy reaches the state through them. It is gated like the table, on fd-table, so the host lint shape for the table's owner (cargo clippy -p toyos --lib --features fd-table) compiles and lints it too.
  • The table's owner calls stdio's changed() after each install, dup, dup2, replace and close, once its writer's lock is let go. A dup2 of a descriptor onto itself changes nothing and calls nothing. Whichever runtime asks for a redirect, the next write asks the descriptor again. stdio::forget is gone, along with libc's and the supervisor's calls to it.
  • A ring is mapped through the descriptor's own handle for the duration of the ask. No duplicate handle is kept, because a mapping outlives its handle.
  • The line's hold never waits. A write that finds the line held goes out as records of its own (a second thread's write, or a panic message raised under the hold). The hold is released on unwind. Asking a descriptor and forgetting an answer are serialised by a futex lock, ASKING. Its holder is named the asker for as long as it holds it (one guard, Asking, used by both). A write by the asker to a stream not yet answered goes to the descriptor, so the message of a panic raised under the lock (in an ask, or in changed() stamping the partial line it finishes) is written rather than waited for. The ask's own refusal panics only after the lock is let go. "No asker" is Tid::MAX, the one tid the kernel never issues; a main thread is Tid(0).
  • Correction to the first commit's message: it said the lock is "never held across a panic". That was false for changed(), which held it across the partial line's clock stamp without naming itself the asker (review r1's blocker). The Asking guard fixes it.

sdk/toyos/src/fd.rs is touched only in mod owner (the five toyos_fd_* definitions that change what a descriptor names) and in one sentence of the module header. The branch is rebased onto #1234's table. Each changed(fd) runs after change(..) returns, never inside its closure. An ask holds ASKING and fd::hold may take the writer's lock, so the only order is ASKING then the writer's lock.

Coordination

#1227 deletes libc's fd_errno, which this branch's dup2 in sdk/libc/src/posix_io.rs still calls. Whichever of #1226 and #1227 lands second writes refuse(crate::errcode::of(e)) there.

Records

  • Closed: a-stdio-sink-cached-in-one-copy-outlives-a-redirect-in-another, a-stream-write-spins-on-a-lock-its-own-panic-can-hold.
  • a-rust-program-holds-two-copies-of-its-stream-state: the stream half is fixed. Its TCP half stays open as a-tcp-stream-is-reached-only-by-the-copy-of-the-sdk-that-holds-its-end.
  • a-programs-two-streams-map-its-one-ring-twice stays open. One process still maps its ring once per stream. The record now names the chosen exit, every launcher handing the ring once with the second slot its alias (userland only), and records that each redirect onto a ring maps it again, 2 MiB kept for the process's life. This PR does not build it.

Tests

  • Host unit tests in log::stdio: two writers on one hold keep every line whole, a write under the stream's own hold returns as its own records, and a hold that a panic unwinds through is released.
  • New shared-boot member stream_state_is_one. Stderr is made a test-owned log ring. It asserts that pieces of one line from std, libc and the SDK form one record, that after a redirect through libc both std and the SDK land in the new ring, and that after a redirect through the SDK libc lands there. It needs the guest because the property is which copy of a static a linked image's three runtimes reach, and no host build links them. It runs on the T14's testcases boot. Like every discovered member, it does not run under QEMU.

Checks

Evidence, masked, with a README per round listing each command, exit code and revision: r1, r2, r3.

Head 1c3788d is 49bcdc3 plus the owed-on-metal record, a records-only commit, so every check below at 49bcdc3 holds for it. It is on main 45935f4 (#1234's table) and merges cleanly with main e558cc1, which touches none of its files.

Lands on: review LAND, the host checks below and guest run 43080395535 green at 49bcdc3, a code-identical head.

  • Host, targeted, at 49bcdc3: cargo test -p toyos (the table's model tests and log::stdio's among them), cargo clippy -p toyos --all-targets and cargo clippy -p toyos --lib --features fd-table, with the adopted lints and -D warnings: all exit 0. Only sdk/toyos has host-built code here; libc, std, the supervisor and the test member are guest-built.
  • Owed on metal (the benches are down, so metal is no longer a landing condition; the item is recorded in issues/a-tcp-stream-is-reached-only-by-the-copy-of-the-sdk-that-holds-its-end.md): the T14's testcases shared boot with testcases-watchdog, at main after this PR. main must show stream_state_is_one green (three runtimes' pieces form one record, and each redirect is seen by the other runtimes), no other testcases member regressed, and testcases-watchdog green. That member runs only on metal and has never run.
  • Image build: the metal staging at 49bcdc3 (cargo test --test toyos-build -- --metal --metal-readback <dir> boot:testcases, exit 2) built the x86_64 testcases image: the sysroot with std's and libc's owner, every rust-tests member and the C corpus. The staged images are deleted.
  • Guest rows, both archs: libc_children stream_holders a_launch_gives_each_unsent_stdio_slot_a_null session_lifecycle a_supervisor_that_fails_early_says_why_on_the_uart the_uart_carries_the_log_again_once_the_console_carrier_goes virt_user_mode virt_jobs_at_el2. These rows exercise a service's ring as stdio, redirects, libc's children and the supervisor's own lines. Every boot runs changed(), because the supervisor dup2s its ring onto 1 and 2.
    • Run 43036786936 at c981194: exit 1, 0 of 8. Every boot lost its system fileserver (code 101), because "no asker" was 0, a main thread's tid. This is the negative control for the Tid::MAX fix.
    • Run 43049938522 at that fix, before the rebase: exit 0, 8 of 8.
    • Run 43080395535 at 49bcdc3, the landing run: exit 0, 8 of 8 (r4). Run 43074728873 at b149ff2 was cancelled unrun.
  • Defects these checks found, all fixed:
    • "No asker" was a main thread's tid (the guest red above).
    • The fd-table clippy shape did not compile (the stream state was gated on ToyOS alone).
    • stream_state_is_one was unlinked (no toyos_c link attribute).
    • Review r1 found changed() holding ASKING across a panic without being the asker.
  • Not run: the full --ci host. The merge queue runs it.

🤖 Generated with Claude Code

Japabu and others added 4 commits November 11, 2026 20:05
std, a program's own copy of toyos and libc each linked their own copy of
log::stdio's statics: what descriptors 1 and 2 were found to name, the
ring's mapping, the line each stream is assembling and the pid. So a line
said in pieces through two runtimes was two records, a ring was mapped and
its handle duplicated once per copy and stream, and a redirect was seen
only by the copy asked to make it: libc's dup2 forgot libc's answer, and
std's dup2 forgot nobody's.

The state now lives in the copy that owns the descriptor table (the
fd-table feature: std's in a Rust program, libc's in a C one), which
defines toyos_stdio_* beside toyos_fd_*; every copy reaches it through
those. The table's owner calls stdio's changed() after each install, dup,
dup2, replace and close, so whichever copy asked for a redirect, the next
write asks the descriptor again. forget() is gone from the public
interface, and with it libc's and the supervisor's calls.

A ring is mapped through the descriptor's own handle for the length of the
ask, with no duplicate kept: a mapping outlives its handle.

The line's hold no longer waits. Held::with answers None where another
write holds the line, and that write's bytes go out as records of their
own, so a real-time thread never spins on a preempted holder and the
message of a panic raised under the hold is written rather than waited
for. The hold is let go on unwind. The ask of a descriptor is serialised
by a futex lock, never held across a panic, and the asking thread's own
write meanwhile goes to the descriptor.

fd.rs is touched in its owner module only: the five toyos_fd_* definitions
that change what a descriptor names each call changed() outside the table's
lock, and the module header says so.

Records: closes a-stdio-sink-cached-in-one-copy-outlives-a-redirect-in-
another and a-stream-write-spins-on-a-lock-its-own-panic-can-hold. The
stream-state record keeps its TCP half under a slug that says so. The
two-mappings record stays: one process still maps its ring once per
stream, and telling that two handles name one object is a kernel answer or
a change to every launcher.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The owner marked "no thread is asking a descriptor" with 0, and a
process's main thread is Tid(0). Every main-thread write found itself the
asker and went to its descriptor as bytes, never to the ring, and a ring's
handle takes no byte write. On every guest row of the previous head the
system fileserver ended with code 101 at boot without ever asking getpid,
which a found ring does, and the supervisor ended waiting for os-release.

The mark is now Tid::MAX, the one tid the kernel never issues.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…r does

The table's owner calls stdio's changed() from every build with fd-table,
host builds included, while the stream state was compiled for ToyOS
alone, so `cargo clippy -p toyos --lib --features fd-table`, the host
lint shape for the table's owner, did not compile. The stream state is now
gated like the table, on fd-table, and that shape lints it too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… them does

A Rust program links libc's archive only where it asks for it, and this
member's extern block named none, so `write` and `dup2` were undefined at
link and the testcases image did not build.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Japabu
Japabu force-pushed the sdk-stdio-one-owner branch from 7561f88 to b149ff2 Compare November 11, 2026 18:07
Japabu added a commit to ToyOSOrg/evidence that referenced this pull request Nov 11, 2026
@Japabu

Japabu commented Nov 11, 2026

Copy link
Copy Markdown
Collaborator Author

Review r1 of #1226 at b149ff2 (on main 45935f4)

Architecture

The stream state now lives in the copy that owns the descriptor table, and every copy reaches it through toyos_stdio_*. This follows the toyos_fd_* pattern, so it is the right owner and the simplest representation. It removes the per-copy statics, the duplicated ring handle and both forget call sites, and the table invalidates the cache itself through changed(). The decision to drop the duplicate handle is sound: the ABI's own contract for shm_map (sdk/abi/src/syscall.rs, "closing a handle takes no mapping away, but for a device's window") backs it.

Lock order against #1234's settle path is sound.

  • An ask holds ASKING and reaches WRITING through fd::hold → toyos_fd_acquire, via either table()'s first-use endowment or settled().
  • All five changed() calls (fd.rs:515, 563, 574, 583, 595) run after change() has dropped WRITING.
  • change() itself never takes ASKING. Its post-lock refusal line is files::write(2, ..), a descriptor write, not a stdio write.
  • A hold dropped under ASKING can run a release function. Those take only files' OPEN or net's TABLE, briefly, and no path holds either of those while taking ASKING.
  • The only order is therefore ASKING → WRITING.

The stdio state stays correct across dup2, close and spawn.

  • Install, dup, dup2, replace and close all invalidate.
  • Endowed descriptors are installed before any answer can exist, because an ask reaches table(), which endows first.
  • close followed by a write answers DESCRIPTOR, and the bytes are dropped as before.
  • A spawned child starts UNASKED. fd::copy changes nothing in the parent.

The Tid::MAX fix holds. kernel/proclife/src/tids.rs guarantees the kernel never issues it.

Findings

BLOCKER: sdk/toyos/src/log/stdio.rs:499-505. changed() holds ASKING across push without naming itself the asker, so a panic raised there deadlocks its own thread.

  • Mechanism. push stamps the record with toyos_abi::clock::stamp_ns(), which panics on a clock page that carries no clock (sdk/abi/src/clock.rs:216). The panic message is a write to stderr: toyos_stdio_write(Err) → answer(Err). If the change was to descriptor 2, its word was swapped to UNASKED two lines earlier. If stderr was never asked, it is also UNASKED. In both cases ASKER is not this thread, so answer calls ASKING.lock() on the thread that already holds it. That is a futex wait with no timeout, a hang where a loud failure is owed.
  • Why it matters. This is the hang that the deleted record a-stream-write-spins-on-a-lock-its-own-panic-can-hold describes, and that record named the stamp as its trigger. Closing the record is not yet true. Commit a1443e7's statement that the futex lock is "never held across a panic" is false for changed().
  • Scope. The trigger is narrow: a broken clock page, a partial line pending on a ring stderr, then a redirect of descriptor 2. It is deterministic once those hold.
  • Fix. One guard that takes ASKING and marks ASKER, resetting it on drop, used by both answer() and changed(). The panic message then goes to the new descriptor. The docs of ASKING and ASKER (stdio.rs:367-372) should then say "held by the one thread asking or forgetting, which is named the asker".

Tests and evidence

stream_state_is_one has teeth. On main its first assertion fails, because std and libc assembled lines in separate composers. Its second and third assertions also fail on main: the SDK's dup2 forgot nothing, and libc's forgot only libc's copy. It defers every assertion until stderr is restored, so a red is readable. Its header names why it needs the guest: cross-copy symbol resolution, which no host build links. It has never run.

Host evidence is sufficient for Held. cargo test -p toyos and both clippy shapes at b149ff2 exit 0 (r2/host). The hold now has no wait loop, so the host tests suffice for that part and no guest test of a spin is needed.

Round-1 guest runs are verified. Run 43036786936 at c981194 failed, run 43049938522 at ab77bd2 succeeded, and together they form a real negative control for the tid fix.

LAND-pending conditions after the fix:

  • Guest. Re-dispatch the same 8-row filter (both architectures) at the final head and require all green. Every boot runs changed(), because the supervisor dup2s its ring onto 1 and 2. The tid-0 red showed that an error in asker handling reds every row, so run 43074728873 at b149ff2 does not cover a change to that path.
  • Metal. Run the T14 testcases boot at the final head. stream_state_is_one must pass: three runtimes' pieces form one record, and each redirect is seen by the other runtimes. No other member may regress against main's latest testcases result, and testcases-watchdog must be green.

Line counts. Production grows about +145 lines (stdio.rs about +140, fd.rs +15, libc −11, supervisor −1). Tests grow +24 host lines and +105 guest lines. Records go −100/+39.

On the open record

Leaving a-programs-two-streams-map-its-one-ring-twice open is right, and its text is accurate.

SUGGESTION: issues/a-programs-two-streams-map-its-one-ring-twice.md. Two improvements to the record:

  • The root rule "kernel additions must do something userland cannot" already favours the launcher-alias route. That route uses userland only and an existing mechanism, so presenting it as an open owner choice overstates what is undecided.
  • The record could also say that each redirect onto a ring adds a 2 MiB mapping kept for the process's life. That is not new, but now any copy's change reaches it, so a program that redirects repeatedly keeps growing.

Suggestions

SUGGESTION: sdk/toyos/src/fd.rs:580-589. dup2(fd, fd) calls changed(fd). For descriptor 1 or 2 that forgets the answer, finishes a partial line as unended and maps the ring again on the next write. POSIX makes dup2 onto itself a no-op, so skip changed when old == new.

Coordination note: #1227 deletes fd_errno, which this branch keeps at sdk/libc/src/posix_io.rs dup2, and the two hunks overlap. Whichever lands second writes refuse(crate::errcode::of(e)).

SEND BACK

changed() took ASKING to forget a stream's answer and finish its partial
line into the old ring, but did not name itself the asker. A panic raised
there, as push's clock stamp raises on a clock page that carries none,
wrote its message to stderr, whose word changed() had just made UNASKED,
and so waited on the lock its own thread held: the hang the closed record
named. The earlier commit's "never held across a panic" was false for
changed().

One guard, Asking, now takes ASKING and names its thread the asker until it
drops, and both answer() and changed() hold the lock only through it. A
write by the asker to an unasked stream goes to the descriptor, so the
panic's message goes to the descriptor the change just made.

dup2 of a descriptor onto itself changes nothing, so it no longer makes
the stream ask again (finishing a partial line as unended and mapping the
ring once more).

The open record of a program's two mappings of its ring names the chosen
exit: every launcher hands the ring once with the second slot its alias,
userland only. It also records that each redirect onto a ring maps it
again, 2 MiB kept for the process's life.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Japabu added a commit to ToyOSOrg/evidence that referenced this pull request Nov 11, 2026
@Japabu

Japabu commented Nov 11, 2026

Copy link
Copy Markdown
Collaborator Author

Response to review r1 (head 49bcdc3)

  • BLOCKER changed() holds ASKING across a panic without being the asker: fixed. One guard, Asking, takes ASKING and sets ASKER to its thread. On drop it resets ASKER and then lets go of the lock. answer() and changed() both take the lock only through it. A panic in push's stamp inside changed() therefore finds its thread the asker, and its message goes to the descriptor the change just made. The docs of ASKING and ASKER say "held by the one thread asking or forgetting, which is named the asker". The PR body corrects the first commit's "never held across a panic".
  • SUGGESTION dup2(fd, fd): taken. toyos_fd_dup2 calls changed only when old != new. The table already treats that case as a no-op.
  • SUGGESTION, the open record: taken, per the orchestrator's decision. The launcher-alias route (userland only) is now recorded as the chosen exit, and the record now says each redirect onto a ring maps it again (2 MiB kept for the process's life). It is not built here.
  • Coordination with One table reads every refusal, each runtime keeps one from it to its word, and _SC_ARG_MAX is what a caller may pass #1227 (fd_errno in libc's dup2): noted in the body.

Checks at 49bcdc3: cargo test -p toyos and both toyos clippy shapes exit 0, and the T14 testcases boot is staged. The 8 rows are re-dispatched (run 43080395535), and their result will be added when it lands. Evidence: https://github.com/ToyOSOrg/evidence/tree/b15edee9e2b7cb350181f37da09ad64b84ab9ef5/r3

@Japabu

Japabu commented Nov 11, 2026

Copy link
Copy Markdown
Collaborator Author

Review r2 of #1226 at 49bcdc3 (on main 45935f4)

Scope: r1's findings and the changed code, git diff b149ff2f9 49bcdc369 (stdio.rs, fd.rs and one record).

Architecture

Unchanged from r1. The fix adds no mechanism: marking the asker moves into the lock's guard.

r1 findings

BLOCKER, changed() holds ASKING across a panic: resolved.

  • The guard is the only way in. ASKING.lock() occurs once in the tree, in Asking::take (sdk/toyos/src/log/stdio.rs:399). answer() (423) and changed() (510) take the lock only through the guard.
  • A panic in push's stamp inside changed() no longer waits on itself. Its thread is now the asker, and the panic message's write calls answer(Err):
    • If stderr's word is UNASKED, answer returns DESCRIPTOR at once, so std writes descriptor 2, which the change has already made. The word is UNASKED when changed(2) just swapped it or stderr was never asked.
    • If stderr is an answered ring (changed(1)), the write feeds PENDING[Err]. changed(1) does not hold that line, so the write does not wait. With a broken clock, its own stamp then panics again and std aborts, which is loud.
    • No case reaches ASKING.lock() on its own holder.
  • Taking and dropping the guard leave no gap.
    • Lock::lock cannot panic, so nothing unwinds between taking the lock and naming the asker.
    • Asking::drop resets ASKER before the Held field drops, so it never overwrites a successor's tid.
    • Relaxed suffices, because a thread compares ASKER only with its own tid, and coherence keeps it from reading its own superseded store.
  • The lock order holds. Under ASKING, changed() takes only the line's hold and a ring push, never the writer's lock. answer() reaches WRITING only through fd::hold. All five changed() calls in fd.rs run after change() returns. The only order is still ASKING → WRITING.
  • The ASKING and ASKER docs now say what r1 asked. The PR body corrects the first commit's "never held across a panic", and no copy of that claim remains in the tree.

SUGGESTION, dup2(fd, fd): taken. sdk/toyos/src/fd.rs:583-586 skips changed when old == new. The table's dup2 checks that old is open before it answers Ok(None) for that case, so an unopened old is still refused.

SUGGESTION, the open record: taken.

New findings

None.

Evidence

LAND-pending:

  • Guest. Run 43080395535 at 49bcdc3, the r1 8-row filter on both architectures, is queued. All 8 rows must be green. The run is also the guest boot of this head that a bench flash needs first: every boot goes through the new guard in changed(), because the supervisor dup2s its ring onto 1 and 2.
  • Metal, once that run is green. Run T14 testcases at 49bcdc3:
    • stream_state_is_one passes: three runtimes' pieces of a line form one record, and each redirect is seen by the other runtimes;
    • no member regresses against main's latest testcases;
    • testcases-watchdog is green.

LAND

stream_state_is_one runs only on the T14's testcases boot, and no bench
can boot. The record of the stream state's TCP half, which names the stream
state as one per process, carries what main must show there when a bench
returns.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Japabu

Japabu commented Nov 11, 2026

Copy link
Copy Markdown
Collaborator Author

Orchestrator: landing under the benches-down rule. Review r2 LAND; head 1c3788d is 49bcdc3 plus one record (verified). Guest run 43080395535 at 49bcdc3: success. Owed on metal recorded (T14 testcases incl. stream_state_is_one, testcases-watchdog). stream_state_is_one will also run on the new shared-boot guest row (#1271) on main once that lands; a red there is fixed forward.

@Japabu
Japabu marked this pull request as ready for review November 11, 2026 21:20
@Japabu
Japabu added this pull request to the merge queue Nov 11, 2026
Japabu added a commit to ToyOSOrg/evidence that referenced this pull request Nov 11, 2026
Merged via the queue into main with commit bd07581 Nov 11, 2026
10 checks passed
@Japabu
Japabu deleted the sdk-stdio-one-owner branch November 11, 2026 21:57
Japabu added a commit that referenced this pull request Nov 12, 2026
…toyos

Correction to the previous commit's message: the adopter's early listing
did not refuse its adoption. std links its own copy of `toyos`, and each
copy keeps its own adopted services. std's `adopt_services` filled std's
copy; the adopter's `toyos::endow::services()` read its own, which adopted
nothing, so the check that it held `fs:/system` after adopting went red
(run 43156966054, `assert_failed::<Vec<&str>, [&str; 1]>`), and the listing
before it had sealed only the copy std never consults.

- The adopter checks again that it was endowed no service, through its own
  copy, which never adopts. The check after the adoption goes: the helper's
  spawn proves the adoption, since std reads its image through `/system`,
  which std holds only by adopting it.
- The two doc lines the wrong diagnosis added go. std's `adopt_services`
  says what ends its adoption: std's own resolution of a file or lookup of
  a service.
- `process_tree`'s `a_pipe_is_no_place` no longer explains its refusal by
  the order the kernel no longer has, and `WrongType`'s doc says that
  `get_sent` never builds it.
- `issues/a-programs-own-toyos-never-sees-the-services-std-adopted.md`
  records the two adopted sets, the defect #1226 closed for stdio's state.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Japabu added a commit that referenced this pull request Nov 12, 2026
The adopter, which std's `adopt_services` gives `fs:/system`, then checked
that `toyos::endow::services()` listed it. std links its own copy of
`toyos`, and each copy keeps its own adopted services: std's adoption filled
std's copy, and the program's listing read its own, which adopted nothing.
So the check went red in `testcases_members_exit_0` (run 43156966054,
`assert_failed::<Vec<&str>, [&str; 1]>`).

- The adopter still checks, through its own copy, that it was endowed no
  service: that copy never adopts, so the check holds at any point. The
  check after the adoption goes. The helper's spawn proves the adoption,
  since std reads the helper's image through `/system`, which std holds
  only by adopting it.
- std's `adopt_services` says what ends its adoption: std's own resolution
  of a file or lookup of a service.
- `issues/a-programs-own-toyos-never-sees-the-services-std-adopted.md`
  records the two adopted sets, the defect #1226 closed for stdio's state.
- The owed-on-metal record for #1252 says `testcases_members_exit_0` runs
  the members #1252 adapted on QEMU.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Japabu added a commit that referenced this pull request Nov 12, 2026
…escriptor table

std links its own copy of `toyos`, and a program that uses the SDK links
another. Each copy kept its own record of what the process had done with
its endowments: which names were taken, its launcher and the services it
adopted.
- A program's `toyos::endow` never saw what std adopted:
  `adopted_services_stay`'s adopter listed nothing after std had adopted
  `fs:/system`.
- Nothing kept two copies from taking one label, which then had two
  owners. The launcher was such a label: each copy that launches took it
  for itself, so a program that launched through both std and its own copy
  would have held it twice.

That state now lives once, in the copy that owns the descriptor table
(std's in a Rust program, libc's in a C one), as #1226 made the stdio state
single. That copy defines `toyos_endow_take`, `_holds`, `_launcher`,
`_adopt` and `_adopted`, and every copy reaches the state through them.
Each copy still reads the names itself, since they are the startup block's.
- A name answers once in the process: a second take, from any copy, is
  refused.
- The launcher is taken once, kept for the process's life and lent to every
  copy.
- The adopted set is one set. What std adopts is what a program's
  `toyos::endow::services` and `connector` see, and a lookup through any
  copy ends the adoption.

Off ToyOS a copy holds a state of its own, as the owner holds the
process's. A host build only links it: a host binary such as the file
server's links endowment code that never runs there, since reading the
startup block reads a guest address.

Host tests hold the state's rules: a name is taken once, the launcher is
kept and refused to a take, and the adopted set is made once and ended by a
lookup. That every ToyOS copy reaches the owner's one state rests on the
arms' selection by target, by inspection, and on `adopted_services_stay`.
Its adopter again checks that its own copy lists the `/system` std
adopted. That check was red at main 302fb36 (run 43156966054), where
std's adoption filled std's copy alone. The adopter lists only after
adopting, since a listing ends the adoption.

`issues/a-programs-own-toyos-never-sees-the-services-std-adopted.md` is
closed. Its rule now heads `toyos::endow`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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