mirror of https://github.com/garrytan/gstack.git
fix(browse): checkpoint on real cookie mutators and after the command runs
The debounced auto-cookie checkpoint had two gaps. First, the trigger set was a hand-maintained list that missed the actual cookie-mutating commands (cookie, cookie-import, load-html) while listing a non-existent set-cookie. Drive the decision off WRITE_COMMANDS (the real mutator registry) plus a small extra set for js/eval and the meta commands (chain, state, newtab, tab-each) that can change the cookie jar. Second, the schedule call ran before dispatch, so a slow navigation or login redirect snapshotted the pre-command jar; move it to after the command attempt (success and error paths, plus the scoped-newtab early return) so the debounce captures the post-mutation state. Also wire cookie-picker import/remove: those flow through the /cookie-picker route, not /command, so they never scheduled a save. Pass scheduleAutoCookieCheckpoint as an onCookieMutation callback and fire it after addCookies / removal. Serialize checkpoints with an in-flight promise guard so an overlapping periodic + debounced + shutdown checkpoint can't race two concurrent saveAutoCookieState writes onto the same slot. The shutdown flush awaits any in-flight checkpoint first, then runs its own, so no save is lost. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
53c2750cf1
commit
13ff147233
|
|
@ -110,6 +110,7 @@ export async function handleCookiePickerRoute(
|
|||
req: Request,
|
||||
bm: BrowserManager,
|
||||
authToken?: string,
|
||||
onCookieMutation?: () => void,
|
||||
): Promise<Response> {
|
||||
const pathname = url.pathname;
|
||||
const port = parseInt(url.port, 10) || 9400;
|
||||
|
|
@ -268,6 +269,7 @@ export async function handleCookiePickerRoute(
|
|||
// Add to Playwright context
|
||||
const page = bm.getActiveSession().getPage();
|
||||
await page.context().addCookies(result.cookies);
|
||||
onCookieMutation?.();
|
||||
|
||||
// Track what was imported
|
||||
for (const domain of Object.keys(result.domainCounts)) {
|
||||
|
|
@ -305,6 +307,7 @@ export async function handleCookiePickerRoute(
|
|||
importedDomains.delete(domain);
|
||||
importedCounts.delete(domain);
|
||||
}
|
||||
onCookieMutation?.();
|
||||
|
||||
console.log(`[cookie-picker] Removed cookies for ${domains.length} domains`);
|
||||
|
||||
|
|
|
|||
|
|
@ -666,11 +666,24 @@ const idleCheckInterval = setInterval(idleCheckTick, 60_000);
|
|||
let autoCookieLockHeld = false;
|
||||
let autoCookieLastHash: string | null = null;
|
||||
let autoCookieDebounce: ReturnType<typeof setTimeout> | null = null;
|
||||
let autoCookieCheckpointInFlight: Promise<void> | null = null;
|
||||
|
||||
async function autoCookieCheckpoint(): Promise<void> {
|
||||
while (autoCookieCheckpointInFlight) {
|
||||
await autoCookieCheckpointInFlight;
|
||||
}
|
||||
if (!autoCookieLockHeld) return; // never write without owning the lock
|
||||
const res = await saveAutoCookieState(config, activeBrowserManager.getContext(), autoCookieLastHash);
|
||||
autoCookieLastHash = res.hash;
|
||||
const run = (async () => {
|
||||
if (!autoCookieLockHeld) return;
|
||||
const res = await saveAutoCookieState(config, activeBrowserManager.getContext(), autoCookieLastHash);
|
||||
if (autoCookieLockHeld) autoCookieLastHash = res.hash;
|
||||
})();
|
||||
autoCookieCheckpointInFlight = run;
|
||||
try {
|
||||
await run;
|
||||
} finally {
|
||||
if (autoCookieCheckpointInFlight === run) autoCookieCheckpointInFlight = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Debounced checkpoint: coalesces bursts of mutating commands into one write a
|
||||
|
|
@ -692,10 +705,14 @@ if (typeof autoCookieInterval.unref === 'function') autoCookieInterval.unref();
|
|||
|
||||
// Commands that can mutate cookies/browser state and thus warrant a checkpoint.
|
||||
const AUTO_COOKIE_MUTATING_COMMANDS = new Set([
|
||||
'goto', 'click', 'fill', 'type', 'press', 'select', 'reload', 'back', 'forward',
|
||||
'chain', 'state', 'cookie-picker', 'set-cookie',
|
||||
'chain', 'state', 'newtab', 'tab-each',
|
||||
'js', 'eval',
|
||||
]);
|
||||
|
||||
function shouldAutoCookieCheckpoint(command: string): boolean {
|
||||
return WRITE_COMMANDS.has(command) || AUTO_COOKIE_MUTATING_COMMANDS.has(command);
|
||||
}
|
||||
|
||||
// Test-only surface for server-factory.test.ts. Lets the dual-instance
|
||||
// idle-timer behavior be exercised deterministically without mutating
|
||||
// Date.now (which would interact with the leaked module-level setInterval).
|
||||
|
|
@ -1006,14 +1023,7 @@ async function handleCommandInternalImpl(
|
|||
// so the trail records what the agent actually typed.
|
||||
const command = canonicalizeCommand(rawCommand);
|
||||
const isAliased = command !== rawCommand;
|
||||
|
||||
// Auto-cookie checkpoint (opt-in): a mutating command may have changed
|
||||
// cookies. Schedule a debounced checkpoint — the 1.5s debounce means the
|
||||
// capture runs after the command has actually applied. No-op unless the lock
|
||||
// is held. Skip nested chain subcommands: the outer chain already scheduled.
|
||||
if ((opts?.chainDepth ?? 0) === 0 && AUTO_COOKIE_MUTATING_COMMANDS.has(command)) {
|
||||
scheduleAutoCookieCheckpoint();
|
||||
}
|
||||
const shouldCheckpointAutoCookies = (opts?.chainDepth ?? 0) === 0 && shouldAutoCookieCheckpoint(command);
|
||||
|
||||
// ─── Recursion guard: reject nested chains ──────────────────
|
||||
if (command === 'chain' && (opts?.chainDepth ?? 0) > 0) {
|
||||
|
|
@ -1113,6 +1123,7 @@ async function handleCommandInternalImpl(
|
|||
// ─── newtab with ownership for scoped tokens ──────────────
|
||||
if (command === 'newtab' && tokenInfo && tokenInfo.clientId !== 'root') {
|
||||
const newId = await browserManager.newTab(args[0] || undefined, tokenInfo.clientId);
|
||||
if (shouldCheckpointAutoCookies) scheduleAutoCookieCheckpoint();
|
||||
return {
|
||||
status: 200, json: true,
|
||||
result: JSON.stringify({
|
||||
|
|
@ -1240,6 +1251,11 @@ async function handleCommandInternalImpl(
|
|||
};
|
||||
}
|
||||
|
||||
// Auto-cookie checkpoint (opt-in): schedule after the command attempt has
|
||||
// actually run, so slow navigations/login redirects do not snapshot the
|
||||
// pre-command cookie jar. No-op unless the lock is held.
|
||||
if (shouldCheckpointAutoCookies) scheduleAutoCookieCheckpoint();
|
||||
|
||||
// ─── Centralized content wrapping (single location for all commands) ───
|
||||
// Scoped tokens: content filter + enhanced envelope + datamarking
|
||||
// Root tokens: basic untrusted content wrapper (backward compat)
|
||||
|
|
@ -1311,6 +1327,8 @@ async function handleCommandInternalImpl(
|
|||
}
|
||||
return { status: 200, result };
|
||||
} catch (err: any) {
|
||||
if (shouldCheckpointAutoCookies) scheduleAutoCookieCheckpoint();
|
||||
|
||||
// Restore original active tab even on error
|
||||
if (savedTabId !== null) {
|
||||
try { browserManager.switchTab(savedTabId, { bringToFront: false }); } catch (restoreErr: any) {
|
||||
|
|
@ -1803,7 +1821,7 @@ export function buildFetchHandler(cfg: ServerConfig): ServerHandle {
|
|||
|
||||
// Cookie picker routes — HTML page unauthenticated, data/action routes require auth
|
||||
if (url.pathname.startsWith('/cookie-picker')) {
|
||||
return handleCookiePickerRoute(url, req, browserManager, authToken);
|
||||
return handleCookiePickerRoute(url, req, browserManager, authToken, scheduleAutoCookieCheckpoint);
|
||||
}
|
||||
|
||||
// Welcome page — served when GStack Browser launches in headed mode
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ import { writeSecureFileAtomic } from '../src/file-permissions';
|
|||
|
||||
const META = fs.readFileSync(path.join(import.meta.dir, '../src/server.ts'), 'utf-8');
|
||||
const BM = fs.readFileSync(path.join(import.meta.dir, '../src/browser-manager.ts'), 'utf-8');
|
||||
const PICKER = fs.readFileSync(path.join(import.meta.dir, '../src/cookie-picker-routes.ts'), 'utf-8');
|
||||
|
||||
function tmpConfig(): BrowseConfig {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'auto-cookie-'));
|
||||
|
|
@ -320,4 +321,22 @@ describe('wiring (static)', () => {
|
|||
expect(loadIdx).toBeGreaterThan(0);
|
||||
expect(newCtxIdx).toBeGreaterThan(loadIdx); // restore assigned into contextOptions before use
|
||||
});
|
||||
|
||||
test('debounced command checkpoint runs after dispatch and covers real mutators', () => {
|
||||
expect(META).toContain('WRITE_COMMANDS.has(command)');
|
||||
expect(META).toContain("'js', 'eval'");
|
||||
expect(META).toContain("'chain', 'state', 'newtab', 'tab-each'");
|
||||
expect(META).not.toContain("'set-cookie'");
|
||||
const canonicalIdx = META.indexOf('const command = canonicalizeCommand(rawCommand);');
|
||||
const dispatchIdx = META.indexOf('if (READ_COMMANDS.has(command))', canonicalIdx);
|
||||
const scheduleIdx = META.indexOf('// Auto-cookie checkpoint (opt-in): schedule after', dispatchIdx);
|
||||
expect(dispatchIdx).toBeGreaterThan(canonicalIdx);
|
||||
expect(scheduleIdx).toBeGreaterThan(dispatchIdx);
|
||||
});
|
||||
|
||||
test('cookie picker imports/removals schedule auto-cookie persistence', () => {
|
||||
expect(META).toContain('handleCookiePickerRoute(url, req, browserManager, authToken, scheduleAutoCookieCheckpoint)');
|
||||
expect(PICKER).toContain('onCookieMutation?: () => void');
|
||||
expect(PICKER.match(/onCookieMutation\?\.\(\)/g)?.length).toBe(2);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue