Skip to content

feat(client): accept an AbortSignal per call and close the request on abort - #3200

Merged
thymikee merged 14 commits into
mainfrom
feat/client-abort-signal
Oct 4, 2026
Merged

thymikee merged 14 commits into
mainfrom
feat/client-abort-signal

Conversation

@thymikee

@thymikee thymikee commented Oct 3, 2026 •

Copy link
Copy Markdown
Member

What

Every typed-client call now accepts an AbortSignal. Aborting closes that request's connection so the daemon marks the request canceled — it is never treated as a timeout, and the daemon and session stay alive.

const controller = new AbortController();
await client.press({ text: 'Submit', signal: controller.signal });

Closes #3178

How

signal is declared once, on AgentDeviceRequestOverrides, AgentDeviceDaemonTransportContext, and InternalRequestOptions, so it reaches every method through the same override path. The requester-side half lives beside the transports it serves, in src/daemon-client/daemon-client-transport.ts: createRequestGuard gives the client a refuseIfAborted check and a guard wrapper, and owns the request_canceled error factory so both transports reject with one shape. @agent-device/host-kit/request is untouched — the guard is a client concern, not a shared request primitive.

  • Already aborted → reject before anything is sent, details.dispatched: 'no'. The guard is checked before the daemon-ensure, the artifact-upload, and each transport attempt, so a pre-aborted call opens nothing.
  • Aborted in flight → the socket is destroyed for that request; the daemon sees the closed connection and records the cancel. The client rejects typed with details.reason: 'request_canceled' and details.dispatched: 'unknown', which is the honest answer: the request had left the process, whether the daemon finished it is not knowable from here.
  • Never a timeout. The abort path returns before handleRequestTimeout, so the pkill sweep and daemon reset that a deadline triggers do not run. daemon-client-abort-timeout.test.ts proves this at the seam by mocking the timeout handler, with a paired no-signal case that reaches it.

Every call now carries a meta.requestId — generated before the guard so a canceled call and the daemon's diagnostics for the request the transport goes on to cancel name the same id. The caller signal rides the transport context, never the wire — verified against the actual envelopes for flags, meta, and input (gesture input is rebuilt from an explicit field table, so an unknown key cannot ride along). No CLI flag is added: a per-call signal is a Node-caller concept, and the CLI has its own process lifecycle. The remote health probe composes the caller signal with its own timeout budget, and an in-flight lease beat is aborted through the control the lease beat now owns, so a canceled upload cuts the same request everywhere.

Tests

16 files, +1207/−85 overall; the test files alone are 9 files, +847/−13.

  • daemon-client-request-guard.test.ts — guard semantics, including that a guard installed after an abort still refuses and that signal.reason passes through unchanged.
  • daemon-client-abort-default-transport.test.ts — loopback against the real createSocketServer / createDaemonHttpServer, asserting the daemon-side cancel and that the daemon's own record of the request carries the same requestId the client's rejection names.
  • daemon-client-abort.test.ts — transport-level abort on both built-in transports, with the settle-before-destroy ordering and the spurious socket-error diagnostic both pinned.
  • client-abort-signal.test.ts — the client surface: pre-aborted, in-flight, and that the daemon survives an abort.
  • daemon-client-abort-timeout.test.ts — the abort-is-never-a-timing-out case and its no-signal counterfactual.

The wiring tests fail against fc7df6c4b and pass here, so they test the change rather than the harness. Counterfactuals pin details that are easy to lose: deleting the signal: options.signal handoff fails the default-transport proofs, and dropping the wire token from the fixtures fails only after the typecheck fix.

Known limits

  1. An abort during materializeRemoteArtifacts (the response-artifact download) is not signal-threaded. The caller promise still rejects typed, but the download may run to completion and leave a partial file. This is a partial fix for fix(agent-device): clean up screenshots after cancelled captures tester-army/e2e#206, not a complete one.
  2. An aborted one-shot replay can tear down a client-started daemon via cleanupDaemonAfterRequest. This matches the existing timeout behavior rather than adding a new teardown path, so I left it alone rather than widen this change.

