Summary
On the DeviceLab Android driver, webViewManager.refreshPage can hit a nil-pointer dereference inside go-rod and take down the entire runner process — every in-flight flow plus every flow not yet scheduled. On a 19-flow --parallel 4 suite this turns one flaky WebView into a total run loss: 4 flows die mid-execution and 9 never start.
It reproduced 3 times in a single day on our CI with an identical stack.
Environment
maestro-runner 1.1.24 (linux-amd64), --driver devicelab --parallel 4
- Android emulators, SDK 35, x86_64, headless CI (4 emulators on one host)
- App under test is React Native / Expo; the WebView involved is expo-print's offscreen WebView, created transiently to render a PDF
I haven't confirmed on 1.1.25, but its release notes carry no WebView or go-rod change, so I assume it's still present.
Stack
panic: runtime error: invalid memory address or nil pointer dereference
[signal SIGSEGV: segmentation violation code=0x1 addr=0x10 pc=0x8b7bbc]
goroutine 310 [running]:
github.com/ysmood/goob.(*Observable).Subscribe(0x0, {0x12f8840?, 0x247ee47e8a00?})
/Users/omnarayan/work/go/pkg/mod/github.com/ysmood/goob@v0.4.0/goob.go:37 +0x3c
github.com/go-rod/rod.(*Browser).Event(0x247ee3e145a0)
/Users/omnarayan/work/go/pkg/mod/github.com/go-rod/rod@v0.116.2/browser.go:415 +0x2e
github.com/go-rod/rod.(*Page).initEvents(0x247ee4770bb0)
/Users/omnarayan/work/go/pkg/mod/github.com/go-rod/rod@v0.116.2/page.go:1041 +0x1ab
github.com/go-rod/rod.(*Browser).PageFromTarget(0x247ee3e14480, {0x247ee3ffd680, 0x20})
/Users/omnarayan/work/go/pkg/mod/github.com/go-rod/rod@v0.116.2/browser.go:309 +0x615
github.com/go-rod/rod.(*Browser).Pages(0x247ee3e14480)
/Users/omnarayan/work/go/pkg/mod/github.com/go-rod/rod@v0.116.2/browser.go:229 +0x11f
github.com/devicelab-dev/maestro-runner/pkg/driver/devicelab.(*webViewManager).refreshPage(0x247ee4048360)
/Users/omnarayan/work/go/src/maestro-runner/pkg/driver/devicelab/webview.go:447 +0x8e
github.com/devicelab-dev/maestro-runner/pkg/driver/devicelab.(*webViewManager).findWebOnce(_, {{0x0, 0x0}, {0x247ee4202288, 0x18}, 0x0, 0x0, 0x0, 0x0, 0x0, ...})
/Users/omnarayan/work/go/src/maestro-runner/pkg/driver/devicelab/webview.go:513 +0xea
github.com/devicelab-dev/maestro-runner/pkg/driver/devicelab.(*Driver).findElementOnce(_, {{0x0, 0x0}, {0x247ee4202288, 0x18}, 0x0, 0x0, 0x0, 0x0, 0x0, ...})
/Users/omnarayan/work/go/src/maestro-runner/pkg/driver/devicelab/driver.go:1222 +0x13c
github.com/devicelab-dev/maestro-runner/pkg/driver/devicelab.(*Driver).waitUntil(0x247ee4462308, 0x247ee401c2d0)
/Users/omnarayan/work/go/src/maestro-runner/pkg/driver/devicelab/commands.go:2047 +0x519
github.com/devicelab-dev/maestro-runner/pkg/driver/devicelab.(*Driver).Execute(0x247ee4462308, {0x12fcf80, 0x247ee401c2d0})
/Users/omnarayan/work/go/src/maestro-runner/pkg/driver/devicelab/driver.go:567 +0x15a
github.com/devicelab-dev/maestro-runner/pkg/flutter.(*FlutterDriver).Execute(0x247ee3de0000, {0x12fcf80, 0x247ee401c2d0})
/Users/omnarayan/work/go/src/maestro-runner/pkg/flutter/wrapper.go:92 +0x4b4
github.com/devicelab-dev/maestro-runner/pkg/executor.(*FlowRunner).executeStep(0x247ee4781618, 0x11, {0x12fcf80, 0x247ee401c2d0})
/Users/omnarayan/work/go/src/maestro-runner/pkg/executor/flow_runner.go:538 +0x1837
github.com/devicelab-dev/maestro-runner/pkg/executor.(*FlowRunner).Run(0x247ee4781618)
/Users/omnarayan/work/go/src/maestro-runner/pkg/executor/flow_runner.go:237 +0xbeb
github.com/devicelab-dev/maestro-runner/pkg/executor.(*Runner).executeFlow(_, {_, _}, {{0x247ee40aa150, 0x18}, {{0x247ee3e90780, 0x9}, {0x0, 0x0}, {0x0, ...}, ...}, ...}, ...)
/Users/omnarayan/work/go/src/maestro-runner/pkg/executor/runner.go:233 +0x18b
goob.(*Observable).Subscribe(0x0, …) — the receiver is nil, i.e. Browser.event is nil at the point Pages() → PageFromTarget() → initEvents() → Browser.Event() runs. That reads like a rod.Browser that was torn down (or never fully connected) being used concurrently by refreshPage.
What triggers it
A selector wait that spans the lifetime of a short-lived, offscreen WebView.
The flow taps a button that starts PDF generation, then waits for the screen to change:
- tapOn:
id: "account-statement-button"
- extendedWaitUntil:
notVisible:
id: "account-statement-button"
timeout: 60000
While expo-print's offscreen WebView is alive, every poll iteration falls through the native lookup into findWebOnce. Two things make this a wide window:
- The miss path is cheap and unthrottled. 5,664 of the 5,665 WebView probes in the crashed run logged
— webview not visible (WebView attached, not on screen). Sustained rate was ~400 probes/sec for 14s, with no backoff. A full 60s budget is ~24,000 attempts at the race.
- It's not specific to
id: selectors. A text: selector reaches the same call — in a passing run I see [webview] findWebOnce failed: JS findByText failed: … , refreshing page… for a plain text wait. So any wait for a not-yet-present element during WebView teardown is exposed.
For contrast, in the same suite every other selector combined accounted for 9 WebView probes. All the exposure is concentrated in waits that overlap a WebView's lifetime.
Impact
A panic in a worker goroutine is process-fatal in Go, so there's no per-flow containment: one racing WebView loses the whole suite, including flows that never ran. Our CI works around it by classifying the unexecuted flows as infrastructure loss and re-running them, but that's a workaround for a crash, not a fix.
Suggestions
- Guard
refreshPage against a browser whose event Observable is gone — a nil check on the rod.Browser state before Pages(), and/or recover() around the WebView find path so a CDP-side failure fails the step rather than the process.
- Back off the
webview not visible path the way ensureWebViewConnection already backs off a stalled devtools socket (1.1.22). ~400 probes/sec against a WebView that was just reported invisible is mostly wasted work, and it's what widens the race window.
- An opt-out would help as a stopgap — something like
MAESTRO_NO_WEBVIEW=1 to skip CDP element finding entirely for suites with no WebView assertions. I couldn't find an existing switch for this.
Happy to supply more log context if useful — the runner logs are ~18k lines, most of it the probe loop.
Summary
On the DeviceLab Android driver,
webViewManager.refreshPagecan hit a nil-pointer dereference inside go-rod and take down the entire runner process — every in-flight flow plus every flow not yet scheduled. On a 19-flow--parallel 4suite this turns one flaky WebView into a total run loss: 4 flows die mid-execution and 9 never start.It reproduced 3 times in a single day on our CI with an identical stack.
Environment
maestro-runner1.1.24 (linux-amd64),--driver devicelab --parallel 4I haven't confirmed on 1.1.25, but its release notes carry no WebView or go-rod change, so I assume it's still present.
Stack
goob.(*Observable).Subscribe(0x0, …)— the receiver is nil, i.e.Browser.eventis nil at the pointPages()→PageFromTarget()→initEvents()→Browser.Event()runs. That reads like arod.Browserthat was torn down (or never fully connected) being used concurrently byrefreshPage.What triggers it
A selector wait that spans the lifetime of a short-lived, offscreen WebView.
The flow taps a button that starts PDF generation, then waits for the screen to change:
While expo-print's offscreen WebView is alive, every poll iteration falls through the native lookup into
findWebOnce. Two things make this a wide window:— webview not visible(WebView attached, not on screen). Sustained rate was ~400 probes/sec for 14s, with no backoff. A full 60s budget is ~24,000 attempts at the race.id:selectors. Atext:selector reaches the same call — in a passing run I see[webview] findWebOnce failed: JS findByText failed: … , refreshing page…for a plain text wait. So any wait for a not-yet-present element during WebView teardown is exposed.For contrast, in the same suite every other selector combined accounted for 9 WebView probes. All the exposure is concentrated in waits that overlap a WebView's lifetime.
Impact
A panic in a worker goroutine is process-fatal in Go, so there's no per-flow containment: one racing WebView loses the whole suite, including flows that never ran. Our CI works around it by classifying the unexecuted flows as infrastructure loss and re-running them, but that's a workaround for a crash, not a fix.
Suggestions
refreshPageagainst a browser whose event Observable is gone — a nil check on therod.Browserstate beforePages(), and/orrecover()around the WebView find path so a CDP-side failure fails the step rather than the process.webview not visiblepath the wayensureWebViewConnectionalready backs off a stalled devtools socket (1.1.22). ~400 probes/sec against a WebView that was just reported invisible is mostly wasted work, and it's what widens the race window.MAESTRO_NO_WEBVIEW=1to skip CDP element finding entirely for suites with no WebView assertions. I couldn't find an existing switch for this.Happy to supply more log context if useful — the runner logs are ~18k lines, most of it the probe loop.