Bound Playwright daemon executions#306
Conversation
Keep reconnect retries within the request deadline and check the deadline immediately before user code starts. Preserve the final Playwright retry failure in the display resize readiness helper so flaky failures stay diagnosable.
rgarcia
left a comment
There was a problem hiding this comment.
asks
server/runtime/playwright-daemon.ts:150-190— reduce the fiverequireWithinDeadlinecheckpoints to the single one beforeuserFunction(...). the PR body states the goal precisely: a request that timed out during setup must not later run the submitted script. the check afternew AsyncFunction(...)achieves that by itself. the checks after transform, connection,newContext, andnewPageguard nothing observable — once the outer race is lost the response is already sent and the orphaned promise's result is discarded; a context or page created a beat late is harmless. each extra checkpoint is a line a future reader has to justify.server/runtime/playwright-daemon.ts:53-63— with the checkpoints gone,delayWithinDeadlinereduces to the plainsetTimeoutsleep it replaced. sleeping past the deadline inside an orphaned promise has no effect; drop the helper.
nits
server/e2e/e2e_display_resize_window_test.go:468-485— theif err != nil { require.NoError(collect, err); return }guards are redundant:requireagainst aCollectTalready aborts the closure viaFailNow, so the three branches collapse to three consecutiverequirecalls. the trailingreturnin the!Successbranch is dead either way.
Keep only the deadline guard that prevents timed-out setup from starting user code, drop the now-unneeded reconnect delay helper, and simplify the display resize retry assertions.
|
Reduced the timeout setup checks to the single guard immediately before userFunction(...), removed the now-redundant delayWithinDeadline helper, and collapsed the display resize EventuallyWithT assertions. |
rgarcia
left a comment
There was a problem hiding this comment.
follow-up on the deadline guard: requireWithinDeadline(deadlineMs, timeoutMs) has two legibility problems, and timeoutMs is dead weight besides.
deadlineMsis an epoch timestamp, but nothing in the name or type says so — it reads like a duration at the call site, especially next totimeoutMswhich is one.timeoutMsexists only to format the error message — and that message is effectively unobservable. the deadline and the outerwithTimeouttimer start at the same moment with the same duration, so by the timeDate.now() >= deadlineMsthe race has already rejected and the socket has already written the timeout response; the guard's response object loses the race and is discarded. its only real job is "don't run user code."
replace both with an AbortSignal — the platform primitive for exactly this:
// handleConnection
const timeoutMs = request.timeout_ms ?? 60000;
const signal = AbortSignal.timeout(timeoutMs);
let response: ExecuteResponse;
try {
response = await withTimeout(executeCode(request, signal), timeoutMs);
} catch (error: any) {
...// executeCode — drop requireWithinDeadline entirely
async function executeCode(request: ExecuteRequest, signal: AbortSignal): Promise<ExecuteResponse> {
...
const userFunction = new AsyncFunction('page', 'context', 'browser', jsCode);
signal.throwIfAborted();
const result = await userFunction(page, context, browserInstance);same line count, but: the type says what the parameter is, the message-only param disappears (throwIfAborted throws a standard TimeoutError), and executeCode(request, signal) needs no explanation.
Replace the custom deadline timestamp check with AbortSignal.timeout and throwIfAborted so the pre-user-code timeout guard is self-describing and drops the message-only timeout parameter.
|
The daemon now passes an AbortSignal into executeCode, creates it with AbortSignal.timeout(timeoutMs), and uses signal.throwIfAborted() immediately before userFunction(...) Thank you for your feedback :) |
rgarcia
left a comment
There was a problem hiding this comment.
one last polish on the same thread: there are now two independent timers for the same deadline — the signal's internal one (drives throwIfAborted) and withTimeout's setTimeout (drives the race). they fire within milliseconds of each other but not atomically, so there's a small window where the race has rejected and the timeout response is already written, but the signal hasn't aborted yet — throwIfAborted() passes and user code starts anyway. that's the exact edge the guard exists to close.
drive the race off the signal so "response written" and "signal aborted" are the same event:
function withTimeout<T>(promise: Promise<T>, signal: AbortSignal): Promise<T> {
return Promise.race([
promise,
new Promise<never>((_, reject) => {
signal.addEventListener('abort', () => reject(signal.reason), { once: true });
}),
]);
}// handleConnection
const signal = AbortSignal.timeout(request.timeout_ms ?? 60000);
let response: ExecuteResponse;
try {
response = await withTimeout(executeCode(request, signal), signal);
} catch (error: any) {
...one deadline authority, the clearTimeout/.finally bookkeeping disappears (the platform owns the timer), and the race rejects with the same TimeoutError the guard throws.
Use the same AbortSignal for both the response timeout race and the pre-user-code guard so there is a single deadline authority and no gap between the timeout response and signal abortion.
|
withTimeout now races on the same AbortSignal passed to executeCode, so the response timeout and signal abortion are driven by the same event |
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 5a8b33d. Configure here.
| new Promise<never>((_, reject) => { | ||
| signal.addEventListener('abort', () => reject(signal.reason), { once: true }); | ||
| }), | ||
| ]); |
There was a problem hiding this comment.
Late abort promise unhandled rejections
Medium Severity
withTimeout uses Promise.race with an abort listener that still rejects after the main work promise settles. When executeCode finishes before AbortSignal.timeout fires, the timer later rejects the losing racer with no handler, which can emit unhandled promise rejections under steady Playwright traffic.
Reviewed by Cursor Bugbot for commit 5a8b33d. Configure here.
There was a problem hiding this comment.
Not changing code for this one. Promise.race registers handlers on each input promise, so a later rejection from the losing abort branch is still handled by the race and does not emit an unhandledRejection. I verified this locally with the exact AbortSignal.timeout + withTimeout shape from this PR; the abort promise rejected after the work promise resolved and Node emitted no unhandledRejection. The response timeout and pre-user-code guard are now driven by the same signal as intended.
rgarcia
left a comment
There was a problem hiding this comment.
one gap before this lands: test-server-e2e never exercises the timeout actually firing. TestPlaywrightExecuteAPI covers the happy path through the new plumbing and TestPlaywrightDaemonRecovery covers the reconnect/disconnected path, but no test submits code that exceeds timeout_ms and asserts a prompt success=false response — which is the behavior this PR exists to add. the withTimeout race and throwIfAborted() guard only run their non-triggering branches in CI, so a green run verifies the change didn't break normal execution but not that the fix fixes the bug.
add a test alongside the existing ones in e2e_playwright_test.go:
- execute
await new Promise(r => setTimeout(r, 30000));withTimeoutSec: 2 - assert the response arrives within a few seconds with
success=falseand a timeout error — this is the regression test for the originalread unix @->/tmp/playwright-daemon.sock: i/o timeoutflake - execute a second normal request (e.g. the existing
navigator.userAgentsnippet) and assert it succeeds — locking in the "daemon stays responsive after a timed-out request" behavior deferred from the bugbot thread
Cover the request timeout path Raf called out by executing a long-running Playwright snippet with a short timeout and asserting the daemon returns a prompt success=false response. Also verify a follow-up userAgent request succeeds so the test locks in recovery after a timed-out execution.
added this in It submits |


