Skip to content

op-batcher: bound every Espresso network call and drop the streamer on stop - #500

Open
philippecamacho wants to merge 2 commits into
espresso/batcherfrom
493-bound-espresso-network-calls
Open

op-batcher: bound every Espresso network call and drop the streamer on stop#500
philippecamacho wants to merge 2 commits into
espresso/batcherfrom
493-bound-espresso-network-calls

Conversation

@philippecamacho

@philippecamacho philippecamacho commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator

Closes #493. Stacked on #459 (espresso/batcher).

Problem

Three wedge modes, all the same family: a hung network call with no deadline parks a component forever.

  1. Submit/verify workers: the Espresso SDK issues plain HTTP requests with no client-side timeout, and the workers called it with their long-lived loop contexts. One black-holed connection consumed a worker permanently; with all four wedged, submission stayed stopped even after the endpoint recovered.
  2. Loading loop: Peek retries undecided batches' L1-backed validity checks (a contract call plus a header fetch) on the loop's shutdown context, so a hung L1 RPC silently stalled frame publication with no error logs.
  3. Stop/start: l.espressoStreamer was never nil'ed, so the next StartBatchSubmitting ran clearState against the stale streamer, whose re-anchor gate retries every 5s with no deadline while holding the start mutex — the very mutex the stop that could cancel it needs. Against a resyncing op-node, a later SIGTERM blocked forever (the same start-mutex-hang pattern already fixed for waitForLocalSafeHead and registration).

Fix

Per the issue's suggestion, the SDK timeout is enforced once at a client-wrapper seam rather than per call site:

  • The submitter's client dependency is narrowed to the three-method EspressoSubmitClient (the SDK's full interface includes streaming endpoints, which a per-call deadline would break), and the driver hands it a boundedEspressoClient wrapping every call with NetworkTimeout. This covers the block-height tracker too.
  • Peek gets the same per-call bound as every other raw RPC on this path (via the existing networkTimeoutCtx); on expiry the batch stays undecided and is retried next tick, and the streamer's own warn logs now surface instead of silence.
  • StopBatchSubmitting and rollbackFailedStart drop the stopped streamer, so every start reaches clearState streamer-free and the no-deadline re-anchor gate only ever runs from the loading loop, whose context a stop can cancel without taking the mutex.

Tests

  • TestBoundedEspressoClientAppliesDeadline: a blocking fake (models the timeout-less SDK on a black-holed connection) called with a deadline-free parent context — each of the three wrapped methods must return DeadlineExceeded promptly.
  • TestStopBatchSubmittingDropsStreamer / TestRollbackFailedStartDropsStreamer: both teardown paths must leave espressoStreamer nil (a zero-value Streamer is Stop()-safe, which makes the lifecycle directly unit-testable).
  • The Peek bound is a context wrap with no seam a unit test can observe; it rides on the streamer's existing undecided-retry semantics.

go vet and the full op-batcher/batcher suite pass.

🤖 Generated with Claude Code


philippecamacho and others added 2 commits August 14, 2026 16:57
…n stop

Three wedge modes from the #459 review round, all the same family: a hung
network call with no deadline parks a component forever.

Submit/verify workers: the Espresso SDK issues plain HTTP requests with no
client-side timeout, and the workers called it with their long-lived loop
contexts, so one black-holed connection consumed a worker permanently -
with all four wedged, submission stayed stopped even after the endpoint
recovered. The submitter's client dependency is now the three-method
EspressoSubmitClient (narrowed from the SDK's full interface, whose
streaming endpoints a per-call deadline would break), and the driver hands
it a boundedEspressoClient that wraps every call with the network timeout.
This covers the height tracker too.

Loading loop: Peek retries undecided batches' L1-backed validity checks
(a contract call plus a header fetch) on the loop's shutdown context, so a
hung L1 RPC silently stalled frame publication for good. Peek now gets the
same per-call bound as every other raw RPC; on expiry the batch stays
undecided and is retried next tick.

Stop/start: l.espressoStreamer was never nil'ed, so the next
StartBatchSubmitting ran clearState against the stale streamer and its
re-anchor gate retried every 5s with no deadline while holding the start
mutex - which the stop that could cancel it also needs. Against a
resyncing op-node that deadlocks SIGTERM forever, the same
start-mutex-hang pattern already fixed for waitForLocalSafeHead and
registration. StopBatchSubmitting and rollbackFailedStart now drop the
stopped streamer, so every start reaches clearState streamer-free and the
gate only ever runs from the loading loop, whose context a stop can
cancel without the mutex.

Tests pin the wrapper's per-call deadline on all three methods (blocking
fake with no parent deadline) and the streamer drop on both teardown
paths. The Peek bound is a context wrap with no seam to observe it
through a unit test; it rides on the streamer's own retry semantics.

