Skip to content

Commit 6a1002b

Browse files
authored
Merge pull request #442 from Li-john1021/fix/441-navigate-timeout-exit
fix(cli): exit 4 when navigate lifecycle wait times out
2 parents a07b2bb + e5a4885 commit 6a1002b

5 files changed

Lines changed: 312 additions & 10 deletions

File tree

‎crates/bsk-cli/src/cli/navigate.rs‎

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -295,6 +295,13 @@ fn render_navigate(reply: &NavigateResult, format: Format) -> Result<(), CliErro
295295
print_dialog_summaries(&reply.dialogs);
296296
}
297297
}
298+
// `--timeout` is documented as a hard timeout; treat lifecycle wait
299+
// expiry as exit 4 (design §3.1) while still printing the payload so
300+
// agents can inspect `final_url` / `error_text`. Distinct from
301+
// `wait-for-element` / `wait-for-navigation`, which intentionally stay 0.
302+
if reply.reached == "timeout" {
303+
return Err(CliError::RenderedExit { exit_code: 4 });
304+
}
298305
Ok(())
299306
}
300307

@@ -325,6 +332,9 @@ fn render_history(
325332
print_dialog_summaries(&view.dialogs);
326333
}
327334
}
335+
if view.reached == "timeout" {
336+
return Err(CliError::RenderedExit { exit_code: 4 });
337+
}
328338
Ok(())
329339
}
330340

@@ -414,4 +424,63 @@ mod tests {
414424
Duration::from_secs(60) + Duration::from_secs(15)
415425
);
416426
}
427+
428+
fn sample_navigate(reached: &str) -> NavigateResult {
429+
NavigateResult {
430+
tab_id: 1,
431+
url: "https://example.com/".into(),
432+
final_url: Some("https://example.com/".into()),
433+
reached: reached.into(),
434+
error_text: if reached == "timeout" {
435+
Some("timed out waiting for load".into())
436+
} else {
437+
None
438+
},
439+
dialogs: vec![],
440+
}
441+
}
442+
443+
#[test]
444+
fn render_navigate_exits_4_on_timeout_after_printing() {
445+
let err = render_navigate(&sample_navigate("timeout"), Format::Json).unwrap_err();
446+
match err {
447+
CliError::RenderedExit { exit_code } => assert_eq!(exit_code, 4),
448+
other => panic!("expected RenderedExit(4), got {other:?}"),
449+
}
450+
}
451+
452+
#[test]
453+
fn render_navigate_ok_when_lifecycle_reached() {
454+
assert!(render_navigate(&sample_navigate("load"), Format::Json).is_ok());
455+
}
456+
457+
#[test]
458+
fn render_history_exits_4_on_timeout() {
459+
let view = OwnedHistoryView {
460+
tab_id: 2,
461+
previous_url: Some("https://a.example/".into()),
462+
final_url: Some("https://b.example/".into()),
463+
reached: "timeout".into(),
464+
error_text: Some("timed out waiting for load".into()),
465+
dialogs: vec![],
466+
};
467+
let err = render_history(&view, "reload", Format::Json).unwrap_err();
468+
match err {
469+
CliError::RenderedExit { exit_code } => assert_eq!(exit_code, 4),
470+
other => panic!("expected RenderedExit(4), got {other:?}"),
471+
}
472+
}
473+
474+
#[test]
475+
fn render_history_ok_when_lifecycle_reached() {
476+
let view = OwnedHistoryView {
477+
tab_id: 2,
478+
previous_url: None,
479+
final_url: Some("https://example.com/".into()),
480+
reached: "load".into(),
481+
error_text: None,
482+
dialogs: vec![],
483+
};
484+
assert!(render_history(&view, "reload", Format::Json).is_ok());
485+
}
417486
}

‎packages/dsh-plugin-browserskill/src/runner.ts‎

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -369,12 +369,46 @@ export function bskInstallMessage(bskPath: string): string {
369369
);
370370
}
371371

