Skip to content

Cancel the request by name when the editor abandons a stream (#81) - #106

Draft
AmanSwar wants to merge 4 commits into
issue-80-shim-keepalivefrom
issue-81-shim-cancel
Draft

AmanSwar wants to merge 4 commits into
issue-80-shim-keepalivefrom
issue-81-shim-cancel

Conversation

@AmanSwar

@AmanSwar AmanSwar commented Sep 13, 2026

Copy link
Copy Markdown
Collaborator

Closes #81. The client half of RunanywhereAI/InferenceInfra#440; the server half is RunanywhereAI/InferenceInfra#475 (draft). Stacked on #82 (issue-80-shim-keepalive), which it builds on — the pool, the lease, the stale-retry rule — so the base is that branch and this PR retargets main when #82 lands. See "For the owner" for Sanchit's requested merge order with #103/#104.

⚠️ Not tested against the real API — not end-to-end tested

Every test in this PR runs against a fake upstream on loopback. Nothing here has been exercised through the shim against inference.runanywhere.ai with a real session; no real abandoned generation has been observed stopping, and the cancel route this speaks (POST /v1/requests/{request_id}/cancel) exists only in InferenceInfra draft #475, deployed nowhere. Two facts this PR relies on are read from pinned source, not measured: that the gateway sends x-request-id on the response headers, and that it sends them at the first token (LiteLLM 39e4f1ae buffers the first chunk before opening the response).

Blocked on a signed-in dev session plus a node running #475. The acceptance in #81 — "abandoning a stream from Claude Code through wally stops the engine within 2 s" — is what the live round must show and quote here before this leaves draft.

Re-stacked onto the rebased #82 (main @ 9a262ea underneath) — head ee4235e

Two "both sides added here" collisions with #88 (rate-limited console, error phrasing), no behaviour change: src/account/console.cpp gained #88's AppendEndpointIfOverridden and this PR's per-request timeout helpers side by side; tests/test_wally_account.cpp carries #88's rate-limit test and this PR's three; src/harness/harness.cpp keeps #88's phrasing of the "using … as …" line with this PR's console_url propagation. 13/13 ctest, the contract --check, versions and agents-sync checks pass. Still no CI until retargeted to main.

What changed, and why

When the editor abandoned a stream — Esc in Claude Code, the app quitting — the endpoint saw only a TCP close, and only once the next upstream chunk failed to write. During prefill nothing arrives, so nothing failed; and the managed edge in front of the endpoint loses the close anyway (#440 measured 22–41 s of paid decode after the client was gone). Two things fix that, and one call does both.

src/net/upstream_call.{h,cpp}PostWatched: one upstream POST that watches the reader it streams to.

  • It notices the editor leaving without waiting for a chunk. A watch thread polls the server request's is_connection_closed every 100 ms — httplib's peek on the editor's socket, on an fd captured by value; the join at the end of the call bounds its life. The common abandon (Esc while tokens are flowing) is noticed even sooner, by the failed write to the editor: the receiver's false is treated as an abandon too, not as a plain stop nobody follows up. That second path was missing in the first cut and found by the code review — it is the path most real abandons take.
  • It captures x-request-id the moment the headers arrive (response_handler, before any body byte) — the name the cancel route wants — and on abandon hands that id to on_abandoned, exactly once, then stops the upstream socket. stop() is re-issued every poll until send() returns, because a single stop that lands with no request in flight only disconnects, after which httplib would reconnect and re-send the prompt.
  • A refusal (status ≥ 400) ran nothing and is never named as a cancel. A completed call is never held for the rest of a poll (the watch waits on a condition variable the call signals).
  • Both retries are vetoed once the editor left: the stop that ended an abandoned call looks exactly like a stale keep-alive to Reuse upstream connections in the Anthropic shim and the JetBrains proxy #82's rule, and re-sending the prompt for a reader that is gone is the waste this exists to end. The proxy's auth-renew retry is vetoed the same way.

src/account/cancel_worker.{h,cpp} — one worker thread per wrapper sends the cancels off the request path, in order, each bounded by 3 s. Stop() drains the queue then joins, so the last abandon's cancel goes out before the process does. The bearer is read when each cancel goes out (the JetBrains proxy renews its token mid-session; a cancel carrying the old one would be refused). Outcomes are logged — abandoned during=stream id=… cancel=queued, then cancel id=… result=202|404|failed — to shim.log under the state directory for the translator and to the proxy's --verbose trace, never to the editor's terminal. The bearer is never logged.

Contract first. cancelRequest is vendored from InferenceInfra's control-plane-v1.openapi.json (the #475 branch) into contracts/wally-cli-v1.openapi.json; src/account/console_contract.h is regenerated; ConsoleClient::CancelRequest builds nothing by hand beyond the path template. The extractor (contracts/extract-cli-contract.py) now follows every component section its operations reference, refuses dangling refs, and carries listModels/getModelCatalog, which console.cpp already called but the artifact had drifted from. HttpRequest gains a per-request timeout_ms that both transports (curl, WinHTTP) honour — the cancel's 3 s bound — where before the 30 s default was the only choice.

Both translators route every upstream POST through the watched call: src/anthropic/messages.cpp (Claude Code / Claude Desktop) and src/ide/openai_proxy.cpp (JetBrains). Stop()/StopProxy() run in a fixed order: stopping first (a watch still waiting for an id gives up on its next poll instead of holding a server thread until the first token), then the server (joins every handler), then the cancel queue. A local server has no console to tell: the log says cancel=skipped(local) and the dropped connection is enough.

The limit, stated

The gateway opens the response at the first token, so the id is unknown during prefill. An abandon during prefill keeps the upstream open until the first token arrives (or 120 s pass, or the wrapper is stopping), cancels at that moment — which ends the decode, the 22–41 s the issue measures — and discards everything after. Cancelling inside prefill needs the gateway to name the request earlier; that ask is filed on InferenceInfra #440 (comment of 2026-09-13), not solved here. #81's "including when abandoned during prefill" is therefore met as cancel-at-first-token, not cancel-during-prefill, and the live round should measure both.

Proof

Every test observes behaviour from the fake upstream's side — its cancel route, its arrival count, whether its drip stopped — through the real httplib server request and sink, never source text. Each guard was neutered and watched go red, then restored (dev/notes/issue-81-breaktests.md has the full record; the commit message quotes it).

Test Proves Neuter → red
an_abandoned_stream_is_cancelled_by_name_and_never_resent (shim; proxy_… in the proxy suite) editor leaves with the body withheld (the poll notices): exactly one POST /v1/requests/req-2/cancel with the session's bearer within 1 s, warmed lease not retried veto removed → re-send; poll removed → no cancel
leaving_while_tokens_flow_cancels_by_name (shim and proxy) editor leaves while the fake drips a frame every 2 ms (the failed write notices): one cancel, the drip stops (upstream socket dropped), nothing re-sent receiver's false treated as a plain stop → 8/8 runs red (proxy 6/8)
a_receiver_that_refuses_the_bytes_names_the_cancel (net_call) the same, deterministically: poll set to 30 s so only the receiver path can name it same neuter → abandoned=0 calls=0
leaving_during_prefill_cancels_at_the_first_token headers withheld, editor leaves, headers released: the cancel follows the headers within 1 s poll removed
stopping_during_prefill_does_not_wait_for_the_first_token / stopping_ends_the_wait_for_an_id Stop() returns within 1.5 s while the id is still unknown stopping ignored → 4780 ms / 5016 ms
stop_sends_the_last_cancel_before_returning Stop() waits for the queued cancel (fake answers after 500 ms) drain dropped → Stop() returned without sending the abandoned request's cancel
a_refusal_is_not_cancelled a 429 with an id is not cancelled < 400< 1000calls=1
a_completed_stream_is_not_cancelled, a_local_endpoint_is_never_cancelled, a_completed_stream_is_never_abandoned, an_id_that_never_comes_gives_up_after_the_wait the negatives
a_completed_call_is_not_held_for_a_poll poll 2 s, a completed call returns in ms wait → sleep → 2004 ms
the_cancel_worker_sends_in_order_with_the_current_bearer (account) bearer read at send time, outcomes in order, Stop() drains bearer captured at construction → second=old-token
cancel_request_speaks_the_contract, a_request_timeout_bounds_the_real_transport URL/verb/bearer/body/timeout shape; 202/404/500/unreachable/empty-id; timeout_ms honoured by the real transport timeout ignored → 10013 ms

One finding worth recording for #82's reviewer too: #82's body says an "editor abandons the stream" test was tried and removed because "the translator's writes to the closed reader kept succeeding". That was httplib::Client::stop(), which only shuts the socket down and keeps the fd until the client is destroyed; a real editor closes (Claude Code aborts the fetch, a quitting app closes everything), and the next write fails. The test editors here stop, join and destroy, and the abort path is observable.

The four threaded suites (net_call, anthropic, ide_proxy, account) ran under ThreadSanitizer in a separate build tree on the final sources: 41/41, no reports. The fake's destructor now releases its holds, which took ctest from 44.6 s to 15.8 s.

After a teammate's cross-read (commit 4, 4e7569a)

Three notes, all taken: the watch thread is now joined by an RAII guard on every exit from PostWatched — a callback throwing out of send() used to unwind past a joinable std::thread, i.e. std::terminate (guard: a_throwing_receiver_unwinds_with_the_watch_joined; neutered, the suite dies with exit 134 / libc++abi: terminating); done is an atomic stored the instant send() returns, so a poll landing in the gap cannot mistake a completed call for an abandon — for the Anthropic shim the race is unreachable for a well-behaved editor (it cannot see message_stop, written after PostWatched returns), for the JetBrains proxy ([DONE] forwarded byte for byte) the window is httplib's own return, so a_completed_stream_is_never_abandoned is proven for the shim and probabilistic for the proxy; and the WatchedCall contract says on_abandoned runs with the call's lock held and must not block. TSAN on the three threaded suites after the change: 27 passed, no reports.

Review

Fresh-context review (Fable 5.1) of the plan before implementation and of the code after: the code review's one blocker — the receiver-refusal path above — is fixed with the guards in the table; its should-fixes (the poll-hold on every call, the stale bearer after renewal, the log paths in docs/EDITORS.md, TSAN re-run on the final tree) are all in. Every cpp-httplib claim the design rests on was verified by the reviewer against the vendored 0.46.1 header (is_connection_closed captures the fd by value; Client::stop() from another thread only shuts the socket down while a request is in flight; response_handler returning false is Error::Canceled; Server::stop() + join runs the thread pool's shutdown, so no handler is alive when the cancel queue is drained).

For the owner (not taken silently)

Validation

cmake --build build && ctest --test-dir build      100% tests passed, 0 tests failed out of 13  (15.8 s)
four suites × 3 runs                               8 / 15 / 14 / 4 passed, every run
TSAN (-fsanitize=thread), four suites              41 passed, 0 reports
python3 contracts/generate_console_binding.py --check   console_contract.h matches the pinned contract
bash scripts/ci/check-agents-sync.sh                ok

No CI on this PR yet: ci.yml runs pull-request checks only for bases main and launch/**, so a PR stacked on issue-80-shim-keepalive (this one, and #103/#104 alike) reports nothing until it is retargeted to main. The runs above are local (macOS arm64); Windows is not compiled here — the WinHTTP timeout_ms path is code-read only.

Docs: docs/EDITORS.md (what happens when the tool stops listening; where the log is), AGENTS.md (the nine contract operations).

@coderabbitai

coderabbitai Bot commented Sep 13, 2026

Copy link
Copy Markdown

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
  auto_review:
    drafts: true

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

…its operations reference (#81)

The shim will call POST /v1/requests/{request_id}/cancel (InferenceInfra
#440, PR #475) when the editor abandons a stream, so per the P0
contract-first rule the operation comes first: contracts/wally-cli-v1.openapi.json
is re-extracted from that branch's control-plane-v1.openapi.json,
src/account/console_contract.h regenerated (CancelRequestResponse, two
strings -- the status is a const to the generator, not an enum), and the
pin bumped; test_wally_contract holds the three together.

Two things the extractor got wrong for as long as it has existed, fixed
while here because the new operation would have made both worse:

- It collapsed every $ref to its last segment and looked it up in
  components.schemas only, so `#/components/parameters/...` and
  `#/components/responses/...` references in a kept operation were carried
  into the extract unresolved -- thirteen dangling references in an
  artifact whose docstring calls itself "self-contained, valid OpenAPI".
  cancelRequest's path parameter lives under parameters, which would have
  been the fourteenth. The closure now follows every component section a
  kept operation references (and path-level parameters), and the extract
  refuses to be written with a dangling reference in it.
- Its operation list said six while console.cpp has called GET /v1/models
  and GET /v1/models/catalog since #75 (FetchModels, FetchCatalog) and the
  committed artifact carried both: the artifact and the extractor had
  drifted, which a hash pin between the artifact and the HEADER cannot
  see. listModels and getModelCatalog are listed now; the extract is nine
  operations and reproduces the committed paths plus the new one.

The source contract is a draft branch's, not main's: the operation is not
deployed anywhere yet, and this commit says so where it is consumed.

  python3 contracts/extract-cli-contract.py <#475's contract>: 9 operations, 61 components
  python3 contracts/generate_console_binding.py --check: fresh
  ctest: 12/12
… transports honour (#81)

The shim's abandon path (next commit) needs to tell the control plane to end
a request, fire-and-forget: POST /v1/requests/{request_id}/cancel with the
session's bearer, through the vendored binding rather than a hand-built
call (AGENTS.md, the P0 contract-first rule). ConsoleClient::CancelRequest
does that the way FetchUsage does -- the id escaped into the path, Send and
ParseContract, HttpError on anything unexpected -- and maps the contract's
three answers: 202 Cancelled (a node ended it; the body is informational),
404 NotFound (unknown, finished, or not this key's -- the server does not
say which, by design), anything else Failed. An empty id or token is refused
before any call.

Fire-and-forget means the caller has already dropped the stream, so the
call must never hold an exiting wrapper for the transport's 30 s default.
HttpRequest gains timeout_ms (0 keeps today's 10 s connect / 30 s total);
both transports honour it -- curl's CURLOPT_TIMEOUT_MS/CONNECTTIMEOUT_MS
and WinHTTP's session, request and per-read deadlines -- with the connect
phase never given more than its usual share. Every existing initializer
stays valid: the field is trailing and defaulted.

Guards bite. The contract test drives a mock transport through the four
outcomes and the exact request shape (method, escaped path on the dev and
production origins, bearer, empty body, timeout_ms). The timeout test
stands up a real server that holds the request until released and asserts
the real transport gives up within the request's own bound -- neutered
(TotalTimeoutMs returning the default), it went red:
  [FAIL] a_request_timeout_bounds_the_real_transport - timeout_ms was not honoured: the call took 10013 ms
restored: [PASS], 14 passed.

  ctest: 12/12 suites; test_wally_account 14 passed
…81)

The Anthropic translator and the JetBrains proxy sit between an editor and
the model endpoint. When the editor abandoned a stream (Esc in Claude Code,
the app quitting) the endpoint used to see only a TCP close, and only once
the next upstream chunk failed to write; during prefill nothing arrives so
nothing failed, and the managed edge in front of the endpoint loses the
close anyway (InferenceInfra #440 measured 22-41 s of paid decode after the
client was gone). Two things fix that, and one call does both:

  net/upstream_call: PostWatched(lease, WatchedCall) is one upstream POST
  that watches the reader it streams to. A watch thread polls the server
  request's is_connection_closed (a peek on the editor's socket; the fd is
  captured by value and the join at the end of the call bounds its life).
  The response handler captures x-request-id and the status the moment the
  headers arrive, before any body byte. On abandon the call hands the id
  to on_abandoned -- once -- and stops the upstream socket, re-issuing
  stop() every poll until send() returns (a single stop with no request in
  flight only disconnects, after which httplib would reconnect and resend).
  A refusal (status >= 400) ran nothing and is never named as a cancel.

  The common abandon -- Esc while tokens are flowing -- is noticed by the
  RECEIVER, not the poll: the next write to the editor fails before the
  watch gets a turn. A receiver saying no is therefore an abandon too: the
  cancel is named right there, with the id already in hand, and the
  request aborted; without that the call would end as a plain Canceled
  that nobody follows up (found in review). The watch waits on a condition
  variable the call signals when send() returns, so a completed call is
  never held for the rest of a poll. on_abandoned also says whether the
  reader left before the headers (during_prefill), so the log line is true
  when the id arrived at the first token.

  The limit, stated in the header: the endpoint's gateway opens the
  response at the FIRST TOKEN, so the id is unknown during prefill. An
  abandon during prefill keeps the upstream open until the headers arrive
  (or id_wait = 120 s runs out, or the wrapper is stopping), cancels at
  that moment -- which ends the decode, the long part -- and discards
  everything after. Cancelling inside prefill needs the gateway to name the
  request earlier: InferenceInfra #440's follow-up, not this change.

  account/cancel_worker: one worker thread per wrapper sends the cancels
  off the request path (the abandon fires on the watch thread, inside the
  response handler or inside the receiver, none of which may block), in
  order, each bounded by timeout_ms = 3 s. Stop() drains the queue then
  joins, so the last abandon's cancel goes out before the process does.
  The bearer is read when each cancel goes OUT, through a supplier: the
  JetBrains proxy renews its token mid-session (RenewToken on a 401), and
  a cancel carrying the old one would be refused. The proxy's token now
  sits behind a small guard (Token()/SetToken()) that the renewal, the
  sinks and the worker all use. Outcomes go to the translator's log
  ("cancel id=... result=202|404|failed"), never the editor's terminal;
  the bearer is never logged.

Both translators route every upstream POST through the watched call and
veto both of their retries once the editor left: the stop that ended an
abandoned call looks exactly like a stale keep-alive to the #80 rule, and
re-sending the prompt for a reader that is gone is the waste this exists
to end. An abandoned stream ends its sink; an abandoned buffered call
answers 499 to nobody. Stop()/StopProxy() run in a fixed order: `stopping`
first (an in-flight watch waiting for an id gives up on its next poll
instead of holding the server thread until the first token), then the
server (joins every handler), then the cancel queue. A local server has no
console_url, so nothing is cancelled there: the dropped connection is
enough, and the log says "cancel=skipped(local)".

harness::Endpoint carries console_url (the session's control plane, base
path included) beside base_url; docs/EDITORS.md describes the behaviour
and names the two logs (shim.log under the state directory; the proxy's
trace under --verbose), and AGENTS.md counts the nine contract operations.

Tests. test_wally_net_call (new, 8) drives PostWatched against the fake
with the reader as a flag: a completed stream is never abandoned; reader
gone mid-body names the cancel and drops the socket within 1 s with
nothing more reaching the sink; reader gone during prefill cancels at the
headers (Error::Canceled from the handler); an id that never comes gives
up after id_wait with an empty id and no cancel; stopping ends the wait
within a poll; a 429 is not cancelled; a receiver that refuses the bytes
names the cancel (the poll set to 30 s so only that path can); a
completed call is not held for a poll (poll 2 s, returns in ms).
test_wally_anthropic (+7) and test_wally_ide_proxy (+2) prove the wiring
end to end through the real server request: an editor that leaves
mid-stream produces exactly one POST /v1/requests/req-N/cancel with the
session's bearer within 1 s and no second arrival of the prompt, both
with the body withheld (the poll notices) and with the fake dripping a
frame every 2 ms (the failed write notices; the drip then stops, which is
the upstream socket dropped); a completed stream produces no cancel; a
local endpoint is never cancelled; Stop() sends the last cancel before
returning (fake answers after 500 ms); leaving during prefill cancels at
the first token; stopping during prefill does not wait for the first
token. The test editors CLOSE their sockets when they leave (stop, join,
destroy), the way Claude Code and a quitting app do; stop() alone keeps
the fd open and the shim's writes into it keep succeeding.
test_wally_account (+1) drives CancelWorker on the mock transport: in
order, the bearer read at send time (changed between two enqueues, the
second carries the new one), every outcome reported, Stop() drains. The
fake upstream gains per-arrival x-request-id, hold_headers/
release_headers (prefill) beside hold_streams_until (decode), drip(), and
the cancel route, which records a cancel only once it is about to answer
it -- so a Stop() that did not wait for the worker cannot get credit for
a cancel it never sent. Its destructor releases every hold, which took
the anthropic suite from 17.5 s to 4.9 s.

Guards bite. Each neuter was applied, the suites run, and the change
restored (dev/notes/issue-81-breaktests.md):

  veto removed (an abandoned call falls through to the stale retry):
    [FAIL] an_abandoned_stream_is_cancelled_by_name_and_never_resent
  stopping ignored by the watch:
    [FAIL] stopping_during_prefill_does_not_wait_for_the_first_token -
      Stop() waited for the first token: 4780 ms
    [FAIL] stopping_ends_the_wait_for_an_id - ... took 5016 ms
  a refusal cancelled too (< 400 -> < 1000):
    [FAIL] a_refusal_is_not_cancelled - a 4xx must not be cancelled: calls=1
  reader never polled:
    10 FAILs across the three suites, e.g.
    [FAIL] an_abandoned_stream_is_cancelled_by_name_and_never_resent - no
      cancel reached the endpoint within 1 s of the editor leaving
  Stop() without draining the cancel queue:
    [FAIL] stop_sends_the_last_cancel_before_returning - Stop() returned
      without sending the abandoned request's cancel
  a refusing receiver treated as a plain stop (the review's blocker):
    [FAIL] a_receiver_that_refuses_the_bytes_names_the_cancel - a refused
      write must be an abandon with the id in hand: abandoned=0 calls=0
    [FAIL] leaving_while_tokens_flow_cancels_by_name - no cancel reached
      the endpoint within 1 s of the editor leaving   (8/8 runs; proxy 6/8)
  the watch sleeping out its poll instead of waking on send():
    [FAIL] a_completed_call_is_not_held_for_a_poll - ... took 2004 ms
  the worker reading the bearer once, at construction:
    [FAIL] the_cancel_worker_sends_in_order_with_the_current_bearer - the
      bearer must be read when the cancel goes out: first=old-token
      second=old-token
  restored: 8 / 15 / 14 / 4 passed, three runs each; ctest 13/13.

The four threaded suites also ran under ThreadSanitizer (-fsanitize=thread,
separate build tree, binaries newer than every source): 41 passed, no
reports.

Not tested against the real endpoint: the cancel route this speaks is
InferenceInfra #475 (draft), and cancel-at-first-token against the pinned
gateway is asserted from its source, not measured.
…call is never an abandon (#81)

Three notes from a cross-read of the draft, all taken:

The watch thread was joined only after client.send() returned. A callback
throwing out of send() -- the receiver on a translator bug, on_abandoned
on an allocation failure -- unwound past a joinable std::thread, whose
destructor is std::terminate: the whole wrapper down for one bad chunk.
An EndWatch guard now ends and joins the watch on every exit from
PostWatched, exceptions included.

`done` is an atomic stored the instant send() returns, before any lock,
so a poll that lands in the gap between the reply completing and the
call's own bookkeeping cannot mistake the reader's close-after-the-last-
chunk for an abandon. What remains is httplib's own unwinding, and for
the Anthropic shim not even that: a well-behaved editor cannot see
message_stop, which is written after PostWatched returns, so it cannot
close before send() does. For the JetBrains proxy, which forwards [DONE]
byte for byte, the window is httplib's return -- microseconds -- and
`a_completed_stream_is_never_abandoned` is proven for the shim and
probabilistic for the proxy; the PR says so.

The WatchedCall contract now says on_abandoned runs with the call's own
lock held and must not block; Enqueue is a push and a notify.

Guard: a_throwing_receiver_unwinds_with_the_watch_joined (net_call, 9
now) -- the receiver throws, the exception reaches the caller (httplib
does not swallow it), the process is still here and a normal call on a
fresh lease works. Neutered (the guard removed, join after send() as
before): the suite dies at that test, exit 134 -- SIGABRT,
"libc++abi: terminating". Restored: 9 passed. TSAN on net_call,
anthropic, ide_proxy: 27 passed, no reports.
@AmanSwar
AmanSwar force-pushed the issue-81-shim-cancel branch from 4e7569a to ee4235e Compare September 13, 2026 14:37
@AmanSwar

Copy link
Copy Markdown
Collaborator Author

The approach, in plain words (for review without reading the code)

The problem. When you press Esc in Claude Code (or quit the app), wally only closed its connection to the cloud. The cloud did not notice for up to 40 seconds and kept generating — and billing. Worse, during a long prefill nothing is flowing, so wally itself did not even notice you had left until the next chunk failed to deliver.

What this PR does. wally now cancels by name. Four pieces:

  • It notices you leaving right away — a small watcher checks your connection every 100 ms while the request is in flight, and a failed write to you is treated as "you left" too (that is how most abandons show up while tokens are flowing).
  • It knows the request's name — the cloud sends an x-request-id with its first token; wally captures it the moment the headers arrive.
  • It tells the cloud — one background worker sends POST /v1/requests/{id}/cancel (the endpoint InferenceInfra #475 adds) with your session's key, 3 s per cancel, and drains its queue before wally exits. Every outcome goes to shim.log, never to the editor's terminal.
  • It never re-sends your prompt after you left — the connection-retry rule from Reuse upstream connections in the Anthropic shim and the JetBrains proxy #82 is vetoed on an abandon.

Picture ordering food by phone: before, hanging up was the only way to cancel, and the kitchen kept cooking; now you say "cancel order #123" — and you can only do that once the kitchen has told you your order number.

Why this way (and what we did not do). A cancel by name is the only signal that survives the load balancer. We did not add a thread per abandon (one worker per wrapper). The limit is stated plainly: the cloud names the request only at the first token, so an abandon during prefill is cancelled at the first token — that ends the decode, the long part — not inside prefill; the ask for an earlier id is filed on #440.

How you can tell it works.

  • Leave mid-stream → exactly one cancel naming that request reaches the fake cloud within 1 s, with your bearer, and the prompt is not re-sent (leaving_while_tokens_flow_cancels_by_name, an_abandoned_stream_is_cancelled_by_name_and_never_resent).
  • A completed stream, a refused request (429), or a local model → no cancel.
  • Quit while a cancel is queued → it is sent before wally returns.

What it does not do / still needs.

  • Not run against the real API: the cancel route exists only in draft #475. The live "Esc stops the engine within 2 s" round is the acceptance.

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