Repository navigation
feat(client): accept an AbortSignal per call and close the request on abort #3200
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
14 commits
Select commit
Hold shift + click to select a range
df51767
feat(contracts,host-kit): declare the per-call AbortSignal and its re…
thymikee 88a0846
feat(client): thread the per-call signal through the daemon client an…
thymikee 215826f
refactor(host-kit): own the transport-facing canceled-request helpers…
thymikee 9f9cac7
test(client): cover the per-call AbortSignal on the client surface an…
thymikee c8ba9af
docs(client): document the per-call signal on every client method
thymikee e33c53d
test(daemon-client): carry the wire token in the abort fixtures' daem…
thymikee db4ee91
test(daemon-client): prove an abort is never a timeout at the transpo…
thymikee 10eb9d6
fix(client): own the per-call guard beside its transport and answer t…
thymikee e2c10e1
refactor(daemon-client): keep the guard's helpers module-local and na…
thymikee e8a3427
test(provider-scenarios): resolve alert interactors per call from the…
thymikee e079b2b
refactor(test): one per-call alert-leg resolver for the iOS scenario …
thymikee 942bfab
test(daemon-client): destroy tracked sockets when a loopback abort ca…
thymikee b431157
chore(gates): ack the caller-signal digest on the HTTP health probe
thymikee bb8e19d
docs(agents): name both request-cancellation seams
thymikee File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,103 @@ | ||
| /** | ||
| * #3178: every client call accepts `signal?: AbortSignal`. | ||
| * | ||
| * These pin the client's own half of the contract against a scripted transport: an already-aborted | ||
| * call never reaches the transport (`details.dispatched: 'no'`), the signal rides the transport | ||
| * context for a transport that honors it, and a custom transport that ignores the signal still | ||
| * loses the race — the caller's promise rejects with the typed canceled-request error | ||
| * (`details.dispatched: 'unknown'`). The transport-into-daemon half is covered by | ||
| * `src/daemon-client/__tests__/daemon-client-abort.test.ts`. | ||
| */ | ||
| import assert from 'node:assert/strict'; | ||
| import { test } from 'vitest'; | ||
| import { isRequestCanceledError } from '@agent-device/kernel/errors'; | ||
| import { createAgentDeviceClient } from '../agent-device-client.ts'; | ||
| import type { AgentDeviceDaemonTransportContext } from '@agent-device/contracts/client'; | ||
| import type { DaemonRequest, DaemonResponse } from '@agent-device/kernel/contracts'; | ||
| import { createTransport } from './client-transport-fixture.ts'; | ||
|
|
||
| function canceledWith(error: unknown, dispatched: 'no' | 'unknown'): boolean { | ||
| return ( | ||
| isRequestCanceledError(error) && | ||
| (error as { details?: Record<string, unknown> }).details?.dispatched === dispatched | ||
| ); | ||
| } | ||
|
|
||
| test('a client call with an already-aborted signal never reaches the transport', async () => { | ||
| const { config, calls, transport } = createTransport(() => ({ ok: true, data: {} })); | ||
| const client = createAgentDeviceClient(config, { transport }); | ||
| const controller = new AbortController(); | ||
| controller.abort(); | ||
| await assert.rejects( | ||
| client.interactions.press({ ref: '@e12', signal: controller.signal }), | ||
| (error: unknown) => canceledWith(error, 'no'), | ||
| ); | ||
| assert.deepEqual(calls, []); | ||
| }); | ||
|
|
||
| test('the signal rides the transport context so a built-in-style transport can close the request', async () => { | ||
| const contexts: Array<AgentDeviceDaemonTransportContext | undefined> = []; | ||
| const client = createAgentDeviceClient( | ||
| {}, | ||
| { | ||
| transport: async (_req, context) => { | ||
| contexts.push(context); | ||
| return { ok: true, data: {} } satisfies DaemonResponse; | ||
| }, | ||
| }, | ||
| ); | ||
| const controller = new AbortController(); | ||
| await client.command.wait({ durationMs: 1, signal: controller.signal }); | ||
| assert.equal(contexts.length, 1); | ||
| assert.equal(contexts[0]?.signal, controller.signal); | ||
| }); | ||
|
|
||
| test('aborting a call whose transport ignores the signal still rejects the caller with the typed canceled error', async () => { | ||
| let lingering: ReturnType<typeof setTimeout> | undefined; | ||
| const client = createAgentDeviceClient( | ||
| {}, | ||
| { | ||
| transport: (req) => | ||
| new Promise<DaemonResponse>((resolve) => { | ||
| // A custom transport that never inspects the context signal: the guard must still settle | ||
| // the caller's promise when the abort fires. The late resolve is cleared once the caller | ||
| // has been rejected, so the worker keeps no timer alive for its own promise. | ||
| lingering = setTimeout( | ||
| () => resolve({ ok: true, data: { ignored: req.command } }), | ||
| 10_000, | ||
| ); | ||
| lingering.unref?.(); | ||
| }), | ||
| }, | ||
| ); | ||
| const controller = new AbortController(); | ||
| const call = client.interactions.press({ ref: '@e12', signal: controller.signal }); | ||
| setTimeout(() => controller.abort(), 10); | ||
| await assert.rejects(call, (error: unknown) => canceledWith(error, 'unknown')); | ||
| if (lingering) clearTimeout(lingering); | ||
| }); | ||
|
|
||
| test('a signal on one call does not cancel another', async () => { | ||
| const client = createAgentDeviceClient( | ||
| {}, | ||
| { | ||
| transport: async (req: Omit<DaemonRequest, 'token'>) => { | ||
| if (req.command === 'wait') { | ||
| await new Promise((resolve) => setTimeout(resolve, 50)); | ||
| } | ||
| return { ok: true, data: {} }; | ||
| }, | ||
| }, | ||
| ); | ||
| const canceled = new AbortController(); | ||
| const doomed = client.command.wait({ durationMs: 5000, signal: canceled.signal }); | ||
| const survivor = client.command.wait({ durationMs: 1 }); | ||
| // Deferred so the doomed call is genuinely in flight when the abort fires: a synchronous abort | ||
| // would land before `execute` installs the guard and reject through the pre-abort `no` path that | ||
| // the first test already covers. The guard answers for a transport that ignores the signal, so | ||
| // this exercises the in-flight `unknown` rejection while the survivor runs untouched. | ||
| await new Promise((resolve) => setTimeout(resolve, 10)); | ||
| canceled.abort(); | ||
|
thymikee marked this conversation as resolved.
|
||
| await assert.rejects(doomed, (error: unknown) => canceledWith(error, 'unknown')); | ||
| assert.deepEqual(await survivor, {}); | ||
| }); | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.