Closes #493.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…st fakes

Cleanup pass over the deadline/teardown change:

- teardownEspressoStreamer now owns the Stop+nil sequence and the single
  canonical statement of why a dead run's streamer must not survive into
  the next start; StopBatchSubmitting and rollbackFailedStart call it, so
  a future teardown path cannot forget the nil, and the fork-specific
  block in upstream-owned driver.go shrinks to one line.
- Comment dedup around it: espressoReanchorTarget's doc and both lifecycle
  tests now point at the helper instead of restating the mutex-deadlock
  argument; networkTimeoutCtx's doc no longer maintains a caller registry;
  the wrapper construction site lets the constructor name speak.
- The interface narrowing made the helpers-file fakes' full-SDK surface
  dead weight: ~150 lines of ErrNotImplemented stubs and proxy methods for
  endpoints nothing calls, plus compile-time assertions that re-coupled
  the tests to SDK interface growth. All three fakes now implement
  batcher.EspressoSubmitClient directly.
- The three-case deadline test drops its map/subtest scaffolding for three
  direct assertions.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@palango

palango commented Aug 18, 2026

Copy link
Copy Markdown

The mechanism checks out. I put the real SDK behind a listener that accepts TCP and never answers, called it through a 500ms bound on a deadline-free parent, and every method came back on time: both the GETs (client.go:357) and the submit POST (query_submitter.go:63) go through http.NewRequestWithContext. Modes 1 and 2 are gone.

It's where the bound sits that I'd change. l.Espresso.Client is a MultipleNodesClient (espresso_driver.go:32), and NewMultipleNodesClient refuses fewer than two URLs, so multi-node is the shape we deploy. SubmitTransaction walks the nodes one at a time on a single ctx (multiple_nodes_client.go:156-177), and the wrapper puts one 10s budget around the whole walk. Black-hole node[0] and it eats all ten seconds; node[1] gets an expired context and fails instantly, every retry the same. My two-node probe against two black-holes came back at 501ms having "tried" both. The worker survives, which was the point, but the reason the multi-node client exists is bounded away. Either wrap each *Client, or size the wrapper NetworkTimeout * len(QueryServiceURLs).

I also don't think this closes #493. That wedge isn't the streamer, it's clearState under the start mutex: StartBatchSubmitting holds l.mutex from driver.go:200 across the clearState call at :206, and that loop only exits on success or on a context nothing but StopBatchSubmitting can cancel. Nil-ing the streamer removes the espressoReanchorTarget failure branch and leaves safeL1Origin (:890-893), so an op-node restart during admin_startBatcher still deadlocks until SIGKILL — the likelier trigger of the two. Upstream's branch, not yours, but teardownEspressoStreamer's comment reads like the class is closed, and #493 should stay open with the leftover written down.

Smaller stuff:

  1. Nothing tests the wiring. Reverting newBoundedEspressoClient at its one call site (espresso_driver.go:285) leaves the package green. Better to make it unavoidable than to test it: wrap inside NewEspressoTransactionSubmitter.
  2. The blocking fake waits on <-ctx.Done() under context.Background(), so a lost bound gives panic: test timed out and takes the test binary with it rather than naming the failure. select against time.After(time.Second).
  3. Only the nil is pinned, not the Stop() — delete the Stop() call and both tests still pass, because the zero-value Streamer{} makes it a no-op. That hides a leaked poller set per stop/start cycle.
  4. I don't buy that Peek has no seam. Five methods on that field, and the only other consumer (espresso_service.go:60) has no callers. Make it an interface and a twelve-line fake pins the deadline; that accessor also reads without the mutex and now returns nil after a stop, so I'd delete it.
  5. A timing-out submit is invisible: the SDK wraps ctx failures as ErrEphemeral, so evaluateSubmission (espresso.go:352-357) skips its Warn and retries silently, and RecordFallbackAuthWindowExceeded is the only Espresso entry in Metricer. l.degradedLog is right there.

Two I chased and left: a re-sent submit is deduped by EspressoBatch.Hash() in batchStore.insert (worth a line in the PR body, the argument lives in a vendored dep), and Peek under a deadline maps to BatchUndecided and never BatchDrop, so nothing is lost.

@philippecamacho

philippecamacho commented Aug 19, 2026

Copy link
Copy Markdown
Collaborator Author

@palango

It's where the bound sits that I'd change. l.Espresso.Client is a MultipleNodesClient (espresso_driver.go:32), and NewMultipleNodesClient refuses fewer than two URLs, so multi-node is the shape we deploy. SubmitTransaction walks the nodes one at a time on a single ctx (multiple_nodes_client.go:156-177), and the wrapper puts one 10s budget around the whole walk. Black-hole node[0] and it eats all ten seconds; node[1] gets an expired context and fails instantly, every retry the same. My two-node probe against two black-holes came back at 501ms having "tried" both. The worker survives, which was the point, but the reason the multi-node client exists is bounded away. Either wrap each *Client, or size the wrapper NetworkTimeout * len(QueryServiceURLs).

