Skip to content

A process's tids stop below Tid::MAX, and its next thread start is refused - #1184

Merged
Japabu merged 1 commit into
mainfrom
kfu-thread-ids
Nov 5, 2026
Merged

Japabu merged 1 commit into
mainfrom
kfu-thread-ids

Conversation

@Japabu

@Japabu Japabu commented Nov 5, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

process::spawn_thread took a thread's id from IdMap<Tid, ThreadEntry>
(kernel/src/id_map.rs), whose insert stepped its counter with Tid + Tid.
The kernel builds with overflow-checks = true, so one process starting and
joining threads in a loop panicked the kernel on its 2^32nd start, under
PROCESS_TABLE, which hangs the machine rather than reporting. The start
before that was issued Tid(u32::MAX), the per-CPU word for no thread, so
handle_page_fault refused every fault that thread took.

Change

Which tid a thread gets is a lifecycle decision, so it now lives in
toyos_proclife::tids, beside pids:

  • Tids issues each tid of a process once, never Tid::MAX, and says when it
    is spent.
  • Lifecycle::tids exposes it, and spawn::admit answers Admit::TidsSpent
    at both of a thread start's moments. spawn_thread answers that like its
    other refusals, so SYS_THREAD_SPAWN returns ResourceExhausted.
  • The insert takes its tid under the same lock that admitted it, so it can
    never find the tids spent. If it did, that would be a broken invariant and
    expect panics.
  • kernel/src/id_map.rs is deleted. Its only user was the thread map, which
    is now a BTreeMap beside the process's Tids. impl Add for Tid in
    toyos-abi had no other user and is deleted too. That is a token change in
    sdk/abi, so CI builds a new sysroot.

Closes issues/a-process-that-starts-four-billion-threads-panics-the-kernel.md.

Checks

Revision 4eebd97. Evidence: 5fa237b98/pr-1184/r1.

  • cargo test in kernel/proclife passes. The new tests are
    tids::tests::the_last_tid_is_one_below_max_and_none_follows_it and
    spawn::tests::a_process_whose_tids_are_spent_refuses_at_both_moments.
  • Negative controls are checked patches, each applied, built, run and reverted
    at 4eebd97. Removing the spent check from Tids::take makes the first
    test fail, and removing it from admit makes the second fail; both exit
    101 (controls).
  • cargo run -- --ci host on the tree just before the commit: exit 1, with
    clippy the only red step (log). Under
    mutate-spawn-skips-the-insert-recheck, the new test was cfg'd out, which
    left an import and a model helper unused. The fix keeps the test in both
    arms and gates only its insert assertions. The parts that change can affect
    were rerun at 4eebd97: --ci host lint, exit 0
    (log), and --ci host models, exit 0
    (log).
  • cargo run -- --build-only: exit 0 (log), on the
    same tree. The fix changed only test code.

No guest or metal run. What changed is the decision and the type that holds
it, and the host tests establish both. The kernel side is a mechanical swap of
the map type, and the exhaustive match on Admit checks it at compile time.

Risks

  • PR kernel: user copies go through the process's own mapping, and none runs under a lock (S0-a's lock half) #1150 also edits kernel/src/process.rs, in fault_in around line 1104.
    This branch changes lines 150–245 and spawn_thread, so they do not overlap.
  • kernel/src/loader/mod.rs gets one mechanical line (get_mut(&tid)).
    That file belongs to the loader move another agent is making, so this is a
    trivial overlap.
  • issues/a-double-fault-on-cpu-1-under-a-wide-suite.md says "Pids come from
    IdMap". That was already stale before this change, since pids come from
    toyos_proclife::pids. It is investigation history, so I left it unchanged.

🤖 Generated with Claude Code

…fused

`process::spawn_thread` took a thread's id from `IdMap<Tid, ThreadEntry>`,
whose `insert` stepped its counter with `Tid + Tid`. The kernel builds with
`overflow-checks = true`, so a process's 2^32nd thread start panicked under
`PROCESS_TABLE` and hung the machine; the start before it was issued
`Tid(u32::MAX)`, the per-CPU word for no thread, whose every page fault
`handle_page_fault` refuses.

Which tid a thread gets is a lifecycle decision, so it moves to
`toyos_proclife::tids`, beside `pids`: `Tids` issues each tid once, never
`Tid::MAX`, and says when it is spent. `Lifecycle::tids` exposes it, and
`spawn::admit` answers `Admit::TidsSpent` at both of a start's moments,
which `spawn_thread` answers as its other refusals (`SYS_THREAD_SPAWN`:
`ResourceExhausted`). The insert takes its tid under the lock that admitted
it, so the take cannot find them spent.

`kernel/src/id_map.rs` is deleted: its one user was the thread map, now a
`BTreeMap` beside the process's `Tids`. `impl Add for Tid` in `toyos-abi`
had no other user and goes with it.

