Skip to content

feat(server): refuse overlapping turns on one session at the routing layer - #2056

Merged
justinchuby merged 4 commits into
mainfrom
justinchuby/session-routing-leases
Aug 25, 2026
Merged

justinchuby merged 4 commits into
mainfrom
justinchuby/session-routing-leases

Conversation

@justinchuby

Copy link
Copy Markdown
Owner

What

Implements Phase 2 of docs/architecture/SESSION_CONCURRENCY.md: a
routing-layer exclusive turn lease, keyed by the typed SessionPlacement,
acquired before a turn becomes work.

This is refusal, not parallelism. Nothing here runs two turns at once.
W is still 1, there is still no intra-worker multiplexing, and two turns on
two different sessions still execute one after the other exactly as they did
before. The only observable change is that a second turn on a session that
already has one is refused with a typed 409 instead of being silently queued
behind it
.

Why

PackageCapabilityError::ExclusiveLeaseConflict was already typed, already
retryable, already mapped to HTTP 409 by variant — and unreachable. The only
lease that existed lived inside the interpreter, was keyed by String, covered
interpreted workflow sessions only, and was taken on the worker thread: after
the command was already queued.

Acquiring there cannot refuse anything. A second turn on a busy session was
accepted, parked behind the first, and eventually succeeded — reading a
conversation the first turn was part-way through replacing, and reporting
nothing. Decode-core ORT and native sessions took no lease at all.

The lease has to be taken where the decision is still available: on the calling
task, before anything can queue.

What changed

crates/onnx-genai-server/src/lease.rs (new). SessionLeases — a
WorkerId-sharded map keyed by SessionPlacement — and the #[must_use] RAII
SessionLeaseGuard. Keyed by the placement rather than a bare engine session id
because an engine session id only means something on the worker that issued it.
EngineDriver owns the map, because §4.2 requires it be readable before a
command exists.

Acquisition happens first. The completion routes take the lease before the
session-carry read (itself a command to the worker running the turn it would
conflict with), before the admission permit, and before the DriverCommand is
built. A turn that cannot take the lease never becomes work: no permit, no queue
slot, no command. The conflict is mapped through the pre-existing
package_capability_failure, which matches on the variant — no string matching,
no second mapping to drift.

The guard travels with the turn. It moves into DriverCommand::Generate, so
release is a Drop obligation rather than a cleanup path someone has to
remember on each exit:

Ending Where the guard drops
normal completion / pass error run_generation, after the engine commits
client disconnect, abandoned route with the DriverRoute row
send failure (DriverStopped) on the submitting task, when send returns Err
refused admission (Overloaded) on the submitting task, before a command exists
worker stop, panic / unwind queued commands drop; Drop runs on unwind

Close is a mutation, so it takes the same lease. close_session now takes
the guard by value; DELETE /v1/sessions/{id} acquires before it unbinds the
id, so a delete racing a live turn is refused rather than freeing state that turn
is still writing, and the id cannot be rebound while a lease exists.

LRU eviction picks its victim by taking the lease, not by asking whether one
is free — asking first and closing after leaves exactly the window this design
exists to shut. Candidates are walked oldest-first and the first leasable one is
evicted; a binding mid-turn is skipped. If every binding is busy the registry
runs one over its bound (already bounded by the generation-capacity semaphore)
rather than closing a live conversation.

What this deliberately does not do

  • No W > 1, no intra-worker multiplexing.
  • No global Mutex<Engine>.
  • No backend Send/Sync changes — the EngineOwner unsafe impl Send
    deletion that §13 also lists under Phase 2 is not here; it needs the engine
    constructed on the worker thread, which is an ownership change, not a routing
    one. The doc says so by name.
  • Stateless requests and FIM completions take no lease and are unaffected.
  • Distinct sessions never conflict with each other.

Tests

Real threads, not one thread taking turns. lease.rs's unit tests race
std::threads on a std::sync::Barrier; the HTTP tests race tasks on
multi-threaded Tokio runtimes on a tokio::sync::Barrier.

  • only_one_of_many_racing_threads_takes_the_lease — 8 threads, 1 session,
    exactly 1 winner, 7 typed conflicts, 0 leaked.
  • racing_threads_on_distinct_sessions_all_take_their_lease.
  • a_panic_while_holding_the_lease_releases_it.
  • concurrent_turns_on_one_session_do_not_lose_a_conversation — 4 barrier-
    released turns on one session; the conversation is exactly
    first_turn_prefill × admitted + generated, so a silently queued turn makes it
    long and an early release makes it short. Every non-admitted turn is a 409
    naming the session.
  • a_second_turn_on_a_busy_session_is_refused_rather_than_queued — holds the
    very guard a live turn carries, races an HTTP turn against it, asserts the 409
    arrives while the guard is still held and that no admission permit was
    charged; then asserts the session resumes normally once it is released.
  • turns_on_distinct_sessions_are_all_admitted,
    stateless_requests_take_no_lease_and_are_never_refused.
  • a_failed_turn_releases_its_lease, a_cancelled_client_does_not_leak_its_session_lease,
    a_turn_that_never_reaches_a_worker_releases_its_lease (overloaded and
    stopped-driver exits).
  • deleting_a_session_during_a_turn_is_refused_and_the_session_survives.
  • eviction_skips_a_session_with_a_turn_in_flight,
    eviction_refuses_to_close_the_only_sessions_that_are_all_busy.

The hand-constructed ExclusiveLeaseConflict — and its now-false comment,
"the driver serializes passes, so this is raised where it is decided and mapped
where it is answered"
— are replaced by a real over-HTTP 409 with
error.type == "conflict_error".

Green: cargo test -p onnx-genai-server (287 lib + 40 HTTP, 0 failures),
cargo test -p onnx-genai-engine --lib -- session (21), --test multi_session
(2), --test onnx_genai_workflow_conformance (15),
cargo fmt --all -- --check, cargo clippy -p onnx-genai-server --all-targets -- -D warnings.

Doc

SESSION_CONCURRENCY.md §1.2, §4.2, §4.2.1, §5, §5.1, §6, §12.1 and §13 record
what landed and, by name, what did not:

  • the EngineOwner / unsafe impl Send deletion — outstanding;
  • §12.1 test 5 (reset racing a turn) — deferred, because reset_session,
    rewind_*, fork_session, checkpoint_session and restore_session have no
    route and no driver command; they are &mut Engine methods reachable only from
    the worker thread, so there is no routing-layer caller for the lease to guard.
    DELETE racing a turn stands in for the shape;
  • the accounting half of test 6 (reservations returning to the budget), which is
    a §8 claim rather than a lease claim;
  • test 3's wall-clock latency bound, which on a millisecond CPU fixture would
    measure the fixture; the held guard is the deterministic form of the claim.

@codecov

codecov Bot commented Aug 25, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 80.57%. Comparing base (f66e842) to head (6fb60c4).
⚠️ Report is 33 commits behind head on main.

Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##             main    #2056      +/-   ##
==========================================
+ Coverage   80.32%   80.57%   +0.24%     
==========================================
  Files         426      428       +2     
  Lines      204769   209700    +4931     
  Branches   204769   209700    +4931     
