Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 12 additions & 2 deletions docs/browser.md
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,16 @@ then runs, and the page is visible and focused again when it does. A tab
closed while asleep is woken first, and a freeze that fails is tried again after the wait. A page asleep keeps its state; its timers
resume where they stopped, and a page that reads the clock sees the time pass.

Each call of a freeze or a wake has 5 seconds. A frozen page still answers
DevTools at once (measured on Windows on 2026-10-19), so a call that misses
them met a stalled renderer or browser, not a frozen page: a wake that fails
fails the command that asked for it, the tab stays asleep and the next use
wakes it again. The command fails at those 5 seconds; after it, a
`browser.sleep-stalled` warning says whether the browser itself still
answered (`Target.getTargetInfo` for the tab, within 1 second), and a
`browser.sleep-late` warning says when the page answered, or that it never did
within 30 seconds.

A tab the desktop app hosts gets the same calls through the relay: `Page` and
`Emulation` are among the domains an agent webview takes. The desktop's own
pages, the ones the user browses, are suspended once hidden
Expand Down Expand Up @@ -417,8 +427,8 @@ read back, a recording discarded at the end of a turn, a page that sleeps
once nobody uses it and meets the next command and viewer awake and focused,
one kept awake by `browser.remoteWatch`, and a machine without a browser. `browser-sleep.test.ts` checks, with the page
stubbed, that a command arriving during a freeze runs after the wake, and that
a held or closed tab never sleeps, and that a running turn holds an unused
tab only 5 minutes. `AgentBrowserSurface.test.ts` checks that the desktop app
a held or closed tab never sleeps, that a running turn holds an unused
tab only 5 minutes, and that a wake call that misses its time fails at once and then names who stalled. `AgentBrowserSurface.test.ts` checks that the desktop app
reports a hosted tab it shows, only while its window is visible. `browser-cli.test.ts` checks the CLI's files with the browser stubbed.
`tests/e2e/agent-browser-core.test.ts` runs the whole path on a real core: the
owner's desktop at 1280 × 900 and a paired phone at 390 × 844 see the cover,
Expand Down
8 changes: 8 additions & 0 deletions packages/core/src/browser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,14 @@ export class AgentBrowser {

constructor(core: Core, private findBrowser = findChromium) {
this.#core = core;
// A freeze or wake step that missed its time: the browser process answers its own targets without the page's renderer.
this.#sleep.browserAnswers = (tab, timeoutMs) => tab.engine.cdp.send('Target.getTargetInfo', { targetId: tab.targetId }, undefined, timeoutMs);
this.#sleep.browserAnswered = (tab, method, ms) => core.logs.record('warn', ms === null
? `A sleeping tab missed ${method}, and the browser did not answer for itself within 1 s either`
: `A sleeping tab missed ${method}, while the browser answered for itself in ${ms} ms: the page's renderer stalled`, { source: 'browser', event: 'browser.sleep-stalled', threadId: tab.threadId, data: { method } });
this.#sleep.lateAnswer = (tab, method, after) => core.logs.record('warn', typeof after === 'number'
? `A sleeping tab's page answered ${method} ${after} ms after it was sent`
: `A sleeping tab's page never answered ${method}: ${after.message}`, { source: 'browser', event: 'browser.sleep-late', threadId: tab.threadId, data: { method } });
this.#downloads = new BrowserDownloads(() => core.dataDir, message => core.log('warn', message));
this.#engines = new BrowserEngines(core, this.#host, () => this.findBrowser(), this.#machine, BROWSER_SCOPE,
engine => this.#targets(engine), id => this.#drop(id), this.#downloads);
Expand Down
52 changes: 47 additions & 5 deletions packages/core/src/browser/sleep.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,25 @@ export function sleepState(): SleepState {
}

const STEP_MS = 5000;
/** How long a step that missed its time is still listened to, so the log says whether its answer came late or not at all. */
const LATE_MS = 30_000;
/** What the browser gets to answer for itself once a step missed its time. */
const PROBE_MS = 1000;

