Skip to content

apollo_state_reader,apollo_batcher: bound class manager reads in ClassReader - #14984

Merged
asaf-sw merged 3 commits into
main-v0.14.3from
claude/dreamy-curie-igc0ss
Aug 18, 2026
Merged

apollo_state_reader,apollo_batcher: bound class manager reads in ClassReader#14984
asaf-sw merged 3 commits into
main-v0.14.3from
claude/dreamy-curie-igc0ss

Conversation

@gkaempfer

@gkaempfer gkaempfer commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

Summary

Follow-up to #14967, found by an automated post-merge security scan.

That PR made call_contract view calls read Cairo 1 class definitions through ClassReader, which blocks the calling thread on the class manager via self.runtime.block_on(...). Its own comment on the added outer timeout says the quiet part out loud:

Timing out releases the batcher's request slot but does not cancel the blocking task, which runs to completion on its own thread.

ClassReader::block_on had no bound of its own beneath that outer timeout. If the class manager never answers — a network partition, an overloaded remote class manager, or (for a locally-deployed class manager reached over an in-process channel) a stalled component, since LocalComponentClient::send awaits a channel recv() with no timeout — the spawn_blocking task pins its OS thread on Tokio's shared blocking-thread pool forever. call_contract's outer tokio::time::timeout returns an error to the caller, but frees nothing: the thread stays blocked.

Note on severity, found while preparing this fix: #14972 (already on this base) caps concurrent view calls at 32 via a semaphore, so a stalled class manager can no longer exhaust Tokio's whole shared blocking pool and stall block production — that worst case is already closed. What's left, and what this PR fixes, is narrower but still real: without a bound, a persistently stalled class manager permanently consumes all 32 view-call slots (each pinned forever), permanently disabling call_contract until the batcher process is restarted, since a slot is only freed when its blocking closure returns. With this fix, each stuck request eventually times out, the closure returns, and the slot (and thread) is freed — view calls degrade and self-heal instead of failing shut permanently.