Gate

Rebased onto origin/main (fae0c39); upstream #3199 landed the same record-stop timeout-policy fix my branch carried, so those hunks dropped out as already-applied and the diff is now purely abort-signal work. pnpm check:affected --run passed on the exact pushed head b431157c5: all runnable checks green — format, lint, typecheck, layering, check:fallow --base origin/main, build, the affected vitest set (163 files / 1045 tests, including the provider-integration alert scenario), and daemon-wire-compat reporting the protocol unchanged against v0.21.18 (191 declarations, 0 removed, 2 added; both health-probe digests re-acked — the wire envelope is untouched, only the in-process probe composes an extra signal). CI Integration Tests and Coverage jobs on this head are the remaining external evidence.

@github-actions

github-actions Bot commented Oct 3, 2026 •

Copy link
Copy Markdown
PR Preview Action v1.8.1
Preview removed because the pull request was closed.
2026-10-04 10:21 UTC

@github-actions

github-actions Bot commented Oct 3, 2026 •

Copy link
Copy Markdown

Size Report

Metric Base Current Diff
Installed (including dependencies) 4.97 MB 4.97 MB +3.1 kB
Package (unpacked) 4.97 MB 4.97 MB +3.1 kB
Package (download) 1.49 MB 1.49 MB +998 B

Startup median (7 runs, lower is better):

Scenario Base Current Diff
CLI --version 27.5 ms 26.6 ms -0.9 ms
CLI --help 82.5 ms 82.6 ms +0.0 ms

@cubic-dev-ai cubic-dev-ai Bot left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

All reported issues were addressed across 12 files

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread src/daemon-client/__tests__/daemon-client-abort.test.ts Outdated
Comment thread packages/contracts/src/client-connection.ts
Comment thread packages/host-kit/src/internal/request-guard.ts Outdated
Comment thread website/docs/docs/client-api.md Outdated
Comment thread src/agent-device-client.ts Outdated
Comment thread src/daemon-client/daemon-client-transport.ts
Comment thread src/__tests__/client-abort-signal.test.ts
Comment thread packages/host-kit/src/internal/request-guard.ts Outdated
Comment thread packages/host-kit/src/internal/request-guard.test.ts Outdated
Comment thread website/docs/docs/client-api.md Outdated
@thymikee

thymikee commented Oct 3, 2026

Copy link
Copy Markdown
Member Author

Coverage fails on this PR, and I found one gap in the tests at 7fa0dc6. The failing check is related to the change. There are no conflicts. Smoke Tests was still queued, so I have no result for it. The next step before merge is to fix the Coverage failure and add one test for the real client route.

The line that makes the daemon see a client abort is signal: options.signal in daemon-client.ts, and no test drives it. daemon-client-abort*.test.ts calls sendRequest directly, and client-abort-signal.test.ts injects a fake transport. If someone deletes that property, or drops the AbortSignal.any merge at line 348, every new test still passes. The caller would still get request_canceled from the guard at agent-device-client.ts:117, while the daemon keeps running the command on the device. That is the bug in #3178. The rule is that the daemon-side cancel must be observed through the real default transport. Please add one loopback test per transport: createAgentDeviceClient with no injected transport, a long wait against a real createSocketServer and createDaemonHttpServer, and the state dir pointed at the loopback daemon. Abort mid-flight, then assert the daemon saw the cancel through getRequestSignal or isRequestCanceled. Removing signal at daemon-client.ts:119 must make each test fail.

