Skip to content

Commit 7edf537

Browse files
authored
fix(daemon): report unknown browser command results (#1558)
1 parent af7b941 commit 7edf537

5 files changed

Lines changed: 182 additions & 18 deletions

File tree

src/browser/daemon-client.test.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
22

33
import {
4+
BrowserCommandError,
45
fetchDaemonStatus,
56
getDaemonHealth,
67
requestDaemonShutdown,
@@ -220,4 +221,26 @@ describe('daemon-client', () => {
220221
});
221222
expect(ids[0]).not.toBe(ids[1]);
222223
});
224+
225+
it('sendCommand does not retry command_result_unknown even when the message looks transient', async () => {
226+
const fetchMock = vi.mocked(fetch);
227+
fetchMock.mockResolvedValue({
228+
ok: false,
229+
status: 503,
230+
json: () => Promise.resolve({
231+
id: 'server',
232+
ok: false,
233+
errorCode: 'command_result_unknown',
234+
error: 'Extension disconnected after command timeout',
235+
errorHint: 'Inspect state before retrying.',
236+
}),
237+
} as Response);
238+
239+
await expect(sendCommand('exec', { code: 'window.__mutate = true' })).rejects.toMatchObject({
240+
name: 'BrowserCommandError',
241+
code: 'command_result_unknown',
242+
hint: 'Inspect state before retrying.',
243+
} satisfies Partial<BrowserCommandError>);
244+
expect(fetchMock).toHaveBeenCalledTimes(1);
245+
});
223246
});

src/browser/daemon-client.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,7 @@ export interface DaemonStatus {
9494
profileDisconnected?: boolean;
9595
profiles?: BrowserProfileStatus[];
9696
pending: number;
97+
commandResultUnknown?: number;
9798
memoryMB: number;
9899
port: number;
99100
}
@@ -197,6 +198,9 @@ async function sendCommandRaw(
197198
const result = (await res.json()) as DaemonResult;
198199

199200
if (!result.ok) {
201+
if (result.errorCode === 'command_result_unknown') {
202+
throw new BrowserCommandError(result.error ?? 'Browser command result is unknown', result.errorCode, result.errorHint);
203+
}
200204
const isDuplicateCommandId = res.status === 409
201205
|| (result.error ?? '').includes('Duplicate command id');
202206
if (isDuplicateCommandId && attempt < maxRetries) {

src/daemon-utils.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
export const COMMAND_RESULT_UNKNOWN_CODE = 'command_result_unknown';
2+
3+
export const COMMAND_RESULT_UNKNOWN_HINT =
4+
'Inspect the browser/session state before retrying. Do not blindly retry write commands such as navigate, click, type, or eval.';
5+
6+
export const PROFILE_DISCONNECTED_HINT =
7+
'Open that Chrome profile and make sure the OpenCLI extension is enabled, or choose another profile with opencli profile use <name>.';
8+
9+
export type DaemonFailureContract = {
10+
message: string;
11+
errorCode: string;
12+
errorHint: string;
13+
status: number;
14+
countAsCommandResultUnknown: boolean;
15+
};
16+
17+
export function commandResultUnknownMessage(action: string): string {
18+
return `Browser connection dropped after the ${action} command was dispatched; it may have completed.`;
19+
}
20+
21+
export function buildExtensionDisconnectFailure(input: {
22+
contextId: string;
23+
action: string;
24+
dispatched: boolean;
25+
}): DaemonFailureContract {
26+
if (input.dispatched) {
27+
return {
28+
message: commandResultUnknownMessage(input.action),
29+
errorCode: COMMAND_RESULT_UNKNOWN_CODE,
30+
errorHint: COMMAND_RESULT_UNKNOWN_HINT,
31+
status: 503,
32+
countAsCommandResultUnknown: true,
33+
};
34+
}
35+
return buildCommandDispatchFailure(input.contextId);
36+
}
37+
38+
export function buildCommandDispatchFailure(contextId: string): DaemonFailureContract {
39+
return {
40+
message: `Browser profile "${contextId}" disconnected before command dispatch`,
41+
errorCode: 'profile_disconnected',
42+
errorHint: PROFILE_DISCONNECTED_HINT,
43+
status: 503,
44+
countAsCommandResultUnknown: false,
45+
};
46+
}
47+
48+
export function getResponseCorsHeaders(pathname: string, origin?: string): Record<string, string> | undefined {
49+
if (pathname !== '/ping') return undefined;
50+
if (!origin || !origin.startsWith('chrome-extension://')) return undefined;
51+
return {
52+
'Access-Control-Allow-Origin': origin,
53+
Vary: 'Origin',
54+
};
55+
}

src/daemon.test.ts

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,13 @@
11
import { describe, expect, it } from 'vitest';
22

3-
import { getResponseCorsHeaders } from './daemon.js';
3+
import {
4+
COMMAND_RESULT_UNKNOWN_CODE,
5+
COMMAND_RESULT_UNKNOWN_HINT,
6+
buildCommandDispatchFailure,
7+
buildExtensionDisconnectFailure,
8+
commandResultUnknownMessage,
9+
getResponseCorsHeaders,
10+
} from './daemon-utils.js';
411

512
describe('getResponseCorsHeaders', () => {
613
it('allows the Browser Bridge extension origin to read /ping', () => {
@@ -22,3 +29,48 @@ describe('getResponseCorsHeaders', () => {
2229
expect(getResponseCorsHeaders('/command', 'chrome-extension://abc123')).toBeUndefined();
2330
});
2431
});
32+
33+
describe('daemon command dispatch', () => {
34+
it('uses a distinct command_result_unknown contract for ambiguous dispatched commands', () => {
35+
expect(COMMAND_RESULT_UNKNOWN_CODE).toBe('command_result_unknown');
36+
expect(commandResultUnknownMessage('navigate')).toContain('navigate command was dispatched');
37+
expect(COMMAND_RESULT_UNKNOWN_HINT).toContain('Inspect the browser/session state');
38+
expect(COMMAND_RESULT_UNKNOWN_HINT).toContain('Do not blindly retry write commands');
39+
});
40+
41+
it('classifies dispatched extension disconnects as command_result_unknown', () => {
42+
expect(buildExtensionDisconnectFailure({
43+
contextId: 'work',
44+
action: 'navigate',
45+
dispatched: true,
46+
})).toEqual({
47+
message: 'Browser connection dropped after the navigate command was dispatched; it may have completed.',
48+
errorCode: 'command_result_unknown',
49+
errorHint: COMMAND_RESULT_UNKNOWN_HINT,
50+
status: 503,
51+
countAsCommandResultUnknown: true,
52+
});
53+
});
54+
55+
it('classifies pre-dispatch extension disconnects as profile_disconnected', () => {
56+
expect(buildExtensionDisconnectFailure({
57+
contextId: 'work',
58+
action: 'navigate',
59+
dispatched: false,
60+
})).toMatchObject({
61+
message: 'Browser profile "work" disconnected before command dispatch',
62+
errorCode: 'profile_disconnected',
63+
status: 503,
64+
countAsCommandResultUnknown: false,
65+
});
66+
});
67+
68+
it('classifies ws.send dispatch failures as profile_disconnected', () => {
69+
expect(buildCommandDispatchFailure('work')).toMatchObject({
70+
message: 'Browser profile "work" disconnected before command dispatch',
71+
errorCode: 'profile_disconnected',
72+
status: 503,
73+
countAsCommandResultUnknown: false,
74+
});
75+
});
76+
});

src/daemon.ts

Lines changed: 47 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,11 @@ import { log } from './logger.js';
2828
import { PKG_VERSION } from './version.js';
2929
import { DEFAULT_CONTEXT_ID } from './browser/profile.js';
3030
import { recordExtensionVersion } from './update-check.js';
31+
import {
32+
buildCommandDispatchFailure,
33+
buildExtensionDisconnectFailure,
34+
getResponseCorsHeaders,
35+
} from './daemon-utils.js';
3136

3237
const PORT = parseInt(process.env.OPENCLI_DAEMON_PORT ?? String(DEFAULT_DAEMON_PORT), 10);
3338

@@ -44,10 +49,13 @@ type ExtensionProfileConnection = {
4449
const extensionProfiles = new Map<string, ExtensionProfileConnection>();
4550
const pending = new Map<string, {
4651
contextId: string;
52+
action: string;
53+
dispatched: boolean;
4754
resolve: (data: unknown) => void;
4855
reject: (error: Error) => void;
4956
timer: ReturnType<typeof setTimeout>;
5057
}>();
58+
let commandResultUnknownCount = 0;
5159
// Extension log ring buffer
5260
interface LogEntry { level: string; msg: string; ts: number; }
5361
const LOG_BUFFER_SIZE = 200;
@@ -136,12 +144,16 @@ function unregisterExtensionConnection(ws: WebSocket): void {
136144
for (const [id, p] of pending) {
137145
if (p.contextId !== contextId) continue;
138146
clearTimeout(p.timer);
139-
p.reject(new DaemonCommandFailure(
140-
`Browser profile "${contextId}" disconnected`,
141-
'profile_disconnected',
142-
'Open that Chrome profile and make sure the OpenCLI extension is enabled, or choose another profile with opencli profile use <name>.',
143-
503,
144-
));
147+
const failure = buildExtensionDisconnectFailure({
148+
contextId,
149+
action: p.action,
150+
dispatched: p.dispatched,
151+
});
152+
if (failure.countAsCommandResultUnknown) {
153+
commandResultUnknownCount++;
154+
log.warn(`[daemon] Command result unknown after extension disconnect (id=${id}, action=${p.action}, context=${contextId})`);
155+
}
156+
p.reject(new DaemonCommandFailure(failure.message, failure.errorCode, failure.errorHint, failure.status));
145157
pending.delete(id);
146158
}
147159
}
@@ -176,15 +188,6 @@ function jsonResponse(
176188
res.end(JSON.stringify(data));
177189
}
178190

179-
export function getResponseCorsHeaders(pathname: string, origin?: string): Record<string, string> | undefined {
180-
if (pathname !== '/ping') return undefined;
181-
if (!origin || !origin.startsWith('chrome-extension://')) return undefined;
182-
return {
183-
'Access-Control-Allow-Origin': origin,
184-
Vary: 'Origin',
185-
};
186-
}
187-
188191
async function handleRequest(req: IncomingMessage, res: ServerResponse): Promise<void> {
189192
// ─── Security: Origin & custom-header check ──────────────────────
190193
// Block browser-based CSRF: browsers always send an Origin header on
@@ -257,6 +260,7 @@ async function handleRequest(req: IncomingMessage, res: ServerResponse): Promise
257260
profileDisconnected: route.errorCode === 'profile_disconnected',
258261
profiles,
259262
pending: pending.size,
263+
commandResultUnknown: commandResultUnknownCount,
260264
memoryMB: Math.round(mem.rss / 1024 / 1024 * 10) / 10,
261265
port: PORT,
262266
});
@@ -321,8 +325,34 @@ async function handleRequest(req: IncomingMessage, res: ServerResponse): Promise
321325
pending.delete(body.id);
322326
reject(new Error(`Command timeout (${timeoutMs / 1000}s)`));
323327
}, timeoutMs);
324-
pending.set(body.id, { contextId: route.connection!.contextId, resolve, reject, timer });
325-
route.connection!.ws.send(JSON.stringify(body));
328+
const entry = {
329+
contextId: route.connection!.contextId,
330+
action: typeof body.action === 'string' ? body.action : 'unknown',
331+
dispatched: false,
332+
resolve,
333+
reject,
334+
timer,
335+
};
336+
pending.set(body.id, entry);
337+
const failBeforeDispatch = (err: unknown) => {
338+
if (pending.get(body.id) !== entry) return;
339+
const failure = buildCommandDispatchFailure(entry.contextId);
340+
clearTimeout(timer);
341+
pending.delete(body.id);
342+
reject(new DaemonCommandFailure(failure.message, failure.errorCode, failure.errorHint, failure.status));
343+
log.warn(`[daemon] Failed to dispatch command ${body.id}: ${err instanceof Error ? err.message : String(err)}`);
344+
};
345+
try {
346+
route.connection!.ws.send(JSON.stringify(body), (err?: Error) => {
347+
if (err && !entry.dispatched) failBeforeDispatch(err);
348+
});
349+
// Once ws accepts the frame, the command may execute even if the
350+
// result is later lost; do not downgrade later disconnects to a
351+
// pre-dispatch failure just because no result/ack has arrived yet.
352+
entry.dispatched = true;
353+
} catch (err) {
354+
failBeforeDispatch(err);
355+
}
326356
});
327357

328358
jsonResponse(res, 200, result);

0 commit comments

Comments
 (0)