[v3] InvokeSync* never returns when the dispatched callback panics: wg.Done() is skipped by the recovering handlePanic
Description
All five InvokeSync* variants in v3/pkg/application/mainthread.go call wg.Done() as the last statement of the dispatched closure instead of deferring it, while defer handlePanic() sits above it:
func InvokeSync(fn func()) {
var wg sync.WaitGroup
wg.Add(1)
globalApplication.dispatchOnMainThread(func() {
defer handlePanic()
fn()
wg.Done() // skipped when fn() panics
})
wg.Wait() // blocks forever
}handlePanic calls recover() and returns normally (v3/pkg/application/panic_handler.go), so a panic inside fn is swallowed and the closure unwinds cleanly — but wg.Done() never runs, and wg.Wait() blocks for the life of the process.
The recovery is the trap. Because the panic is handled rather than propagated, the process keeps running and there is no crash to point at: the caller is simply wedged forever, with a healthy-looking application around it.
Affected on main today (mainthread.go): InvokeSync :23, InvokeSyncWithResult :34, InvokeSyncWithError :46, InvokeSyncWithResultAndError :58, InvokeSyncWithResultAndOther :70. InvokeAsync :82 is correct — it has no WaitGroup.
Application code cannot work around this. The sync.WaitGroup is a local variable inside the Wails function; there is no handle a caller could use to release it. The only remedies available downstream are to guarantee no callback ever panics, or to avoid InvokeSync* entirely.
Where it bites hardest
A panic handler that wants to show UI. On Linux, linuxDialog.show wraps its work in gtkDispatch, which in the default build (gtkdispatch_linux.go, //go:build linux && !gtk3 && !android && !server) is an unconditional goroutine, and runQuestionDialog marshals to the main thread with InvokeAsync and blocks on its result channel. If anything on that path panics, the GTK main thread is parked in wg.Wait() and no dialog can ever be drawn again.
The practical consequence for us: a crash dialog that should let the user send a crash report cannot render, so a panic on Linux produces no report and no visible explanation — the window just disappears.
To Reproduce
package main
import (
"time"
"github.com/wailsapp/wails/v3/pkg/application"
)
func main() {
app := application.New(application.Options{Name: "invokesync-panic"})
app.Window.NewWithOptions(application.WebviewWindowOptions{})
go func() {
time.Sleep(2 * time.Second)
application.InvokeSync(func() {
panic("boom")
})
// unreachable: InvokeSync never returns
println("InvokeSync returned")
}()
_ = app.Run()
}Expected: the panic is handled and InvokeSync returns, printing InvokeSync returned.
Actual: the panic is logged by handlePanic, the goroutine is wedged in wg.Wait() forever, and the line never prints. The same happens for the four other InvokeSync* variants.
Expected behaviour
InvokeSync* returns after a recovered panic in the callback, releasing the caller. A panic in dispatched work should not permanently block the caller, and on the platforms where the dispatch is inline it should not strand the main thread.
Attempted Fixes
Deferring the release fixes all five. Note the ordering: because deferred calls run LIFO, wg.Done() should be deferred first so that handlePanic still runs before the caller is released.
globalApplication.dispatchOnMainThread(func() {
defer wg.Done()
defer handlePanic()
fn()
})I verified both orderings in a standalone probe that mirrors the closure shape with a stub handlePanic that recovers and returns:
| shape | panicking callback | normal callback |
|---|---|---|
current (wg.Done() last statement) |
caller never released | released |
defer handlePanic() then defer wg.Done() |
released, but before the panic is processed | released |
defer wg.Done() then defer handlePanic() |
released after the panic is processed | released |
recover() still works in the last shape — both calls are deferred directly by the closure that panics.
One deliberate semantic change worth calling out: for the variants that assign a result, a callback that panics before its assignment completes will now return the zero value to the caller instead of blocking forever. That is the point of the change, but it does mean a caller that previously hung will now proceed with a zero value, so it is a behavioural change rather than a pure bug fix.
Happy to open a PR with this plus a regression test if the shape looks right to you.
System Details
Found while investigating why a Linux crash dialog never renders. Verified by reading main rather than only the pinned release: the code is identical in v3.0.0-beta.19 (latest published) and on main at the time of filing. Reproduced against v3.0.0-beta.19.
Additional context
I searched open and closed issues and PRs for this before filing and found nothing covering it. #6026 (fix(v3): keep serving main thread work on macOS while a modal loop runs) touches only mainthread_darwin.go and the darwin harness, not the shared InvokeSync* bodies. #5107 fixes a different deadlock (an RLock held across InvokeSync).
Source: wailsapp/wails