Skip to content

rpc: answer calls pipelined on an already-failed answer with that exception - #8

Closed
nullstyle wants to merge 1 commit into
mainfrom
claude/quirky-jones-d7ee04
Closed

rpc: answer calls pipelined on an already-failed answer with that exception#8
nullstyle wants to merge 1 commit into
mainfrom
claude/quirky-jones-d7ee04

Conversation

@nullstyle

@nullstyle nullstyle commented Jul 31, 2026

Copy link
Copy Markdown
Owner

What

A Call targeting the promisedAnswer of an answer that already returned an exception was never answered — it queued in pending_promises forever. Found on the wire by the cross-impl L3 lane (just e2e-l3-vatc, pipelined-provide scenarios): the C++ recipient pipelines its Call on the Accept question, the host refuses the Accept fail-closed (CrossPeerReceiverHostedTargetUnsupported), and the pipelined Call never receives a Return. The recipient hung on the spec's exactly-one-Return-per-call guarantee (only the harness's 15s deadline unwedged the e2e).

Why it happened

resolved_answers records results Returns only (kept until Finish so late pipelined calls can resolve against them). Exception Returns were recorded nowhere, so at the planner a failed answer was indistinguishable from a still-pending one → .queue_promised_call. The queued-call drain in sendReturnException only reaches calls queued before the exception Return; a call pipelined in the same burst always arrives after a synchronously-failed answer. Not Accept-specific — any answer failed synchronously during dispatch had the same window.

Fix

  • failed_answers map on Peer (owned reason + exception type, kept until Finish — the exact resolved_answers lifecycle). Written in sendReturnExceptionNoDrain, the funnel every exception Return passes through, so drained pipeline children self-record and a call pipelined on a failed pipelined call is covered transitively.
  • planPromisedTarget consults the record exactly where it would otherwise queue; the new .fail_broken_answer plan arm answers the call immediately with a copy of the recorded exception, preserving the retryability type — the broken-pipeline behavior of the C++ reference.
  • Hygiene: records are skipped for loopback and finished-early answers (no Finish would clear them; a stale entry poisons legal id reuse), failed_answers joins inboundAnswerQuestionIdInUse so a reused id can never replay a stale record, and the map is bounded by max_active_inbound_questions rather than a new PeerLimits field, since PeerLimits is Stable-frozen. Recording is best-effort under OOM/budget pressure — a skip degrades to the old behavior for that answer, never a crash.

Reviewer notes

  • Rebased onto 51abe13 (the revert of the receiverHosted lift). This branch briefly tracked the lift while it was on main; with it reverted, the fail-closed refusal this bug is observed through is back, and the branch is once again the original change — no lift-specific adaptation remains.
  • Stable API surface is byte-identical (check-api green; docs/api-snapshot-experimental.txt +4 lines: the Peer field and state.FailedAnswer).
  • The e2e C++ driver previously observed the refusal via whenResolved() with a comment marking this exact gap; it now probes through a pipelined call — that wait hung forever pre-fix, so the lane itself is the cross-impl regression proof.
  • Tests are ablation-proven: with lookupFailedAnswer stubbed to return null, both new tests fail exactly as the pre-fix bug predicts (no Return for the pipelined call; call parked in pending_promises).

Commands run

  • zig build test --summary all — 143/143 steps, 1166/1166 tests
  • zig build check, zig build check-api, just fmt-check — green
  • just e2e-l3-vatc — 18/18 TAP against the vendored C++ reference

🤖 Generated with Claude Code

…eption

A Call targeting the promisedAnswer of an answer that already returned an
exception queued in pending_promises forever: resolved_answers records
results Returns only, so a failed answer missed there identically to a
still-pending one, and the queued-call drain in sendReturnException runs
before a pipelined call sent in the same burst can arrive. Found on the
wire by the cross-impl L3 lane (pipelined-provide scenarios): the C++
recipient pipelines its Call on the Accept question, the host refuses the
Accept fail-closed, and the Call never got a Return — the recipient hung
on the exactly-one-Return-per-call guarantee.

Record exception Returns in a failed_answers map (owned reason + type,
kept until Finish — the resolved_answers lifecycle), written in
sendReturnExceptionNoDrain, the funnel every exception Return passes
through, so drained pipeline children self-record and transitive late
arrivals are covered. planPromisedTarget consults the record exactly where
it would otherwise queue, and the call is answered immediately with a COPY
of the recorded exception, preserving the retryability signal — the
broken-pipeline behavior of the C++ reference. failed_answers also joins
the inbound answer-id namespace so a stale record can never be replayed
against a reused id, and the record is skipped for loopback and
finished-early answers (no Finish would ever clear it).