export class TabSleep<T extends Sleeper> {
/** What each DevTools call of a freeze or a wake may take; tests shorten it. */
stepMs = STEP_MS;
/**
* Asked of the browser itself, within `timeoutMs`, when a step of a tab
* misses its time: an answer puts the stall in that page's renderer, none
* in the browser or its connection.
*/
browserAnswers: (tab: T, timeoutMs: number) => Promise<unknown> = () => Promise.resolve();
/** Hears, once the step has failed, how long the browser took to answer for itself, or null for no answer in time. */
browserAnswered: (tab: T, method: string, ms: number | null) => void = () => {};
/** Hears when a step that missed its time got its answer, in ms after it was sent, or why none came. */
lateAnswer: (tab: T, method: string, after: number | Error) => void = () => {};

/**
* `held` says whether something keeps the tab awake without a use running:
* a viewer, a recording. `turning` says whether its agent's turn runs, which
Expand Down Expand Up @@ -72,8 +89,8 @@ export class TabSleep<T extends Sleeper> {
wake(tab: T): Promise<void> {
return this.#queue(tab, async () => {
if (!tab.sleep.asleep) return;
await tab.page.send('Page.setWebLifecycleState', { state: 'active' }, STEP_MS);
await tab.page.send('Emulation.setFocusEmulationEnabled', { enabled: true }, STEP_MS);
await this.#step(tab, 'Page.setWebLifecycleState', { state: 'active' });
await this.#step(tab, 'Emulation.setFocusEmulationEnabled', { enabled: true });
tab.sleep.asleep = false;
});
}
Expand Down Expand Up @@ -119,21 +136,46 @@ export class TabSleep<T extends Sleeper> {
return this.#queue(tab, async () => {
if (tab.sleep.asleep || tab.sleep.dropped || tab.sleep.uses > 0 || this.#held(tab) || this.#turnHolds(tab)) return;
try {
await tab.page.send('Emulation.setFocusEmulationEnabled', { enabled: false }, STEP_MS);
await tab.page.send('Page.setWebLifecycleState', { state: 'frozen' }, STEP_MS);
await this.#step(tab, 'Emulation.setFocusEmulationEnabled', { enabled: false });
await this.#step(tab, 'Page.setWebLifecycleState', { state: 'frozen' });
tab.sleep.asleep = true;
} catch {
// A page that cannot freeze stays as it was and tries again after the wait; one that is gone is dropped by its tab.
await tab.page.send('Emulation.setFocusEmulationEnabled', { enabled: true }, STEP_MS).catch(() => {});
await this.#step(tab, 'Emulation.setFocusEmulationEnabled', { enabled: true }).catch(() => {});
tab.sleep.heldAt = Date.now();
this.schedule(tab);
}
});
}

/**
* One DevTools call of a freeze or a wake, given `stepMs`. A frozen page
* answers at once (measured on 2026-10-19), so a call that misses its time
* met a stalled renderer or browser. The step fails at its time; whether
* the browser itself still answers, and when the page does, reach the log after.
*/
async #step(tab: T, method: string, params: Record<string, unknown>): Promise<void> {
const sent = performance.now();
const answer = tab.page.send(method, params, LATE_MS);
if (await within(answer.then(() => true), this.stepMs) !== null) return;
void answer.then(() => this.lateAnswer(tab, method, Math.round(performance.now() - sent)), (error: Error) => this.lateAnswer(tab, method, error));
const probeMs = Math.min(PROBE_MS, this.stepMs), probed = performance.now();
void within(Promise.resolve(this.browserAnswers(tab, probeMs)).then(() => true, () => null), probeMs)
.then(answered => this.browserAnswered(tab, method, answered === null ? null : Math.round(performance.now() - probed)));
throw new Error(`the browser did not answer ${method} within ${this.stepMs / 1000} seconds`);
}

#queue(tab: T, run: () => Promise<void>): Promise<void> {
const next = tab.sleep.turn.then(run);
tab.sleep.turn = next.catch(() => {});
return next;
}
}

/** What `promise` gives within `ms`, or null; its rejection passes through. */
async function within<R>(promise: Promise<R>, ms: number): Promise<R | null> {
let timer: ReturnType<typeof setTimeout> | undefined;
try {
return await Promise.race([promise, new Promise<null>(resolve => { timer = setTimeout(() => resolve(null), ms); })]);
} finally { clearTimeout(timer); }
}
45 changes: 45 additions & 0 deletions packages/core/test/browser-sleep.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,51 @@ test('a wake that fails leaves the tab asleep, and the next use wakes it', async
sleep.drop(tab);
});

