Skip to content

Commit b59b4e5

Browse files
authored
fix(daemon): make request-timeout cleanup and hints platform-aware (#1751)
* fix(daemon): make request-timeout cleanup and hints platform-aware Every local request timeout ran the Apple-runner xcodebuild pkill cleanup and emitted Apple-specific hint wording ("Apple runner work was aborted", "Timed-out Apple runner xcodebuild processes were terminated") regardless of the session's actual platform, which was misleading on Android/web/ Harmony timeouts. The client already carries the request's declared --platform on the exact DaemonRequest object both timeout call sites hold (req.flags?.platform), so no new plumbing or src/platforms import is needed. When the platform is known non-Apple, the Apple pkill cleanup is skipped and the hint drops its Apple-runner claim; when it's unknown, both stay on their historical fail-safe behavior. Part of #1739 (independent cleanups) * fix(daemon): split timeout cleanup eligibility from hint evidence Maintainer review on #1751 found a real correctness inversion in the first pass: gating the Apple xcodebuild pkill cleanup on the request's declared --platform flag is wrong in both directions. - req.flags.platform is not authoritative for session-bound execution. applyStripLockPolicy (request-lock-policy.ts) lets an existing session's real device platform silently override a conflicting declared selector under --session-lock strip, so a request declaring a non-Apple platform can still legitimately execute Apple work. Skipping cleanup on that declared flag would skip real cleanup — the dangerous direction. - The common session-bound request omits --platform entirely, so the previous fail-safe (undeclared -> treat as Apple) left the hint wrongly claiming Apple involvement on the motivating case (Android/web/Harmony session timeouts) too. Redesign: separate the two decisions. - Cleanup eligibility is unconditional again for every local timeout, matching the original pre-fix behavior: the pkill patterns are Apple-process-name-specific, so sweeping them on a non-Apple host or session matches nothing and costs a few no-op subprocess spawns, never a wrong skip. There is no client-visible signal that proves a session-bound request cannot touch an Apple runner, so eligibility does not try to prove one. - The hint may only name Apple-runner involvement on evidence this call site actually has: an explicitly declared Apple platform selector, or the cleanup itself having terminated a matching process (appleCleanupEvidence). Anything else gets platform-neutral wording. Adds production-seam route tests (src/daemon/client/__tests__/daemon-client-timeout-route.test.ts) that spy on the real exec seam and drive actual socket/HTTP timeouts through sendRequest, covering the rebound-session and unknown-session cases a pure formatter test cannot catch. Proved red against the prior commit (82aa5ad) via a scoped git stash before restoring the fix. Part of #1739 (independent cleanups)
1 parent 39cd4d3 commit b59b4e5

4 files changed

Lines changed: 391 additions & 6 deletions

File tree

Lines changed: 241 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,241 @@
1+
// Production-seam coverage for the real request-timeout route.
2+
//
3+
// src/utils/__tests__/daemon-client.test.ts covers `resolveRequestTimeoutHint`
4+
// as a pure formatter, but a pure-formatter test cannot catch a bug in
5+
// CLEANUP ELIGIBILITY: whether `cleanupTimedOutIosRunnerBuilds` (the Apple
6+
// xcodebuild pkill sweep) actually runs. This file spies on the real
7+
// process-execution seam (`runCmdSync`, src/utils/exec.ts) and drives an
8+
// actual socket/HTTP timeout through `sendRequest` so the assertions exercise
9+
// the same code path a real client does.
10+
//
11+
// Why cleanup eligibility must stay unconditional for local timeouts: the
12+
// client's declared --platform is not authoritative for session-bound
13+
// execution. `applyStripLockPolicy` (src/daemon/request-lock-policy.ts) lets
14+
// an existing session's real device platform silently override a conflicting
15+
// declared selector under --session-lock strip, and the common session-bound
16+
// request omits --platform entirely. So a request declaring `platform:
17+
// 'android'` can still legitimately execute against an Apple-bound session
18+
// (the "rebound-session" case below), and a request with no platform at all
19+
// (the "unknown-session" case) is the common route the original bug misled.
20+
// A design that skips the pkill sweep based on the declared flag alone would
21+
// skip real cleanup in the rebound case — the dangerous direction. This test
22+
// proves the sweep always fires for local timeouts, and that the HINT text
23+
// (not the cleanup) is what carries the platform-evidence gating.
24+
25+
import net from 'node:net';
26+
import http from 'node:http';
27+
import os from 'node:os';
28+
import path from 'node:path';
29+
import assert from 'node:assert/strict';
30+
import { beforeEach, test, vi } from 'vitest';
31+
32+
const { mockRunCmdSync } = vi.hoisted(() => ({ mockRunCmdSync: vi.fn() }));
33+
34+
vi.mock('../../../utils/exec.ts', async () => {
35+
const actual =
36+
await vi.importActual<typeof import('../../../utils/exec.ts')>('../../../utils/exec.ts');
37+
return { ...actual, runCmdSync: mockRunCmdSync };
38+
});
39+
40+
import { AppError } from '@agent-device/kernel/errors';
41+
import { sendRequest } from '../daemon-client-transport.ts';
42+
import type { DaemonRequest } from '../../types.ts';
43+
import type { DaemonInfo } from '../daemon-client-metadata.ts';
44+
import type { DaemonPaths } from '../../config.ts';
45+
46+
const TIMEOUT_MS = 120;
47+
48+
// `snapshot`'s timeout policy preserves the daemon (onTimeout !==
49+
// 'reset-daemon'), so `handleRequestTimeout` never reaches
50+
// `resetDaemonAfterTimeout` (`process.kill`) here — keeping this suite
51+
// side-effect-free outside the mocked pkill sweep.
52+
const SNAPSHOT_COMMAND = 'snapshot';
53+
54+
function dummyStatePaths(): DaemonPaths {
55+
const baseDir = path.join(os.tmpdir(), 'agent-device-timeout-route-test');
56+
return {
57+
baseDir,
58+
infoPath: path.join(baseDir, 'daemon.json'),
59+
lockPath: path.join(baseDir, 'daemon.lock'),
60+
logPath: path.join(baseDir, 'daemon.log'),
61+
sessionsDir: path.join(baseDir, 'sessions'),
62+
};
63+
}
64+
65+
function buildRequest(platform: 'android' | 'ios' | undefined): DaemonRequest {
66+
return {
67+
token: 'test-token',
68+
session: 'default',
69+
command: SNAPSHOT_COMMAND,
70+
positionals: [],
71+
flags: platform ? { platform } : {},
72+
meta: { requestId: 'req-timeout-route' },
73+
};
74+
}
75+
76+
function startHangingSocketServer(): Promise<{ server: net.Server; port: number }> {
77+
return new Promise((resolve, reject) => {
78+
const server = net.createServer((socket) => {
79+
// Accept the connection but never write a response — forces the
80+
// client's own request-timeout envelope to fire.
81+
socket.on('error', () => {});
82+
});
83+
server.on('error', reject);
84+
server.listen(0, '127.0.0.1', () => {
85+
const address = server.address();
86+
if (address && typeof address === 'object') {
87+
resolve({ server, port: address.port });
88+
} else {
89+
reject(new Error('failed to bind hanging socket test server'));
90+
}
91+
});
92+
});
93+
}
94+
95+
function startHangingHttpServer(): Promise<{ server: http.Server; port: number }> {
96+
return new Promise((resolve, reject) => {
97+
const server = http.createServer((_req, res) => {
98+
// Never call res.end() — forces the client's own request-timeout
99+
// envelope to fire instead of a real response.
100+
res.on('error', () => {});
101+
});
102+
server.on('clientError', (_err, socket) => socket.destroy());
103+
server.on('error', reject);
104+
server.listen(0, '127.0.0.1', () => {
105+
const address = server.address();
106+
if (address && typeof address === 'object') {
107+
resolve({ server, port: address.port });
108+
} else {
109+
reject(new Error('failed to bind hanging http test server'));
110+
}
111+
});
112+
});
113+
}
114+
115+
beforeEach(() => {
116+
mockRunCmdSync.mockReset();
117+
});
118+
119+
test('socket timeout: pkill cleanup still runs for a declared non-Apple platform that actually terminates a runner (rebound-session case), and the hint claims Apple on that evidence', async () => {
120+
// Simulates --session-lock strip silently rebinding this request onto an
121+
// existing Apple session: the client declared `platform: 'android'`, but
122+
// real Apple xcodebuild work was in flight and the pkill sweep kills it.
123+
mockRunCmdSync.mockImplementation((cmd: string) =>
124+
cmd === 'pkill'
125+
? { exitCode: 0, stdout: '', stderr: '' }
126+
: { exitCode: 1, stdout: '', stderr: '' },
127+
);
128+
129+
const { server, port } = await startHangingSocketServer();
130+
try {
131+
const info: DaemonInfo = { port, token: 'test-token', pid: process.pid };
132+
const req = buildRequest('android');
133+
134+
await assert.rejects(
135+
sendRequest(info, req, 'socket', dummyStatePaths(), TIMEOUT_MS),
136+
(error: unknown) => {
137+
assert.ok(error instanceof AppError);
138+
assert.match(error.details?.hint as string, /Apple runner work was aborted when detected/);
139+
return true;
140+
},
141+
);
142+
} finally {
143+
server.close();
144+
}
145+
146+
// The eligibility assertion: cleanup ran (all three kill patterns
147+
// attempted) even though the request declared a non-Apple platform. A
148+
// design that skips cleanup based on the declared flag would fail this.
149+
const pkillCalls = mockRunCmdSync.mock.calls.filter(([cmd]) => cmd === 'pkill');
150+
assert.equal(pkillCalls.length, 3);
151+
});
152+
153+
test('http timeout: pkill cleanup still runs for an undeclared platform (unknown-session case) that terminates nothing, and the hint stays platform-neutral', async () => {
154+
// Simulates the common session-bound request that never repeats
155+
// --platform, on a real Android/web/Harmony session: no processes match
156+
// the Apple-specific kill patterns.
157+
mockRunCmdSync.mockImplementation(() => ({ exitCode: 1, stdout: '', stderr: '' }));
158+
159+
const { server, port } = await startHangingHttpServer();
160+
try {
161+
const info: DaemonInfo = { httpPort: port, token: 'test-token', pid: process.pid };
162+
const req = buildRequest(undefined);
163+
164+
await assert.rejects(
165+
sendRequest(info, req, 'http', dummyStatePaths(), TIMEOUT_MS),
166+
(error: unknown) => {
167+
assert.ok(error instanceof AppError);
168+
const hint = error.details?.hint as string;
169+
assert.doesNotMatch(hint, /Apple/);
170+
assert.match(
171+
hint,
172+
/The timed-out snapshot request was canceled; the daemon was kept alive/,
173+
);
174+
return true;
175+
},
176+
);
177+
} finally {
178+
server.close();
179+
}
180+
181+
// Cleanup still ran — this is the regression this suite exists to catch:
182+
// an eligibility design keyed off the (here, absent) declared platform
183+
// would either skip cleanup entirely or — under the original unconditional
184+
// hint — falsely claim Apple involvement anyway. Neither happens here.
185+
const pkillCalls = mockRunCmdSync.mock.calls.filter(([cmd]) => cmd === 'pkill');
186+
assert.equal(pkillCalls.length, 3);
187+
});
188+
189+
test('http timeout: an explicitly declared Apple platform keeps the Apple hint even when the sweep terminates nothing', async () => {
190+
mockRunCmdSync.mockImplementation(() => ({ exitCode: 1, stdout: '', stderr: '' }));
191+
192+
const { server, port } = await startHangingHttpServer();
193+
try {
194+
const info: DaemonInfo = { httpPort: port, token: 'test-token', pid: process.pid };
195+
const req = buildRequest('ios');
196+
197+
await assert.rejects(
198+
sendRequest(info, req, 'http', dummyStatePaths(), TIMEOUT_MS),
199+
(error: unknown) => {
200+
assert.ok(error instanceof AppError);
201+
assert.match(error.details?.hint as string, /Apple runner work was aborted when detected/);
202+
return true;
203+
},
204+
);
205+
} finally {
206+
server.close();
207+
}
208+
209+
const pkillCalls = mockRunCmdSync.mock.calls.filter(([cmd]) => cmd === 'pkill');
210+
assert.equal(pkillCalls.length, 3);
211+
});
212+
213+
test('remote HTTP timeout never runs the Apple pkill cleanup and uses the remote-specific hint', async () => {
214+
mockRunCmdSync.mockImplementation(() => ({ exitCode: 0, stdout: '', stderr: '' }));
215+
216+
const { server, port } = await startHangingHttpServer();
217+
try {
218+
const info: DaemonInfo = {
219+
baseUrl: `http://127.0.0.1:${port}`,
220+
token: 'test-token',
221+
pid: process.pid,
222+
};
223+
const req = buildRequest('android');
224+
225+
await assert.rejects(
226+
sendRequest(info, req, 'http', dummyStatePaths(), TIMEOUT_MS),
227+
(error: unknown) => {
228+
assert.ok(error instanceof AppError);
229+
assert.match(
230+
error.details?.hint as string,
231+
/verify the remote daemon URL, auth token, and remote host logs/,
232+
);
233+
return true;
234+
},
235+
);
236+
} finally {
237+
server.close();
238+
}
239+
240+
assert.equal(mockRunCmdSync.mock.calls.length, 0);
241+
});

src/daemon/client/daemon-client-timeout.ts

Lines changed: 56 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import type {
1010
} from '../../core/command-descriptor/types.ts';
1111
import type { DaemonRequest } from '../types.ts';
1212
import type { DaemonPaths } from '../config.ts';
13+
import type { PlatformSelector } from '@agent-device/kernel/device';
1314
import {
1415
removeDaemonInfo,
1516
removeDaemonLock,
@@ -23,6 +24,22 @@ const IOS_RUNNER_XCODEBUILD_KILL_PATTERNS = [
2324
'xcodebuild build-for-testing .*apple/runner/AgentDeviceRunner/AgentDeviceRunner\\.xcodeproj',
2425
];
2526

27+
// `--platform` selectors that AFFIRMATIVELY name (or alias) an Apple device.
28+
// This is deliberately narrower than "not proven non-Apple": the client's
29+
// declared platform is not authoritative for session-bound execution (see
30+
// the eligibility note on `handleRequestTimeout` below), so an undeclared or
31+
// declared-non-Apple platform is not evidence of anything — it only counts
32+
// as Apple evidence when it says so outright.
33+
const AFFIRMATIVE_APPLE_PLATFORM_SELECTORS: ReadonlySet<PlatformSelector> = new Set([
34+
'apple',
35+
'ios',
36+
'macos',
37+
]);
38+
39+
function isAffirmativelyApplePlatform(platform: PlatformSelector | undefined): boolean {
40+
return platform !== undefined && AFFIRMATIVE_APPLE_PLATFORM_SELECTORS.has(platform);
41+
}
42+
2643
type BoundedTimeoutPolicy = CommandTimeoutPolicy & { envelopeMs: number };
2744
type FlagTimeoutBudget = Extract<CommandTimeoutBudget, { source: 'flag' }>;
2845
type RequestFlags = Omit<DaemonRequest, 'token'>['flags'];
@@ -98,12 +115,32 @@ export function handleRequestTimeout(
98115
command: string | undefined,
99116
remote: boolean,
100117
timeoutMs: number,
118+
platform: PlatformSelector | undefined,
101119
): AppError {
120+
// Cleanup eligibility stays UNCONDITIONAL for every local (non-remote)
121+
// timeout, on purpose: the request's declared --platform is not
122+
// authoritative for session-bound execution. An existing session's real
123+
// device platform can silently override a conflicting declared selector
124+
// (`applyStripLockPolicy` in request-lock-policy.ts, reached via
125+
// --session-lock strip), and the common session-bound request omits
126+
// --platform entirely — so there is no client-visible signal that proves
127+
// a request cannot touch an Apple runner. The pkill patterns are
128+
// Apple-process-name-specific, so sweeping them on a non-Apple host or
129+
// session matches nothing and costs a few no-op subprocess spawns, never
130+
// a wrong skip.
102131
const cleanup = remote ? { terminated: 0 } : cleanupTimedOutIosRunnerBuilds();
103132
const resetDaemon = !remote && shouldResetDaemonAfterRequestTimeout(command);
104133
const daemonReset = resetDaemon
105134
? resetDaemonAfterTimeout(info, statePaths)
106135
: { forcedKill: false };
136+
// The HINT, unlike cleanup, may only name Apple-runner involvement on
137+
// evidence this call site actually has: an explicitly declared Apple
138+
// platform selector, or the cleanup itself having terminated a matching
139+
// process (proof positive regardless of what --platform claimed). Any
140+
// other combination — undeclared platform, declared non-Apple platform,
141+
// zero processes terminated — gets platform-neutral wording instead of
142+
// asserting Apple specifics the client cannot back up.
143+
const appleCleanupEvidence = isAffirmativelyApplePlatform(platform) || cleanup.terminated > 0;
107144
emitDiagnostic({
108145
level: 'error',
109146
phase: 'daemon_request_timeout',
@@ -122,7 +159,7 @@ export function handleRequestTimeout(
122159
return new AppError('COMMAND_FAILED', 'Daemon request timed out', {
123160
timeoutMs,
124161
requestId,
125-
hint: resolveRequestTimeoutHint({ remote, resetDaemon, command }),
162+
hint: resolveRequestTimeoutHint({ remote, resetDaemon, command, appleCleanupEvidence }),
126163
});
127164
}
128165

@@ -135,23 +172,36 @@ export function shouldResetDaemonAfterRequestTimeout(command: string | undefined
135172
return resolveCommandTimeoutPolicy(command).onTimeout === 'reset-daemon';
136173
}
137174

138-
function resolveRequestTimeoutHint(params: {
175+
// Exported for direct hint-matrix testing: handleRequestTimeout also triggers
176+
// real pkill/process-kill side effects, so its wording is verified through
177+
// this pure sub-function rather than the full timeout path (see also the
178+
// production-seam route tests in
179+
// src/daemon/client/__tests__/daemon-client-timeout-route.test.ts, which
180+
// prove the cleanup-eligibility side of this contract that a pure formatter
181+
// test cannot).
182+
export function resolveRequestTimeoutHint(params: {
139183
remote: boolean;
140184
resetDaemon: boolean;
141185
command: string | undefined;
186+
appleCleanupEvidence: boolean;
142187
}): string {
143-
const { remote, resetDaemon, command } = params;
188+
const { remote, resetDaemon, command, appleCleanupEvidence } = params;
144189
if (remote) {
145190
return 'Retry with --debug and verify the remote daemon URL, auth token, and remote host logs.';
146191
}
147192
if (!resetDaemon) {
148193
const iosPrepareHint =
149-
command === PUBLIC_COMMANDS.snapshot
194+
appleCleanupEvidence && command === PUBLIC_COMMANDS.snapshot
150195
? ' If this was the first Apple-platform snapshot on the device, run agent-device prepare ios-runner with the same --platform before snapshot/test so runner startup is handled explicitly.'
151196
: '';
152-
return `Retry with --debug and check daemon diagnostics logs. The timed-out ${command ?? 'request'} request was canceled and Apple runner work was aborted when detected; the daemon was kept alive so the session can still be closed or inspected.${iosPrepareHint}`;
197+
const appleCleanupNote = appleCleanupEvidence
198+
? ' and Apple runner work was aborted when detected'
199+
: '';
200+
return `Retry with --debug and check daemon diagnostics logs. The timed-out ${command ?? 'request'} request was canceled${appleCleanupNote}; the daemon was kept alive so the session can still be closed or inspected.${iosPrepareHint}`;
153201
}
154-
return 'Retry with --debug and check daemon diagnostics logs. Timed-out Apple runner xcodebuild processes were terminated when detected.';
202+
return appleCleanupEvidence
203+
? 'Retry with --debug and check daemon diagnostics logs. Timed-out Apple runner xcodebuild processes were terminated when detected.'
204+
: 'Retry with --debug and check daemon diagnostics logs. The daemon was reset after the timeout.';
155205
}
156206

157207
function cleanupTimedOutIosRunnerBuilds(): { terminated: number; error?: string } {

src/daemon/client/daemon-client-transport.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -322,6 +322,7 @@ async function sendSocketRequest(
322322
req.command,
323323
false,
324324
timeoutMs,
325+
req.flags?.platform,
325326
),
326327
);
327328
}, timeoutMs)
@@ -436,6 +437,7 @@ async function sendHttpRequest(
436437
req.command,
437438
remote,
438439
timeoutMs,
440+
req.flags?.platform,
439441
),
440442
);
441443
}, timeoutMs)

0 commit comments

Comments
 (0)