|
| 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