{"record":{"id":"7b6bab9857efca2f","repo":"wailsapp/wails","slug":"invokecallback-must-always-be-called-on-the-mainos","errorCode":null,"errorMessage":"invokeCallback must always be called on the MainOSThread","messagePattern":"invokeCallback must always be called on the MainOSThread","errorType":"panic","errorClass":null,"httpStatus":null,"severity":"error","filePath":"v3/pkg/application/mainthread_windows.go","lineNumber":107,"sourceCode":"\t\tfn()\n\t}\n}\n\nfunc (m *windowsApp) invokeRequired() bool {\n\tmainThreadID := m.mainThreadID\n\tif mainThreadID == 0 {\n\t\tpanic(\"initMainLoop was not called\")\n\t}\n\n\treturn mainThreadID != w32.GetCurrentThreadId()\n}\n\nfunc (m *windowsApp) invokeCallback(wParam, lParam uintptr) {\n\t// TODO: Should we invoke just one or all queued? In v2 we always invoked all pendings...\n\truntime.LockOSThread()\n\tdefer runtime.UnlockOSThread()\n\tif m.invokeRequired() {\n\t\tpanic(\"invokeCallback must always be called on the MainOSThread\")\n\t}\n\n\tmainThreadFunctionStoreLock.Lock()\n\tfnIDs := make([]uint, 0, len(mainThreadFunctionStore))\n\tfor id := range mainThreadFunctionStore {\n\t\tfnIDs = append(fnIDs, id)\n\t}\n\tsort.Slice(fnIDs, func(i, j int) bool { return fnIDs[i] < fnIDs[j] })\n\n\tfns := make([]func(), len(fnIDs))\n\tfor i, id := range fnIDs {\n\t\tfns[i] = mainThreadFunctionStore[id]\n\t\tdelete(mainThreadFunctionStore, id)\n\t}\n\tmainThreadFunctionStoreLock.Unlock()\n\n\tfor _, fn := range fns {\n\t\tfn()","sourceCodeStart":89,"sourceCodeEnd":125,"githubUrl":"https://github.com/wailsapp/wails/blob/0e754b1b40ba9044c2a1460e23b7c3de20fc5cf3/v3/pkg/application/mainthread_windows.go#L89-L125","documentation":"This panic fires in windowsApp.invokeCallback (v3/pkg/application/mainthread_windows.go:107) when the wmInvokeCallback window message is dispatched on an OS thread that is not the thread that created Wails' hidden main-thread window. Wails Windows marshals cross-thread calls by PostMessage'ing wmInvokeCallback to that window, and the handler asserts via invokeRequired() (which compares m.mainThreadID to w32.GetCurrentThreadId()) that it is running on the main OS thread. The guard protects the shared mainThreadFunctionStore and all queued main-thread closures from being executed on an arbitrary thread.","triggerScenarios":"Any setup where the WndProc for the main-thread window (application_windows.go:232 calls m.invokeCallback) runs on a different OS thread than the one that called initMainLoop(): starting the Wails event loop (runMainLoop) from a goroutine that Go migrated to another OS thread without LockOSThread; creating/running the app on one thread and pumping messages on another; a custom WndProcInterceptor or window subclass that forwards wmInvokeCallback messages to another thread's message pump; or embedding Wails in a host app whose main window pump is not the Wails main thread.","commonSituations":"Embedding a Wails v3 window inside an existing Win32/Qt/SDL host application; calling application.Run from a non-main goroutine 'to avoid blocking'; using experimental setups that spawn their own GetMessage loop; upgrading from v2 where threading expectations were looser; CGO callbacks invoked from worker threads that end up dispatching into the Wails message window.","solutions":["Audit the startup path: call app.Run() from the main goroutine exactly as the Wails v3 templates do, and let Wails own initMainLoop/runMainLoop so both run on the same locked OS thread.","If you must drive the loop yourself, guarantee thread affinity: call runtime.LockOSThread() in main() before any Wails call and keep every message pump for the main-thread window on that same thread.","Check any custom options.Windows.WndProcInterceptor or third-party subclass: make sure it calls through on the same thread and never re-posts wmInvokeCallback messages to another thread or window.","Search your code for direct use of application-internal thread helpers (InvokeOnMainThread is safe; anything calling invokeCallback/wmInvokeCallback indirectly is not) and route through the public API.","If the panic persists, file a Wails issue with the goroutine dump: the stack shows which thread dispatched the message and usually pinpoints the rogue pump."],"exampleFix":"// before\nfunc main() {\n\tgo app.Run() // Go scheduler may run this on any OS thread\n\tselect {}\n}\n\n// after\nfunc main() {\n\t// Wails creates and pumps its main-thread window on the\n\t// goroutine that calls Run; keep that on the main thread.\n\tapp.Run()\n}","handlingStrategy":"validation","validationCode":"// Before pumping messages or driving the Wails loop yourself,\n// verify you are on the main OS thread that called initMainLoop.\n// (Illustrative; the IDs come from w32.GetCurrentThreadId / the\n// thread that created the main-thread window.)\nfunc runOnMainThreadGuard(fn func()) {\n\truntime.LockOSThread()\n\tdefer runtime.UnlockOSThread()\n\tif !isWailsMainThread() { // compare against the thread that ran app init\n\t\tpanic(\"must run on the Wails main OS thread\")\n\t}\n\tfn()\n}","typeGuard":null,"tryCatchPattern":"// Go: recover only to capture diagnostics, then crash deliberately —\n// continuing after a main-thread violation corrupts app state.\ndefer func() {\n\tif r := recover(); r != nil {\n\t\tlog.Printf(\"main-thread violation: %v\\n%s\", r, debug.Stack())\n\t\tos.Exit(1)\n\t}\n}()","preventionTips":["Call app.Run() from the main goroutine exactly as the Wails v3 templates do; never wrap it in 'go'.","Never build custom message pumps for the Wails main-thread window; use application.InvokeOnMainThread for main-thread work.","Keep any Windows WndProcInterceptor pass-through and synchronous — do not re-post messages to other threads.","LockOSThread in main() if your startup performs Win32 calls before app.Run().","Pin a known-good Wails version and read release notes for threading changes."],"tags":["windows","main-thread","thread-affinity","panic","wails-v3"],"backgroundTag":null,"analyzedSha":"0e754b1b40ba9044c2a1460e23b7c3de20fc5cf3","analyzedAt":"2026-08-15T14:17:36.034Z","schemaVersion":2},"datasetVersion":"2026-08-15T17:31:12.345Z"}