[client] Fix the Windows tray deadlock on re-entrant window creation

The Wails systray runs the left-click handler synchronously inside the
tray window procedure, and creating a window on a running app pumps a
nested Win32 message loop while WebView2 initialises. ensureWindow held
the non-reentrant createMu across that creation, so the second button-up
of a double click re-entered ShowWindow from the pump and blocked the
main thread on its own lock. A goroutine holding createMu while the main
thread pumped, and the Open* dialogs holding mu across NewWithOptions,
Show, Hide and InvokeSync, exposed the same inversion.

WindowManager now serialises creation with a per-slot creating flag and
queues the callers' operations until the window exists, and no Wails call
runs while mu is held. The tray click and second-instance handlers call
ShowWindow off the message loop.
This commit is contained in:
Zoltán Papp
2026-09-07 11:53:25 +02:00
parent 76ea72237f
commit e42402c49e
4 changed files with 410 additions and 215 deletions
+1 -1
View File
@@ -100,7 +100,7 @@ func main() {
var tray *Tray
app := newApplication(func() {
if tray != nil {
tray.ShowWindow()
go tray.ShowWindow()
}
})
+281 -213
View File
@@ -21,6 +21,8 @@ type LanguageSubscriber interface {
Subscribe() (<-chan preferences.UIPreferences, func())
}
type windowOp func(w *application.WebviewWindow, created bool)
// EventTriggerLogin asks the frontend's startLogin() to begin an SSO flow.
const EventTriggerLogin = "trigger-login"
@@ -36,6 +38,16 @@ const paintedFallback = 2 * time.Second
const headlessTeardownDelay = 2 * time.Second
const (
windowMain = "main"
windowSettings = "settings"
windowBrowserLogin = "browser-login"
windowSessionExpiration = "session-expiration"
windowInstallProgress = "install-progress"
windowWelcome = "welcome"
windowError = "error"
)
var WindowBackgroundColour = application.NewRGB(24, 26, 29) // bg-nb-gray-950
// WindowHeight is shared by the main and Settings windows.
@@ -116,8 +128,9 @@ type WindowManager struct {
// hiddenForLogin holds windows hidden while the BrowserLogin popup is open, restored on close.
hiddenForLogin []application.Window
mu sync.Mutex
createMu sync.Mutex
newMain func(startURL string) *application.WebviewWindow
creating map[string]bool
pendingOps map[string][]windowOp
ready map[uint]bool
showPending map[uint]bool
pendingTab map[uint]string
@@ -137,6 +150,8 @@ func NewWindowManager(app *application.App, mainWindow *application.WebviewWindo
translator: translator,
prefs: prefs,
linuxIcon: linuxIcon,
creating: map[string]bool{},
pendingOps: map[string][]windowOp{},
ready: map[uint]bool{},
showPending: map[uint]bool{},
pendingTab: map[uint]string{},
@@ -165,7 +180,7 @@ func NewWindowManager(app *application.App, mainWindow *application.WebviewWindo
func (s *WindowManager) newSettingsWindow() *application.WebviewWindow {
w := s.app.Window.NewWithOptions(application.WebviewWindowOptions{
Name: "settings",
Name: windowSettings,
Title: s.title("window.title.settings"),
Width: 900,
Height: WindowHeight,
@@ -186,6 +201,7 @@ func (s *WindowManager) newSettingsWindow() *application.WebviewWindow {
s.forgetWindowLocked(w)
s.mu.Unlock()
})
s.armReady(w)
return w
}
@@ -197,63 +213,68 @@ func (s *WindowManager) OpenSettings(tab string) {
target = "general"
}
w, _ := s.ensureWindow(&s.settings, s.newSettingsWindow)
s.withWindow(windowSettings, &s.settings, s.newSettingsWindow, func(w *application.WebviewWindow, _ bool) {
s.mu.Lock()
ready := s.ready[w.ID()]
if !ready {
s.pendingTab[w.ID()] = target
}
s.mu.Unlock()
s.mu.Lock()
ready := s.ready[w.ID()]
if !ready {
s.pendingTab[w.ID()] = target
}
s.mu.Unlock()
if ready {
s.app.Event.Emit(EventSettingsOpen, target)
}
s.showWhenReady(w)
if ready {
s.app.Event.Emit(EventSettingsOpen, target)
}
s.showWhenReady(w)
})
}
// OpenBrowserLogin shows the SSO popup, creating it on first use.
func (s *WindowManager) OpenBrowserLogin(uri string) {
s.mu.Lock()
defer s.mu.Unlock()
if s.browserLogin == nil {
startURL := "/#/dialog/browser-login"
if uri != "" {
startURL = "/#/dialog/browser-login?uri=" + url.QueryEscape(uri)
}
s.hideOtherWindowsLocked("browser-login")
opts := DialogWindowOptions("browser-login", s.title("window.title.signIn"), startURL, s.linuxIcon)
// Not always-on-top: it would obscure the browser tab the user logs in through.
opts.AlwaysOnTop = false
opts.InitialPosition = application.WindowCentered
// Open on the active (where users cursor is) display, like the session-expiration dialog.
opts.Screen = s.getScreenBasedOnCursorPosition()
s.browserLogin = s.app.Window.NewWithOptions(opts)
bl := s.browserLogin
bl.OnWindowEvent(events.Common.WindowClosing, func(_ *application.WindowEvent) {
s.mu.Lock()
// Only a live user red-X still has this registered; programmatic closers
// nil s.browserLogin first and clean up themselves. Guarding here stops a
// stale close event from wiping a replacement popup's state.
userClosed := s.browserLogin == bl
if userClosed {
s.browserLogin = nil
s.restoreHiddenWindowsLocked()
}
s.mu.Unlock()
if userClosed {
s.app.Event.Emit(EventBrowserLoginCancel)
}
})
s.centerOnCursorScreen(s.browserLogin)
return
}
startURL := "/#/dialog/browser-login"
if uri != "" {
s.browserLogin.SetURL("/#/dialog/browser-login?uri=" + url.QueryEscape(uri))
startURL = "/#/dialog/browser-login?uri=" + url.QueryEscape(uri)
}
s.centerOnCursorScreen(s.browserLogin)
s.browserLogin.Show()
s.browserLogin.Focus()
s.withWindow(windowBrowserLogin, &s.browserLogin, func() *application.WebviewWindow {
return s.newBrowserLoginWindow(startURL)
}, func(w *application.WebviewWindow, created bool) {
if created {
s.centerOnCursorScreen(w)
return
}
if uri != "" {
w.SetURL(startURL)
}
s.centerOnCursorScreen(w)
w.Show()
w.Focus()
})
}
func (s *WindowManager) newBrowserLoginWindow(startURL string) *application.WebviewWindow {
s.hideOtherWindows(windowBrowserLogin)
opts := DialogWindowOptions(windowBrowserLogin, s.title("window.title.signIn"), startURL, s.linuxIcon)
// Not always-on-top: it would obscure the browser tab the user logs in through.
opts.AlwaysOnTop = false
opts.InitialPosition = application.WindowCentered
// Open on the active (where users cursor is) display, like the session-expiration dialog.
opts.Screen = s.getScreenBasedOnCursorPosition()
w := s.app.Window.NewWithOptions(opts)
w.OnWindowEvent(events.Common.WindowClosing, func(_ *application.WindowEvent) {
s.mu.Lock()
// Only a live user red-X still has this registered; programmatic closers
// nil s.browserLogin first and clean up themselves. Guarding here stops a
// stale close event from wiping a replacement popup's state.
userClosed := s.browserLogin == w
if userClosed {
s.browserLogin = nil
}
s.mu.Unlock()
if userClosed {
s.restoreHiddenWindows()
s.app.Event.Emit(EventBrowserLoginCancel)
}
})
return w
}
// BrowserLoginWindow returns the live SSO popup, or nil. While non-nil it is the
@@ -276,48 +297,55 @@ func (s *WindowManager) CloseBrowserLogin() {
s.mu.Lock()
w := s.browserLogin
s.browserLogin = nil
s.mu.Unlock()
// The WindowClosing hook no-ops on a programmatic close, so restore here —
// but only if a popup was actually open. The frontend calls this even when no
// popup was ever shown (e.g. resetDialog() after an early RequestExtend failure,
// or connection.ts's catch path), and hiddenForLogin is shared with
// OpenInstallProgress, so an unconditional restore could re-show windows a
// still-running install-progress is hiding.
if w != nil {
s.restoreHiddenWindowsLocked()
}
s.mu.Unlock()
if w != nil {
w.Close()
if w == nil {
return
}
s.restoreHiddenWindows()
w.Close()
}
// OpenSessionExpiration shows the countdown warning on the cursor's display; seconds seeds
// the countdown and deadlineUnixMilli (0 when unknown) is the absolute deadline the dialog
// compares renewal snapshots against. Singleton, destroyed on close.
func (s *WindowManager) OpenSessionExpiration(seconds int, deadlineUnixMilli int64) {
s.mu.Lock()
defer s.mu.Unlock()
startURL := "/#/dialog/session-expiration?seconds=" + strconv.Itoa(seconds)
if deadlineUnixMilli > 0 {
startURL += "&deadline=" + strconv.FormatInt(deadlineUnixMilli, 10)
}
if s.sessionExpiration == nil {
opts := DialogWindowOptions("session-expiration", s.title("window.title.sessionExpiration"), startURL, s.linuxIcon)
opts.Screen = s.getScreenBasedOnCursorPosition()
opts.InitialPosition = application.WindowCentered
s.sessionExpiration = s.app.Window.NewWithOptions(opts)
s.sessionExpiration.OnWindowEvent(events.Common.WindowClosing, func(_ *application.WindowEvent) {
s.mu.Lock()
s.withWindow(windowSessionExpiration, &s.sessionExpiration, func() *application.WebviewWindow {
return s.newSessionExpirationWindow(startURL)
}, func(w *application.WebviewWindow, created bool) {
if created {
s.centerOnCursorScreen(w)
return
}
w.SetURL(startURL)
s.centerOnCursorScreen(w)
w.Show()
w.Focus()
})
}
func (s *WindowManager) newSessionExpirationWindow(startURL string) *application.WebviewWindow {
opts := DialogWindowOptions(windowSessionExpiration, s.title("window.title.sessionExpiration"), startURL, s.linuxIcon)
opts.Screen = s.getScreenBasedOnCursorPosition()
opts.InitialPosition = application.WindowCentered
w := s.app.Window.NewWithOptions(opts)
w.OnWindowEvent(events.Common.WindowClosing, func(_ *application.WindowEvent) {
s.mu.Lock()
if s.sessionExpiration == w {
s.sessionExpiration = nil
s.mu.Unlock()
})
s.centerOnCursorScreen(s.sessionExpiration)
return
}
s.sessionExpiration.SetURL(startURL)
s.centerOnCursorScreen(s.sessionExpiration)
s.sessionExpiration.Show()
s.sessionExpiration.Focus()
}
s.mu.Unlock()
})
return w
}
func (s *WindowManager) CloseSessionExpiration() {
@@ -347,9 +375,9 @@ func (s *WindowManager) CloseRenewFlow() {
}
s.hiddenForLogin = kept
}
s.restoreHiddenWindowsLocked()
s.mu.Unlock()
s.restoreHiddenWindows()
// Close after unlock so the re-entrant handlers can take s.mu.
if bl != nil {
bl.Close()
@@ -362,30 +390,36 @@ func (s *WindowManager) CloseRenewFlow() {
// OpenInstallProgress shows the install-progress window and hides the rest for the duration
// (restored on close). It owns its own result polling since the daemon restarts mid-install.
func (s *WindowManager) OpenInstallProgress(version string) {
s.mu.Lock()
defer s.mu.Unlock()
startURL := "/#/dialog/install-progress"
if version != "" {
startURL = "/#/dialog/install-progress?version=" + url.QueryEscape(version)
}
if s.installProgress == nil {
s.hideOtherWindowsLocked("install-progress")
s.installProgress = s.app.Window.NewWithOptions(
DialogWindowOptions("install-progress", s.title("window.title.updating"), startURL, s.linuxIcon),
)
s.installProgress.OnWindowEvent(events.Common.WindowClosing, func(_ *application.WindowEvent) {
s.mu.Lock()
s.withWindow(windowInstallProgress, &s.installProgress, func() *application.WebviewWindow {
return s.newInstallProgressWindow(startURL)
}, func(w *application.WebviewWindow, created bool) {
if !created {
w.SetURL(startURL)
w.Show()
w.Focus()
}
s.centerWhenReady(w)
})
}
func (s *WindowManager) newInstallProgressWindow(startURL string) *application.WebviewWindow {
s.hideOtherWindows(windowInstallProgress)
w := s.app.Window.NewWithOptions(
DialogWindowOptions(windowInstallProgress, s.title("window.title.updating"), startURL, s.linuxIcon),
)
w.OnWindowEvent(events.Common.WindowClosing, func(_ *application.WindowEvent) {
s.mu.Lock()
if s.installProgress == w {
s.installProgress = nil
s.restoreHiddenWindowsLocked()
s.mu.Unlock()
})
s.centerWhenReady(s.installProgress)
return
}
s.installProgress.SetURL(startURL)
s.installProgress.Show()
s.installProgress.Focus()
s.centerWhenReady(s.installProgress)
}
s.mu.Unlock()
s.restoreHiddenWindows()
})
return w
}
func (s *WindowManager) CloseInstallProgress() {
@@ -400,25 +434,28 @@ func (s *WindowManager) CloseInstallProgress() {
// OpenWelcome shows the first-launch onboarding window. Singleton, destroyed on close.
func (s *WindowManager) OpenWelcome() {
s.mu.Lock()
defer s.mu.Unlock()
if s.welcome == nil {
opts := DialogWindowOptions("welcome", s.title("window.title.welcome"), "/#/dialog/welcome", s.linuxIcon)
opts.Width = 420
opts.InitialPosition = application.WindowCentered
s.welcome = s.app.Window.NewWithOptions(opts)
w := s.welcome
w.OnWindowEvent(events.Common.WindowClosing, func(_ *application.WindowEvent) {
s.mu.Lock()
s.withWindow(windowWelcome, &s.welcome, s.newWelcomeWindow, func(w *application.WebviewWindow, created bool) {
if !created {
w.Show()
w.Focus()
}
s.centerWhenReady(w)
})
}
func (s *WindowManager) newWelcomeWindow() *application.WebviewWindow {
opts := DialogWindowOptions(windowWelcome, s.title("window.title.welcome"), "/#/dialog/welcome", s.linuxIcon)
opts.Width = 420
opts.InitialPosition = application.WindowCentered
w := s.app.Window.NewWithOptions(opts)
w.OnWindowEvent(events.Common.WindowClosing, func(_ *application.WindowEvent) {
s.mu.Lock()
if s.welcome == w {
s.welcome = nil
s.mu.Unlock()
})
s.centerWhenReady(s.welcome)
return
}
s.welcome.Show()
s.welcome.Focus()
s.centerWhenReady(s.welcome)
}
s.mu.Unlock()
})
return w
}
func (s *WindowManager) CloseWelcome() {
@@ -439,25 +476,31 @@ func (s *WindowManager) OpenError(title, message, command string) {
if ShuttingDown() {
return
}
s.mu.Lock()
defer s.mu.Unlock()
startURL := errorDialogURL(title, message, command)
if s.errorDialog == nil {
s.errorDialog = s.app.Window.NewWithOptions(
DialogWindowOptions("error", s.title("window.title.error"), startURL, s.linuxIcon),
)
s.errorDialog.OnWindowEvent(events.Common.WindowClosing, func(_ *application.WindowEvent) {
s.mu.Lock()
s.withWindow(windowError, &s.errorDialog, func() *application.WebviewWindow {
return s.newErrorWindow(startURL)
}, func(w *application.WebviewWindow, created bool) {
if !created {
w.SetURL(startURL)
w.Show()
w.Focus()
}
s.centerWhenReady(w)
})
}
func (s *WindowManager) newErrorWindow(startURL string) *application.WebviewWindow {
w := s.app.Window.NewWithOptions(
DialogWindowOptions(windowError, s.title("window.title.error"), startURL, s.linuxIcon),
)
w.OnWindowEvent(events.Common.WindowClosing, func(_ *application.WindowEvent) {
s.mu.Lock()
if s.errorDialog == w {
s.errorDialog = nil
s.mu.Unlock()
})
s.centerWhenReady(s.errorDialog)
return
}
s.errorDialog.SetURL(startURL)
s.errorDialog.Show()
s.errorDialog.Focus()
s.centerWhenReady(s.errorDialog)
}
s.mu.Unlock()
})
return w
}
func (s *WindowManager) CloseError() {
@@ -478,65 +521,85 @@ func (s *WindowManager) OpenMain() {
// ShowMain brings the main window forward (re-centering on minimal WMs). The single entry
// point every surface (tray, SIGUSR1, welcome) should use so centering applies uniformly.
func (s *WindowManager) ShowMain() {
s.showWhenReady(s.MainWindow())
s.ensureMain("/", func(w *application.WebviewWindow, _ bool) {
s.showWhenReady(w)
})
}
// ShowMainAndEmit brings the main window forward and emits event once its frontend is ready.
func (s *WindowManager) ShowMainAndEmit(event string) {
w := s.MainWindow()
if w == nil {
return
}
s.ensureMain("/", func(w *application.WebviewWindow, _ bool) {
id := w.ID()
s.mu.Lock()
ready := s.ready[id]
if !ready {
s.pendingEmits[id] = append(s.pendingEmits[id], event)
}
s.mu.Unlock()
id := w.ID()
s.mu.Lock()
ready := s.ready[id]
if !ready {
s.pendingEmits[id] = append(s.pendingEmits[id], event)
}
s.mu.Unlock()
s.showWhenReady(w)
if ready {
s.app.Event.Emit(event)
}
s.showWhenReady(w)
if ready {
s.app.Event.Emit(event)
}
})
}
func (s *WindowManager) MainWindow() *application.WebviewWindow {
w, _ := s.ensureMain("/")
return w
s.mu.Lock()
defer s.mu.Unlock()
return s.mainWindow
}
func (s *WindowManager) ensureMain(startURL string) (*application.WebviewWindow, bool) {
func (s *WindowManager) ensureMain(startURL string, op windowOp) {
s.mu.Lock()
factory := s.newMain
s.mu.Unlock()
if factory == nil {
return s.ensureWindow(&s.mainWindow, nil)
s.withWindow(windowMain, &s.mainWindow, nil, op)
return
}
return s.ensureWindow(&s.mainWindow, func() *application.WebviewWindow {
return factory(startURL)
})
s.withWindow(windowMain, &s.mainWindow, func() *application.WebviewWindow {
w := factory(startURL)
s.armReady(w)
return w
}, op)
}
func (s *WindowManager) ensureWindow(slot **application.WebviewWindow, factory func() *application.WebviewWindow) (*application.WebviewWindow, bool) {
s.createMu.Lock()
defer s.createMu.Unlock()
func (s *WindowManager) withWindow(name string, slot **application.WebviewWindow, factory func() *application.WebviewWindow, op windowOp) {
s.mu.Lock()
w := *slot
s.mu.Unlock()
if w != nil || factory == nil {
return w, false
if w := *slot; w != nil {
s.mu.Unlock()
op(w, false)
return
}
if factory == nil {
s.mu.Unlock()
return
}
if s.creating[name] {
s.pendingOps[name] = append(s.pendingOps[name], op)
s.mu.Unlock()
return
}
s.creating[name] = true
s.mu.Unlock()
w = factory()
s.armReady(w)
w := factory()
s.mu.Lock()
*slot = w
delete(s.creating, name)
queued := s.pendingOps[name]
delete(s.pendingOps, name)
s.mu.Unlock()
return w, true
if w == nil {
return
}
op(w, true)
for _, queuedOp := range queued {
queuedOp(w, false)
}
}
func (s *WindowManager) armReady(w *application.WebviewWindow) {
@@ -576,24 +639,21 @@ func (s *WindowManager) watchTriggerLogin() {
return
}
w, created := s.ensureMain("/")
if w == nil {
return
}
s.ensureMain("/", func(w *application.WebviewWindow, created bool) {
s.mu.Lock()
if created {
s.headlessMain = true
}
pending := !s.ready[w.ID()]
if pending {
s.pendingEmits[w.ID()] = append(s.pendingEmits[w.ID()], EventTriggerLogin)
}
s.mu.Unlock()
s.mu.Lock()
if created {
s.headlessMain = true
}
pending := !s.ready[w.ID()]
if pending {
s.pendingEmits[w.ID()] = append(s.pendingEmits[w.ID()], EventTriggerLogin)
}
s.mu.Unlock()
if !pending {
s.app.Event.Emit(EventTriggerLogin)
}
if !pending {
s.app.Event.Emit(EventTriggerLogin)
}
})
})
s.app.Event.On(EventBrowserLoginCancel, func(_ *application.CustomEvent) {
@@ -664,9 +724,9 @@ func (s *WindowManager) windowByName(name string) *application.WebviewWindow {
s.mu.Lock()
defer s.mu.Unlock()
switch name {
case "main":
case windowMain:
return s.mainWindow
case "settings":
case windowSettings:
return s.settings
default:
return nil
@@ -741,14 +801,12 @@ func (s *WindowManager) showNow(w *application.WebviewWindow) {
}
func (s *WindowManager) ShowMainAt(url string) {
w, created := s.ensureMain(url)
if w == nil {
return
}
if !created {
w.SetURL(url)
}
s.showWhenReady(w)
s.ensureMain(url, func(w *application.WebviewWindow, created bool) {
if !created {
w.SetURL(url)
}
s.showWhenReady(w)
})
}
func (s *WindowManager) SetMainFactory(f func(startURL string) *application.WebviewWindow) {
@@ -868,39 +926,46 @@ func (s *WindowManager) retitleAll() {
}
}
// hideOtherWindowsLocked hides every visible window except keepName, recording
// them in hiddenForLogin for restoreHiddenWindowsLocked. Caller must hold s.mu.
func (s *WindowManager) hideOtherWindowsLocked(keepName string) {
func (s *WindowManager) hideOtherWindows(keepName string) {
var hidden []application.Window
for _, w := range s.app.Window.GetAll() {
if w == nil || w.Name() == keepName {
continue
}
if !w.IsVisible() {
if w == nil || w.Name() == keepName || !w.IsVisible() {
continue
}
w.Hide()
s.hiddenForLogin = append(s.hiddenForLogin, w)
hidden = append(hidden, w)
}
if len(hidden) == 0 {
return
}
s.mu.Lock()
s.hiddenForLogin = append(s.hiddenForLogin, hidden...)
s.mu.Unlock()
}
// restoreHiddenWindowsLocked re-shows windows hidden by hideOtherWindowsLocked
// (caller holds s.mu). If the main window was among them, raiseToForeground
// lifts it above the SSO browser, which still owns the foreground — a plain
// Show/Focus would be demoted to a taskbar flash and leave it stranded behind.
func (s *WindowManager) restoreHiddenWindowsLocked() {
// restoreHiddenWindows re-shows windows hidden by hideOtherWindows. If the main
// window was among them, raiseToForeground lifts it above the SSO browser, which
// still owns the foreground — a plain Show/Focus would be demoted to a taskbar
// flash and leave it stranded behind.
func (s *WindowManager) restoreHiddenWindows() {
s.mu.Lock()
hidden := s.hiddenForLogin
s.hiddenForLogin = nil
mainWindow := s.mainWindow
s.mu.Unlock()
mainRestored := false
for _, w := range s.hiddenForLogin {
for _, w := range hidden {
if w == nil {
continue
}
w.Show()
if w == s.mainWindow {
if w == mainWindow {
mainRestored = true
}
}
s.hiddenForLogin = nil
if mainRestored && s.mainWindow != nil {
raiseToForeground(s.mainWindow)
if mainRestored && mainWindow != nil {
raiseToForeground(mainWindow)
}
}
@@ -915,8 +980,11 @@ func (s *WindowManager) getScreenBasedOnCursorPosition() *application.Screen {
return sc
}
}
if s.mainWindow != nil {
if sc, err := s.mainWindow.GetScreen(); err == nil {
s.mu.Lock()
mainWindow := s.mainWindow
s.mu.Unlock()
if mainWindow != nil {
if sc, err := mainWindow.GetScreen(); err == nil {
return sc
}
}
+127
View File
@@ -0,0 +1,127 @@
//go:build !android && !ios && !freebsd && !js
package services
import (
"fmt"
"sync/atomic"
"testing"
"time"
"github.com/stretchr/testify/require"
"github.com/wailsapp/wails/v3/pkg/application"
)
func newTestWindowManager() *WindowManager {
return &WindowManager{
creating: map[string]bool{},
pendingOps: map[string][]windowOp{},
}
}
func waitDone(t *testing.T, done <-chan struct{}, msg string) {
t.Helper()
select {
case <-done:
case <-time.After(5 * time.Second):
t.Fatal(msg)
}
}
func TestWithWindowReusesExistingWindow(t *testing.T) {
s := newTestWindowManager()
existing := &application.WebviewWindow{}
slot := existing
factoryCalls := 0
var got *application.WebviewWindow
created := true
s.withWindow(windowMain, &slot, func() *application.WebviewWindow {
factoryCalls++
return &application.WebviewWindow{}
}, func(w *application.WebviewWindow, c bool) {
got, created = w, c
})
require.Equal(t, 0, factoryCalls)
require.Same(t, existing, got)
require.False(t, created)
}
func TestWithWindowNilFactoryWithoutWindowSkipsOp(t *testing.T) {
s := newTestWindowManager()
var slot *application.WebviewWindow
opCalls := 0
s.withWindow(windowMain, &slot, nil, func(*application.WebviewWindow, bool) {
opCalls++
})
require.Equal(t, 0, opCalls)
require.Nil(t, slot)
}
func TestWithWindowReentrantCallDuringCreationIsQueued(t *testing.T) {
s := newTestWindowManager()
var slot *application.WebviewWindow
factoryCalls := 0
var order []string
var factory func() *application.WebviewWindow
factory = func() *application.WebviewWindow {
factoryCalls++
// Simulates the Windows message pump re-entering the tray click handler
// while WebView2 is still initialising the window being created.
s.withWindow(windowMain, &slot, factory, func(_ *application.WebviewWindow, created bool) {
order = append(order, fmt.Sprintf("reentrant:%v", created))
})
return &application.WebviewWindow{}
}
done := make(chan struct{})
go func() {
defer close(done)
s.withWindow(windowMain, &slot, factory, func(_ *application.WebviewWindow, created bool) {
order = append(order, fmt.Sprintf("outer:%v", created))
})
}()
waitDone(t, done, "withWindow deadlocked on a re-entrant call during creation")
require.Equal(t, 1, factoryCalls)
require.Equal(t, []string{"outer:true", "reentrant:false"}, order)
require.NotNil(t, slot)
require.Empty(t, s.creating)
require.Empty(t, s.pendingOps)
}
func TestWithWindowConcurrentCallersShareOneCreation(t *testing.T) {
s := newTestWindowManager()
var slot *application.WebviewWindow
factoryEntered := make(chan struct{})
release := make(chan struct{})
var factoryCalls, opCalls atomic.Int32
factory := func() *application.WebviewWindow {
factoryCalls.Add(1)
close(factoryEntered)
<-release
return &application.WebviewWindow{}
}
op := func(*application.WebviewWindow, bool) { opCalls.Add(1) }
first := make(chan struct{})
go func() {
defer close(first)
s.withWindow(windowSettings, &slot, factory, op)
}()
<-factoryEntered
second := make(chan struct{})
go func() {
defer close(second)
s.withWindow(windowSettings, &slot, factory, op)
}()
waitDone(t, second, "second caller blocked while the window was being created")
require.Equal(t, int32(0), opCalls.Load())
close(release)
waitDone(t, first, "creator did not finish")
require.Equal(t, int32(1), factoryCalls.Load())
require.Equal(t, int32(2), opCalls.Load())
require.NotNil(t, slot)
}
+1 -1
View File
@@ -4,5 +4,5 @@ package main
// Open application window on left click, right click opens the tray menu
func bindTrayClick(t *Tray) {
t.tray.OnClick(func() { t.ShowWindow() })
t.tray.OnClick(func() { go t.ShowWindow() })
}