Skip to content

fix: flaky navigation timeouts caused by a lost wakeup in WaitForLoadState and WaitForURL - #641

Merged
mxschmitt merged 1 commit into
mxschmitt:mainfrom
myrjola:fix/lost-wakeup-in-wait-for-load-state
Sep 9, 2026
Merged

mxschmitt merged 1 commit into
mxschmitt:mainfrom
myrjola:fix/lost-wakeup-in-wait-for-load-state

Conversation

@myrjola

@myrjola myrjola commented Sep 6, 2026 •

Copy link
Copy Markdown
Contributor

Thank you for this awesome project! Here's a fix to a flaky navigation timeout caused by a subtle concurrency bug in the client which causes WaitForURL, WaitForLoadState and ExpectNavigation to occasionally fail even when the page is already at the wanted URL and fully loaded. This failure became quite common in the CI pipelines of my projects but they have been running fine after taking the fix into use. I published a minimal reproduction here https://github.com/myrjola/playwright-go-lost-wakeup. I've verified the reproduction and fix on my Linux server and Macbook.

I did not find a prior issue for this; happy to open one if you want it tracked separately. I'm also happy to put in the work to to improve this PR.

Click to open sequence diagrams for understanding the bug and fix.
sequenceDiagram
    participant W as Wait goroutine<br/>(waitForLoadStateImpl)
    participant F as frameImpl<br/>(loadStates, listeners)
    participant D as Dispatch goroutine<br/>(connection)

    W->>F: read loadStates
    F-->>W: "load" absent
    W->>F: setNavigationWaiter(): start timeout timer,<br/>register close / crash / framedetached listeners

    rect rgba(220, 38, 38, 0.15)
        Note over F,D: gap: nobody is subscribed to "loadstate" yet
        D->>F: onLoadState("load")
        Note over F: loadStates += "load"
        Note over F: emit "loadstate" to zero listeners,<br/>never replayed
    end

    W->>F: On("loadstate", handler)
    Note over W: nothing re-reads loadStates
    W->>W: block on errChan
    W-->>W: ErrTimeout after the full timeout,<br/>page is at the URL and fully loaded
Loading

And the same interleaving after the fix:

sequenceDiagram
    participant W as Wait goroutine<br/>(waitForLoadStateImpl)
    participant F as frameImpl<br/>(loadStates, listeners)
    participant D as Dispatch goroutine<br/>(connection)

    W->>F: read loadStates
    F-->>W: "load" absent
    W->>W: setNavigationWaiter()
    D->>F: onLoadState("load"): loadStates += "load",<br/>emit to zero listeners
    W->>F: On("loadstate", handler)
    W->>F: re-check loadStates
    F-->>W: "load" present
    W->>W: waiter.dispose(): stop timer, remove listeners
    W-->>W: return nil