Ablation-proven tests pin both shapes: the refused cross-peer Accept with
a pipelined Call (the e2e wire trace, in the vatc suite) and the plain
two-party failed answer (from_peer suite), each asserting reason AND type
survive the copy and that Finish drains the record. The e2e C++ driver now
observes the refusal THROUGH a pipelined call instead of working around
the gap with whenResolved(); the full lane passes against the vendored
C++ reference (18/18), full suite 914/914, Stable API surface unchanged
(experimental snapshot +4 lines).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@nullstyle
nullstyle force-pushed the claude/quirky-jones-d7ee04 branch from 286d77f to cade98e Compare July 31, 2026 05:56
nullstyle added a commit that referenced this pull request Jul 31, 2026
…remove

Its original staging used a receiverHosted target, which f2d89ee now SERVES, so
the test referenced a constant that no longer exists and did not compile. The
rule under test is the broken-pipeline one -- every Call gets exactly one
Return, and a call pipelined on a failed answer gets a copy of that exception --
not any particular refusal, so it moves to the refusal that survives: a site-2
`.promised` target whose import dies before the Accept (site 2 takes no
Provide-time pin by design).

Also applies the lift's probe-lifetime lesson: the staging probes live in the
test frame, because the staged question is still outstanding when the vat is
torn down and is cancelled THROUGH that ctx pointer.

Ablation: disabling the failed_answers lookup reddens this test with
`expected .exception, found null` -- the parked-forever symptom -- and #8's own
unit test alongside it. The teeth survive the rebase.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@nullstyle nullstyle closed this Jul 31, 2026
nullstyle added a commit that referenced this pull request Jul 31, 2026
…eption (rebased on L17) (#10)

* rpc: answer calls pipelined on an already-failed answer with that exception

A Call targeting the promisedAnswer of an answer that already returned an
exception queued in pending_promises forever: resolved_answers records
results Returns only, so a failed answer missed there identically to a
still-pending one, and the queued-call drain in sendReturnException runs
before a pipelined call sent in the same burst can arrive. Found on the
wire by the cross-impl L3 lane (pipelined-provide scenarios): the C++
recipient pipelines its Call on the Accept question, the host refuses the
Accept fail-closed, and the Call never got a Return — the recipient hung
on the exactly-one-Return-per-call guarantee.

Record exception Returns in a failed_answers map (owned reason + type,
kept until Finish — the resolved_answers lifecycle), written in
sendReturnExceptionNoDrain, the funnel every exception Return passes
through, so drained pipeline children self-record and transitive late
arrivals are covered. planPromisedTarget consults the record exactly where
it would otherwise queue, and the call is answered immediately with a COPY
of the recorded exception, preserving the retryability signal — the
broken-pipeline behavior of the C++ reference. failed_answers also joins
the inbound answer-id namespace so a stale record can never be replayed
against a reused id, and the record is skipped for loopback and
finished-early answers (no Finish would ever clear it).

Ablation-proven tests pin both shapes: the refused cross-peer Accept with
a pipelined Call (the e2e wire trace, in the vatc suite) and the plain
two-party failed answer (from_peer suite), each asserting reason AND type
survive the copy and that Finish drains the record. The e2e C++ driver now
observes the refusal THROUGH a pipelined call instead of working around
the gap with whenResolved(); the full lane passes against the vendored
C++ reference (18/18), full suite 914/914, Stable API surface unchanged
(experimental snapshot +4 lines).

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

* test: repoint the broken-pipeline test at a refusal the lift did not remove

Its original staging used a receiverHosted target, which f2d89ee now SERVES, so
the test referenced a constant that no longer exists and did not compile. The
rule under test is the broken-pipeline one -- every Call gets exactly one
Return, and a call pipelined on a failed answer gets a copy of that exception --
not any particular refusal, so it moves to the refusal that survives: a site-2
`.promised` target whose import dies before the Accept (site 2 takes no
Provide-time pin by design).

Also applies the lift's probe-lifetime lesson: the staging probes live in the
test frame, because the staged question is still outstanding when the vat is
torn down and is cancelled THROUGH that ctx pointer.

Ablation: disabling the failed_answers lookup reddens this test with
`expected .exception, found null` -- the parked-forever symptom -- and #8's own
unit test alongside it. The teeth survive the rebase.

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

* e2e: the pipelined probe is a real call, so B's cap is invoked twice

Merge artifact, caught by CI's e2e lane. On main the probe was
`whenResolved()`, which settles on the Accept's Return WITHOUT invoking the
capability, so one real call followed and the driver asserted
`localNum.calls == 1`. The rebase correctly kept the stronger probe from the
broken-pipeline work -- a genuinely pipelined `getNumberRequest()`, which is
what exercises the rule -- but L17's count assertion rode along unchanged.

Two invocations is the right expectation now, and both are load-bearing: the
first proves a call pipelined on the Accept question is answered, the second
that the settled capability still routes to B's own cap rather than
host-Carol's.

Lane verified locally: 18/18.

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

---------

Co-authored-by: Claude Fable 5 <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.

1 participant