Skip to content

fix(runtime): do not hand a thread a shut-down no-steal runtime - #982

Open
rickcrawford wants to merge 1 commit into
cloudflare:mainfrom
rickcrawford:fix/current-handle-stale-runtime
Open

fix(runtime): do not hand a thread a shut-down no-steal runtime#982
rickcrawford wants to merge 1 commit into
cloudflare:mainfrom
rickcrawford:fix/current-handle-stale-runtime

Conversation

@rickcrawford

Copy link
Copy Markdown

Fixes #981.

CURRENT_HANDLE is a ThreadLocal keyed by an id the thread_local crate recycles on thread exit, so a registration outlives the worker that made it and turns up under whichever thread is handed the same id next. A NoStealRuntime built after an earlier one has shut down can start life holding the dead runtime's handles, and every task its workers spawn through current_handle() is canceled the instant it is spawned, on a runtime that is otherwise healthy. The issue has the full mechanism.

The fix stores the owning ThreadId beside the pools. A std::thread::ThreadId is never reused for the life of the process, so the comparison is exact rather than probabilistic. init_pools() claims the slot by assignment rather than get_or, so a worker that inherits a recycled id overwrites the stale entry instead of adopting it, and current_handle() falls through to Handle::current() unless the registration belongs to the calling thread. The unwrap() on the pools OnceCell goes with it, since the owner check makes it unnecessary. The assignment also drops the stale Arc that get_or used to keep alive, so this retains less, not more.

The added test drives the registration side of the bug and fails on main without this change:

test no_steal_worker_spawns_onto_its_own_runtime ... FAILED
a worker of the live second runtime spawned onto the shut-down first one: task 2 was cancelled

It is in a file of its own with a single #[test], because it depends on being the only thing in its process allocating thread_local ids. The reasoning is in the file's module comment so a later reader does not add a second test and quietly break it.

cargo test -p pingora-runtime, cargo clippy -p pingora-runtime --all-targets -- -D warnings, and cargo fmt --check are clean.

CURRENT_HANDLE is a ThreadLocal keyed by an id the thread_local crate
recycles on thread exit, so a registration outlives the worker that made
it and reappears under whichever thread is handed the same id next. A
NoStealRuntime built after an earlier one has shut down can therefore
start life holding the dead runtime's handles, and every task its
workers spawn through current_handle() is canceled the instant it is
spawned, on a runtime that is alive and healthy. This is issue cloudflare#981.

Store the owning ThreadId beside the pools. A std::thread::ThreadId is
never reused for the life of the process, so the comparison is exact.
init_pools() now claims the slot by assignment rather than get_or, so a
worker that inherits a recycled id overwrites the stale entry instead of
adopting it, and current_handle() falls through to Handle::current()
unless the registration belongs to the calling thread. The unwrap() on
the pools OnceCell goes with it, since the owner check makes it
unnecessary.

