diff options
| author | Paul Buetow <paul@buetow.org> | 2026-04-06 11:16:19 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-04-06 11:16:19 +0300 |
| commit | aa9444a75c22e381e94ef4988d42e44af5c51016 (patch) | |
| tree | 8fadbb04f6a168e7cac972a7b7792198da5ccdb1 /internal | |
| parent | 616beecc41b573503dad9f5bfd9f353c6f826a8a (diff) | |
fix: track all GUI goroutines with WaitGroup and ctx.Done() (Go Mistake #62)
Fire-and-forget goroutines in the tooltip setup, word-change handler, and
audio playback could write to freed Fyne widgets after the window was closed.
All four patterns are now fixed:
- setupTooltips() and the secondary-toolbar tooltip block: replaced
time.AfterFunc(500ms) with wg-tracked goroutines using select/ctx.Done().
- handleWordChange(): replaced time.AfterFunc(100ms) with the same pattern.
- onWindowClosed(): added wordChangeTimer.Stop() to prevent its AfterFunc
callback from firing after context cancellation.
- AudioPlayer: added ctx context.Context field + SetContext(); the
post-playback goroutine now guards fyne.Do with ctx.Err() == nil, and the
auto-play AfterFunc is replaced with a ctx-aware goroutine.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Diffstat (limited to 'internal')
| -rw-r--r-- | internal/gui/app.go | 54 | ||||
| -rw-r--r-- | internal/gui/audio_player.go | 49 |
2 files changed, 81 insertions, 22 deletions
diff --git a/internal/gui/app.go b/internal/gui/app.go index e1895a4..f7a7d51 100644 --- a/internal/gui/app.go +++ b/internal/gui/app.go @@ -410,7 +410,16 @@ func (a *Application) setupUI() { a.setupTooltips() // Secondary toolbar button tooltips need a short delay to initialise. - time.AfterFunc(500*time.Millisecond, func() { + // Tracked via WaitGroup and respects ctx.Done() so the callback never + // writes to freed widgets after the window is closed (Go Mistake #62). + a.wg.Add(1) + go func() { + defer a.wg.Done() + select { + case <-a.ctx.Done(): + return + case <-time.After(500 * time.Millisecond): + } fyne.Do(func() { if exportButton != nil { exportButton.SetToolTip("Export to Anki (x)") @@ -422,7 +431,7 @@ func (a *Application) setupUI() { helpButton.SetToolTip("Show hotkeys (?)") } }) - }) + }() a.window.SetOnClosed(a.onWindowClosed) a.setupKeyboardShortcuts() @@ -522,6 +531,9 @@ func (a *Application) buildTranslationInput() { func (a *Application) buildDisplaySection() fyne.CanvasObject { a.imageDisplay = NewImageDisplay() a.audioPlayer = NewAudioPlayer() + // Wire the application context into the player so its post-playback goroutine + // can check ctx.Done() before writing to widgets (Go Mistake #62). + a.audioPlayer.SetContext(a.ctx) a.audioPlayer.SetAutoPlayEnabled(&a.autoPlayEnabled) a.imagePromptEntry = NewCustomMultiLineEntry() @@ -598,6 +610,11 @@ func (a *Application) onWindowClosed() { if a.fileCheckTicker != nil { a.fileCheckTicker.Stop() } + // Stop the word-change debounce timer so its callback cannot fire + // after the context is cancelled and widgets are freed. + if a.wordChangeTimer != nil { + a.wordChangeTimer.Stop() + } if a.logViewer != nil { a.logViewer.StopCapture() } @@ -1834,9 +1851,18 @@ func (a *Application) clearUI() { } // setupTooltips sets up all tooltips after the tooltip layer has been created. -// AfterFunc fires after the tooltip layer is initialized without blocking a goroutine. +// A tracked goroutine waits 500 ms and then sets tooltips on the main thread. +// WaitGroup tracking and ctx.Done() ensure the callback never writes to freed +// widgets after the window is closed (Go Mistake #62). func (a *Application) setupTooltips() { - time.AfterFunc(500*time.Millisecond, func() { + a.wg.Add(1) + go func() { + defer a.wg.Done() + select { + case <-a.ctx.Done(): + return + case <-time.After(500 * time.Millisecond): + } fyne.Do(func() { // Navigation button tooltips if a.submitButton != nil { @@ -1869,8 +1895,8 @@ func (a *Application) setupTooltips() { a.deleteButton.SetToolTip("Delete word (d)") } - // Export and help button tooltips need to be set after creation - // They are set in the main window setup + // Export and help button tooltips are set in the main window setup + // goroutine (see setupUI) to avoid a double 500 ms wait here. // Audio player tooltips if a.audioPlayer != nil && a.audioPlayer.playButton != nil { @@ -1883,7 +1909,7 @@ func (a *Application) setupTooltips() { a.audioPlayer.stopButton.SetToolTip("Stop audio") } }) - }) + }() } // processNextInQueue processes the next word in the queue @@ -2586,11 +2612,19 @@ func (a *Application) handleWordChange(oldWord, newWord string) { a.updateStatus(fmt.Sprintf("Word changed from '%s' to '%s' - regenerating image...", oldWord, newWord)) }) - // Small delay to ensure UI updates - time.AfterFunc(100*time.Millisecond, func() { + // Small delay to ensure UI updates. Tracked via WaitGroup and respects + // ctx.Done() to prevent writing to freed widgets on shutdown (Go Mistake #62). + a.wg.Add(1) + go func() { + defer a.wg.Done() + select { + case <-a.ctx.Done(): + return + case <-time.After(100 * time.Millisecond): + } fyne.Do(func() { a.onRegenerateImage() }) - }) + }() } } diff --git a/internal/gui/audio_player.go b/internal/gui/audio_player.go index ddd67d6..bc58bbe 100644 --- a/internal/gui/audio_player.go +++ b/internal/gui/audio_player.go @@ -1,6 +1,7 @@ package gui import ( + "context" "errors" "fmt" "os" @@ -35,8 +36,9 @@ type AudioPlayer struct { isBgBg bool // Track if this is a bg-bg card isPlaying bool playCmd *exec.Cmd - voiceInfo string // Stores voice and speed info - autoPlayEnabled *bool // Pointer to parent's auto-play state + voiceInfo string // Stores voice and speed info + autoPlayEnabled *bool // Pointer to parent's auto-play state + ctx context.Context // Application context; guards post-playback UI updates } type audioCommandCandidate struct { @@ -44,9 +46,14 @@ type audioCommandCandidate struct { args []string } -// NewAudioPlayer creates a new audio player widget +// NewAudioPlayer creates a new audio player widget. Call SetContext before use +// so that post-playback UI updates are guarded against the app shutting down. func NewAudioPlayer() *AudioPlayer { - p := &AudioPlayer{} + p := &AudioPlayer{ + // Default to a background context so the player is usable even if + // SetContext is never called (e.g. in unit tests). + ctx: context.Background(), + } // Create controls (tooltips will be set later after tooltip layer is created) p.playButton = ttwidget.NewButton("", p.onPlay) @@ -95,6 +102,14 @@ func NewAudioPlayer() *AudioPlayer { return p } +// SetContext wires the application lifecycle context into the player. +// The post-playback UI update goroutine checks this context before calling +// fyne.Do, preventing writes to freed widgets after the window is closed +// (Go Mistake #62). Must be called before the first playback attempt. +func (p *AudioPlayer) SetContext(ctx context.Context) { + p.ctx = ctx +} + // CreateRenderer implements fyne.Widget func (p *AudioPlayer) CreateRenderer() fyne.WidgetRenderer { return widget.NewSimpleRenderer(p.container) @@ -154,12 +169,19 @@ func (p *AudioPlayer) setAudioFileInternal(audioFile string, allowAutoPlay bool) statusText := fmt.Sprintf("Audio: %s%s", filepath.Base(audioFile), p.voiceInfo) p.statusLabel.SetText(statusText) - // Auto-play if enabled and allowed. AfterFunc fires the callback after - // the UI has had a chance to render without blocking a goroutine. + // Auto-play if enabled and allowed. A short goroutine gives the UI a + // chance to render and checks ctx.Done() so it won't write to freed + // widgets if the window is closed before the delay expires (Go Mistake #62). if allowAutoPlay && p.autoPlayEnabled != nil && *p.autoPlayEnabled { - time.AfterFunc(100*time.Millisecond, func() { + ctx := p.ctx + go func() { + select { + case <-ctx.Done(): + return + case <-time.After(100 * time.Millisecond): + } fyne.Do(p.onPlay) - }) + }() } } else { p.Clear() @@ -334,13 +356,16 @@ func (p *AudioPlayer) startPlaybackForFile(audioFile string) error { // Store the command so we can stop it later p.playCmd = cmd - // Start playback in background - // Capture whether this is playing back audio or front audio for proper icon reset + // Start playback in background. + // Capture which audio track is playing for correct icon reset on completion. + // ctx.Done() is checked before fyne.Do so we never write to freed widgets + // after the application window has been closed (Go Mistake #62). isPlayingBack := audioFile == p.audioFileBack + ctx := p.ctx go func() { err := cmd.Run() - if err == nil { - // Playback finished normally + if err == nil && ctx.Err() == nil { + // Playback finished normally and the app is still alive. fyne.Do(func() { p.isPlaying = false // Reset correct button icon based on which audio was playing |
