Skip to content

Commit db4ee91

Browse files
committed
test(daemon-client): prove an abort is never a timeout at the transport 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.
1 parent e33c53d commit db4ee91

2 files changed

Lines changed: 117 additions & 2 deletions

File tree

‎src/__tests__/client-abort-signal.test.ts‎

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -53,21 +53,28 @@ test('the signal rides the transport context so a built-in-style transport can c
5353
});
5454

5555
test('aborting a call whose transport ignores the signal still rejects the caller with the typed canceled error', async () => {
56+
let lingering: ReturnType<typeof setTimeout> | undefined;
5657
const client = createAgentDeviceClient(
5758
{},
5859
{
5960
transport: (req) =>
6061
new Promise<DaemonResponse>((resolve) => {
6162
// A custom transport that never inspects the context signal: the guard must still settle
62-
// the caller's promise when the abort fires.
63-
setTimeout(() => resolve({ ok: true, data: { ignored: req.command } }), 10_000);
63+
// the caller's promise when the abort fires. The late resolve is cleared once the caller
64+
// has been rejected, so the worker keeps no timer alive for its own promise.
65+
lingering = setTimeout(
66+
() => resolve({ ok: true, data: { ignored: req.command } }),
67+
10_000,
68+
);
69+
lingering.unref?.();
6470
}),
6571
},
6672
);
6773
const controller = new AbortController();
6874
const call = client.interactions.press({ ref: '@e12', signal: controller.signal });
6975
setTimeout(() => controller.abort(), 10);
7076
await assert.rejects(call, (error: unknown) => canceledWith(error, 'unknown'));
77+
if (lingering) clearTimeout(lingering);
7178
});
7279

7380
test('a signal on one call does not cancel another', async () => {
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
/**
2+
* #3178: an abort is never a timeout. `handleRequestTimeout` is the one seam that sweeps runner
3+
* processes and resets the local daemon, so an abort that settles a request must also disarm that
4+
* request's timeout timer. The seam is mocked here — the subject is the transport's timer
5+
* discipline, not the sweep — and each case outlives the armed budget, so the assertion proves the
6+
* timer was cleared rather than merely beaten to the settle.
7+
*
8+
* The abort case aborts right after `sendRequest` arms its timer, so the clear is what is tested.
9+
* The paired no-signal case lets the same budget expire and must reach the mocked seam, which
10+
* makes the abort case's empty-calls assertion evidence instead of an absent hook: delete the
11+
* abort path's `clearTimeout` and only the abort case fails.
12+
*/
13+
import assert from 'node:assert/strict';
14+
import { test, vi } from 'vitest';
15+
import { AppError, isRequestCanceledError } from '@agent-device/kernel/errors';
16+
import { getRequestSignal } from '@agent-device/host-kit/request';
17+
import type { DaemonInvokeFn, DaemonRequest, DaemonResponse } from '../../daemon/daemon-request.ts';
18+
import { createSocketServer, listenNetServer } from '../../daemon/server/transport.ts';
19+
import { sendRequest } from '../daemon-client-transport.ts';
20+
import { resolveDaemonPaths } from '../../daemon-resolution.ts';
21+
import {
22+
closeLoopbackServer,
23+
skipWhenLoopbackUnavailable,
24+
type SkippableTestContext,
25+
} from '../../__tests__/test-utils/loopback.ts';
26+
import { mkdtempForTestSync } from '../../__tests__/test-utils/tmp-dir.ts';
27+
28+
const { handleRequestTimeoutCalls } = vi.hoisted(() => ({
29+
handleRequestTimeoutCalls: [] as unknown[],
30+
}));
31+
32+
vi.mock('../daemon-client-timeout.ts', () => ({
33+
handleRequestTimeout: (params: unknown) => {
34+
handleRequestTimeoutCalls.push(params);
35+
return new AppError('COMMAND_FAILED', 'Daemon request timed out', {
36+
reason: 'daemon_transport_timeout',
37+
});
38+
},
39+
}));
40+
41+
const STATE_PATHS = resolveDaemonPaths(mkdtempForTestSync('agent-device-abort-timeout-'));
42+
const TIMEOUT_MS = 15;
43+
// Past the armed budget, so a timer the abort forgot to clear fires inside the case.
44+
const OUTLIVE_MS = 80;
45+
46+
// A request the daemon never answers: the only things that can end it are the client's abort and
47+
// the transport's own budget, which is what makes the timer's fate observable.
48+
function hangingHandler(): DaemonInvokeFn {
49+
return async (req: DaemonRequest): Promise<DaemonResponse> => {
50+
const signal = getRequestSignal(req.meta?.requestId);
51+
return await new Promise<DaemonResponse>((_resolve, reject) => {
52+
signal?.addEventListener('abort', () => reject(signal.reason), { once: true });
53+
});
54+
};
55+
}
56+
57+
async function runTimerDiscipline(
58+
t: SkippableTestContext,
59+
outcome: 'aborted' | 'timed-out',
60+
): Promise<void> {
61+
if (await skipWhenLoopbackUnavailable(t)) return;
62+
const aborted = outcome === 'aborted';
63+
handleRequestTimeoutCalls.length = 0;
64+
const server = createSocketServer(hangingHandler());
65+
try {
66+
const port = await listenNetServer(server);
67+
const controller = new AbortController();
68+
const request = sendRequest(
69+
{ port, token: 't', pid: 1 },
70+
{
71+
token: 't',
72+
command: 'wait',
73+
session: 'default',
74+
positionals: [],
75+
flags: {},
76+
meta: { requestId: `req-${outcome}-timeout-timer` },
77+
},
78+
'socket',
79+
STATE_PATHS,
80+
TIMEOUT_MS,
81+
aborted ? { signal: controller.signal } : {},
82+
);
83+
if (aborted) {
84+
// `sendRequest` armed the timeout timer synchronously by returning; this abort must disarm it.
85+
controller.abort();
86+
await assert.rejects(request, (error: unknown) => isRequestCanceledError(error));
87+
} else {
88+
await assert.rejects(
89+
request,
90+
(error: unknown) =>
91+
error instanceof AppError && error.details?.reason === 'daemon_transport_timeout',
92+
);
93+
assert.equal(handleRequestTimeoutCalls.length, 1);
94+
}
95+
await new Promise((resolve) => setTimeout(resolve, OUTLIVE_MS));
96+
if (aborted) assert.deepEqual(handleRequestTimeoutCalls, []);
97+
} finally {
98+
await closeLoopbackServer(server);
99+
}
100+
}
101+
102+
test('an abort with a timeout armed clears the timer instead of running the timeout path', async (t) => {
103+
await runTimerDiscipline(t, 'aborted');
104+
});
105+
106+
test('the same budget without a signal reaches the timeout seam', async (t) => {
107+
await runTimerDiscipline(t, 'timed-out');
108+
});

0 commit comments

Comments
 (0)