Summary
Bound each Playwright daemon request with one request-level timeout at the socket handler. The timeout now covers transform, browser connection/setup, and user code, and the timer is cleared after completion.
The daemon also carries the request deadline into setup and checks it before starting user code, so a request that times out during setup cannot later run the submitted script after the caller already received a timeout response.
Also make the cached browser disconnect handler instance-aware so a stale disconnected event cannot clear a newer live browser connection.
For the display resize e2e path, retry the initial
about:blankPlaywright navigation withrequire.Eventuallyinstead of failing on one transient daemon/socket timeout.Validation
Local:
git diff --checkbun build server/runtime/playwright-daemon.ts --outfile=/tmp/playwright-daemon.js --target=node --external playwright-core --external patchright --external esbuildcd server && go test ./e2e -run TestDoesNotExist -count=0~/.agents/skills/autoreview/scripts/autoreview --mode branch --base origin/mainclean after the deadline guard fixGitHub Actions:
test-server-unit: passedbuild-headful / docker: passedbuild-headless / docker: passedtest-server-e2e: passedNote
Medium Risk
Changes execution and timeout semantics on the Playwright automation path used by the instance API; behavior is improved but mis-timed aborts could affect long-running scripts or slow CDP setup.
Overview
Playwright daemon now enforces one request-level deadline via
AbortSignal.timeoutandwithTimeout, so transform, CDP connect/setup, and user script all share the same bound. The handler aborts before running user code if setup already exceeded the limit, avoiding late script execution after the client got a timeout. Per-requestsetTimeoutracing insideexecuteCodeis removed.CDP reconnect only clears the cached
browserwhen thedisconnectedevent comes from the current connection instance, so a stale disconnect cannot wipe a newer live session.E2e:
navigateBlankretriesabout:blankwithrequire.EventuallyWithT(30s) instead of a single attempt. NewTestPlaywrightExecuteTimeoutReturnsPromptlyAndRecoversasserts a 2s timeout returns within 5s with a proper timeout error (not socket I/O timeout) and that the next execute succeeds.Reviewed by Cursor Bugbot for commit 3f3b5ad. Bugbot is set up for automated code reviews on this repo. Configure here.