// A wake step that gets no answer in time failed a command on a macOS runner
// with only "did not answer Emulation.setFocusEmulationEnabled within 5
// seconds" (job 118094995614). A frozen page answers at once, so the log says
// after whether the browser itself still answered, and when, or whether, the
// page did. The command fails at the step's time, not after the probe.
test('a wake step that misses its time fails at once, then logs whether the browser answered and when the page did', async () => {
const { tab, calls, holdNext, release } = fakeTab();
const sleep = new TabSleep<Sleeper>(() => false, () => false, 20);
sleep.stepMs = 50;
const late: Array<[string, number | string]> = [];
const stalls: Array<[string, number | null]> = [];
sleep.lateAnswer = (_tab, method, after) => late.push([method, typeof after === 'number' ? after : after.message]);
sleep.browserAnswered = (_tab, method, ms) => stalls.push([method, ms]);
let answerProbe!: () => void;
const probes: number[] = [];
sleep.browserAnswers = (_tab, timeoutMs) => { probes.push(timeoutMs); return new Promise<void>(resolve => { answerProbe = resolve; }); };
sleep.schedule(tab);
await until(() => tab.sleep.asleep);
holdNext('Emulation.setFocusEmulationEnabled');
await expect(sleep.use(tab, async () => { calls.push('first'); })).rejects.toThrow(/^the browser did not answer Emulation\.setFocusEmulationEnabled within 0\.05 seconds$/);
// The probe is still out: the command did not wait for it.
expect(stalls).toEqual([]);
expect(probes).toEqual([50]);
expect(tab.sleep.asleep).toBe(true);
answerProbe();
await until(() => stalls.length === 1);
expect(stalls[0]![0]).toBe('Emulation.setFocusEmulationEnabled');
expect(stalls[0]![1]).toBeGreaterThanOrEqual(0);
expect(late).toEqual([]);
release('Emulation.setFocusEmulationEnabled');
await until(() => late.length === 1);
expect(late[0]![0]).toBe('Emulation.setFocusEmulationEnabled');
expect(late[0]![1]).toBeGreaterThanOrEqual(50);
// A browser that does not answer its own targets either is named so once its time is out.
sleep.browserAnswers = () => new Promise(() => {});
holdNext('Page.setWebLifecycleState');
await expect(sleep.use(tab, async () => {})).rejects.toThrow('within 0.05 seconds');
await until(() => stalls.length === 2);
expect(stalls[1]).toEqual(['Page.setWebLifecycleState', null]);
release('Page.setWebLifecycleState');
await sleep.use(tab, async () => { calls.push('second'); });
expect(calls.at(-1)).toBe('second');
sleep.drop(tab);
});