372+
/**
373+
* Options for interpreting a finished `bsk` run.
374+
*
375+
* `allowNavigationTimeout`: when the CLI prints a success-shaped navigation
376+
* payload with `reached: "timeout"` and then exits 4 (RenderedExit), return
377+
* that payload instead of throwing. Exit 4 alone is not enough — session-busy
378+
* and other failures also use 4 — so the payload must explicitly say
379+
* `reached: "timeout"`.
380+
*/
381+
export interface ParseBskJsonOptions {
382+
allowNavigationTimeout?: boolean;
383+
}
384+
385+
/**
386+
* True when stdout is a navigation/history payload whose lifecycle wait
387+
* expired (`reached: "timeout"`). Used to distinguish that soft timeout from
388+
* other exit-4 failures (e.g. session_busy).
389+
*/
390+
export function isNavigationLifecycleTimeoutPayload(value: unknown): value is {
391+
reached: "timeout";
392+
[key: string]: unknown;
393+
} {
394+
return (
395+
typeof value === "object" &&
396+
value !== null &&
397+
!Array.isArray(value) &&
398+
(value as { reached?: unknown }).reached === "timeout"
399+
);
400+
}
401+
372402
/**
373403
* Interpret one finished run: throw `BskError` on timeout / non-zero exit
374404
* (parsing the CLI's JSON error envelope when present), otherwise parse and
375405
* return the stdout JSON payload.
376406
*/
377-
export function parseBskJson(result: BskRunResult, commandLabel: string): unknown {
407+
export function parseBskJson(
408+
result: BskRunResult,
409+
commandLabel: string,
410+
options: ParseBskJsonOptions = {},
411+
): unknown {
378412
if (result.timedOut) {
379413
throw new BskError(`bsk ${commandLabel} timed out`, { timedOut: true });
380414
}
@@ -385,6 +419,20 @@ export function parseBskJson(result: BskRunResult, commandLabel: string): unknow
385419
throw new BskError(`bsk ${commandLabel} was interrupted (process killed)`);
386420
}
387421
if (result.code !== 0) {
422+
// Navigate / back / forward / reload print the success-shaped body then
423+
// exit 4 on lifecycle wait expiry. Callers that opt in (combined session
424+
// start-with-URL) can keep the session and surface the timeout instead of
425+
// treating it as a hard failure.
426+
if (options.allowNavigationTimeout === true && result.code === 4) {
427+
try {
428+
const payload: unknown = JSON.parse(body);
429+
if (isNavigationLifecycleTimeoutPayload(payload)) {
430+
return payload;
431+
}
432+
} catch {
433+
// Fall through to the normal error path.
434+
}
435+
}
388436
let parsed: BskErrorBody | undefined;
389437
try {
390438
parsed = JSON.parse(body) as BskErrorBody;

‎packages/dsh-plugin-browserskill/src/tools.ts‎

Lines changed: 66 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ import {
2525
type BskRunner,
2626
bskInstallMessage,
2727
isCommandNotFound,
28+
isNavigationLifecycleTimeoutPayload,
29+
type ParseBskJsonOptions,
2830
parseBskJson,
2931
runWithSessionBusyRetry,
3032
} from "./runner";
@@ -88,6 +90,7 @@ async function runBsk(
8890
observeSession?: string,
8991
runnerTimeoutMs?: number,
9092
initializing = false,
93+
parseOptions: ParseBskJsonOptions = {},
9194
): Promise<unknown> {
9295
const releaseForeground =
9396
observeSession !== undefined ? deps.observation.acquireForeground(observeSession) : undefined;
@@ -133,7 +136,7 @@ async function runBsk(
133136
error.name = "AbortError";
134137
throw error;
135138
}
136-
return parseBskJson(result, label);
139+
return parseBskJson(result, label, parseOptions);
137140
} catch (error) {
138141
actionError = error instanceof Error ? error.message.split("\n")[0] : String(error);
139142
throw error;
@@ -213,6 +216,8 @@ function defineBrowserOperations(deps: ToolDeps, register: DefinitionRegistrar):
213216
browserInstanceId: { type: "string", required: true },
214217
url: { type: "string" },
215218
device: { type: "string" },
219+
reached: { type: "string" },
220+
warning: { type: "string" },
216221
},
217222
},
218223
render: (_args, value) => [
@@ -221,7 +226,10 @@ function defineBrowserOperations(deps: ToolDeps, register: DefinitionRegistrar):
221226
text:
222227
`started browser session ${value.sessionId}` +
223228
(value.url !== undefined ? ` and navigated to ${value.url}` : "") +
224-
(value.device !== undefined ? ` (device: ${value.device})` : ""),
229+
(value.device !== undefined ? ` (device: ${value.device})` : "") +
230+
(value.reached === "timeout"
231+
? " — initial navigation timed out before the requested lifecycle phase; session is usable"
232+
: ""),
225233
},
226234
],
227235
},
@@ -266,28 +274,77 @@ function defineBrowserOperations(deps: ToolDeps, register: DefinitionRegistrar):
266274
true,
267275
);
268276
}
277+
let navigationTimeout:
278+
| {
279+
reached: "timeout";
280+
url?: string;
281+
final_url?: string;
282+
error_text?: string;
283+
}
284+
| undefined;
269285
if (args.url !== undefined) {
270-
await runBsk(
286+
// Combined start-with-URL: a lifecycle wait timeout (exit 4 +
287+
// reached:"timeout") must not cancel the session that was already
288+
// created — the page may still be usable. Genuine navigate failures
289+
// still fall through to starts.fail below.
290+
const navigateReply = await runBsk(
271291
deps,
272292
exec,
273293
["navigate", "--session", reply.session_id, args.url],
274294
"navigate",
275295
reply.session_id,
276296
undefined,
277297
true,
298+
{ allowNavigationTimeout: true },
278299
);
300+
if (isNavigationLifecycleTimeoutPayload(navigateReply)) {
301+
navigationTimeout = {
302+
reached: "timeout",
303+
url: typeof navigateReply.url === "string" ? navigateReply.url : undefined,
304+
final_url:
305+
typeof navigateReply.final_url === "string" ? navigateReply.final_url : undefined,
306+
error_text:
307+
typeof navigateReply.error_text === "string"
308+
? navigateReply.error_text
309+
: undefined,
310+
};
311+
deps.observation.setUrl(
312+
reply.session_id,
313+
navigationTimeout.final_url ?? navigationTimeout.url ?? args.url,
314+
);
315+
} else {
316+
const okReply = navigateReply as {
317+
url?: string;
318+
final_url?: string;
319+
};
320+
deps.observation.setUrl(
321+
reply.session_id,
322+
okReply.final_url ?? okReply.url ?? args.url,
323+
);
324+
}
279325
}
280326
await starts.claim(record, exec.signal);
327+
return {
328+
sessionId: reply.session_id,
329+
browserInstanceId: reply.browser_instance_id,
330+
...(args.url !== undefined ? { url: args.url } : {}),
331+
...(args.device !== undefined ? { device: args.device } : {}),
332+
...(navigationTimeout !== undefined
333+
? {
334+
reached: "timeout" as const,
335+
warning:
336+
"Initial navigation timed out waiting for the page lifecycle; " +
337+
"the session remains open — observe the page and decide how to proceed." +
338+
(navigationTimeout.error_text !== undefined
339+
? ` (${navigationTimeout.error_text})`
340+
: ""),
341+
}
342+
: {}),
343+
};
281344
} catch (error) {
282345
await starts.fail(record).catch(() => {});
283346
throw error;
284347
}
285-
return {
286-
sessionId: reply.session_id,
287-
browserInstanceId: reply.browser_instance_id,
288-
...(args.url !== undefined ? { url: args.url } : {}),
289-
...(args.device !== undefined ? { device: args.device } : {}),
290-
};
291348
},
292349
presentCall: (args) => ({
293350
card: "terminal",

‎packages/dsh-plugin-browserskill/tests/runner.test.ts‎

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
type BskRunResult,
1010
createBskRunner,
1111
isCommandNotFound,
12+
isNavigationLifecycleTimeoutPayload,
1213
isSessionBusyResult,
1314
parseBskJson,
1415
runWithSessionBusyRetry,
@@ -535,6 +536,41 @@ describe("parseBskJson", () => {
535536
/did not produce JSON/,
536537
);
537538
});
539+
540+
it("returns navigation lifecycle timeout payloads when opted in", () => {
541+
const payload = {
542+
tab_id: 1,
543+
url: "https://slow.example/",
544+
final_url: "https://slow.example/",
545+
reached: "timeout",
546+
error_text: "timed out waiting for load",
547+
};
548+
const result = parseBskJson({ ...base, code: 4, stdout: JSON.stringify(payload) }, "navigate", {
549+
allowNavigationTimeout: true,
550+
});
551+
expect(result).toEqual(payload);
552+
expect(isNavigationLifecycleTimeoutPayload(result)).toBe(true);
553+
});
554+
555+
it("still throws exit 4 without reached: timeout even when opted in", () => {
556+
const busy = {
557+
code: "timeout",
558+
message: "session already has an unfinished command",
559+
data: { reason: "session_busy" },
560+
};
561+
expect(() =>
562+
parseBskJson({ ...base, code: 4, stdout: JSON.stringify(busy) }, "navigate", {
563+
allowNavigationTimeout: true,
564+
}),
565+
).toThrow(/session already has an unfinished command/);
566+
});
567+
568+
it("throws navigation timeout payloads by default (no opt-in)", () => {
569+
const payload = { tab_id: 1, url: "https://x/", reached: "timeout" };
570+
expect(() =>
571+
parseBskJson({ ...base, code: 4, stdout: JSON.stringify(payload) }, "navigate"),
572+
).toThrow(/failed/);
573+
});
538574
});
539575

540576
describe("isCommandNotFound", () => {

0 commit comments

Comments
 (0)