Fix

  • ClassReader gains deadline: Option<Instant>. Every class manager request (get_executable, get_sierra, get_executable_class_hash_v2) is wrapped in tokio::time::timeout_at(deadline, ...) before block_on, when set.
  • The deadline is a single Instant computed once when the reader is constructed, not a duration re-applied per request: reading a Cairo 1 class needs two sequential class-manager calls (read_casm then read_sierra), and a per-request timeout would let their durations add up to a multiple of the intended bound. An earlier revision of this PR had that bug; caught by an independent review pass before merge (see commit history).
  • The view-call path (StorageViewStateReaderFactory::create, threaded through ViewStateReaderFactory::create) computes the deadline from view_call_timeout_millis — the same duration already advertised as bounding a view call's hold on the batcher's request slot. That promise now holds end to end: the blocking-pool thread (and, thanks to apollo_batcher: bound in-flight view calls so they cannot exhaust the blocking pool #14972, the semaphore permit) is released within that same window instead of being pinned indefinitely.
  • Block production's ClassReader (block_builder.rs) passes deadline: None, preserving its existing unbounded behavior — it has no per-call deadline to bound against, and changing that is a separate decision out of scope here.

Test plan

  • class_reader_times_out_when_the_class_manager_never_answers (apollo_state_reader) — a hand-written ClassManagerClient stub whose get_executable never resolves. Verified this hangs indefinitely against the pre-fix code (killed only by an external 60s cap, no test output at all), and passes deterministically (~0.2s) with the fix.
  • class_reader_deadline_bounds_the_total_wait_across_requests (apollo_state_reader) — a stub whose get_executable succeeds after a delay and whose get_sierra then stalls; asserts the read fails close to the original deadline rather than after a fresh window starting from get_sierra.
  • cargo test -p apollo_batcher --lib — 132 passed, 0 failed.
  • cargo test -p apollo_state_reader --lib — 3 passed, 0 failed.
  • scripts/rust_fmt.sh
  • cargo clippy -p apollo_state_reader -p apollo_batcher --all-targets --tests -- -D warnings — clean.
  • Independent review pass (Opus) against the diff, which caught the per-request-vs-cumulative timeout gap above; addressed in a follow-up commit.

@gkaempfer
gkaempfer requested a review from asaf-sw August 18, 2026 07:40
@cursor

cursor Bot commented Aug 18, 2026

Copy link
Copy Markdown

PR Summary

Medium Risk
Touches sync/async bridging on the shared blocking pool and view-call path; behavior change is scoped to view calls with explicit tests, while block production stays unbounded.

Overview
Bounds how long view-call state reads can block on the class manager, so a stalled class manager no longer pins blocking-pool threads (and view-call semaphore slots) past the configured view-call timeout.

ClassReader gains an optional deadline: Instant and routes class-manager RPCs through block_on_request, which uses timeout_at on a spawned future so the caller stops waiting on expiry without dropping the in-flight request (avoiding local channel panics). The deadline is set once per reader, shared across Casm/Sierra/etc., so multi-step reads cannot exceed one budget.

View calls pass view_call_timeout_millis into ViewStateReaderFactory::create and set deadline at factory time in StorageViewStateReaderFactory. Block production keeps deadline: None in block_builder.rs. Tests cover stall timeout, cumulative deadline across requests, and detached completion after timeout.

Reviewed by Cursor Bugbot for commit bebd128. Bugbot is set up for automated code reviews on this repo. Configure here.

@reviewable-StarkWare

Copy link
Copy Markdown

This change is Reviewable

Comment thread crates/apollo_state_reader/src/apollo_state.rs
claude added 2 commits August 18, 2026 07:47
…sReader

call_contract's outer view-call timeout frees the batcher's request slot but
does not cancel the spawn_blocking task underneath it: ClassReader::block_on
has no bound of its own, so a class manager that never answers (a network
partition, an overloaded remote class manager, or a locally-deployed class
manager reached over a channel with no request timeout) pins that blocking-
pool thread forever. Block production shares the same pool via its own
spawn_blocking calls, so enough stalled view calls exhaust it and stall block
production too.

Add request_timeout to ClassReader and bound every class manager request
with it. View calls pass view_call_timeout_millis, so the promise behind that
config value now holds end to end. Block production keeps its prior
unbounded behavior (request_timeout: None), since it has no per-call deadline
to bound against and changing that is out of scope here.

A new test drives get_compiled_class through a class manager stub that never
answers: without the bound it hangs indefinitely (confirmed against this
change before the fix landed); with it, the read fails within
request_timeout instead of pinning the thread.
…ot each request

An independent review of the fix caught that request_timeout bounded each class
manager call separately: read_casm_and_sierra makes two sequential calls, so a
Cairo 1 class could still pin the blocking-pool thread for up to 2x the intended
bound (more with additional classes touched in one view call).

Replace request_timeout: Option<Duration> with deadline: Option<Instant>,
computed once when the reader is constructed. Every request through the reader
now bounds against that same deadline via tokio::time::timeout_at, so the
reader's total time waiting on the class manager cannot exceed the caller's
original budget regardless of how many requests it takes.

Also declares apollo_state_reader's tokio dependency features explicitly
(rt, time) rather than relying on workspace feature unification, and adds a
test proving the cumulative bound: a class manager that answers get_executable
after a delay and then stalls on get_sierra must still fail close to the
original deadline, not after a fresh window starting from get_sierra.
@gkaempfer
gkaempfer force-pushed the claude/dreamy-curie-igc0ss branch from 63ef09a to 3ce4047 Compare August 18, 2026 07:54
@github-actions

github-actions Bot commented Aug 18, 2026

Copy link
Copy Markdown

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, have a team admin enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 3ce4047. Configure here.

Comment thread crates/apollo_state_reader/src/apollo_state.rs
…g them on timeout

Cursor Bugbot caught a high-severity regression in the previous commit:
tokio::time::timeout_at raced the class manager request future directly and
dropped it when the deadline passed. For a locally-deployed class manager,
that future (LocalComponentClient::send) hands its response sender to the
server before it resolves; dropping the future drops the matching receiver,
and the server's tx.send(response).await.expect("Response connection should
be open.") then panics — turning a slow view call into a crash of the class
manager's request-processing task instead of just a failed read.

Spawn the request onto the runtime and race the join handle against the
deadline instead of the request itself. Only our wait is bounded; if the
deadline passes first, the spawned task keeps running to completion in the
background rather than being dropped, so the server always gets to send its
response and never observes a closed channel. This still fully closes the
original bug: read_executable/read_sierra/read_compiled_class_hash_v2 now
build owned, 'static futures over a cloned reader so they can be spawned.

Added a regression test reproducing the exact panic pattern with a bare
channel standing in for the local transport: confirmed it panics against the
race-and-drop version of block_on_request, and passes with the spawn-based
one.

@asaf-sw asaf-sw left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

:lgtm:

@asaf-sw partially reviewed 16 files and made 1 comment.
Reviewable status: 10 of 16 files reviewed, all discussions resolved (waiting on gkaempfer).

@asaf-sw asaf-sw left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@asaf-sw reviewed 6 files and all commit messages.
Reviewable status: :shipit: complete! all files reviewed, all discussions resolved (waiting on gkaempfer).

@asaf-sw
asaf-sw added this pull request to the merge queue Aug 18, 2026
Merged via the queue into main-v0.14.3 with commit 2f482d4 Aug 18, 2026
40 checks passed

Copy link
Copy Markdown
Contributor Author

Security scan complete — no issues detected.


Generated by Claude Code

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.

4 participants