// A turn that runs for half an hour without touching its tab must not keep
// it awake throughout: the turn holds it only for a while after its last use,
// and the next command wakes it. The hold is measured from the use to the
Expand Down
3 changes: 2 additions & 1 deletion packages/core/test/browser/fixture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,8 @@ export function createBrowserFixture() {
opened.harness = harness = await startTestCore();
const record = harness.core.logs.record.bind(harness.core.logs);
harness.core.logs.record = (level, message, context = {}, at) => {
if (context.event === 'rpc.failed') failures.push(message);
// What a sleeping tab's stalled step turned out to be explains a wake that failed (`browser/sleep.ts`).
if (context.event === 'rpc.failed' || context.event === 'browser.sleep-stalled' || context.event === 'browser.sleep-late') failures.push(message);
record(level, message, context, at);
};
opened.owner = owner = await harness.connect();
Expand Down
58 changes: 39 additions & 19 deletions packages/core/test/cache-keepalive.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
import { afterEach, beforeEach, describe, expect, spyOn, test } from 'bun:test';
import { DEFAULT_DELEGATION_CONFIG, checkSettingsPatch, type Turn, type Usage } from '@boite/contracts';
import type { CoreClient } from '../src/client.ts';
import { SHORT_ANSWERS } from '../src/agent-guide.ts';
Expand Down Expand Up @@ -210,42 +210,62 @@ describe('cache keepalive', () => {
test('turning the setting off, archiving and a user turn each hold the ping', async () => {
const s = await setup(h);
try {
// The ping is due 1 s after the last request, 900 ms after a turn ends: each hold below lands well
// before it, and each wait outlasts it. At 200 ms, a Linux arm64 run archived too late (#635).
CACHE_KEEPALIVE.marginMs = 300_000 - 1_000;
// Off before the ping comes.
// The timer is set a minute after the last request, past the end of the test, so none
// races a hold. Each check makes the ping due and runs the timer's own callback once the
// hold is done. A ping 200 ms, then 900 ms, after the turn lost to a late archive on a
// loaded runner (#639).
const later = 300_000 - 60_000;
CACHE_KEEPALIVE.marginMs = later;
const keepalive = h.core.threads.cacheKeepalive as unknown as { waits: Map<string, { timer: unknown }>; fire(threadId: string): void };
const settled = spyOn(h.core.threads.cacheKeepalive, 'turnFinished');
const turn = async () => { const before = settled.mock.calls.length; await s.turn(); await waitFor(() => settled.mock.calls.length > before); };
const armed = () => keepalive.waits.get(s.threadId)?.timer != null;
const fire = async () => {
CACHE_KEEPALIVE.marginMs = 300_000;
try { keepalive.fire(s.threadId); } finally { CACHE_KEEPALIVE.marginMs = later; }
return (await s.pings()).length;
};

// Nothing held, the timer's call sends the ping.
await s.client.call('settings.set', { cacheKeepalive: { hours: 12 } });
await s.spawn();
await s.turn();
await turn();
expect(armed()).toBe(true);
const pinged = s.client.next('turn.finished', t => t.threadId === s.threadId && t.execution?.operation === 'keepalive');
expect(await fire()).toBe(1);
expect((await pinged).status).toBe('done');
await waitFor(armed);

// Off before the ping comes.
await s.client.call('settings.set', { cacheKeepalive: null });
await pause(1_200);
expect(await s.pings()).toEqual([]);
expect(await fire()).toBe(1);

// Off from the start: nothing is planned.
await s.turn();
await pause(1_200);
expect(await s.pings()).toEqual([]);
await turn();
expect(armed()).toBe(false);
expect(await fire()).toBe(1);

// A user turn that runs past the ping's time: no ping while it runs.
await s.client.call('settings.set', { cacheKeepalive: { hours: 12 } });
await s.turn();
await turn();
expect(armed()).toBe(true);
s.state.holdParent = true;
const started = s.client.next('turn.started', t => t.threadId === s.threadId && !t.execution?.operation);
await s.client.call('turns.start', { threadId: s.threadId, prompt: 'long' });
await started;
await pause(1_200);
expect(await s.pings()).toEqual([]);
expect(await fire()).toBe(1);
s.state.holdParent = false;
const ended = s.client.next('turn.finished', t => t.threadId === s.threadId && !t.execution?.operation);
const ended = settled.mock.calls.length;
s.held.get(s.threadId)!();
await ended;
await waitFor(() => settled.mock.calls.length > ended);
expect(armed()).toBe(true);

// Archived before the ping comes.
await s.client.call('threads.archive', { threadId: s.threadId, archived: true });
await pause(1_200);
expect(await s.pings()).toEqual([]);
expect(await fire()).toBe(1);
expect(armed()).toBe(false);
} finally { s.restore(); }
}, 10_000);
});

test('a child result that comes in during a ping stops it and is delivered', async () => {
const s = await setup(h);
Expand Down
15 changes: 9 additions & 6 deletions packages/core/test/push.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -206,19 +206,22 @@ test('no push about a thread someone is looking at and uses, on the PC or the ph
finish(threadId);
await delivered(3);
// Looking away without having used the thread since sends what waited.
// The core dates a use at the report's arrival minus its idleMs, so any
// margin the test picks loses to a slow enough trip to the core, which then
// drops the news as seen. A still clock dates the use 1 ms before the news.
setSystemTime(new Date());
await desktop.call('threads.focus', { threadId, attentive: true, idleMs: 0 });
const heldFrom = Date.now();
finish(threadId);
await Bun.sleep(5);
// The last use is dated before the news, however long the runner took to
// get here: a fixed 20 ms fell after it on a slow runner and dropped it as seen.
await desktop.call('threads.focus', { threadId, attentive: true, idleMs: Date.now() - heldFrom + 20 });
// The report takes its time to arrive, as on a loaded runner.
await Bun.sleep(40);
await desktop.call('threads.focus', { threadId, attentive: true, idleMs: 1 });
setSystemTime();
await delivered(3);
desktop.close();
await delivered(4);
await expect(phone.call('threads.focus', { threadId, attentive: 'yes' as never })).rejects.toThrow('attentive');
await expect(phone.call('threads.focus', { threadId, idleMs: -1 })).rejects.toThrow('idleMs');
} finally { phone.close(); }
} finally { setSystemTime(); phone.close(); }
});
test('questions and permissions push immediately even when their thread is attended', async () => {
const paired = session();
Expand Down
Loading
Loading