The added test drives the registration side and fails without the fix.
It lives in a file of its own with a single test because it depends on
being the only thing in its process allocating thread_local ids.
rickcrawford added a commit to soapbucket/sbproxy that referenced this pull request Aug 29, 2026
…es it (#1245)

* fix(runtime): give Pingora workers an 8 MiB stack and measure what uses it

main is aborting on CI's request-path smoke lane with

    thread 'Pingora HTTP Proxy Service' has overflowed its stack
    fatal runtime error: stack overflow, aborting

edf8b93 is the commit that tipped it, but the AI request path was
already using more than half a worker's 2 MiB stack before that landed,
so it is the straw and not the load.

Pingora never set thread_stack_size, so every worker ran on tokio's
2 MiB default. The fork now defaults to 8 MiB, the RLIMIT_STACK default
Linux gives a main thread, and exposes it as runtime_thread_stack_size;
this bumps the pinned rev and adds SB_WORKER_STACK_BYTES beside
SB_WORKER_THREADS. The cost on a 64-bit target is reserved address
space, not memory: roughly 264 MiB across 33 threads out of 128 TiB,
resident nothing, because a thread stack is committed page by page as it
is touched.

Raising the ceiling without a floor gauge trades a painful signal for no
signal, so the guards change shape. The two that measured size_of on a
future are gone: they reported 13.9% and 74.9% of budgets that between
them could see about 4% of what fills the stack, and both stayed green
through three overflows. A future's size is the state it holds between
polls; the stack is the chain of frames live during one.

server::stack_probe measures the second thing. Pingora's runtime records
each worker's stack base, the request path takes the address of a local
at its deepest point, and the difference is bytes in use.
the_ai_dispatch_path_stays_inside_its_stack_budget drives a whole
streamed AI request through request_phase::request_filter on a worker
sized to scripts/stack-budget-baseline.count, and checks two things: the
request completes, which covers every frame below the probes; and the
probe reported a non-zero depth, so the check cannot pass vacuously. The
budget only falls, enforced by check-stack-budget-ratchet.sh.

The probe costs a call, two thread-local reads, a subtract and an
untaken branch per request, and uses no unsafe.

The smoke lane sat behind a twelve-path trigger that neither breaking
commit touched, which is why main sat broken with nothing to say so. A
stack budget cannot be gated on a path list: a frame added in any crate
spends the same budget. It moves to its own workflow with no filter and
gains the budget test as its first step.

* fix(test): filter the stack budget test by name, not by exact path

`--exact` matches the full module path, so `--exact
the_ai_dispatch_path_stays_inside_its_stack_budget` selected nothing and
the run reported "0 passed; 2857 filtered out" with exit code 0. A CI
step that cannot select its own test is a green lane that checks
nothing, which is the failure this branch exists to end.

* test(stack): fix the fixture YAML mangled when lifting the helper

Lifting `ai_request_context` to module scope meant dedenting it, and the
dedent ran over the raw string literal too, flattening the YAML the
fixture pipeline compiles from. The test failed loudly rather than
quietly measuring a shorter path, which is the behavior worth having.

Also: the budget is now an assertion on a production-sized worker rather
than an overflow on a budget-sized one. An overflow aborts, and an abort
has no number and no message, which is the wrong failure for the check
that exists to explain this class of bug. The abort stays underneath for
a path that outgrows the whole production stack.

Measured on this branch: 1,600,928 bytes of 4,194,304.

* fix(stack): follow the fork's StackMark API and re-pin the rev

pingora-runtime's worker_stack::here() now returns a StackMark rather
than a bare usize. In a debug build the mark carries the ThreadId that
made it and used_here asserts the two agree, so comparing a mark against
a stack it does not belong to fails loudly where tests run; a release
build carries neither the field nor the check, so the probe still costs
a call, two thread-local reads, a subtract and an untaken branch.

The module doc also stops promising more than the fork delivers. A
work-stealing worker, which is the flavor sbproxy runs, is marked from
tokio's on_thread_start callback rather than from the top of the thread,
so the number under-reports by roughly one closure frame: tens of bytes
against a budget in the megabytes. The fork documents which thread
flavors are marked from where; this points at it.

Lockfile: the pinned rev only, 11 lines, one per patched crate.

* docs(pingora): record what the fork actually is, and fix the re-sync step

Three things, all about the same confusion: the pinned fork declares
0.8.0 and is named for it, and neither is a release we track.

Cargo.toml's comment above [patch.crates-io] now says what we run. The
tree carries 167 upstream commits the 0.8.0 tag does not, plus our
seven, so the version is an API generation and not something to match an
advisory against. The concrete numbers come from scripts/divergence.sh
in the fork, which its CI also prints on every PR:

  base:   0046038 (2026-08-07)
  ahead:  7 commits of ours
  behind: 23 upstream commits
  files:  20 differ, 14 outside .github/

CLAUDE.md and AGENTS.md both told the release procedure to rebase
sbproxy-0.8.0 onto "the target upstream tag". That is the step somebody
actually follows at a release, and following it would move the fork onto
a different line: Cloudflare cuts releases on a release branch, so 0.8.1
is not an ancestor of main. It holds 8 commits main lacks while main
holds 190 it lacks. It would also strand the current_handle() fix we
have upstreamed at cloudflare/pingora#982, which lands on main.

Both files now say to rebase onto a newer upstream main, give the
release-branch reason in a sentence so nobody re-derives it, and point
at scripts/divergence.sh for the before-and-after. The lockfile step
gained the rule this branch followed: diff it and revert anything that
is not the pingora-* rev bumps.

Lockfile re-pinned to 2d9dc2b, the merged fork tip, which is the stack
work plus the divergence report. Eleven rev lines and nothing else.

* fix(stack): make the ratchet and the budget assertion able to fail

Independent review found that the guard this branch adds could not fail,
which is the defect the branch exists to remove. Four of the findings
are that same shape.

C1. check-stack-budget-ratchet.sh resolved its base with `git merge-base
HEAD origin/main` and ran in ci.yml's lint job, whose checkout has no
fetch-depth. On a pull_request event neither origin/main nor main
resolves, so it printed a note and exited 0: it ran on every pull
request and checked nothing. The three sibling ratchets in that lane
count source sites, which a depth-1 checkout serves, which is why none
of them exposed it. It now fails closed when no base resolves and takes
STACK_BUDGET_BASE_REF the way the changelog guard next door does, and
the step moved to the guards job, whose checkout is full depth for
exactly this reason. Its --self-test is wired there too.

M1. the_ai_dispatch_path_stays_inside_its_stack_budget built its worker
with .thread_stack_size(STACK_BUDGET), so the thread's stack was the
budget and `used <= budget` could not fail without the process having
already aborted, while the doc claimed the opposite. The abort also sat
at half the production stack, so it could abort on a path production
serves. The worker is now sized to DEFAULT_THREAD_STACK_SIZE, the budget
is the assertion, and the two tiers are named in the doc and printed in
the log line.

M2. The budget was half the stack, 4,194,304 against 1,600,952 measured:
2.6x of slack, when the commit that broke main moved the depth 31,152
bytes. Now set from the measurement, pending the Linux number this PR's
own smoke lane prints.

M4. ServerConf::validate() runs only from from_yaml and sbproxy passes a
struct literal, so the sub-page refusal documented in docs/manual.md
never ran: SB_WORKER_STACK_BYTES=8 started and aborted on the first
request. resolve_worker_stack_bytes refuses it with a warning and falls
back to the default, and the manual says what actually happens.

M5 gives that hunk its first test. M3 adds the key to the env-var table.
M6 corrects five comments that still called the stack 2 MiB, one of them
inside an assertion message. m1 restores the buffered path's stack
coverage, which deleting the non-streaming test lost along with the only
reach to the probe in relay_ai_response_with_cache. m3 makes the smoke
lane count the tests that ran, because cargo test exits 0 on a filter
that matches nothing, which is the third appearance of that defect in
this change. m4 scopes the workflow trigger. m5 retypes the fragment.

Measured after: streaming 1,600,952 and buffered 1,468,456 of a
2,621,440 budget on an 8,388,608 stack.

* test(ci): TEMPORARY C1 proof, ratchet in a shallow checkout

Reverted in the next commit. Runs the stack-budget ratchet in ci.yml's
lint job, whose checkout has no fetch-depth, which is the exact
environment where it used to resolve no base and exit 0. With the
fail-closed fix it has to turn this job red. The guards job runs the
same script against a full-depth checkout in the same workflow, so one
run shows both halves.

* Revert "test(ci): TEMPORARY C1 proof, ratchet in a shallow checkout"

The proof is recorded. Run 33220527341 on PR #1245:

  lint job,   checkout fetch-depth: 1
              cannot resolve a base to ratchet against
              job FAILED

  guards job, checkout fetch-depth: 0
              check-stack-budget-ratchet self-test: ok
              stack budget: 2621440 bytes (new on this branch)
              job PASSED

That is the exact environment where the ratchet used to print a note and
exit 0, now failing closed, beside the job where it actually runs. This
reverts the temporary step; the real one stays in guards.

* test(eval): count timeouts against the jobs present, not a literal

Moving the request-path smoke job to its own unfiltered workflow left
context-compression-eval.yml with one job, and
readme_and_workflow_cover_reproducibility_and_external_data_boundaries
asserted `timeout-minutes:` appeared at least twice. The comment above
that line already said to pin the property and not the number, and the
line did the opposite.

It now counts `runs-on:`, which appears once per job, and requires a
timeout for each. Verified against both workflows: 1 job, 1 timeout,
each.

Not run locally. This harness needs the 1.98 pin and the only toolchains
on this machine are Homebrew 1.95 and rustup 1.90, so CI is the check.

* fix(stack): set the budget from the Linux measurement

2,359,296 = ceil(1_604_072 / 256 KiB) * 256 KiB + 512 KiB, where
1,604,072 is STACK_HIGH_WATER_BYTES printed by this PR's own
production request-path smoke lane on Linux. Buffered measured
1,471,496 on the same run.

One thing the two numbers settle, because I had assumed otherwise: the
same fixture measures 1,600,952 on aarch64 macOS, 3,120 bytes less than
Linux. Linux debug frames are not meaningfully larger for this path. The
distance between 1.6 MiB here and the 2 MiB stack that actually
overflowed in CI is the configuration that lane runs, compression and a
CEL policy plane and guardrails, not the target.

---------

Co-authored-by: t <t@t>
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.

current_handle() can return a NoStealRuntime that has already shut down

1 participant