Loading
Click to open AI generated report below with more details. It shows up as a rare, load-correlated timeout whose failure screenshot shows the right page. One instance from the reproduction below, with `framenavigated` and `load` listeners attached before the click:
iter 3 LOST: wait returned after 3.000555759s; framenavigated(http://127.0.0.1:41031/a) at -2.482707ms after the call; load at 262.241µs after the call; page.URL() now http://127.0.0.1:41031/a; err: timeout:Timeout 3000.00ms exceeded.

Cause. waitForLoadStateImpl reads f.loadStates, then builds its waiter (setNavigationWaiter: a timer goroutine and four listener registrations), and only then subscribes to "loadstate". Events arrive on the connection's single dispatch goroutine, which can run between that read and the subscription. A loadstate delivered in the gap is emitted to no listener and is never replayed; loadStates is updated but nothing re-reads it, so the wait sleeps out its whole timeout. WaitForURL has the same shape around "navigated": it checks f.URL(), then calls ExpectNavigation, which subscribes several registrations later.

Upstream. client/frame.ts waitForLoadState builds the waiter first, checks _loadStates, and calls waiter.dispose() when the state is already there; run-to-completion makes that check atomic with the subscription. The Go port checks first and builds the waiter afterwards, and the dispatch goroutine can interleave.

Fix. Bring the port to upstream's shape:

  • waiter.dispose() (new, after upstream's Waiter.dispose()): marks the waiter fulfilled so a racing handler drops its event, cancels the timeout goroutine, removes every listener.
  • waitForLoadStateImpl: keep the cheap first check (the common already-loaded case builds no waiter), subscribe, re-check loadStates, dispose on a hit. onLoadState adds to the set before emitting, so a re-check after subscribing cannot miss an event that fired before it.
  • WaitForURL: the not-yet-matching path goes through an internal expectNavigation(cb, alreadyNavigated, ...) that re-checks the URL once the "navigated" listener is attached. ExpectNavigation passes nil and is unchanged. On a hit only the load state is awaited and no Response is returned, which WaitForURL discards anyway.

Evidence. Reproduction with both runs' full output: https://github.com/myrjola/playwright-go-lost-wakeup. Six Chromium pages click between two pages whose stylesheet and script make load trail the commit by a few ms; a timeout is LOST if the page is at the wanted URL and load was delivered well inside the budget, otherwise slow. Same box, same knobs, -race:

Library ok LOST slow load avg during run
v0.6201.1 1794 6 0 ~7
this branch 1800 0 0 ~50

Every loss has the same shape: the navigation committed a few ms before the call, load arrived 0.2 to 0.4 ms after it, and the wait ran its full 3 s.

Tests.

  • frame_wait_race_test.go (new, no browser): a frameImpl from the existing newTimeoutSemanticsFixture; a goroutine fires onLoadState / onFrameNavigated the moment the wait's "framedetached" rejection appears on the page, which is the registration just before the wait subscribes to its own event. Both tests fail within a few rounds on v0.6201.1 and pass here; they raise GOMAXPROCS to 2 when they find 1, since at 1 the waiting goroutine never yields before it subscribes, and they check no listener is left on the frame or page.
  • TestWaiterDisposeRemovesEveryListener (new).
  • Root package under -race, and the full tests/ suite on Chromium under -race: pass. golangci-lint with the repo config: 0 issues. gofumpt clean. Driver version untouched.

Written with Claude (Fable 5.1) in Claude Code; the measurements are from real runs on my machine.

…so a state recorded in between is not lost

Frame.WaitForLoadState read the frame's load states, then built its
waiter (a timer goroutine and four listener registrations), and only then
subscribed to "loadstate". Events arrive on the connection's single
dispatch goroutine, which can run in that gap; a "loadstate" it delivered
there was emitted to no listener and is never replayed, so the wait slept
out its whole timeout with the state it wanted already recorded.
WaitForURL had the same shape around "navigated": it checked the URL, then
called ExpectNavigation, which subscribes several registrations later.

Upstream's client/frame.ts waitForLoadState builds the waiter first, checks
_loadStates, and calls waiter.dispose() when the state is already there;
JavaScript's run-to-completion makes that check atomic with the
subscription. This brings the Go port to the same shape: attach the
listener, re-check the recorded state, and dispose of the waiter (new
waiter.dispose(), after upstream's) rather than wait for an event that has
already been delivered. onLoadState and onFrameNavigated both update their
state before emitting, so a re-check after subscribing cannot miss an
event that fired before it.

Measured with a browser (six pages clicking between two pages whose
stylesheet and script make `load` trail the commit by a few ms):
page.WaitForURL after a click timed out 6 times in 1,800 with the page
already at the wanted URL and `load` delivered 0.2-0.4 ms after the call;
with this change 0 in 1,800 under heavy load. The two new unit tests aim
the event at the gap with no browser and fail within a few rounds on the
previous code at any GOMAXPROCS (they raise it to 2 if needed).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Comment thread frame_wait_race_test.go
Comment on lines +26 to +36
// raceRoundsNeedParallelism gives the emitting goroutine its own P: at
// GOMAXPROCS=1 the waiting goroutine does not yield until it blocks in Wait(),
// which is after it has subscribed, and the old code passes trivially.
func raceRoundsNeedParallelism(t *testing.T) {
t.Helper()
if runtime.GOMAXPROCS(0) >= 2 {
return
}
prev := runtime.GOMAXPROCS(2)
t.Cleanup(func() { runtime.GOMAXPROCS(prev) })
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Required, not decorative. I ran these tests against current main (adapted for the extra cb argument): WaitForLoadState lost the event on round 8, WaitForURL on round 4, both timeout:Timeout 2000.00ms exceeded. On this branch they pass in tens of milliseconds.

Using the page’s framedetached registration as the “about to subscribe” signal is a stable way to aim at the gap. The ListenerCount checks afterwards are what would catch a dispose leak.

Comment thread frame.go
Comment on lines +314 to +321
// If the page is already closed, fail immediately rather than waiting for
// the navigation timeout, matching upstream's rejectImmediately guard. The
// check comes after the subscription: a close dispatched in between would
// otherwise reach no listener and never be replayed. reject drops the
// duplicate when the listener saw it first.
if f.page.IsClosed() {
waiter.reject(f.page.closeErrorWithReason())
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct Go-specific divergence from upstream.

TS _setupNavigationWaiter checks isClosed() before rejectOnEvent. That is safe under run-to-completion. Here a close dispatched between an early check and RejectOnEvent is the same lost-wakeup class as loadstate. Checking after the subscriptions, and letting reject drop a duplicate, is the right order. Don’t move this back in front of RejectOnEvent to match frame.ts.

Comment thread waiter.go
Comment on lines 247 to 252
func (w *waiter) reject(err error) {
w.fulfilled.Store(true)
if w.fulfilled.Swap(true) {
return
}
w.errChan <- err
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Load-bearing for dispose. After a successful re-check, dispose marks the waiter fulfilled and stops the timer; a timeout or close that already passed its fulfilled check must not send a second value into the capacity-2 errChan (that would block the dispatch goroutine). TestWaiterHasNotDeadlockForErrChanCapBiggerThan1AndCallbackErr still covers the old deadlock case.

Comment thread waiter.go
Comment on lines +121 to +137
// dispose releases the waiter: it marks it fulfilled so a handler still
// running on the dispatch goroutine drops its event, stops the timeout and
// removes every listener. Wait calls it once the wait has resolved; a caller
// whose condition was met after subscribing but before waiting calls it
// directly and must not Wait afterward. Mirrors upstream's Waiter.dispose().
func (w *waiter) dispose() {
w.fulfilled.Store(true)
w.mu.Lock()
defer w.mu.Unlock()
if w.stopTimeout != nil {
w.stopTimeout()
}
for _, l := range w.listeners {
l.emitter.RemoveListener(l.event, l.handler)
}
w.listeners = nil
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Needed. Without this, the re-check path would leak the page close/crash/framedetached listeners and leave a timer that later sends on errChan.

Leaving evChan open in WaitForEvent is also right: a handler that already passed the fulfilled check must not send on a closed channel.

Comment thread frame.go
Comment on lines +209 to +217
// expectNavigation is ExpectNavigation with one extra knob for WaitForURL.
// With acceptCurrentURL the frame's URL is compared against options.URL once
// the "navigated" listener is attached; a match means the commit was recorded
// before the subscription, so the navigation event is skipped, only the load
// state is awaited and no Response is returned (the event that carried the
// request is the one that was missed). ExpectNavigation itself must not do
// this: its contract is to wait for a new navigation, so a reload of the
// current URL has to be observed rather than short-circuited. Only meaningful
// with a nil cb; there is no action to run when the navigation has happened.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good split. WaitForURL means “be at this URL and this load state,” so accepting a commit that was recorded before subscribe is correct (and dropping Response is fine; WaitForURL discards it).

ExpectNavigation must stay acceptCurrentURL=false. Short-circuiting on the current URL would break reload-to-same-URL, which has to observe a new navigation.

Comment thread frame.go
Comment on lines +166 to 171
// Re-check after subscribing: a "loadstate" dispatched between the check
// above and the subscription reached no listener and is never replayed.
if f.loadStates.ContainsOne(state) {
waiter.dispose()
return nil
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the right Go shape, and it is not a literal port of client/frame.ts.

TS can check _loadStates once after creating the waiter: waitForEvent adds the listener synchronously, and run-to-completion means the event loop cannot deliver a loadstate between that check and addListener. Python (no await in the gap) and Java (runUntil is the only thing that calls processOneMessage) are safe for the same reason.

Go’s dispatch goroutine can run between the first ContainsOne and On("loadstate"). A loadstate there updates the set, emits to zero listeners, and is never replayed. Re-checking after subscribe is what actually closes the window; matching TS line-for-line (waiter first, check once) would still be racy here.

Please keep this re-check. A later roll that “aligns” this back to the TS order would reintroduce the flake.

@cursor

cursor Bot commented Sep 8, 2026

Copy link
Copy Markdown

Approve. Real Go-only lost wakeup; the patch is the right shape. CI is green. I reproduced the unit tests on main (fail: load-state round 8, WaitForURL round 4, full 2s timeout) and on this branch (pass under -race).

loadstate / navigated are not replayed. Current main checks state, builds the waiter, then subscribes. The connection dispatch goroutine can deliver the event in that gap: the set/URL update, nobody hears Emit, and the wait pays the full timeout with the page already at the URL and loaded. TS/Python/Java do not have this race (run-to-completion / no await in the gap / caller-thread processOneMessage). Matching frame.ts line-for-line would still be racy in Go; subscribe + re-check + dispose() is what we actually need.

This is not a missed roll. waitForLoadStateImpl has been check-then-subscribe since the v1.30 waiter rewrite. In-repo tests never sit in the window (Goto then WaitForLoadState hits the first check; WaitForURL is armed before the click). The failure looks like ordinary flake (right screenshot, right URL, generic timeout). GOMAXPROCS=1 hides it.

Nits, none blocking:

  • Title/body mention Goto. Goto is a server-side channel.Send("goto") and is not this race. The user-visible flake is WaitForURL / WaitForLoadState / ExpectNavigation(nil).
  • Do not later “align” the post-subscribe re-check or the after-subscribe IsClosed check back to the TS order. Sibling wait helpers are not a concurrency spec for Go.
  • Pre-existing: TS throwIfImmediatelyRejected() still errors on an already-closed but already-loaded page; our cheap first check returns nil. Out of scope here.

Inline notes are on the re-check, acceptCurrentURL, the moved IsClosed guard, dispose / reject, and the race tests.

@myrjola

myrjola commented Sep 9, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for the review. Body corrected: Goto is a server-side channel.Send("goto") and is not affected; the user-visible flake is WaitForURL / WaitForLoadState / ExpectNavigation(nil).

Agreed on the other two notes and leaving the code as is: the post-subscribe re-check and the IsClosed check after RejectOnEvent are deliberate Go-specific divergences from client/frame.ts and should survive future driver rolls. The closed-but-loaded page returning nil from the cheap first check is pre-existing and out of scope here.

@mxschmitt
mxschmitt merged commit a75a7a3 into mxschmitt:main Sep 9, 2026
22 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants