Skip to content

Fix webexec WS: heartbeat past idle timeout, fail loud on lost Python session - #2879

Open
rafel-roboflow wants to merge 14 commits into
mainfrom
fix/webexec-ws-session-protocol
Open

Fix webexec WS: heartbeat past idle timeout, fail loud on lost Python session#2879
rafel-roboflow wants to merge 14 commits into
mainfrom
fix/webexec-ws-session-protocol

Conversation

@rafel-roboflow

@rafel-roboflow rafel-roboflow commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

Description

Fixes the custom-Python WebSocket failures behind recent video-processing incidents (Linear: DG-737).

Root cause: the webexec server closes the socket after 10s idle via an app-level asyncio.wait_for(receive_bytes), while the client keepalive sent protocol-level ws pings — which the ASGI layer answers below the application, so they never reset that timer. Any workflow step with >10s between Python calls guaranteed a dead socket; the client then reused it and fed the text close-frame to msgpack (a bytes-like object is required, not 'str').

Protocol v2, backward compatible in both directions:

  • hello handshake: client sends a session_id; server replies with its idle timeout, its container id, and whether this container still knows the session.
  • App-level heartbeat frames at idle_timeout/3 — real messages the handler receives, so they actually reset the server's timer. The heartbeat interval is capped by idle_timeout/3 so the worst-case gap (the loop skips a tick when idle < interval, making it 2 x interval) stays under the server's deadline for every advertised value.
  • Typed recv: a non-bytes frame is connection death, never parsed. This is what fixes the reported TypeError.
  • request_id + per-container dedup: a resend after a lost response is answered from cache rather than re-executing. The client only ever resends to the container it originally reached, so the registries only see same-container resends. Against a v1 server the legacy no-resend-after-send rule is preserved.
  • Announced graceful close: the server sends {"_kind": "closing"} immediately before close(1000) on its idle and connection-cap paths. Both are decided at the top of the receive loop, before any read, so the frame is proof the client's in-flight write was never processed — it can be retried on any container instead of being reported as "may have already executed". A closing also rotates the client's session, so the scheduled cap-close is a clean restart rather than a session-loss error.
  • Session-loss diagnostic (opt-in, see below): a reconnect reaching a container without the session, after prior successful executions, can raise an explicit error instead of silently continuing with reset globals.

Server hardening in the same change: msgpack collection limits and a byte ceiling on reassembly, chunk count/byte bounds enforced on both send and receive on both sides, decode/encode moved off the shared event loop, a server_error flag so error classification never depends on a user-chosen exception name, SystemExit/CancelledError handling, and TTL-respecting eviction in the dedup registries.

Type of change

  • Bug fix (non-breaking change which fixes an issue)

How has this change been tested?

tests/workflows/unit_tests/execution_engine/dynamic_blocs/274 passed, 0 skipped. Of those, 131 passed across the three new websocket files (103 test functions):

  • test_modal_ws_protocol.py — handshake negotiation and v1 fallback, session-lost enforcement, text-frame typing, v2 resend vs v1 no-resend, the response-id guard, heartbeat timing invariants, graceful-close retry, keepalive backoff while a request genuinely holds the io lock, and that a closing arriving after an earlier delivery does not disarm the same-container guard.
  • test_modal_ws_server_dedup.py — in-flight dedup with two real threads racing one request_id, TTL/LRU eviction on an injected clock, SystemExit from user code, malformed frames, wire-boundary id validation.
  • test_modal_ws_contract.py — drives the real client against the real server route to pin every constant and wire key crossing the boundary, including the chunk-count boundary in both directions, the full-code resend actually firing, the server's closing frame emitted from its own idle and cap triggers (and not emitted to a v1 connection), and a genuinely v1-shaped frame (no _kind, no request_id) still executing.

New/changed configuration

Var Default Effect
WEBEXEC_WS_FAIL_ON_SESSION_LOSS False Opt-in diagnostic for the loud session-lost failure. Off by default: the check cannot tell a stateful block from a stateless one (it arms on any success, on an executor cached per workspace), and container-local Python state cannot survive a long run by construction — a connection is one Modal input capped at 700s, namespaces are keyed by code hash, and reconnects have no container affinity. Enabling it by default would fail the stateless majority on a schedule. Parsed with str2bool, so a bad value fails at boot.
WEBEXEC_WS_IDLE_RELEASE_SECONDS 120 How long an idle connection heartbeats before the client releases it so the container can scale down. Must stay well below the server cap: connection age is >= client idle time by construction, so at 600 it could never fire. <= 0 disables.
WEBEXEC_WS_MAX_CONNECTION_SECONDS 3600600 Server-side connection cap, lowered so the server always closes cleanly before Modal's 700s per-input timeout kills it mid-execution. This raises forced-reconnect frequency 6x, including for v1 clients — which is why the announced graceful close and the session rotation on it both matter.
WEBEXEC_WS_MAX_REQUEST_BYTES 64 MiB Ceiling on one reassembled request or response, enforced on send and receive on both sides. Far below the 1 GiB the chunk count alone would allow, because the decode shares one event loop with up to 10 connections.

websocket-client is now declared (pinned) in requirements/_requirements.txt; it was previously only present transitively via the docker SDK, despite being a hard requirement of WEBEXEC_TRANSPORT=websocket. .gitignore gained patterns for local credential files that were untracked but not ignored.

Any specific deployment considerations

WEBEXEC_TRANSPORT defaults to http, so deployments that have not opted into the websocket transport are unaffected.

Deploy order: modal deploy the webexec server app first (staging, then prod). Both directions are safe, so neither window is dangerous:

  • v1 client → v2 server: unaffected. The server sends v2 control frames only to a connection that sent a hello, so a v1 client never receives one.
  • v2 client → v1 server (the likelier window, since the server deploy is a manual workflow_dispatch while the client ships with any inference ref bump): the handshake gets a non-hello reply, discards it, and falls back to v1 semantics including the no-resend-after-send rule.

Consumers (preview worker, webrtc image, async-serverless) pick up the client on their next inference ref bump.

Known limitations (not addressed here)

  • The session-loss guard is armed by _had_success on a per-workspace executor, so it cannot be confined to the run that actually lost state. Doing that needs a per-stream session identity and connection stickiness per stream. It is off by default for that reason.
  • A positive session_known proves the container still holds a namespace for this code hash — not that a run's state is intact. Namespaces are keyed by code hash and share one globals dict, so two concurrent runs of the same block on one container already read and overwrite each other's state. That predates this change and is unaffected by it.
  • np.array(value, dtype=...) on both transports takes a client-supplied dtype string, so a small frame can request an arbitrarily large allocation. Pre-existing on both paths and not addressed here; it bypasses the byte caps above and is worth a follow-up.

- App-level heartbeat frames replace protocol-level ws pings: the server's
  idle timeout is an asyncio.wait_for on receive_bytes, which protocol
  pings (answered by the ASGI layer) never reset, so any >10s gap between
  custom Python calls guaranteed a server-side close. Heartbeat interval
  derives from the idle timeout the server now advertises in a hello
  handshake instead of a hardcoded guess.
- Every recv is type-checked: a text frame (the payload of a close on a
  dead connection) raises ConnectionError instead of reaching
  msgpack.unpackb as str ("bytes-like object is required" crash).
- Execution frames carry a request_id the server echoes and dedups
  (per-container LRU), making resend-after-failure safe; against a v1
  server the legacy no-resend-after-send rule is preserved.
- Session continuity is explicit: the hello carries a session_id, the
  server answers whether this container still knows it, and a reconnect
  that lands on a fresh container after successful executions raises a
  session-lost error instead of silently continuing with reset Python
  state (wrong results for stateful blocks).
- Backward compatible both ways: v2 client detects a v1 server from the
  hello reply and falls back; v1 clients ignore the new fields.
- Tests: handshake negotiation and fallback, session-lost, text-frame
  typing, v2 resend vs v1 no-resend, response-id guard, heartbeat frames.
@github-actions

Copy link
Copy Markdown
Contributor

👋 Thanks for the pull request! Here is how automated Claude review works here, so you spend credits (and reviewer time) wisely.

🚦 This PR is marked Ready for review, so automated Claude review will run — and every pass spends real credits.

Warning

💸 The Claude reviewer bills in credits, not vibes

Automated review spins up a real agent that reads real code and spends real credits on every pass. It is glad to help — but it is not a rubber duck, a linter you poke in a loop, or a substitute for reading the contributing guide. Treat it like an expensive senior reviewer whose time you booked, and show up prepared.

Draft when unsure, Ready when you mean it:

  • 🌱 Not sure the PR is in good shape yet? Keep it (or set it back) as a draft — drafts pause review, so you can push and iterate without burning credits on a moving target.
  • 💪 Feel strong about the contents? Mark it Ready for review and the reviewer will take a look.

However you get there, arrive prepared:

  • 🧱 Bring a SOLID, thorough PR. Point your local agent at our skills/ to tune it to our guidelines first — or, if you are one of those fabled carbon-based contributors, read them yourself. A half-baked diff costs exactly the same to review as a finished one.
  • Resolve every comment before you re-request review. Re-requesting with threads still open means paying twice for the same conversation.
  • 🔁 Do not use CI review as an inner loop for a local agent. The reviewer is not a step-by-step debugger — do the unfolding locally and arrive with the answer, not the search.
  • 🙋 If something looks off, ask a human. One question to a maintainer is cheaper and faster than three rounds of agent re-review chasing a misread.

Reviews are not free. A draft costs nothing to review; a Ready PR is a promise that it is worth reviewing.

  • Prefer to skip automated review entirely? Add the skip-claude-review label.

@github-actions

Copy link
Copy Markdown
Contributor

🤖 Claude review started at commit e4d2663e8f78df3012c806a445be514a45c6fd85.

New commits are not auto-reviewed. Add the claude-review label to request a re-review — the label is consumed when the review starts, so just add it again next time.

The websocket endpoint imports msgpack at ASGI construction; on base
images that predate msgpack in inference's requirements the wsapp
container crash-loops on boot and every client connect times out at the
proxy. Install it explicitly next to fastapi so the transport works on
any INFERENCE_VERSION image.
- Register a session on the server only after user code succeeds, and
  rotate the client session id when raising WebexecSessionLostError, so
  session loss fails loudly exactly once instead of silently continuing
  with reset runtime state on the next reconnect
- Track in-flight requests on the server (request_id -> task) so a resend
  during execution awaits the original run instead of executing user code
  twice; hello now returns a container_id and the client refuses to resend
  when the reconnect lands on a different container (per-container dedup
  cache cannot answer there)
- Stop keepalive after WEBEXEC_WS_IDLE_RELEASE_SECONDS (new env, default
  600) and release the connection + session, so idle executors no longer
  pin billed Modal containers for the full 1h connection cap
- Await heartbeat acks under a 10s timeout instead of the 720s read
  timeout, so a half-open connection cannot hold _io_lock and stall real
  frames for minutes
- Bound the server response cache (128 entries, 64MB, 120s TTL) and prune
  _ws_sessions; commit handshake results atomically so the keepalive
  thread never observes mid-handshake protocol state
- Extend protocol tests: session rotation, container pinning on resend,
  heartbeat ack timeout and timeout restore
Follow-up review of the previous commit found the two guarantees still
bypassable in several ways. Duplicate execution:

- execute_request now never raises: a result the server cannot serialize
  comes back as a packed, cached error instead of killing the connection
  with nothing cached, which invited a resend of an already-run request
- add an executed-request registry, marked BEFORE user code runs, that
  outlives the response cache; a resend whose payload was evicted (an
  oversized response evicts itself, and concurrent ones evict each other)
  now gets a loud ResponseNoLongerAvailable error instead of re-executing
- the same-container resend guard now fails closed on an unidentified
  container and is evaluated inside _io_lock, immediately before the send,
  since _connect publishes the socket before the handshake commits the new
  container id; _drop_ws_connection clears the id it no longer speaks for

Silent state loss:

- the v1 fallback path now runs the session-lost check too: a legacy server
  cannot confirm the session survived, so a reconnect after a prior success
  fails loudly rather than silently rebuilding a fresh namespace
- the keepalive recomputes idleness under the lock, so a frame completing
  between the unlocked read and the acquire no longer releases and rotates
  a live session

Also: hold the handshake to a 60s reply timeout rather than the 720s
execution timeout (it runs under _io_lock, so a stalled server starved every
request thread for the workspace); refresh a known session on hello so an
erroring-but-live stream cannot age out of the registry; document that
"fail loudly" is per-executor, not per-run, and that the hourly connection
cap makes session loss scheduled for long stateful runs.

Tests: server-side dedup/at-most-once suite driven through the real
websocket app (resend from cache, resend after eviction, unserializable
result, session registration timing), plus client coverage for the v1
session check, fail-closed container guard, and the idle-release race.
Third review round found the previous commit's v1 change to be a net
regression, plus three smaller issues.

Revert the v1-fallback session check. A v1 server cannot confirm a session,
but it also cannot hold a connection open: its idle timeout closes the socket
every WEBEXEC_WS_IDLE_TIMEOUT_SECONDS (10s) and v1's only keepalive is a
protocol ping, which the ASGI layer answers without resetting that app-level
timer. Reconnects are therefore the normal state of a v1 connection, so
failing each one after a prior success failed roughly every other request
(the rotation cleared the latch, so it alternated pass/fail). That is far
worse than the rare silent state reset it prevented, especially since most
blocks are stateless. The guarantee needs protocol v2 to be affordable.

Also:

- publish handshake results as one immutable _ServerInfo instead of three
  separate attributes. The keepalive reads them unlocked, and proto=2 seen
  with a not-yet-written idle timeout falls back to the 25s v1 interval,
  which a v2 server closes under. Rebinding one tuple makes the torn read
  unrepresentable rather than merely ordered-by-convention.
- on a resend whose reconnect lost the session, report the ambiguous outcome
  ("may have already executed") instead of letting the session error win and
  instruct a checkpoint replay that would re-run side effects.
- decode request inputs outside the "the block ran" error path: a malformed
  payload was reported as a user-code failure of a block that never ran, and
  must stay retryable since nothing executed.
- correct the connection-cap comment: Modal's 700s per-input timeout may
  bound a websocket before the 3600s cap, so the previous "replay per hour"
  claim was unverified.

Tests: v1 reconnect after success stays transparent; handshake state traced
line-by-line for torn reads; resend-after-session-loss message; undecodable
inputs reported honestly and left unmarked. All four fixes verified by
mutation (each reverts to a failing test).
@rafel-roboflow rafel-roboflow added the claude-review Use to trigger AI review manually label Aug 28, 2026
@github-actions

Copy link
Copy Markdown
Contributor

🤖 Claude review started at commit b0c0172a46c0d183cc67bca4d2a232e5403c9174.

New commits are not auto-reviewed. Add the claude-review label to request a re-review — the label is consumed when the review starts, so just add it again next time.

@github-actions github-actions Bot removed the claude-review Use to trigger AI review manually label Aug 28, 2026
@grzegorz-roboflow grzegorz-roboflow added the claude-review Use to trigger AI review manually label Aug 28, 2026
@github-actions

Copy link
Copy Markdown
Contributor

🤖 Claude review started at commit b0c0172a46c0d183cc67bca4d2a232e5403c9174.

New commits are not auto-reviewed. Add the claude-review label to request a re-review — the label is consumed when the review starts, so just add it again next time.

@github-actions github-actions Bot removed the claude-review Use to trigger AI review manually label Aug 28, 2026
@github-actions

Copy link
Copy Markdown
Contributor

@rafel-roboflow

This PR is on hold pending your answers — the review will not advance to sign-off until the IMPORTANT question below is answered.

Unanswered questions may keep this out of a release.

IMPORTANT — does the loud session-lost failure intentionally apply to stateless custom‑Python blocks too?

The v2 session-lost check in _handshake fires purely on self._had_success and not reply.get("session_known") (inference/core/workflows/execution_engine/v1/dynamic_blocks/modal_executor.py:1037). The client has no way to tell a stateful block from a stateless one, so this raises WebexecSessionLostError → a DynamicBlockError telling the user to "retry from its last checkpoint" for every block that ever had a prior success, whenever a reconnect lands on a different container.

Two facts make the blast radius larger than the "stateful video run" framing in the description:

  1. _had_success is executor‑scoped, and executors are pooled/cached per workspace (block_scaffolding.py:141-163, cached until the 1800s idle TTL). So once any custom‑Python execution succeeds on a workspace's shared executor, a later container recycle makes the next, unrelated execution's reconnect hard‑fail — even a different, stateless workflow that never touched the dead container's state.

  2. Reconnect-before-send was transparent in v1, and is now a hard failure in v2. With no reconnect affinity and the connection force-closed at the Modal cap (which your own modal_app.py NOTE flags as unverified — possibly the ~700s per-input timeout rather than the 3600s cap), an actively-used or long-running stateless custom‑Python job will hit a session-lost failure on the reconnect after each container recycle — a case v1 recovered from silently by re-running the (idempotent, stateless) code.

Why this is the question that decides the outcome: if failing every stateless custom‑Python job once per container recycle is an accepted trade-off (fail-safe over the rare stateful correctness bug), this ships as-is; if not, the check needs to be narrowed (e.g. gated on evidence the block actually mutates _shared_globals, or on a client-provided per-run "stateful" flag) before merge — so your answer determines whether this is a blocking availability regression or intended behavior. Please also confirm the connection-cap semantics your NOTE leaves open (700s vs 3600s), since that sets how often the failure fires.


Re-review is not automatic: add the claude-review label to request another pass (it is consumed when the review starts, so just add it again next time).

Reviewed at HEAD: b0c0172

@github-actions

Copy link
Copy Markdown
Contributor

📓 Release coordination — Execution Engine

This PR changes run-time behavior of remotely-executed Custom Python Blocks (new WebexecSessionLostError surfaced to users as a DynamicBlockError, changed retry/reconnect semantics, and a new WEBEXEC_WS_IDLE_RELEASE_SECONDS env var).

  • @rafel-roboflow — please add a user-facing entry under ## Unreleased in the Execution Engine changelog in the roboflow/docs repo (workflows/developer-guide/execution-engine-changelog.md), describing the new fail-loud-on-lost-session behavior for hosted custom Python blocks. (Do not choose or bump a version — maintainers own that.)
  • Maintainers — the Execution Engine requires a version change at release time for this behavior change.

This notice does not itself block the PR.

Reviewed at HEAD: b0c0172

@github-actions

Copy link
Copy Markdown
Contributor

Review summary

Skills: review-workflows-execution-engine, review-topic-concurrency-and-resource-safety, review-topic-local-vs-remote-execution, review-topic-test-hygiene, review-core-infra.