Host tests: `tids::the_last_tid_is_one_below_max_and_none_follows_it` and
`spawn::a_process_whose_tids_are_spent_refuses_at_both_moments`, each shown
red by a checked mutation (the spent check removed from `Tids::take`, and
from `admit`).

Closes issues/a-process-that-starts-four-billion-threads-panics-the-kernel.md.

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

Japabu commented Nov 5, 2026

Copy link
Copy Markdown
Collaborator Author

Review r1 of #1184 at 4eebd97

Architecture

The tid choice moves out of a kernel-private generic IdMap and into toyos_proclife::tids. The admission (spawn::admit) that refuses a full or tearing-down process already lives there, so moving the tid choice beside it is the right owner. It mirrors pids without that type's give_back, which it does not need: a thread takes its tid only after the insert admission, under the same lock, so a refused start never holds a tid. Refusing at admission is the right boundary. SYS_THREAD_SPAWN turns None into ResourceExhausted (kernel/src/syscall/proc.rs:234-235), so userland gets a refusal and the kernel no longer panics. The deletion is complete. git grep finds no IdMap, IdKey, id_map or Tid + Tid left outside rust/, apart from the pre-existing stale pid sentence in issues/a-double-fault-on-cpu-1-under-a-wide-suite.md:114, which the PR names. The sysroot built after impl Add for Tid was removed (build-only.log), so the std fork does not use it. Net change: +148/−129. Production code shrinks: the 58-line generic map goes and a 45-line non-test Tids replaces it. Test growth is about 40 lines.

Correctness

  • Reuse: each process issues a tid once and never issues Tid::MAX, which is the per-CPU "no thread" word (arch/*/percpu.rs). A thread is always named as TaskId(pid, tid), and pids are never reused either. So a tid held across a lock release, by a join, by own_thread or by a thread_sched lookup, names that thread or nothing. The change adds no reuse hazard. It also removes an existing one: a thread could previously be issued u32::MAX, and percpu::current_tid read that as None.
  • Concurrency: two starts racing at next == MAX-1 both pass admit_thread_start. The first insert takes MAX-1. The second then gets TidsSpent from admit_thread_insert, under the lock, and goes down the existing release path: TLS is unmapped after the lock is dropped, and ks_alloc drops. The expect at process.rs:705 is a real invariant: admission and take happen under one PROCESS_TABLE hold. The only build in which it could fire is the proclife-only mutation feature, which the kernel never enables.
  • Main thread: ProcessEntry::new takes from a fresh Tids, so Tid(0) is unchanged.
  • Equivalence of the swap: IdMap::iter iterated its BTreeMap in order. The values().rev() in homes and the sysinfo sort see the same order as before. Every other edited kernel site only switches between get(tid) and get(&tid), which the type system checks.

Record

The issue's exit has two parts:

  1. A host test that the allocator refuses after Tid(u32::MAX-1): tids::the_last_tid_is_one_below_max_and_none_follows_it holds it.
  2. SYS_THREAD_SPAWN answering that as a refusal: the exhaustive Admit match plus the existing None → ResourceExhausted mapping show this by inspection.

The durable rule ("never Tid::MAX, never twice, then refused") now lives in the tids.rs header, as issues/README.md asks. No slug or path citation remains. The close is accurate.

Evidence

Checked against the evidence repository at 5fa237b98.

  • Controls: mut-admit and mut-take each fail exactly their intended test, with 61 others passing, at 4eebd97. That also shows the unmutated suite at the head.
  • Full ci-host.log: "1 of 108 step(s) red", and that step is clippy. The two transport failures in the log are expected control verdicts. The clippy errors are the unused Tid import and issue_tids_below under mutate-spawn-skips-the-insert-recheck, which the head fixes.
  • Reruns at the head: ci-host-lint and ci-host-models are both exit 0 at the head.
  • --build-only: this log is from the pre-fix tree. That is acceptable, because the later edit is test-only (#[cfg(test)] code in proclife).

No guest run was done. I accept that: the kernel edit is a type-checked change of map type with identical iteration order, and the decision itself is held by host tests.

Findings

No blockers.

SUGGESTION

  • kernel/proclife/src/lib.rs:7-8: "which pid it has and which tid each of its threads" is missing its verb. Something like "and in [pids] and [tids], its pid and its threads' tids" would fix it.
  • kernel/proclife/src/spawn.rs:157-159: "The insert's answer is the mutation's to change." reads cryptically. "Under mutate-spawn-skips-the-insert-recheck the insert admits anything." says what the cfg! guards.

Note for the orchestrator: kernel/src/loader/mod.rs:393 changes one line, get_mut(tid) → get_mut(&tid). The loader move rewrites that file, so whichever PR lands second needs that one-token rebase.

LAND

@Japabu
Japabu marked this pull request as ready for review November 5, 2026 11:48
@Japabu
Japabu added this pull request to the merge queue Nov 5, 2026
Merged via the queue into main with commit b27dd3e Nov 5, 2026
10 checks passed
@Japabu
Japabu deleted the kfu-thread-ids branch November 5, 2026 12:08
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