fix(runtime): close control-plane, timeout, and scheduler failure-handling gaps - #1086
Closed
doublewhy wants to merge 13 commits into
Closed
fix(runtime): close control-plane, timeout, and scheduler failure-handling gaps#1086doublewhy wants to merge 13 commits into
doublewhy wants to merge 13 commits into
Conversation
Three defects in the HTTP/JSON control-plane adapter: - The request-size guard buffered the whole body via `request.body()` before measuring it. A request with no declared `content-length` (e.g. `Transfer-Encoding: chunked`) is invisible to the content-length guard, so an unbounded upload could exhaust memory even though the response was still 413. The body is now accumulated incrementally and rejected as soon as the running total exceeds the cap; Starlette's body cache is seeded so downstream parsing is unchanged. - A presented-but-unresolvable bearer token fell through to the proxy-header path instead of failing. Where header identities are trusted, a revoked or bogus token kept authenticating and the rejected credential never reached the audit log. An unresolvable token is now a 401. - The bearer path returned its identity without the target-binding check the header path applies, so a token scoped to one target authenticated against another control plane. Both paths now share `_require_target_binding`. Also compare tokens in constant time over encoded bytes (a non-ASCII token previously raised instead of reporting unauthorized), and make `ControlPlaneSecurityConfig` mappings read-only so `strict_defaults()` cannot be granted principals or tokens after construction. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…conciliation `_workflow_has_timed_out` wrapped both timestamp parses in `except Exception: return False`, so any unparseable value reported "not timed out". A RUNNING workflow whose recorded `started_at` could not be parsed therefore had no derivable deadline and stayed RUNNING for the lifetime of the control plane: reconciliation could never reclaim it, even decades past a one-second timeout. The two timestamps have different scope, so they are now handled differently: - `submitted_at` is the caller's reconciliation clock and governs every workflow in the pass, so an unusable value raises instead of quietly disabling all timeouts. The HTTP adapter already maps `ValueError` to 409 for this route. - A per-workflow `started_at` that cannot be parsed no longer blocks reclamation; the workflow is timed out under a distinct terminal reason so it stays diagnosable rather than looking like an ordinary timeout. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…h fails `_execute_concurrent_batch` reserved capacity (bumping `attempted_actions` and `in_flight`, clearing `quiescent`) before calling the backend batch method, but only `_commit_concurrent_result` clears a participant's `in_flight`. Two paths escaped before any result was committed: - a backend returning the wrong number of results raised `ValueError` - a backend raising propagated straight out of the scheduler Both left every selected participant permanently in-flight and the service non-quiescent, so no later occurrence could be admitted for them, and neither reached the scheduler's diagnostic channel. Both are now reported the way this layer reports other backend conformance failures, via `_set_concurrent_failure`, after releasing the reservations. The abandoned attempt is settled as failed rather than erased: the backend was asked to run it, so it happened and did not succeed, and that keeps the snapshot invariant `attempted_actions == succeeded + failed + in_flight` intact. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`dependency_cycles` ran Tarjan recursively, so DFS depth tracked the longest dependency path. A scenario with roughly a thousand or more chained resources raised `RecursionError` out of `_ordering_cycle_diagnostics`, aborting planning instead of reporting ordering cycles; the iterative `topological_dependency_order` beside it handled the same graph. Measured: the recursive walk dies between 900 and 2000 chained resources, while a 5000-node chain and a 3000-node cycle now both resolve. The walk is driven from an explicit frame stack. Emitted cycles are unchanged: a differential property test compares it against the recursive semantics it replaces over graphs containing self-loops and multi-node cycles. Also drops `_ordering_graph`, which was unreferenced anywhere in the repository. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The concurrent-batch failure diagnostic interpolated `str(exc)` from a backend-supplied call. Backend messages can carry host paths, credentials, or participant data, and this diagnostic travels in a portable `ApplyResult`. Only the exception type now crosses the boundary, matching `_backend_call_failed` in `backend_calls.py`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…e reservations Two follow-ups from review of the preceding fixes: - The streaming body guard appended each chunk before comparing the running total, so a single ASGI chunk larger than the limit was copied in full before the 413. The prospective length is now checked first. - Releasing a failed concurrent batch settled the reserved attempt as a failed action, but without per-action results there is no basis for the rest of a failed-action transition (`next_tick`, `next_action_index`, lifecycle). Recording the failure while leaving those untouched would let the same occurrence be serviced again at the same tick, so the reservation is reverted instead, restoring the pre-batch state exactly. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`timeout_seconds` carries no declared upper bound, so adding it to the start instant overflowed for a very large value — as a float timestamp and as a `timedelta`. The previous blanket `except Exception` hid that as "not timed out"; with the exception handling narrowed, it would instead abort the whole reconciliation pass and surface as a 500. Elapsed time is now compared against the timeout, which Python evaluates exactly for an arbitrarily large integer. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The concurrent-batch rollback zeroed the aggregate `in_flight` and subtracted all of it from `attempted_actions`. `_due_contexts` does not require `in_flight == 0`, so a participant can be due while an earlier action is still outstanding; a failed batch then erased that earlier work from the counters, turning `(attempted_actions, in_flight) == (1, 1)` into `(0, 0)`. `_reserve_concurrent_actions` adds exactly one per context, so exactly one is now withdrawn. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…h fails Undoing the reservation field by field had to stay consistent with in-flight work from an earlier batch, which `_due_contexts` does not exclude. Two ways it did not: the participant rollback withdrew only this batch's reservation while `_finish_concurrent_service_state` forced service `in_flight` to zero and `quiescent` to true, so service readback claimed no in-flight work while an earlier participant action was still live. The pre-batch snapshot is captured before reserving and reinstated wholesale on the failure paths, which is exact for both the participant counters and the service counters and needs no arithmetic. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The differential test reused a strategy capped at 8 nodes and 3 edges, which under-samples the case the explicit frame stack actually has to get right: a frame resumed while its remaining iterator still holds an on-stack dependency. The strategy now reaches 18 nodes and 6 edges per node at 400 examples. Verified separately over 20000 random graphs up to 22 nodes with no divergence from the recursive walk. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
SonarCloud reported one new issue in each of the two files this branch touches most. The two batch-failure paths differed only in their diagnostic code and message, so they now share `_abandon_concurrent_batch`, which undoes the reservations and records the failure. That removes the duplication and shortens `_execute_concurrent_batch`. The remaining two constructs are deliberate and are marked as such. The broad `except` is a backend trust boundary, where any failure has to become a diagnostic rather than escape the scheduler, exactly as `backend_calls` already does. Seeding `request._body` is Starlette's own hook for middleware that consumes the body -- `_CachedRequest.wrapped_receive` replays it to the inner app -- and has no public equivalent. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The line carried `# noqa: SLF001` before its `# NOSONAR` marker. SLF is not in this project's ruff select list, so that directive suppressed a rule that was never enabled while occupying the first comment position, which is where Sonar looks for NOSONAR. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`asyncio.get_event_loop()` outside a running loop has warned since 3.12 and raises `RuntimeError: There is no current event loop` on 3.14, so the whole module fails there — 20+ tests — while only emitting a DeprecationWarning on the interpreters CI currently pins. `asyncio.run` is the supported spelling for driving a coroutine from sync test code. Verified by running the module under `-W error::DeprecationWarning`, which reproduces the 3.14 failure: the previous spelling errors out, this one passes all 85 tests. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This was referenced Aug 12, 2026
Collaborator
|
Please note in the comments or body which issue this PR closes. If no issue exists, please create one and link it. Thank you! |
11 tasks
Author
|
Superseded by focused, issue-linked draft PRs: #1133 (HTTP authentication and request admission; closes #1090 and #1091), #1132 (workflow timeout reconciliation; closes #1102), #1135 (concurrent scheduler settlement; closes #1101), #1126 (bounded dependency-cycle detection; closes #1103), and #1134 (MCP event-loop and Python qualification; closes #1117). Together these retain and extend every independent fix from this bundled branch, while making each safety boundary reviewable on its own. Closing this overlapping PR; no unique implementation is being dropped. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Plain-language summary
Related issues
Closes #1090
Closes #1091
Related follow-up hardening remains tracked in #1101, #1102, and #1103.
Seven defects in the runtime control plane and the planner. Two are authentication flaws, one is a denial-of-service opening, three are reliability failures that leave state stuck, and one is a scalability limit.
Nothing under
contracts/schemas/,specs/,_version.pyorCHANGELOG.mdis touched, so no published schema, canonical digest, signature, or evidence bytes change.1. A credential issued for one experiment authenticates against another
Each control plane serves one target (one experiment apparatus). An identity can be scoped to a target so a credential for experiment A is useless against experiment B. That scoping was enforced for callers identified by proxy header, but skipped entirely for bearer tokens — the bearer branch returned its identity and never checked.
So a token minted for target A authenticated against target B's control plane. Run one control plane per experiment and a credential for one grants access to the others.
I enumerated the full decision space — all 72 combinations of (token, identity header, verified header, proxy-trust flag) — and ran it against both the old and new code:
trust_proxy_identity_headers=False, so it was not gated by the proxy-trust setting at allBoth paths now share
_require_target_binding.2. A revoked credential keeps working, and the rejection is never audited
If a caller presented a bearer token that did not resolve, the code did not reject it. It fell through to the proxy-header path and tried to authenticate the request another way.
Two consequences. Where header identities are trusted, a revoked or rotated token keeps working — the revocation appears to have no effect. And because nothing was rejected, the failed credential never reached the audit log, so an operator reviewing the trail sees no sign of it. That matters for a system whose purpose is auditable evidence.
A presented token that does not resolve is now a 401.
Also here: tokens are compared in constant time over encoded bytes (a non-ASCII token previously raised instead of reporting unauthorized), and the security config's mappings are read-only so
strict_defaults()cannot be granted principals or tokens after construction.3. An upload with no declared size can exhaust memory
There is a request-size limit. It was enforced by reading the entire body into memory and then measuring it.
A request that declares
content-lengthis caught by a separate header check. A request that does not declare one —Transfer-Encoding: chunked— is invisible to that check, so the only limit was the one that had already buffered everything. The response was still 413, but the memory was already gone.The body is now measured chunk by chunk and refused as soon as the running total crosses the limit, before the offending chunk is copied. Starlette's body cache is seeded so route parsing is unchanged;
_CachedRequest.wrapped_receivereplays it to the inner app, which is the framework's intended hook for middleware that reads the body.4. A workflow can become permanently unstoppable
Timeout reconciliation parsed two timestamps inside
except Exception: return False. Any unparseable value therefore reported "not timed out".A running workflow whose recorded
started_atcannot be parsed has no derivable deadline, so it reported "not timed out" every time, forever. I verified one still RUNNING 30 years past a one-second timeout. No operator action reclaims it, because reconciliation is the mechanism that would.The two timestamps have different scope, so they are now handled differently:
submitted_atis the caller's clock for the whole pass. An unusable value raises, rather than silently disabling every timeout at once. The HTTP route already mapsValueErrorto 409.started_atthat cannot be parsed no longer blocks reclamation. The workflow times out under a distinct terminal reason, so it stays diagnosable instead of looking like an ordinary timeout.5. A misbehaving backend permanently blocks its participants
Before calling a backend to run a batch of participant actions, the scheduler reserves capacity for each participant. Only a committed result releases a reservation.
If the backend raised, or returned the wrong number of results, the exception escaped the scheduler. Every selected participant stayed marked in-flight with the service non-quiescent, and nothing existed to complete them — so no later action could be admitted for those participants. The escape also bypassed this layer's diagnostic channel, which every other backend fault uses.
Both cases are now reported as diagnostics, and the pre-batch snapshot is reinstated wholesale. Restoring the whole snapshot rather than adjusting counters matters: without per-action results there is no basis for the rest of a failed-action transition, and hand-adjusted counters have to stay consistent with in-flight work from an earlier batch that the scheduler does not exclude. Reinstating is exact for both. Only the exception type crosses the backend boundary, matching
_backend_call_failed.6. A large-enough timeout crashes timeout reconciliation
timeout_secondshas no declared upper bound. Adding a very large one to the start instant overflowed — as a float timestamp and as atimedelta. The old blanketexcept Exceptionhid this as "not timed out"; with the exception handling narrowed it would instead abort the whole reconciliation pass and surface as a 500.Elapsed time is now compared against the timeout, which Python evaluates exactly for an arbitrarily large integer.
7. A large scenario crashes planning instead of being planned
Cycle detection walked the dependency graph recursively, so recursion depth tracked the longest dependency chain. A scenario with roughly a thousand chained resources raised
RecursionErrorout of the planner: no plan, no diagnostic, just a crash. Measured to die between 900 and 2000 chained resources — while the iterative topological sort beside it handled the same graph.This is a real ceiling for the project's own targets. BigRAE is described as instantiating 200 concurrent environments of 30+ assets each.
The walk is now driven from an explicit stack. A 5000-node chain and a 3000-node cycle both resolve. Detected cycles are unchanged: a differential property test compares against the recursive semantics over graphs containing self-loops and multi-node cycles, and I separately checked 20000 random graphs up to 22 nodes with zero divergence. Also removes
_ordering_graph, which was unreferenced anywhere in the repository.How I know
Every fix ships a regression test confirmed to fail before it and pass after.
Full
nox -s verifygreen on Ubuntu 22.04 / Python 3.12 — all six lanes (unit, integration, contracts, static, participant-opacity-proof, docs-local) — at 91% total coverage.🤖 Generated with Claude Code