summaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-04-06 11:16:19 +0300
committerPaul Buetow <paul@buetow.org>2026-04-06 11:16:19 +0300
commitaa9444a75c22e381e94ef4988d42e44af5c51016 (patch)
tree8fadbb04f6a168e7cac972a7b7792198da5ccdb1 /internal
parent616beecc41b573503dad9f5bfd9f353c6f826a8a (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.go54
-rw-r--r--internal/gui/audio_player.go49
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