I agree with the problem. Now the solution proposed is likely not enough as if the first node is failing it will eat up the whole time NetworkTimeout * len(QueryServiceURLs). So I would suggest to assign a timeout to each client.
It seems to me that the cleaner way to achieve this goal would be to make a change in the Espresso Go SDK as currently the http client used has its timeout set to 0.

Every HTTP path in sdks/go/client uses http.DefaultClient, which has Timeout: 0:

client.go:75 (NewClientFromOptions) and client.go:88 (NewClient)
query_submitter.go:25 (NewQuerySubmitter)
builder_submitter.go:34 (NewBuilderSubmitter, a []*http.Client whose entries are all the same global)

WDYT @jjeangal?

@jjeangal

Copy link
Copy Markdown

@palango

It's where the bound sits that I'd change. l.Espresso.Client is a MultipleNodesClient (espresso_driver.go:32), and NewMultipleNodesClient refuses fewer than two URLs, so multi-node is the shape we deploy. SubmitTransaction walks the nodes one at a time on a single ctx (multiple_nodes_client.go:156-177), and the wrapper puts one 10s budget around the whole walk. Black-hole node[0] and it eats all ten seconds; node[1] gets an expired context and fails instantly, every retry the same. My two-node probe against two black-holes came back at 501ms having "tried" both. The worker survives, which was the point, but the reason the multi-node client exists is bounded away. Either wrap each *Client, or size the wrapper NetworkTimeout * len(QueryServiceURLs).

I agree with the problem. Now the solution proposed is likely not enough as if the first node is failing it will eat up the whole time NetworkTimeout * len(QueryServiceURLs). So I would suggest to assign a timeout to each client. It seems to me that the cleaner way to achieve this goal would be to make a change in the Espresso Go SDK as currently the http client used has its timeout set to 0.

Every HTTP path in sdks/go/client uses http.DefaultClient, which has Timeout: 0:

client.go:75 (NewClientFromOptions) and client.go:88 (NewClient) query_submitter.go:25 (NewQuerySubmitter) builder_submitter.go:34 (NewBuilderSubmitter, a []*http.Client whose entries are all the same global)

WDYT @jjeangal?

@philippecamacho agreed, the SDK is the right place. One addition though, a client timeout alone doesn't fix what @palango raised. SubmitTransaction tries the nodes one after another with the same context, so a dead first node still burns everything before the second one is tried. The SDK change should also give each node its own share of the time, inside MultipleNodesClient.

philippecamacho added a commit to EspressoSystems/espresso-network that referenced this pull request Aug 27, 2026
Every HTTP path in the SDK went through http.DefaultClient, whose Timeout
is zero, so a node that accepted the connection and never answered parked
the caller until the caller's own context expired -- forever, for a caller
whose context had no deadline. Reported on celo-org/optimism#500, where
four op-batcher workers wedged permanently on one black-holed endpoint and
submission stayed stopped after the endpoint recovered.

Two changes, because a per-request bound alone is not enough:

- Each unary request is derived from a context bounded by
  DefaultRequestTimeout, covering the body read and not just the round
  trip. Streaming endpoints keep the caller's context: a WebSocket is
  meant to outlive a single request, which is also why the *http.Client
  leaves Timeout at zero -- that field would bound the stream dials too.
  Each client now owns its *http.Client instead of sharing the global.

- MultipleNodesClient and BuilderSubmitter try their endpoints in
  sequence, so each gets its own share of whatever budget the caller
  left. Without the split, a black-holed nodes[0] consumed the caller's
  whole deadline and nodes[1] was handed an already-expired context: the
  reason for holding several node URLs was bounded away, and every retry
  repeated the same walk.

The dev-node client had the same unbounded pattern. It has no streaming
endpoints, so the bound sits on its http.Client directly.

No API was removed or changed. NewClient, NewQuerySubmitter,
NewBuilderSubmitter and NewMultipleNodesClient keep their signatures and
now default to DefaultRequestTimeout; each gains a ...WithTimeout sibling,
and NewClientFromOptions gains WithRequestTimeout, for callers that want
the SDK bound to match their own network timeout. A negative timeout asks
for the old unbounded behaviour.

Tests cover both halves against a server that accepts and never answers:
the deadline-free caller must return rather than park, and both endpoints
of a two-node walk must be reached under a budget that one of them would
otherwise have consumed. Reverting either half fails them, and neither
hangs the test binary on regression.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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.

3 participants