diff options
| author | Lukasz Kasprzak <lukas@labunix.xyz> | 2026-09-17 14:11:52 +0200 |
|---|---|---|
| committer | Lukasz Kasprzak <lukas@labunix.xyz> | 2026-09-17 14:11:52 +0200 |
| commit | ed86f44a926fd1f0d438cbe5e2e10ad5b063db55 (patch) | |
| tree | a0a4c53a9ed5f207b303623e4dfd3cac16ed302a /gui/internal/ui | |
| parent | 4c6fadfab5434317357ad0272ace7927d2945942 (diff) | |
| download | krino-ed86f44a926fd1f0d438cbe5e2e10ad5b063db55.tar.gz krino-ed86f44a926fd1f0d438cbe5e2e10ad5b063db55.zip | |
the window cannot pull the lock out from under a running apply
Close releases the directory lock and closes the log. The window could
reach it while an apply was still running - saving rules, saving
settings, adding a directory, or closing the window - and the engine
then went on moving files with the log shut underneath: a file moved
that no krino undo can see, the rest of the plan silently abandoned,
and the directory unlocked while krino was still working in it.
PlanTab and UndoTab refuse to close while their apply is in flight
(model.ErrApplying), the window's close request and reloadEngine honour
the refusal instead of ignoring it, and the tabs and Settings are greyed
out for the duration so a button that cannot work says so by being
unavailable rather than by an error afterwards.
The test starts an apply, calls Close from another goroutine while it
is in flight, and requires the refusal.
Diffstat (limited to 'gui/internal/ui')
| -rw-r--r-- | gui/internal/ui/history.go | 20 | ||||
| -rw-r--r-- | gui/internal/ui/plan.go | 20 | ||||
| -rw-r--r-- | gui/internal/ui/window.go | 48 |
3 files changed, 80 insertions, 8 deletions
diff --git a/gui/internal/ui/history.go b/gui/internal/ui/history.go index f82f6e3..3a3166e 100644 --- a/gui/internal/ui/history.go +++ b/gui/internal/ui/history.go @@ -4,6 +4,7 @@ package ui import ( "context" + "errors" "fmt" "strings" @@ -36,6 +37,10 @@ type historyView struct { undo *gtk.Button cancel *gtk.Button + // applying is set while a reversal is in flight; the window refuses + // anything that would take its lock or its log away. + applying bool + tab *model.UndoTab cancelOp context.CancelFunc } @@ -349,6 +354,8 @@ func (h *historyView) onUndo() { return } n := h.tab.SelectedCount() + h.applying = true + h.w.setApplying(true) h.setBusy(true) h.w.setStatus("reversing %d file(s)...", n) var res *engine.ApplyResult @@ -357,6 +364,8 @@ func (h *historyView) onUndo() { res, err = h.tab.Apply(ctx) return err }, func(err error) { + h.applying = false + h.w.setApplying(false) h.setBusy(false) h.fillPlan() h.undo.SetSensitive(false) @@ -404,6 +413,9 @@ func (h *historyView) updateUndoButton() { } // setBusy turns the buttons on or off around a background operation. +// busyApplying reports whether an undo is in flight. +func (h *historyView) busyApplying() bool { return h.applying } + func (h *historyView) setBusy(busy bool) { h.reload.SetSensitive(!busy) h.more.SetSensitive(!busy && len(h.runRows) >= h.limit) @@ -419,15 +431,19 @@ func (h *historyView) setBusy(busy bool) { } // closeTab drops the open undo plan and releases its locks. -func (h *historyView) closeTab() { +func (h *historyView) closeTab() bool { if h.tab == nil { - return + return true } if err := h.tab.Close(); err != nil { h.w.setStatus("undo: %v", err) + if errors.Is(err, model.ErrApplying) { + return false + } } h.tab = nil h.clearPlan() + return true } // clearList removes every row of a ListBox. diff --git a/gui/internal/ui/plan.go b/gui/internal/ui/plan.go index 1ee2a51..e59d4dc 100644 --- a/gui/internal/ui/plan.go +++ b/gui/internal/ui/plan.go @@ -4,6 +4,7 @@ package ui import ( "context" + "errors" "fmt" "os" "path/filepath" @@ -63,6 +64,7 @@ type planView struct { previewOff bool sortFollowsPrefs bool startSelected bool + applying bool menu *gtk.Popover keep *gtk.Button menuRow int @@ -555,6 +557,8 @@ func (p *planView) onApply() { return } n := p.tab.SelectedCount() + p.applying = true + p.w.setApplying(true) p.setBusy(true) p.w.setStatus("applying %d file(s)...", n) var res *engine.ApplyResult @@ -563,6 +567,8 @@ func (p *planView) onApply() { res, err = p.tab.Apply(ctx) return err }, func(err error) { + p.applying = false + p.w.setApplying(false) p.setBusy(false) p.apply.SetSensitive(false) p.fillList() @@ -576,17 +582,27 @@ func (p *planView) onApply() { } // closeTab drops the open plan and releases the directory's lock. -func (p *planView) closeTab() { +func (p *planView) closeTab() bool { if p.tab == nil { - return + return true } if err := p.tab.Close(); err != nil { p.w.setStatus("%s: %v", escape(p.tab.Dir.Name), err) + if errors.Is(err, model.ErrApplying) { + // The lock and the log must stay put until the apply is done, + // or files move with nothing recording them. + return false + } } p.tab = nil p.fillList() + return true } +// busyApplying reports whether an apply is in flight, so the window can +// refuse anything that would take the lock or the log away from it. +func (p *planView) busyApplying() bool { return p.applying } + // setBusy turns the buttons on or off around a background operation. func (p *planView) setBusy(busy bool) { p.scan.SetSensitive(!busy) diff --git a/gui/internal/ui/window.go b/gui/internal/ui/window.go index 3d40441..a8a087e 100644 --- a/gui/internal/ui/window.go +++ b/gui/internal/ui/window.go @@ -34,6 +34,8 @@ type Window struct { plan *planView history *historyView rules *rulesView + notebook *gtk.Notebook + settings *gtk.Button status *gtk.Label prefs model.Prefs leaving bool @@ -50,6 +52,7 @@ func NewWindow(app *gtk.Application, e *engine.Engine) *Window { w.win.SetDefaultSize(1200, 720) notebook := gtk.NewNotebook() + w.notebook = notebook w.plan = newPlanView(w) notebook.AppendPage(w.plan.root, gtk.NewLabel("Plan")) w.history = newHistoryView(w) @@ -77,6 +80,7 @@ func NewWindow(app *gtk.Application, e *engine.Engine) *Window { settings.SetTooltipText("krino's defaults, and how this window behaves") settings.SetMarginEnd(6) settings.ConnectClicked(func() { w.showSettings() }) + w.settings = settings notebook.SetActionWidget(settings, gtk.PackEnd) w.status = gtk.NewLabel("") @@ -105,9 +109,12 @@ func NewWindow(app *gtk.Application, e *engine.Engine) *Window { w.confirmLeaving() return true } - w.plan.closeTab() + // An apply in flight keeps its lock and its log: closing the + // window under it would move files nothing records. + if !w.plan.closeTab() || !w.history.closeTab() { + return true + } w.plan.closePreview() - w.history.closeTab() return false }) themeColours(w.win) @@ -118,17 +125,50 @@ func NewWindow(app *gtk.Application, e *engine.Engine) *Window { // Show puts the window on screen. func (w *Window) Show() { w.win.Show() } +// setApplying greys out everything that would pull the directory lock or +// the log away from a running apply: the other tabs, and Settings. The +// model refuses those anyway, but a button that cannot work should say so +// by being unavailable rather than by an error afterwards. +func (w *Window) setApplying(busy bool) { + if w.settings != nil { + w.settings.SetSensitive(!busy) + } + if w.notebook == nil { + return + } + current := int(w.notebook.CurrentPage()) + for i := 0; i < int(w.notebook.NPages()); i++ { + if i == current { + continue + } + if page, ok := w.notebook.NthPage(i).(interface{ SetSensitive(bool) }); ok { + page.SetSensitive(!busy) + } + } +} + // reloadEngine re-reads the configuration, after the rules editor saves, so // every tab works from the rules the user just wrote. An open plan came // from the old ones, so it is closed and its lock released. +// +// It refuses while an apply is running. Saving rules, saving settings and +// adding a directory all come through here, and every one of them is +// reachable from the window while files are being moved; closing the plan +// then releases the directory lock and shuts the log under the engine, so a +// file moves that no krino undo can see and the rest of the plan is +// abandoned. func (w *Window) reloadEngine() error { + if w.plan.busyApplying() || w.history.busyApplying() { + return model.ErrApplying + } e, diags := engine.Load(w.engine.MainFile) if len(diags) > 0 { return diags[0] } e.CacheDir = w.engine.CacheDir - w.plan.closeTab() - w.history.closeTab() + if !w.plan.closeTab() || !w.history.closeTab() { + return model.ErrApplying + } w.engine = e return nil } |