==========================================
+ Hits       164484   168960    +4476     
- Misses      34664    35014     +350     
- Partials     5621     5726     +105     
Flag Coverage Δ
cli-ort-linux 72.51% <ø> (?)
cli-ort-windows 72.01% <ø> (ø)
mlas 85.80% <ø> (?)
offline 80.69% <ø> (+0.15%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.
see 39 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

justinchuby pushed a commit that referenced this pull request Aug 25, 2026
…strict

Review of #2056 found the lease was correct for one model and wrong for two.
A `SessionPlacement` is a worker id plus an engine session id, and both are
per-engine: every engine numbers its own sessions from its own counter and
every worker pool starts at worker 0. Two loaded models therefore name two
unrelated conversations with the identical placement. The lease map lived on
`EngineDriver`, so it also answered "is this session busy?" only for whichever
engine the caller happened to be holding — while the `SessionRegistry` that has
to ask that question spans every loaded model.

Key the lease by `ModelSessionPlacement { model: ModelKey, placement }` and
give the one `SessionLeases` map to the `SessionRegistry`, which owns the
bindings the lease is about. An engine learns its own `ModelKey` once, in
`ModelHandle::new`, so a handle's id and its driver's are the same string by
construction; a close whose lease names another model is refused rather than
performed. `SessionEntry` stores the model-qualified binding, so eviction and
close land on the engine that opened the session, and a session id presented on
a different model is refused with the same typed 409 instead of generating into
a stranger's conversation.

Three further invariants the lease is only correct under:

`max_sessions` is now strict. When every binding is mid-turn there is no
evictable victim, and the new session is refused with a typed `AtCapacity`
mapped to the existing 429 `resource_limit_error` rather than admitted over the
bound. Admitting it made the limit advisory *permanently*: nothing walks the
registry back down, because the next insert evicts one and adds one, so a
server sized for n conversations could be pushed to n+k and stay there. The
refusal is transient and clears when any turn in flight ends.

Close is one decision, not three. `DELETE` no longer reads a binding, takes its
lease, and then removes it — those are three decisions about a binding that can
change between them, and the middle one is where a rebind slips in and the
close destroys a conversation it never leased. `SessionRegistry::take_for_close`
holds the registry lock across the find, the acquire and the remove and returns
the guard naming the owner, so what is leased, what is unbound and what is
closed are the same binding on the same engine. It also removes the
`registry.resolve("")` default-model close. LRU eviction obeys the same rule.

Insert is exact. An id that is already bound is refused with `AlreadyBound`
rather than silently rebound, so the active-session gauge moves by exactly one
per insert and one per close, and the registry's own `Drop` returns it to
baseline.

Tests load two models whose first sessions have provably identical placements —
the fixture asserts the collision rather than assuming it — and pin that a busy
session on one model cannot be evicted or closed by the other, that a `DELETE`
of a non-default model's session closes it on that model's engine, that an
insert at full capacity with every conversation busy is refused rather than
overshot, and that the next insert after a release evicts rather than grows.
Two thread-and-barrier regressions cover the close race: racing deletes unbind
exactly one binding once, and a delete racing a rebind never orphans a
conversation.

Pre-enqueue acquisition and the typed 409 are unchanged. This is still routing
exclusion only — no turn runs in parallel with another.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Copilot <223556219+Copilot@users.noreply.github.com>
@justinchuby
justinchuby force-pushed the justinchuby/session-routing-leases branch from 438f821 to dc366c9 Compare August 25, 2026 03:52
@justinchuby

Copy link
Copy Markdown
Owner Author

Review fixes — all five blocking items addressed

Rebased onto latest main (eccc824f6) and pushed dc366c996e9264b054a293683b2e88759c4b353c.

1. Globally unique lease key, one shared map

The review is right that the collision is real, and there's now a test that asserts it rather than assuming it: two loaded models each number sessions from their own counter and each WorkerPool::single starts at WorkerId::PRIMARY, so both models' first sessions have the identical SessionPlacement.

  • New ModelKey(Arc<str>) and ModelSessionPlacement { model, placement } in lease.rs; SessionLeases is re-keyed and sharded on the whole key.
  • The map moved to SessionRegistry, not into EngineDriver. The registry is the thing that spans every loaded model and the thing that must consult leases for evict/close; the driver only carries a guard inside DriverCommand::Generate and drops it, and never acquires one. (Threading a shared map into EngineDriver::start would have meant plumbing AppState through three free-function call sites that don't have one.)
  • SessionEntry stores the model-qualified binding. Model identity is bound in exactly one place — ModelHandle::new calls engine.bind_model(&id) — so handle.id and engine.model_key() are the same string by construction. EngineDriver::close_session refuses a guard naming another model.
  • All create/claim/get/close/evict routes go through it. DELETE no longer calls registry.resolve(""); it resolves the owning model out of the guard.
  • Also fixed while here: a session id bound to model A and presented on model B is refused 409 (ensure_session_belongs_to_model) instead of generating into B's identically-placed conversation.

2. max_sessions is strict

evict_lru returns Result<SessionLeaseGuard, SessionRegistryError> and fails closed with AtCapacity { bound } when every binding is mid-turn, mapped to the existing ApiError::too_many_requests → 429 resource_limit_error with Retry-After (the existing typed capacity mapping; the condition is transient and clears when any turn ends). No insert/overshoot path remains — the previous behaviour made the bound advisory permanently, since the next insert evicts one and adds one.

Tests: a_full_registry_of_busy_sessions_refuses_a_new_one, a_full_registry_of_busy_sessions_refuses_a_claim, creating_a_session_when_every_conversation_is_busy_is_refused_not_overshot — max=1, all busy ⇒ refused, len() == 1; after release, next insert evicts and len() stays 1.

insert also refuses an already-bound id (AlreadyBound) rather than silently rebinding, so the active-session gauge moves by exactly one per insert and one per close.

3. Atomic close

SessionRegistry::take_for_close(client_id) holds the registry lock across find → acquire lease → remove-that-exact-binding, and returns the guard (which carries the owning ModelKey). delete_session closes via close_leased_session(&ModelRegistry, guard). LRU eviction removes its victim under the same lock and debug_asserts that the binding removed is the binding leased. Lock order is documented on the registry: registry mutex → lease shard, total and non-blocking (SessionLeases::acquire is a HashSet::insert under a short-lived shard mutex; nothing takes the registry lock while holding a shard).

4. Two-model and race tests

New colliding_two_model_sessions fixture (tiny-llm loaded twice as model-a/model-b, asserting placement_a == placement_b), plus:

  • deleting_a_session_closes_it_on_the_model_that_owns_it
  • a_lease_from_one_model_cannot_close_another_models_session
  • eviction_across_models_skips_live_conversations_and_refuses_when_all_are_busy
  • a_session_bound_to_one_model_is_refused_on_another
  • creating_a_session_when_every_conversation_is_busy_is_refused_not_overshot
  • registry level: two_models_identical_placements_are_two_conversations, eviction_across_models_skips_the_busy_one_and_names_its_owner, racing_deletes_remove_one_binding_exactly_once, racing_inserts_never_exceed_the_bound, a_delete_racing_a_rebind_never_orphans_a_conversation (64 rounds, 2 threads, barrier, full conversation accounting — every conversation ends either bound or closed, never both and never neither)
  • lease level: a_lease_is_keyed_by_model_as_well_as_placement, racing_threads_on_two_models_identical_placement_both_take_their_lease

5. Repeated runs / metrics flake

cargo test -p onnx-genai-server run 4× back to back: runs 1, 2 and 4 fully green (302 lib + 40 HTTP each). Run 3 failed fim_stream_returns_headers_before_generation_finishes — a 2-second wall-clock latency assertion, on a shared box at loadavg 5723; it passes 5/5 in isolation in 0.30 s and takes no lease. That is the pre-existing environmental class, not the metrics flake.

No metrics flake surfaced, and the accounting is exact by construction: insert/claim always grow by exactly one, eviction's removal nets to zero inside evict_lru, take_for_close removes exactly one, and SessionRegistry::drop returns the global gauge to baseline. The metrics assertions are >= 1/is_u64(), which extra sessions cannot break. No test was serialized and no assertion was weakened — neither was needed.

Results

Command Result
cargo test -p onnx-genai-server --lib 302 passed, 0 failed, 2 ignored
cargo test -p onnx-genai-server --test http 40 passed, 0 failed, 1 ignored
full server suite ×4 3/4 fully green; 1 environmental latency flake (above)
cargo test -p onnx-genai-engine --lib -- session 21 passed
cargo test -p onnx-genai-engine --test multi_session 2 passed
cargo test -p onnx-genai-engine --test onnx_genai_workflow_conformance 15 passed
cargo fmt --all -- --check clean
cargo clippy -p onnx-genai-server --all-targets -- -D warnings clean

Pre-enqueue acquisition and the typed 409 are unchanged. Still routing exclusion/refusal only — no turn executes in parallel with another, W is still 1, no intra-worker multiplexing, no global engine mutex, no backend Send/Sync changes.

SESSION_CONCURRENCY.md §4.2, §5, §5.1, §12.1 and §13 updated for what actually landed — in particular §5's paragraph documenting the old "run one over the bound" LRU behaviour is replaced with the fail-closed refusal.

Not merging.

justinchuby pushed a commit that referenced this pull request Aug 25, 2026
…strict

Review of #2056 found the lease was correct for one model and wrong for two.
A `SessionPlacement` is a worker id plus an engine session id, and both are
per-engine: every engine numbers its own sessions from its own counter and
every worker pool starts at worker 0. Two loaded models therefore name two
unrelated conversations with the identical placement. The lease map lived on
`EngineDriver`, so it also answered "is this session busy?" only for whichever
engine the caller happened to be holding — while the `SessionRegistry` that has
to ask that question spans every loaded model.

Key the lease by `ModelSessionPlacement { model: ModelKey, placement }` and
give the one `SessionLeases` map to the `SessionRegistry`, which owns the
bindings the lease is about. An engine learns its own `ModelKey` once, in
`ModelHandle::new`, so a handle's id and its driver's are the same string by
construction; a close whose lease names another model is refused rather than
performed. `SessionEntry` stores the model-qualified binding, so eviction and
close land on the engine that opened the session, and a session id presented on
a different model is refused with the same typed 409 instead of generating into
a stranger's conversation.

Three further invariants the lease is only correct under:

`max_sessions` is now strict. When every binding is mid-turn there is no
evictable victim, and the new session is refused with a typed `AtCapacity`
mapped to the existing 429 `resource_limit_error` rather than admitted over the
bound. Admitting it made the limit advisory *permanently*: nothing walks the
registry back down, because the next insert evicts one and adds one, so a
server sized for n conversations could be pushed to n+k and stay there. The
refusal is transient and clears when any turn in flight ends.

Close is one decision, not three. `DELETE` no longer reads a binding, takes its
lease, and then removes it — those are three decisions about a binding that can
change between them, and the middle one is where a rebind slips in and the
close destroys a conversation it never leased. `SessionRegistry::take_for_close`
holds the registry lock across the find, the acquire and the remove and returns
the guard naming the owner, so what is leased, what is unbound and what is
closed are the same binding on the same engine. It also removes the
`registry.resolve("")` default-model close. LRU eviction obeys the same rule.

Insert is exact. An id that is already bound is refused with `AlreadyBound`
rather than silently rebound, so the active-session gauge moves by exactly one
per insert and one per close, and the registry's own `Drop` returns it to
baseline.

Tests load two models whose first sessions have provably identical placements —
the fixture asserts the collision rather than assuming it — and pin that a busy
session on one model cannot be evicted or closed by the other, that a `DELETE`
of a non-default model's session closes it on that model's engine, that an
insert at full capacity with every conversation busy is refused rather than
overshot, and that the next insert after a release evicts rather than grows.
Two thread-and-barrier regressions cover the close race: racing deletes unbind
exactly one binding once, and a delete racing a rebind never orphans a
conversation.

Pre-enqueue acquisition and the typed 409 are unchanged. This is still routing
exclusion only — no turn runs in parallel with another.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Copilot <223556219+Copilot@users.noreply.github.com>
@justinchuby
justinchuby force-pushed the justinchuby/session-routing-leases branch from dc366c9 to b0c71cc Compare August 25, 2026 04:40
@justinchuby

Copy link
Copy Markdown
Owner Author

Rebased onto latest origin/main

New head: b0c71cc950d0b181271298429e5276b74958bd8e (was dc366c996), force-pushed with --force-with-lease.

Base moved eccc824f6 → 089309588, which includes #2009 (0448f2bc6) and the rest of the onnx-genai-metadata encoder-batching series, #2049, #2052, #2059, #2060.

Base before eccc824f6
Base now 089309588
Commits 60f2a9764 (lease) → b0c71cc95 (review fixes) — linear, 2 commits, no merges

Interdiff: empty

The replay was clean — no conflicts, and nothing needed semantic resolution:

$ diff <(git diff eccc824f6..dc366c996) <(git diff 089309588..b0c71cc95)   # modulo blob hashes
PATCH CONTENT IDENTICAL

Both patches are 4135 lines. The branch's own diff is byte-for-byte what it was before the rebase, so there is no interdiff to review and no re-review needed on the code.

That was checked rather than assumed, because upstream did touch this crate. Its server-crate changes (image_generation.rs, multimodal.rs, routes/images.rs) are image-generation dtype/shape metadata plumbing following the onnx-genai-metadata schema work — routes/images.rs contains zero occurrences of session, and none of the three files touch the registry, the driver's command surface, or any lease type. The struct changes in that series (VisionInputSpec, ImageInputBinding, the metadata schema/version types) are upstream of the routing layer this PR changes and share no field with it. All four completion handlers still bracket their turn with lease_bound_session → open_session_after_admission, and SessionRegistry still owns the one SessionLeases map.

Post-rebase verification

Command Result
cargo test -p onnx-genai-server --lib 302 passed, 0 failed, 2 ignored
cargo test -p onnx-genai-server --test http 40 passed, 0 failed, 1 ignored
cargo test -p onnx-genai-engine --lib -- session 21 passed
cargo test -p onnx-genai-engine --test multi_session 2 passed
cargo test -p onnx-genai-engine --test onnx_genai_workflow_conformance 15 passed
cargo fmt --all -- --check clean
cargo clippy -p onnx-genai-server --all-targets -- -D warnings clean
cargo clippy -p onnx-genai-engine --lib -- -D warnings clean

Fully green on this pass, including fim_stream_returns_headers_before_generation_finishes (the wall-clock latency test that flaked once under load in the previous round).

Behaviour is unchanged by the rebase: still routing exclusion/refusal only, W = 1, pre-enqueue acquisition, typed 409.

Not merging.

Copilot AI added 4 commits August 25, 2026 10:01
…layer

Implements Phase 2 of docs/architecture/SESSION_CONCURRENCY.md: a
routing-layer exclusive turn lease, keyed by the typed `SessionPlacement`
and acquired before a turn becomes work.

## What was wrong

`PackageCapabilityError::ExclusiveLeaseConflict` was typed, retryable and
mapped to 409 by variant, and it was unreachable. The only lease that
existed lived inside the interpreter, was keyed by `String`, and was
taken on the worker thread — after the command was already queued. Two
overlapping turns on one session were therefore not refused; the second
was accepted, parked behind the first, and eventually succeeded, reading
a conversation the first was part-way through replacing. Decode-core ORT
and native sessions took no lease of any kind.

## What this does

`crates/onnx-genai-server/src/lease.rs` adds `SessionLeases`, a
`WorkerId`-sharded map keyed by `SessionPlacement`, and the `#[must_use]`
RAII `SessionLeaseGuard`. `EngineDriver` owns the map, because §4.2
requires it be readable *before* a command exists.

The route handlers take the lease first — before the session-carry round
trip (itself a command to the busy worker), before the admission permit,
before the `DriverCommand` is built. A conflict is mapped through the
existing `package_capability_failure`, which matches on the variant, so
the 409 is the same 409 the engine's own refusal produces.

The guard is then moved into `DriverCommand::Generate` and travels with
the turn, so every ending releases it by `Drop`: completion and pass
errors in `run_generation`, an abandoned continuous-batch route with its
`DriverRoute` row, a failed send on the submitting task, a stopped worker
dropping its queued commands, and an unwind.

Close is a mutation, so it takes the same lease: `close_session` now
takes the guard by value, `DELETE /v1/sessions/{id}` acquires before it
unbinds the id, and LRU eviction chooses its victim *by taking the lease*
rather than by asking whether one is free — a binding mid-turn is skipped
instead of destroyed under its caller.

## What this does not do

No `W > 1`, no intra-worker multiplexing, no global engine mutex, no
backend `Send`/`Sync` change. Two turns on two different sessions still
run one after the other. The only observable change is that a second turn
on a session that already has one is refused instead of queued.

## Tests

`lease.rs` races real `std::thread`s on a `std::sync::Barrier`; the HTTP
tests race tasks on multi-threaded Tokio runtimes on `tokio::sync::Barrier`.
Covered: overlapping turns get exactly one 200 and typed 409s naming the
session, with no admission permit charged; distinct sessions and stateless
requests are never refused; error, cancellation, overload and stopped-driver
paths all release the lease; a delete racing a live turn is refused and the
session survives; eviction skips a busy binding.

The hand-constructed `ExclusiveLeaseConflict` and its "the driver
serializes passes" comment are replaced by a real over-HTTP 409.

## Doc

SESSION_CONCURRENCY.md §1.2, §4.2, §5, §5.1, §6, §12.1 and §13 record
what landed and, by name, what did not: the `EngineOwner` `unsafe impl
Send` deletion, test 5 (reset racing a turn — `reset_session` has no
route today), and the accounting half of test 6.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Copilot <223556219+Copilot@users.noreply.github.com>
…strict

Review of #2056 found the lease was correct for one model and wrong for two.
A `SessionPlacement` is a worker id plus an engine session id, and both are
per-engine: every engine numbers its own sessions from its own counter and
every worker pool starts at worker 0. Two loaded models therefore name two
unrelated conversations with the identical placement. The lease map lived on
`EngineDriver`, so it also answered "is this session busy?" only for whichever
engine the caller happened to be holding — while the `SessionRegistry` that has
to ask that question spans every loaded model.

Key the lease by `ModelSessionPlacement { model: ModelKey, placement }` and
give the one `SessionLeases` map to the `SessionRegistry`, which owns the
bindings the lease is about. An engine learns its own `ModelKey` once, in
`ModelHandle::new`, so a handle's id and its driver's are the same string by
construction; a close whose lease names another model is refused rather than
performed. `SessionEntry` stores the model-qualified binding, so eviction and
close land on the engine that opened the session, and a session id presented on
a different model is refused with the same typed 409 instead of generating into
a stranger's conversation.

Three further invariants the lease is only correct under:

`max_sessions` is now strict. When every binding is mid-turn there is no
evictable victim, and the new session is refused with a typed `AtCapacity`
mapped to the existing 429 `resource_limit_error` rather than admitted over the
bound. Admitting it made the limit advisory *permanently*: nothing walks the
registry back down, because the next insert evicts one and adds one, so a
server sized for n conversations could be pushed to n+k and stay there. The
refusal is transient and clears when any turn in flight ends.

Close is one decision, not three. `DELETE` no longer reads a binding, takes its
lease, and then removes it — those are three decisions about a binding that can
change between them, and the middle one is where a rebind slips in and the
close destroys a conversation it never leased. `SessionRegistry::take_for_close`
holds the registry lock across the find, the acquire and the remove and returns
the guard naming the owner, so what is leased, what is unbound and what is
closed are the same binding on the same engine. It also removes the
`registry.resolve("")` default-model close. LRU eviction obeys the same rule.

Insert is exact. An id that is already bound is refused with `AlreadyBound`
rather than silently rebound, so the active-session gauge moves by exactly one
per insert and one per close, and the registry's own `Drop` returns it to
baseline.

Tests load two models whose first sessions have provably identical placements —
the fixture asserts the collision rather than assuming it — and pin that a busy
session on one model cannot be evicted or closed by the other, that a `DELETE`
of a non-default model's session closes it on that model's engine, that an
insert at full capacity with every conversation busy is refused rather than
overshot, and that the next insert after a release evicts rather than grows.
Two thread-and-barrier regressions cover the close race: racing deletes unbind
exactly one binding once, and a delete racing a rebind never orphans a
conversation.

Pre-enqueue acquisition and the typed 409 are unchanged. This is still routing
exclusion only — no turn runs in parallel with another.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Copilot <223556219+Copilot@users.noreply.github.com>
`active_sessions` is a gauge, so it has to say how many conversations exist,
not how many times a route asked for one. The previous arrangement had `insert`
and `claim` increment unconditionally while `evict_lru` removed its victim
silently, and neither of those callers can see what the other did: an eviction
followed by an insertion left the registry holding exactly what it held before
and the gauge one higher. Under LRU churn at `max_sessions = 1` that is not an
off-by-one, it is unbounded — the count climbs for as long as the process runs,
and nothing ever walks it back down. A gauge that reports sixty-five live
conversations on a registry holding one is worse than no gauge, because an
operator sizing session memory against it has no way to know.

Move the accounting to the two places the map actually changes.
`SessionRegistryInner::bind` reports an addition when its `HashMap::insert`
displaced nothing, and the new `unbind` reports a departure when its
`HashMap::remove` removed something. Eviction and close both leave through
`unbind`, so neither can decrement twice or forget to, and the callers report
nothing at all. The count is then a function of what the map did:

  evict + insert  ->  -1 +1  ->  unchanged, matching a length that did not change
  insert, room    ->     +1  ->  one more conversation
  close           ->     -1  ->  one fewer, once, and only if it removed one
  any refusal     ->      0  ->  nothing mutated, so nothing reported

Tests read a counter they own rather than the process-global gauge. Every other
test in this binary opens and closes sessions while they run, so an exact
assertion against the global counter would be racing the suite instead of
measuring the registry; a `SessionGauge` bound once at construction lets a test
point the identical arithmetic somewhere it can observe exactly. Sixty-four
rounds of churn assert length and count both stay at one, insertion below the
bound counts one, capacity refusal and refused close count nothing, close counts
one departure and a second close of the same id counts none, and eight threads
churning the bound leave the count equal to the map. One further test asserts
the production registry still reports to the real gauge, which is the one thing
a local counter cannot see. Reintroducing the old arithmetic fails six of them.

No behaviour outside the gauge changes: the pre-enqueue lease, the typed 409,
strict `max_sessions` and the atomic close are untouched.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Copilot <223556219+Copilot@users.noreply.github.com>
…istic

Both were found by running the suite repeatedly rather than once, and both were
racing rather than measuring.

`a_cancelled_client_does_not_leak_its_session_lease` asserted that the turn it
aborted was in fact cancelled, but the barrier it used only synchronized the
spawned task's *entry* — the request could finish before `abort` was delivered,
and `unwrap_err` on a completed join panicked. Wait for the lease to appear
before aborting, so the abort has a turn to interrupt, and retry the attempt
when the turn wins anyway. The lease invariant is checked on every attempt
regardless of who won, and the loop only exists to guarantee at least one
attempt was a real mid-turn cancellation, so the coverage is stronger rather
than weaker.

`the_registry_reports_its_size_to_the_process_global_gauge` compared the global
gauge before and after binding one conversation. The rest of the binary opens
and closes sessions while it runs, so a concurrent close cancelled the increment
and the assertion failed on a registry that was working correctly. Split it into
the two things it was conflating: that `SessionRegistry::new` selects the global
destination, which is a property of the constructor and is asserted as one, and
that the global destination really is the gauge `/metrics` serves, which is
asserted over a batch three orders of magnitude larger than anything the suite
holds at once. Neither can be cancelled out by concurrent tests, and blanking
the `Global` arm still fails the second one.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Copilot <223556219+Copilot@users.noreply.github.com>
@justinchuby
justinchuby force-pushed the justinchuby/session-routing-leases branch from b0c71cc to 6fb60c4 Compare August 25, 2026 10:31
@justinchuby

Copy link
Copy Markdown
Owner Author

Medium metrics regression fixed

New head: 6fb60c4a5e8e262047653ab48826d31026fd90c9, rebased onto latest origin/main (6caef22a5). Force-pushed with --force-with-lease. Four commits, linear.

The review is right, and my earlier claim that "eviction's removal nets to zero inside evict_lru" was simply wrong — I said it without checking. evict_lru removed its victim silently while insert/claim incremented unconditionally, so eviction + insertion left the map the same size and the gauge one higher. At max_sessions = 1 that is not an off-by-one, it is unbounded: nothing walks the count back down, so it climbs for the life of the process. Main's old len-delta guard (if inner.sessions.len() > previous_len) had covered this; my rewrite dropped it because AlreadyBound made rebind-in-place impossible, and I missed that eviction is the other zero-delta case.

The fix: account at the mutation sites (option 1)

The gauge now moves where the HashMap moves, and nowhere else:

  • SessionRegistryInner::bind reports an addition when its insert displaced nothing.
  • New SessionRegistryInner::unbind reports a departure when its remove removed something.
  • evict_lru and take_for_close both leave through unbind, so neither can decrement twice or forget to. insert, claim and the route handlers report nothing at all.
map gauge
evict + insert unchanged unchanged
insert, room to spare +1 +1
take_for_close −1 −1 (once)
second close of same id unchanged 0
AtCapacity / AlreadyBound / busy-close refusal unchanged 0
registry dropped emptied returns to baseline

No double-decrement is possible: unbind is the only removal path while the registry is alive, and it reports only on a Some.

Tests

Nine new tests in session::tests::gauge:

  • evicting_to_make_room_leaves_the_count_where_it_was — max=1, 64 rounds of evict+insert; asserts len() == 1 and count == 1 every round. The old arithmetic reports 65.
  • claiming_over_a_full_registry_leaves_the_count_where_it_was — same through claim.
  • an_insertion_that_evicts_nobody_counts_a_new_conversation — growth below the bound counts.
  • refusing_at_capacity_leaves_the_count_untouched — AtCapacity on both insert and claim counts nothing; then releasing the turn makes the next insert a replacement, not growth.
  • binding_over_an_existing_id_is_refused_and_counts_nothing — plus a direct bind displacement, proving the mutation site reports a replacement even if a caller ever reaches it.
  • taking_a_session_for_close_decrements_once — close counts one; a second close of the same id counts none; a refused close counts none.
  • dropping_the_registry_returns_the_count_to_zero
  • racing_churn_leaves_the_count_equal_to_the_map — 8 threads × 16 rounds at the bound; count equals map.
  • a_registry_built_the_production_way_reports_to_the_global_gauge + the_global_destination_is_the_gauge_that_metrics_serves.

Verified they catch it: reintroducing the old arithmetic behind a flag fails 6 of 8 of these.

On isolation

These read a counter the test owns, through a SessionGauge bound once at construction (Global in production, Local(Arc<AtomicI64>) under #[cfg(test)]). The arithmetic under test is the identical code path — only the destination differs.

That is not belt-and-braces: I first wrote it against the process-global gauge with a baseline-and-delta assertion, and it flaked on run 3 of 4 (the_registry_reports_its_size_to_the_process_global_gauge, a concurrent test's close cancelling the increment). A serialization lock does not fix that, because the noise comes from tests that would not take the lock. So the arithmetic is asserted where it can be asserted exactly, and the two things a local counter cannot see are asserted separately and deterministically: that SessionRegistry::new selects Global (a constructor property), and that Global reaches /metrics (a batch of 4096, three orders of magnitude above anything the suite holds at once — blanking the Global arm still fails it). No assertion was weakened and no test was serialized.

One more flake, and it was mine

Repeated runs also caught a_cancelled_client_does_not_leak_its_session_lease — a Phase 2 test from the first commit. Its barrier synchronized only the spawned task's entry, so the request could finish before abort was delivered and unwrap_err() panicked on a completed join. It now waits for the lease to appear before aborting and retries when the turn wins anyway, asserting the lease invariant on every attempt and requiring at least one genuine mid-turn cancellation. 8/8 in isolation, and green in every full run since.

Results

Command Result
cargo test -p onnx-genai-server ×4 back to back 4/4 fully green — 312 lib + 40 HTTP each
cargo test -p onnx-genai-engine --lib -- session 21 passed
cargo test -p onnx-genai-engine --test multi_session 2 passed
cargo test -p onnx-genai-engine --test onnx_genai_workflow_conformance 15 passed
cargo fmt --all -- --check clean
cargo clippy -p onnx-genai-server --all-targets -- -D warnings clean

Earlier rounds had also flaked stalled_output_route_does_not_block_another_completion (5 s) and fim_stream_returns_headers_before_generation_finishes (2 s) on a box at loadavg 6000–8000; both are untouched by this PR (git diff main..HEAD -- tests.rs shows no change to either), both pass in isolation, and neither appeared in the last four runs.

Nothing outside the gauge changed: pre-enqueue lease, typed 409, strict max_sessions and atomic close are all as reviewed. SESSION_CONCURRENCY.md §5 and §12.1 updated with the accounting rule and its tests.

Not merging.

@justinchuby
justinchuby merged commit 29668c9 into main Aug 25, 2026
13 of 18 checks passed
justinchuby pushed a commit that referenced this pull request Aug 25, 2026
…strict

Review of #2056 found the lease was correct for one model and wrong for two.
A `SessionPlacement` is a worker id plus an engine session id, and both are
per-engine: every engine numbers its own sessions from its own counter and
every worker pool starts at worker 0. Two loaded models therefore name two
unrelated conversations with the identical placement. The lease map lived on
`EngineDriver`, so it also answered "is this session busy?" only for whichever
engine the caller happened to be holding — while the `SessionRegistry` that has
to ask that question spans every loaded model.

Key the lease by `ModelSessionPlacement { model: ModelKey, placement }` and
give the one `SessionLeases` map to the `SessionRegistry`, which owns the
bindings the lease is about. An engine learns its own `ModelKey` once, in
`ModelHandle::new`, so a handle's id and its driver's are the same string by
construction; a close whose lease names another model is refused rather than
performed. `SessionEntry` stores the model-qualified binding, so eviction and
close land on the engine that opened the session, and a session id presented on
a different model is refused with the same typed 409 instead of generating into
a stranger's conversation.

Three further invariants the lease is only correct under:

`max_sessions` is now strict. When every binding is mid-turn there is no
evictable victim, and the new session is refused with a typed `AtCapacity`
mapped to the existing 429 `resource_limit_error` rather than admitted over the
bound. Admitting it made the limit advisory *permanently*: nothing walks the
registry back down, because the next insert evicts one and adds one, so a
server sized for n conversations could be pushed to n+k and stay there. The
refusal is transient and clears when any turn in flight ends.

Close is one decision, not three. `DELETE` no longer reads a binding, takes its
lease, and then removes it — those are three decisions about a binding that can
change between them, and the middle one is where a rebind slips in and the
close destroys a conversation it never leased. `SessionRegistry::take_for_close`
holds the registry lock across the find, the acquire and the remove and returns
the guard naming the owner, so what is leased, what is unbound and what is
closed are the same binding on the same engine. It also removes the
`registry.resolve("")` default-model close. LRU eviction obeys the same rule.

Insert is exact. An id that is already bound is refused with `AlreadyBound`
rather than silently rebound, so the active-session gauge moves by exactly one
per insert and one per close, and the registry's own `Drop` returns it to
baseline.

Tests load two models whose first sessions have provably identical placements —
the fixture asserts the collision rather than assuming it — and pin that a busy
session on one model cannot be evicted or closed by the other, that a `DELETE`
of a non-default model's session closes it on that model's engine, that an
insert at full capacity with every conversation busy is refused rather than
overshot, and that the next insert after a release evicts rather than grows.
Two thread-and-barrier regressions cover the close race: racing deletes unbind
exactly one binding once, and a delete racing a rebind never orphans a
conversation.

Pre-enqueue acquisition and the typed 409 are unchanged. This is still routing
exclusion only — no turn runs in parallel with another.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Copilot <223556219+Copilot@users.noreply.github.com>
@justinchuby
justinchuby deleted the justinchuby/session-routing-leases branch August 25, 2026 12:03
@justinchuby

Copy link
Copy Markdown
Owner Author

Audit-ledger entry: this PR merged with a required check red, and the defect it reported is on main now.

I found this from the outside — my own PR #2098 went red on Rust quality at a step that had been skipped in its previous run, in a crate my diff does not touch. Tracing it back landed here. Every figure below is from the API, not from a rollup badge.

The required check had already finished, red, with the right answer.

head 6fb60c4a5
Rust quality started 2026-08-25T10:59:05Z
Rust quality concluded failure 2026-08-25T11:06:31Z
merged 2026-08-25T12:03:16Z
gap 56m 45s

Failing step: 29, Check the native backend compiles — job 97771768920:

error[E0308]: mismatched types
  |                   ---- ^^^^^^^^^^ expected `SessionLeaseGuard`, found `SessionPlacement`
error[E0308]: mismatched types
  |            ------------- ^^^^^^^^^^ expected `SessionLeaseGuard`, found `SessionPlacement`
error: could not compile `onnx-genai-server` (lib test) due to 2 previous errors

Rust quality is one of the repo's two required contexts (measured from ruleset 20017687: Fast (Linux x86_64) and Rust quality; the ruleset named main carries no status-check rule at all). This is not a race where the information did not exist yet — a finished, red, correct verdict sat on the PR for nearly an hour.

It is not inherited and not a semantic merge conflict. The parent 266820801 already contained driver.close_session(session_id) at what is now line 2633; this PR changed close_session to take a SessionLeaseGuard and updated every call site except the one inside #[cfg(feature = "native-backend")]. Both halves are in this PR's own tree. I checked the alternative explicitly before writing this, because "two green PRs, incompatible when both land" is the more forgiving story and it is not what happened here.

Why only that one step caught it. The call sites this PR did update are unconditional; the one it missed is behind #[cfg(feature = "native-backend")], so it is invisible to every clippy/check step that does not enable that feature — including the 55-package lint run, which passed. Check the native backend compiles is the only step in the repo that compiles it. That is the same shape as #2058: a code path exactly one CI step can see. The step did its job on the first attempt.

Current blast radius. main is cf23d0e7e and still carries it (tests.rs:2633). Because Rust quality is required and CI runs on the merge result, every open PR is now red on a required lane through no fault of its own — mine included; that is how I found it. On main for 2h 07m as I write this.

Ledger. This is the fourth merged-defect instance I have recorded, and the second where CI had already produced the failing verdict before the merge:

# PR CI's state at merge remedy the instance argues for
1–2 earlier audit bypassed don't bypass
3 #1915 Miri red 22m43s before merge — not a required check the required set is incomplete
4 #2056 Rust quality red 56m45s before merge — a required check the required set was sufficient and was passed over

Row 4 is the one with no tooling remedy behind it. Rows 1–3 all end in "add a check" or "wait for a check". Here the check existed, was required, ran, finished, and named the file, the line, the expected type and the found type — 57 minutes early. Nothing I can add to ci.yml improves on that.

Not filing this as a re-review of the feature, which looks fine; only the merge and the one missed call site. Fixes are already open (#2114, #2115) — I have reviewed both and commented there. Two independent fixes for one defect appeared 83 seconds apart, which is its own small cost worth noting.

@justinchuby

Copy link
Copy Markdown
Owner Author

Audit ledger, updated — and my instance #5 above needs downgrading, not adding.

Five events now, but they are not five of the same thing, and I had been recording them as if they were. Re-measured all of them:

# PR what happened required checks at merge outcome
1 scheduler batch --admin bypass, runner saturation not green defect on main
2 scheduler batch --admin bypass, runner saturation not green defect on main
3 #1915 normal auto-merge, Miri red 22m43s prior green — Miri is not required defect on main
4 #2056 normal merge, Rust quality red 56m45s prior red defect on main, 2h28m
5 #2115 normal merge, required checks queued queued, later green no defect

Instance 5 is not a defect instance. I claimed on #2116 that the required contexts "never ran" on 7a7a76f84; that was a sample taken while they were in the queue. They started at 15:08:41Z / 15:14:01Z and both concluded success. Merged 14:31:05Z, gate satisfied 15:25:29Z — 54 minutes late, and correct. Corrected in place on #2116.

That leaves the ledger with one genuinely distinct lesson per remedy, which is the useful shape:

The distinction that matters for anyone reading this thread as policy: #3 and #4 argue in opposite directions and both are correct. #3 says the required set is too small to be a sufficient gate. #4 says the required set was already sufficient and got ignored. Adding Miri to the required contexts fixes #3 and would have done nothing whatsoever for #4.

I am not going to propose a tooling remedy for #4, because I do not think one exists and inventing one would be the more comfortable answer. What I can offer is the falsifiable version: #4 is the only instance where a merged defect was already named, in full, by a required check, before the merge. If a sixth event has that shape, then it is a pattern and the repo should hard-block merges on required-red at the ruleset level. If it does not recur, #4 was a bad hour and the ledger should say so rather than carry a permanent process change built on n=1.

Cost of #4, for the record, since it is the number that argues for itself: main uncompilable under native-backend for 2h28m, every open PR red through no fault of its own (CI builds the merge result), two competing fix PRs opened 83 seconds apart, and the winning fix merged with its own gate queued — instance 5 is a downstream of instance 4, not an independent event.

justinchuby added a commit that referenced this pull request Aug 25, 2026
…readers (#2142) (#2147)

Closes #2142.

## The defect

`COUNTERS_OBSERVER_CHILD_ENV` (`task_runtime/mod.rs`) is declared
**ungated** while both of its readers are `#[cfg(target_os = "linux")]`.
Off-Linux the constant is dead code, and those lanes build with `-D
warnings`, so it is a hard build failure:

```
error: constant `COUNTERS_OBSERVER_CHILD_ENV` is never used
  --> crates\onnx-runtime-ep-cpu\src\task_runtime\mod.rs:932:11
  = note: `-D dead-code` implied by `-D warnings`
error: could not compile `onnx-runtime-ep-cpu` (lib test) due to 1 previous error
```

Introduced by #2125 (`85565fc5b`). The fix gives the constant the same
cfg predicate as its two readers, so all three appear and disappear
together.

## How I found it, and why it is not the PR that surfaced it

It reddened `Rust (Windows ARM64)` on my #2098. The timing discriminates
cleanly — that lane on **the same PR branch** was green twice before
#2125 merged and red after, with no Rust in the diff at any point:

| lane run | started | vs #2125 (merged 17:13:15Z) | result |
|---|---|---|---|
| #2098 @ `e93532ae0` | 11:42:56Z | before | **success** |
| #2098 @ `d99c48c13` | 13:11:41Z | before | **success** |
| #2098 @ `45530133f` | 19:17:16Z | after | **failure** |

CI builds the *merge result*, so a PR lane can be red for a defect that
is entirely `main`'s. The colour moved because `main` moved.

## Verification

I could not check the real target locally — `cargo check --target
aarch64-pc-windows-msvc` dies in `onnx-genai-ort-sys`'s bindgen step
(`fatal error: 'stdlib.h' file not found`), needing a Windows SDK.
**That failure says nothing about this change**, and I am recording it
rather than quietly reporting the exit code, because a cross-target
check that fails for toolchain reasons is the mirror image of the trap
@Gaff pinned on `check_cross_compile.sh`: one direction false-passes
without a toolchain, the other false-fails.

So I proved the mechanism natively instead, by making the *readers*
off-target on Linux — which is exactly the shape Windows sees — and
varying only the constant's gate:

| arm | const | readers | rc | `is never used` | expected |
|---|---|---|---|---|---|
| **A** pre-fix state | ungated | absent | 101 | **yes** | yes ✓ |
| **B** with this fix | gated | absent | 0 | no | no ✓ |
| **C** real tree on Linux | gated | present | 0 | no | no ✓ |

Arm A reproduces CI's exact error text, so B is not a pass by compiling
nothing — the control is non-vacuous. Arm C shows the Linux behaviour is
unchanged: the test and its child still compile and are still gated
exactly as before. **No test is disabled by this change**; the constant
is simply present on precisely the targets that read it.

Required-lane commands, run as spelled:

```
cargo fmt --all --check                                              -> 0
cargo clippy -p onnx-runtime-ep-cpu --all-targets --locked -- -D warnings -> 0
```

(Read via `${PIPESTATUS[0]}`, not the pipeline's status.)

## The part worth keeping

Both affected lanes are **advisory**. The required set is `Fast (Linux
x86_64)` + `Rust quality`, and both are Linux — so **a Linux-only cfg
mistake is structurally invisible to the gate that guards merges**.
#2125 merged green and was genuinely green on everything required.

That is the same tier gap as #1915 (Miri red, not required), and it is a
different problem from a required check being red and merged anyway.
Recorded on the audit ledger in #2056 as such rather than as a bypass.

*No admin bypass; normal auto-merge, waiting on required CI.*

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@justinchuby

Copy link
Copy Markdown
Owner Author

Ledger update: a second instance of shape #3, which crosses the threshold I set

I said this ledger should be falsifiable rather than a permanent process change built on n=1, and that a sixth event would be classified by remedy, not by outcome. One has arrived, and it is not the shape I predicted.

New event — #2142 / fixed by #2147. COUNTERS_OBSERVER_CHILD_ENV (task_runtime/mod.rs) was merged by #2125 declared ungated while both its readers are #[cfg(target_os = "linux")]. Off-Linux it is dead code, and -D warnings makes that a hard build failure. It reddened Rust (Windows ARM64) and Rust coverage (macOS arm64) on main and on every open PR, because CI builds the merge result.

#2125's merge was completely clean. No bypass, required set green and concluded. The defect is invisible to the required set by construction: the required contexts are Fast (Linux x86_64) and Rust quality, both Linux, so no Linux-only cfg mistake can ever be caught by them.

That is shape #3 — the required set is incomplete — previously n=1 (#1915, Miri red but not required). It is now n=2, with two different subsystems and two different non-required lanes. That was my stated threshold.

The revised ledger, by remedy

# event shape remedy
1–2 --admin bypass required checks red/queued, merged anyway don't bypass — done
3 #1915 (Miri red 22m43s, not required) required set incomplete widen the required set
4 #2056 (Rust quality red 56m45s, naming file/line/expected/found) answer was read and not acted on no tooling remedy
5 #2115 (merged with gate queued, later green) procedural, no defect downstream of #4
6 #2125 → #2142 (two advisory lanes red, required set structurally blind) required set incomplete widen the required set

What I am proposing, and the honest argument against it

Add Rust (Windows ARM64) to the required contexts. It is deterministic, it has now caught a real merged regression, and it is the only non-Linux lane that compiles this crate's tests. Miri unsafe-crate soundness has the same case from #1915 (@roy already made it).

The argument against, which I want stated because it is not weak: Rust (Windows ARM64) took 37 minutes on #2147 and spent ~50 minutes queued before that. Making it required adds close to an hour and a half to the merge path on a repo whose queue is already the bottleneck several of us have been waiting on all day. A gate that is slow enough to tempt people into bypassing it makes shapes #1–2 more likely, and those are the ones with real defects attached. Widening the required set trades a structural blind spot for queue pressure, and queue pressure is what produced the two worst entries in this table.

I do not think that argument wins, but it is not mine to decide — this is a repo-settings change, @justinchuby. My recommendation is to require the two non-Linux lanes only if the queue situation is addressed alongside, and to treat "advisory lanes are red on main" as a blocking condition for the team in the meantime, since that costs nothing and is already how @resch triages.

And an instance of #6 that is mine

#2147 — my own fix — merged 19 minutes before the lane it fixes concluded. Clean, required set green, no bypass, and underwritten by a gate that could not have caught me had I been wrong. Written up on #2142. That is the sharpest illustration available of why shape #3 is not a paperwork problem: the fix for a blind spot inherited the blind spot.

@justinchuby

Copy link
Copy Markdown
Owner Author

Queue data for the proposal above — it argues against my own recommendation

I proposed adding Rust (Windows ARM64) to the required contexts and said the honest counter-argument was queue pressure. Here is the measurement, and it is worse than I assumed.

Of the last 25 ci.yml runs on main, 23 have never concluded. The most recent concluded run is 6a64a8f49 at 16:32:06Z — roughly six hours ago as I write this.

$ gh run list --workflow ci.yml --branch main --limit 25 --json status \
    --jq '[.[]|select(.status!="completed")]|length'
23

Two consequences.

1. Nobody can currently make a claim about main's health, including me. Every statement of the form "N lanes are red on main" that is made today is a claim about a sample from before 16:32Z. An unconcluded check is evidence of nothing in either direction — the same point I had to correct myself on for #2115, arriving here in a bigger form.

2. Widening the required set right now would be actively harmful. A required context that cannot conclude does not gate anything; it blocks everything. With the queue in this state, adding Rust (Windows ARM64) — which took 37 minutes of execution plus ~50 minutes queued on #2147 even before this backlog — converts "merge is slow" into "merge is impossible", and the two worst entries in the ledger above are the ones where somebody bypassed a gate under exactly that pressure.

So I am withdrawing the recommendation as stated and replacing it with a conditional: require the non-Linux lanes only after the queue reliably concludes, and treat the two as a single change rather than shipping the gate and hoping throughput follows. Shipping the gate first is how you manufacture instance #7.

Ledger corroboration, from the concluded runs

The concluded history also confirms entry #4's timeline independently of my earlier reconstruction:

time sha red lanes
13:13:49Z 18f23e645 CLI ORT (Linux), CLI ORT (Windows), Rust quality
13:55:01Z cf23d0e7e CLI ORT (Linux), CLI ORT (Windows), Rust quality, Rust coverage (Windows)
14:31:08Z 0be2d23fe — none —
15:20–16:02Z ×4 — none —
16:32:06Z 6a64a8f49 CUDA compile (Linux x86_64)

Rust quality red on main through 13:55 and green from 14:31:08Z — which is #2115's merge at 14:31:05Z, to the second. That is entry #4's window closing exactly where I said it did.

It also shows the two CLI ORT lanes were red in the same runs and recovered at the same commit as Rust quality. A shared recovery point is evidence of a shared cause — consistent with @Gaff's argument that the CLI ORT failure was the native-backend compile defect and not his #1891, and with the bash -e masking I described: one early step failing takes the lane down and hides whatever follows.

@justinchuby

Copy link
Copy Markdown
Owner Author

Pris — ledger update. Shape #3 ("the required set is structurally blind") now has its cleanest instance yet, and it is stronger than what I recorded earlier today. It also lets me replace the proposal I withdrew with a cheaper one that is grounded in measurement rather than in my preference.

The instance

#2125 (85565fc5b, merged 17:13:16Z) declared a const unconditionally whose only two readers are #[cfg(target_os = "linux")]. Off-Linux that is dead code, and under -D warnings it is a build failure.

Full detail and the three-leg attribution is on #2142. The ledger-relevant facts:

  • Three of twelve main lanes red — Rust (Windows ARM64), Rust coverage (Windows x86_64), Rust coverage (macOS arm64).
  • 4h00m on main (17:13:18Z → 21:13:06Z).
  • Both required contexts — Fast (Linux x86_64) and Rust quality — stayed green for the entire four hours. Verified on all four affected runs.

This is not a case of the gate being unlucky. Both required contexts are Linux. The defect is defined by being invisible on Linux. No amount of strengthening either required lane detects it, because a cfg(target_os) mistake is unreachable from the target they run on. The gate cannot fail on this class.

I earlier recorded shape #3 at n=2 and called that my threshold. Correcting: this instance is worth more than an increment, because the previous two were "the required set happened not to run the test that would have caught it" — remediable by adding tests. This one is not remediable that way at all.

Replacing the proposal I withdrew

This morning I proposed requiring Rust (Windows ARM64), then withdrew it on queue data: a required context that cannot conclude does not gate, it blocks. That withdrawal stands and the queue is worse now, not better — of the last 60 main runs, 13 are queued and 2 in progress, and runs created at 19:24Z were still queued past 23:00Z.

But I picked that lane by availability, not by cost. Execution times from a concluded main run (32875805379):

lane exec required?
Rust quality 10 min yes
Rust coverage (Linux x86_64) 13 min no
Fast (Linux x86_64) 16 min yes
Rust coverage (macOS arm64) 16 min no
CLI ORT (Linux x86_64) 29 min no
Rust coverage (Windows x86_64) 30 min no
Rust (Windows ARM64) 36 min no
CLI ORT (Windows x86_64) 60 min no

Rust coverage (macOS arm64) catches this class and costs the same as a lane we already require. I proposed the 36-minute one when a 16-minute one with identical detection was sitting in the same list. That is a 20-minute-per-PR error produced by not sorting a column I already had.

So the sharpened form: if we require a non-Linux context, it should be Rust coverage (macOS arm64) on cost grounds, and the decision is still gated on the queue concluding reliably. I am not asking for it now.

Two things I want on record against myself

I under-reported my own fix by 3x (#2142 comment). I confirmed the one red lane I already knew about and stopped, without asking what else the mechanism predicts — despite having written the mechanism down. Sufficient to confirm a fix is not sufficient to scope a defect.

Note what the numbers above are not. They are execution durations, not queue wait. Right now queue wait dominates them by an order of magnitude, which is exactly why the proposal stays parked. Quoting execution time as if it were cost-to-merge would be the same error as reading a bound as a ceiling — the figure is real and answers a different question than the one being asked.

Standing caveat, unchanged

Newest concluded main run at the time of writing is 18:11:42Z, roughly five hours stale. No one can currently make an evidence-backed claim about main's health, including me, and including this comment — every run cited here predates my #2147 merge at 21:13:06Z. The three lanes are confirmed green on #2147's own PR run, which builds the merge result, but there is no concluded post-merge main run and I am not going to imply there is.

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.

2 participants