No dedicated surface skill covers modal/modal_app.py (under modal/**) — generic review plus topic skills only.

This is a carefully-engineered protocol change and the test coverage is genuinely strong (server-side dedup driven through the real FastAPI websocket app, torn-read tracing of the handshake, idle-release race, resend/at-most-once, v1 fallback). I traced the concurrency-sensitive paths — the atomic _ServerInfo publish read unlocked by the keepalive, the _io_lock-serialized send/recv/heartbeat, the double-checked _ensure_connection, and the server's _ws_inflight/asyncio.shield/executed-registry at-most-once machinery — and did not find a concrete correctness defect to flag inline.

Not a sign-off. The one IMPORTANT question above (does the loud session-lost failure intentionally apply to stateless blocks, given _had_success is executor-scoped across pooled runs) is open and must be answered before this can advance — its answer could itself reveal a blocking availability regression. The Execution Engine changelog/version coordination above is also outstanding.

Minor doubts (non-blocking): the connection-cap semantics (700s per-input vs 3600s cap) are self-described as unverified in modal_app.py, which makes WEBEXEC_WS_MAX_CONNECTION_SECONDS possibly-dead config; folded into the IMPORTANT question since it sets the failure frequency.

Commands that informed this review: gh pr diff, gh api .../commits|comments|reviews, and reads of modal_executor.py, modal/modal_app.py, block_scaffolding.py, and inference/core/env.py.

Reviewed at HEAD: b0c0172

rafel-roboflow and others added 7 commits August 28, 2026 13:19
- Add WEBEXEC_WS_FAIL_ON_SESSION_LOSS (default True) so the loud
  session-lost failure can be disabled in seconds instead of requiring an
  inference-ref rollback across every consumer; forwarded to the webrtc
  Modal worker along with the other WS timing knobs.
- Drop WEBEXEC_WS_MAX_CONNECTION_SECONDS from 3600 to 600 so the server
  always closes a connection cleanly before Modal's 700s per-input timeout
  kills it mid-execution, and correct the contradictory comments in
  modal_app.py and env.py.
- Server: catch BaseException in execute_request so SystemExit from user
  code (and CancelledError) returns a cached error frame instead of tearing
  down the connection with a permanently poisoned request_id; roll back the
  executed marker when the worker thread never entered the user code;
  handle non-dict frames, bogus chunk headers and any escaping loop error
  as responses rather than opaque disconnects.
- Server: make _TtlKeySet agree on what a key is (a non-str request_id was
  stored but never found, voiding the at-most-once backstop), validate
  request_id/session_id at the wire boundary, refuse to cache an oversized
  payload rather than draining the whole response cache, and stop the cache
  TTL sliding on every hit.
- Client: report server-infrastructure failures as DynamicBlockError rather
  than DynamicBlockCodeError, latch _had_success only on protocol v2, keep
  the "may have already executed" warning on retry exhaustion, take _io_lock
  in _drop_ws_connection, stop and join the keepalive thread in close()
  before freeing the fd, and treat WEBEXEC_WS_IDLE_RELEASE_SECONDS <= 0 as
  disabled to match its sibling knob.
- Client: require the echoed request_id on v2 so a late heartbeat_ack cannot
  masquerade as a response, clamp the advertised idle timeout, validate
  chunk counts, and turn malformed responses into transport errors.
- Tests: new test_modal_ws_contract.py pins every constant and wire key that
  crosses the client/server boundary by driving the real client against the
  real server route; shared loader moved to conftest.py. Adds coverage for
  in-flight concurrent dedup, SystemExit, malformed frames, boundary types,
  container identity and retry exhaustion. 104 websocket tests, 188 in the
  dynamic-blocks suite.
The server closes every connection at the top of its receive loop - on the
idle timeout and on WEBEXEC_WS_MAX_CONNECTION_SECONDS - and never reads
again after deciding to. A frame the client wrote concurrently was therefore
provably unprocessed, but the client could not tell that from mid-execution
death and reported it as "the block may have already executed". At the 600s
cap that failed real work on a fixed ~10 minute cycle.

- Server: send {"_kind": "closing"} immediately before close(1000) on both
  paths, gated on the connection having sent a hello so a v1 client never
  receives an unexpected binary frame. Client: raise _ServerClosingError,
  reset the delivery state and retry on any container.
- Server: run input decode, result serialization and packb inside
  asyncio.to_thread. All three were on the event loop shared by up to
  max_inputs=10 connections, where seconds of GIL-held CPU (cv2.imdecode,
  ndarray.tolist) stall the other connections past their idle deadline -
  starving the very heartbeats this protocol adds. _InputsDecodeError keeps
  a malformed payload reported as never-run and safely resendable.
- Server: bound reassembly by BYTES, not just chunk count; refuse to
  announce more than WEBEXEC_WS_MAX_CHUNKS; stamp server_error on the
  server's own UnknownCodeHash/InvalidRequest control responses; echo the
  in-flight request_id on error frames so the v2 response-id guard does not
  discard the diagnostic.
- Client: install the heartbeat deadline BEFORE the write - settimeout
  bounds sends too, so a blocked write held _io_lock for the 720s execution
  timeout and stalled every request for the workspace.
- Client: decide infrastructure-vs-user failures by the server_error flag on
  v2 instead of the error_type name, which is an exception class name
  untrusted user code controls; a block raising UnknownCodeHash could
  trigger the resend-with-full-code path and execute twice. Names remain the
  v1-only fallback.
- Client: clamp the advertised idle timeout downward only (clamping up made
  the client believe it had headroom the server does not give, reinstating
  the idle close) and move the floor onto the derived interval; give chunk
  continuation reads their own deadline; refuse an over-limit payload before
  it reaches the wire.
- env: parse WEBEXEC_WS_FAIL_ON_SESSION_LOSS with str2bool like the other
  117 booleans, so a typo fails at boot instead of silently disabling the
  safety net. Declare websocket-client, previously only present transitively
  via the docker SDK despite being required by the websocket transport.
- Tests: keepalive backoff while a request genuinely holds _io_lock,
  graceful close in the execution and heartbeat slots, end-to-end
  graceful-close retry across two containers, chunk count at exactly the
  limit and one over, and the full-code resend driven for real instead of
  grepping the client source for a literal. TTL expiry runs on an injected
  clock rather than a wall-clock busy wait. 254 passed.

Known limitation, unchanged: the session-lost guard is armed by
_had_success, which lives on a per-workspace executor, so it can still fire
for a stateless block after an unrelated run succeeded. Confining it to the
run that lost state needs a per-stream session identity and per-stream
connection stickiness. WEBEXEC_WS_FAIL_ON_SESSION_LOSS=False disables it.
Conflict: tests/.../dynamic_blocs/conftest.py was added independently on both
sides — two equivalent harnesses for importing the real modal_app.py with a
stubbed modal package.

Resolved by keeping one implementation (this branch's, which parameterises the
module name so each test gets a FRESH module — the server keeps container-local
state in module/class scope, so sharing one leaks namespaces, session and dedup
registries between tests) and re-exporting main's public names as aliases:
FakeModalApp, FakeModalImage, identity_decorator and the
modal_app_with_fake_modal fixture. Neither side's tests needed rewriting.

main's #2870 (Batch/list-shaped data across the Modal boundary) touches the HTTP
serialization path and auto-merged cleanly with the websocket work here; both
changes verified present. Full dynamic-blocks suite: 264 passed. Whole
tests/workflows/unit_tests: 6139 passed, with the same 17 failures that already
fail on a clean tree (semantic segmentation, RLE, detections stitch).
The graceful-close handling added in b405bb2 was itself unsafe. A closing
frame proves only that THIS attempt's write went unread; it says nothing about
an earlier attempt that was delivered. Clearing the cross-attempt delivery
state disarmed the same-container guard:

  attempt 1: frame -> C1, C1 executes, response lost -> resend_pending, C1
  attempt 2: reconnect C1, resend, server closes before reading -> closing
             -> handler wiped resend_pending
  attempt 3: guard disarmed, lands on C2, C2 has no dedup record -> runs again

Keep the ambiguity once incurred: only an attempt that never followed a
delivery may retry on any container. A second entry point existed via
_handshake, where _ServerClosingError escapes _ensure_connection and the
container check never ran at all. The regression test drives the
resend_pending=True path and was confirmed to fail before this change.

Also closed, all in the same at-most-once/resource family:

- HTTP transport (the DEFAULT) still selected its resend-with-full-code path
  by matching the error_type NAME, so a block raising
  `class UnknownCodeHash(Exception)` re-executed - and HTTP has no request_id
  or dedup to catch it. Both transports now share one helper: the
  server_error flag decides whenever present, names only for servers
  predating it. The name list moved to module scope so the two cannot drift.
- Server cancellation could cache "the block was not run" while the pool
  thread went on to run it: asyncio.to_thread cancellation cancels the
  awaiting future, not the work item. Only a positive decode_failed signal
  from the worker now clears the executed marker; an unknown outcome keeps it
  so a resend gets a loud error instead of a second run.
- Request reassembly was capped at MAX_CHUNKS * MAX_FRAME_BYTES (1 GiB) and
  unpacked on the shared event loop with msgpack's size-derived limits, so
  one frame could expand to tens of GB of GIL-held work and push all ten
  sibling connections past their idle deadline. Capped at 64 MiB, explicit
  collection limits, decode moved into a worker thread. Mirrored client-side,
  where reassembly runs in the shared inference server process.
- request_id / session_id had no length bound, so the 32768-entry executed
  registry could hold gigabytes. Bounded at the wire boundary with an explicit
  error rather than in _TtlKeySet alone: a silently rejected key would execute
  with no dedup record, which is worse than refusing it.
- conn_last_request_id is cleared per iteration so a failure before the id is
  parsed cannot echo the previous request's id.

Tests: 266 passed. Two chunk-boundary tests were allocating 1 GiB each to
exercise a count comparison; they now monkeypatch the limits down. sys.settrace
restores the prior tracer instead of None (it was silently disabling coverage
for everything after it), and the dedup poll loop is bounded and daemonised so
a regression fails fast instead of hanging CI.

.gitignore: c.key and modal/deploy_modal_*.sh were untracked but NOT ignored,
so a single `git add -A` would have committed live credentials. Never in
history; ignoring them does not remove the local files.
…TP hole

BLOCKER — the session-loss check was armed for every block and its trigger was
guaranteed on a schedule. `_had_success` latches on any v2 success, this branch
lowered the server cap 3600->600, and a forced close whose `_ServerClosingError`
was swallowed by the keepalive's generic handler left the latch armed. So a
long-running pipeline using a STATELESS block got a hard DynamicBlockError
roughly every 10 minutes on work that previously succeeded. Two fixes:

- Rotate the session on a graceful close, in both the keepalive and the retry
  path. A close the server scheduled is not evidence state was lost
  unexpectedly, and the at-most-once guard is unaffected (it keys on
  sent_to_container, not the session id).
- Default WEBEXEC_WS_FAIL_ON_SESSION_LOSS to False. The check cannot tell a
  stateful block from a stateless one, and container-local state cannot survive
  a long run by construction: a connection is one Modal input capped at 700s,
  namespaces are keyed by code hash, and reconnects have no affinity. It is an
  opt-in diagnostic, not a guarantee the platform can keep.

BLOCKER — the HTTP transport (the DEFAULT) still had the forged-UnknownCodeHash
double execution. `_is_server_originated_response` fell back to matching
error_type NAMES whenever the server_error key was absent — but a stamping
server omits that key on user-code failures, so the fallback governed exactly
the responses it must not. A block raising `class UnknownCodeHash(Exception)`
triggered the resend-with-full-code and ran twice, with no request_id or dedup
to catch it. The flag is now the only discriminator, on both transports; the
name list is deleted. Servers predating the flag are out of scope by this PR's
own server-first deploy contract.

BLOCKER — close() could free the fd under the keepalive's blocked recv(), the
exact hazard its ordering exists to prevent. The ack deadline is installed on
the socket so it bounds send AND recv; the join covered only one of them. Join
now bounds 2 x ack + slack, and when the lock is still held close() drops only
its own reference instead of closing a socket another thread owns.

Also:
- _ws_executed / _ws_sessions eviction was TTL-blind: the size cap popped the
  oldest entry regardless of age, so under load markers were evicted inside the
  client's retry window and a same-container resend re-ran user code. Entries
  younger than a retention window are now never evicted for size, and the cap
  being exceeded is reported rather than silently papering over it. _TtlKeySet.
  refresh() now prunes first, so an expired entry is not revived.
- Byte caps are mirrored in both directions. Previously each side bounded only
  the chunk COUNT (1 GiB) while the peer bounded bytes (64 MiB), so a payload
  in between surfaced as "may have already executed" or as a frame desync -
  verbatim the failure modes those guards exist to prevent.
- _ws_inflight cleanup is identity-checked; popping by key alone let a stale
  callback delete a newer task under the same id.
- _check_response_id now surfaces server-stamped unaddressed error frames
  instead of reporting them as a desync, so chunk-abort and decode-limit
  diagnostics reach the user.
- Provable non-executions (UnknownCodeHash, InvalidRequest) discard the executed
  marker. A namespace-init failure does NOT: exec()ing the user's module scope
  IS running their code.
- A non-str request_id is refused rather than downgraded to "no dedup", which
  would have executed with no at-most-once record.
- Chunk receive re-derives the connection budget each chunk; b"".join moved
  inside the worker thread; WEBEXEC_WS_MAX_REQUEST_BYTES propagated to the
  container env; response-cache TTL raised to cover the client's actual retry
  window; _container_id uniqueness no longer depends on MODAL_TASK_ID being set.
- Heartbeat floor is capped by idle/3, so the "worst gap is 2 x interval"
  invariant holds at every advertised timeout the clamp accepts, including 1s.
- Continuation chunks read the socket the deadline was installed on.
- WEBEXEC_WS_IDLE_RELEASE_SECONDS 600 -> 120: at 600 it could never fire, since
  connection age is >= client idle time by construction.
- Pool size > 1 with the session guard on now warns; websocket-client pinned.

Two corrected docstrings that were actively wrong: _rotate_session claimed a
per-run identity was available (workflow_execution_id is minted per FRAME, so
using it would report a lost session on every frame) and omitted that a positive
session_known says nothing about state integrity, since namespaces are keyed by
code hash and share one globals dict. decode_failed is no longer described as
the only proof of non-execution.

Tests: 274 passed. New contract tests drive the REAL server's own close
triggers, so the `closing` non-delivery proof the client's retry depends on is
verified rather than assumed, and a genuinely v1-shaped frame (no _kind, no
request_id) is exercised for the backward-compat claim. Retry-exhaustion now
pins the attempt count, the heartbeat invariant test covers the value that
failed, and the dedup test no longer leaks an ASGI portal thread.
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