The Coverage job fails eager-closure-budgets.test.ts on 9 closures. Each chain ends at the re-export in host-kit/request.ts:12 to internal/request-guard.ts, and src/cli.ts grows by 4 modules through the new import in daemon-client.ts. Only agent-device-client.ts and src/daemon-client/* use the guard. Could the guard live in a module the CLI closure already loads, such as daemon-client-transport.ts, with no host-kit/request export? That would also be a smaller design for about 240 net lines that cross contracts, host-kit, the client and the transport. Could each layer keep one abort point, with sendToDaemon using only refuseIfAborted plus the guard around ensureDaemon and upload, and the client-level guard serving only injected custom transports?

Not blocking, and you can take or leave these. The canceled closure in createRequestGuard could call abortedRequestError instead of repeating it. The control-flow comments in daemon-client-transport.ts and daemon-client.ts could shrink to the invariant. Because the client guard's listener runs first, typed-client callers never see the transport's no or unknown distinction for an abort during ensureDaemon, upload or connect. Generating requestId in execute before guarding would fix that.

The open inline threads on the health check, artifact downloads without a signal, the contract doc, the missing requestId and the spurious socket-error diagnostic still stand, along with 8 lower-priority threads. The thread on abort during request preparation does not apply: execute rejects at line 103 with dispatched no before anything is sent, so the rejection is only delayed by local preparation. Please resolve it.

I did not run the added tests or the eager-closure gate locally, and I took the Coverage result from the job log. I did not check whether every custom command writer passes signal through request.options, and I did not verify the claim that the 8 wiring tests fail on fc7df6c.

@thymikee
thymikee force-pushed the feat/client-abort-signal branch from 7fa0dc6 to add99f4 Compare October 4, 2026 06:36

@cubic-dev-ai cubic-dev-ai Bot left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

All reported issues were addressed across 15 files (changes from recent commits).

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

Comment thread test/wire-compat/ledger.json
@thymikee

thymikee commented Oct 4, 2026

Copy link
Copy Markdown
Member Author

Both asks are addressed and pushed.

  • Coverage — the gap was the eager-closure budget in scripts/__tests__/eager-closure-budgets.test.ts: the guard block exported through the @agent-device/host-kit/request barrel pulled the client transport edge into the eagerly-held closure (cli.ts 299>295, replay-port 285>284). The guard now lives beside the transports it serves — createRequestGuard is exported from src/daemon-client/daemon-client-transport.ts, its error helpers are module-private there, and the barrel is back to its base content. Budget test green (757/757).
  • One test for the real client route — daemon-client-abort-default-transport.test.ts: no injected transport, createAgentDeviceClient against a published daemon.json pointing at a real createSocketServer / createDaemonHttpServer; it asserts the daemon-side cancel and that the daemon-recorded requestId equals the id in the caller rejection. Counterfactual checked: deleting the signal handoff in daemon-client.ts fails both cases.

Two things that fell out of running the full gate on the head, both fixed there: the iOS provider-scenario gateway built its alert interactor with an empty runner context, which only matched the scripted provider while requests carried no meta.requestId — every leg now resolves per call from the input execution metadata, like the production binding; and the three socket abort suites now destroy tracked connections in teardown (net.Server has no closeAllConnections()) so a failed assertion reports itself instead of hanging close().

pnpm check:affected --run is green on e2848d5b7 (evidence in the body). All review threads are resolved. Not merging — waiting on your call once CI settles on this head.

…quester-side guard

Declare `signal?: AbortSignal` once at the owning contract types — the per-call
overrides, the transport context, and the internal request envelope — so no
per-site guard repeats it (#3178). Add `createRequestGuard` to
@agent-device/host-kit/request beside the daemon-side cancellation machinery it
answers for: an already-aborted call is refused with `dispatched: 'no'`, and an
abort racing an in-flight send wins with the typed canceled-request error so a
custom transport that ignores the signal cannot outlive the caller's promise.
…d both transports

Wire the declared signal from the client's execute seam down to the transport
context and the request guard, and from the built-in transport into both daemon
wires (#3178):

- `sendToDaemon` refuses an already-aborted call before daemon startup and
  guards each client-side phase; an abort during the artifact upload now
  combines with the lease beat's own signal so the upload stops for either
  owner.
- The socket transport destroys its connection on abort, and the HTTP transport
  destroys its request; either way the daemon sees a client disconnect and
  marks the request canceled. Every settle path detaches the abort listener,
  and the timeout timer is cleared on abort so an abort never triggers the
  timeout path's runner sweep or daemon reset.
- Retries and fallback attempts start behind a refusal check, so nothing is
  sent after an abort.
… in the guard module

`abortedRequestError` and `refuseAbortedRequest` complete the request-cancel seam
the daemon request transports consume, so every rejection a caller's abort
produces is built by the module that declares the cancellation contract rather
than restated per transport.
…d both transports

#3178 acceptance: an abort before send sends nothing (`dispatched: 'no'`); an
abort mid-request closes that one request so the daemon marks it canceled
(`dispatched: 'unknown'`, proven daemon-side through the request-cancel
registry), and the daemon keeps serving follow-up requests. Client-level cases
pin the guard half: the signal rides the transport context, and a custom
transport that ignores it cannot outlive the caller's promise.

Regression evidence: all eight wiring cases fail on the pre-fix sources
(verified by running these files against fc7df6c's client/daemon-client/
transport) and pass with the fix.
#3178's Done-when: the public client docs list `signal`, including the
already-aborted refusal (`dispatched: 'no'`), the mid-flight cancel
(`reason: 'request_canceled'`), and the promise that an abort never takes the
timeout path's runner cleanup or daemon reset.
…rt seam

Add the paired regression cases for #3178's never-a-timeout promise: with the
timeout seam mocked, an abort with an armed budget leaves the seam never
called, while the same budget without a signal reaches it. Counterfactual
verified: deleting the abort path's `clearTimeout` fails only the abort case.
Clear the ignoring-transport test's late resolve so it keeps no timer alive.
…he review

- Move the requester-side AbortSignal guard out of @agent-device/host-kit/request
  into daemon-client-transport.ts, the module the CLI eager closure already
  loads, so the caller-side half and the transport half share one owner and the
  ADR-0019 loading-shape probe stops counting 9 extra modules against cli.ts and
  the replay-port facade.
- Generate the request id in execute before guarding, so a canceled call names
  itself in request.meta and matches the daemon's diagnostic for the same
  request.
- Report dispatched 'no' when a guard is entered already aborted: the refusal
  proves send never ran, so 'unknown' would suppress a safe retry.
- Keep a non-Error abort reason as the cause unchanged.
- Return before handleTransportError once a request is settled, so the error an
  intentional destroy emits cannot log daemon_request_socket_error.
- Cut the remote-retry health probe on the caller's signal and recheck the signal
  before timeout and retry handling, so a canceled call never reads as timed out.
- Forward the caller's abort into the lease beat, which owns its own connection.
- Prove the signal reaches the daemon through the DEFAULT transport: one
  loopback test per transport drives createAgentDeviceClient with no injected
  transport and asserts the daemon saw the cancel.
- Prove the RPC handler still serves after an HTTP cancellation, not just /health.
- Qualify the signal contract and the docs with the two phases the guarantee
  does not cover: a response-artifact download already underway, and a canceled
  one-shot replay's existing daemon cleanup.
…me the beat's outcomes

The code-quality audit found two exports with no consumer outside the transport
module and two functions whose new branches pushed them over the complexity
threshold. Nothing imported the helpers, so the guard's error factory and its
send-attempt refusal stay module-private beside the two callers that use them.
The lease beat now says what a failed beat means — one path ends the protection,
one reports a transient miss — and the retry path names the budget a
deadline-capped probe proves, instead of inlining both decisions.
… request's execution metadata

The scenario gateway built its Apple interactor once at bind time with an empty runner
context, so an alert leg asked the runner for a request id it never had. While the client
sent no meta.requestId the unkeyed provider scope still matched, but every call now carries
one: the request-scoped scripted provider is keyed by (deviceId, requestId), the empty
lookup missed it, and the leg fell through to the local XCTest runner — which spawns real
tooling and hangs the in-process harness. Per-call resolution mirrors the production alert
binding, which projects the input's execution metadata into the interactor context.
…se fails

net.Server.close() waits for accepted connections, and a hanging-handler request keeps
its connection open until the client's abort destroys it. When an assertion before that
fails, the daemon-side handler waits forever and close() hangs the lane, turning the
regression signal the abort tests exist to provide into a test timeout. Track the accepted
sockets and destroy them in teardown at the three socket suites whose handlers never
answer. http.Server already has closeAllConnections(); net.Server has no equivalent.
The compatibleChanges entry must carry the post-change digest; the declaration moved when
the probe began composing the #3178 caller signal with its own timeout budget. The /health
request and accepted payload are untouched, so protocol 2 peers parse it exactly as before.
@thymikee
thymikee force-pushed the feat/client-abort-signal branch from e2848d5 to b431157 Compare October 4, 2026 08:10
@thymikee

thymikee commented Oct 4, 2026

Copy link
Copy Markdown
Member Author

Rebased onto current main (fae0c39). Upstream #3199 shipped the same record-stop timeout-policy fix this branch carried, so those hunks skipped cleanly and the PR diff is now purely the abort-signal work (16 files, +1207/−85). No file overlap with #3194/#3199/#3202. pnpm check:affected --run is green on the rebased head b431157c5 (body updated); not merging.

The caller's per-call abort guard moved beside the transports it serves, while
`@agent-device/host-kit/request` kept the daemon-side cancel registry and
progress. The declaration-site line still credited the whole seam to host-kit,
pointing the next reader at a module that no longer exports the guard.
@thymikee

thymikee commented Oct 4, 2026

Copy link
Copy Markdown
Member Author

This PR is ready. The fixes for the 7fa0dc6 findings are in bb8e19d: the real-route loopback test, the eager-closure budget, listener cleanup, and abort-versus-timeout on the retry probe. All 22 checks pass at bb8e19d, and I know of no conflicts.

Not blocking, and you can take or leave these: (1) In daemon-client-lease-beat.ts:218, stopBeating() now aborts beatControl on every phase settle, so a beat still in flight when an upload finishes normally is closed and dropped silently. A late LEASE_EXPIRED answer can then no longer reach the lease_lost_after_phase diagnostic, and the test at daemon-client-lease-beat.test.ts:207 passes only because its fake heartbeat ignores the signal. Aborting beatControl only from the caller-abort listener would keep the old behavior. (2) Three new branches in daemon-client-transport.ts:387 have no test through sendRequest: the caller signal in the retry health probe, the if (settled) return in the HTTP error listener, and the reordered destroy in the HTTP timeout handler. A loopback case that aborts during a hanging /health probe and one HTTP abort case with no daemon_request_socket_error would cover them. (3) The roughly 20-line JSDoc block at daemon-client-transport.ts:38 is not attached to a declaration, so folding it into the createRequestGuard doc would be cleaner.

The earlier threads that are fixed at this head, and can be resolved, are #3200 (comment), #3200 (comment), #3200 (comment), #3200 (comment), #3200 (comment), #3200 (comment), #3200 (comment), #3200 (comment), #3200 (comment), #3200 (comment), #3200 (comment), #3200 (comment), #3200 (comment) and #3200 (comment). Two threads do not apply: #3200 (comment) (execute refuses with dispatched 'no' before dispatch, so only local preparation delays the rejection) and #3200 (comment) (a detached daemon launch outliving an aborted call matches the timeout path, and #3178 scopes the request phase).

I did not run the new loopback suites or the counterfactual of deleting the signal handoff, so the claim that they fail without it comes from reading the code. I also did not check whether a lease heartbeat canceled mid-handle on the daemon still applies its renewal.

@thymikee thymikee added the ready-for-human Valid work that needs human implementation, judgment, or maintainer merge label Oct 4, 2026
@thymikee
thymikee merged commit 11a395e into main Oct 4, 2026
22 checks passed
@thymikee
thymikee deleted the feat/client-abort-signal branch October 4, 2026 10:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ready-for-human Valid work that needs human implementation, judgment, or maintainer merge

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(client): accept an AbortSignal per call and close the request on abort

1 participant