From 0b3ba7fbe7ba861b21612dee35e81be549b4ed46 Mon Sep 17 00:00:00 2001 From: Angus Kola Date: Sat, 7 Nov 2026 16:49:25 -0500 Subject: [PATCH] Fix clipboard history lock recovery and merged selection --- internal/clipboard/persistence_test.go | 54 ++++++++++++++++++++++++++ internal/clipboard/shared_history.go | 11 +++++- 2 files changed, 63 insertions(+), 2 deletions(-) diff --git a/internal/clipboard/persistence_test.go b/internal/clipboard/persistence_test.go index fce509e8..e26d8397 100644 --- a/internal/clipboard/persistence_test.go +++ b/internal/clipboard/persistence_test.go @@ -225,3 +225,57 @@ func TestClearRemovesTemporaryHistoryLeftByInterruptedWrite(t *testing.T) { t.Fatal("clear removed another history's file", err) } } + +func TestPersistenceRetriesAfterHistoryLockTimeout(t *testing.T) { + path := filepath.Join(t.TempDir(), "private", "terminal.json") + lock, err := acquire_history(path, true) + if err != nil { + t.Fatal(err) + } + defer lock.Close() + if err := save_history(path, Snapshot{Version: 1, Entries: []string{"shared"}}); err != nil { + t.Fatal(err) + } + store := New_persistence(path, true) + defer store.Close() + if !errors.Is(store.LoadErr, syscall.EWOULDBLOCK) { + t.Fatalf("expected startup lock contention, got %v", store.LoadErr) + } + select { + case err := <-store.Errors(): + if !errors.Is(err, syscall.EWOULDBLOCK) { + t.Fatal(err) + } + case <-time.After(4 * time.Second): + t.Fatal("refresh did not time out on held lock") + } + if err := lock.Close(); err != nil { + t.Fatal(err) + } + select { + case update := <-store.Updates(): + if !reflect.DeepEqual(update.State.Entries, []string{"shared"}) { + t.Fatal(update) + } + case <-time.After(3 * time.Second): + t.Fatal("history did not recover after lock release") + } + if err := store.Close(); err != nil { + t.Fatalf("recovered lock failure persisted at close: %v", err) + } +} + +func TestEnableMergePreservesSavedSelection(t *testing.T) { + path := filepath.Join(t.TempDir(), "private", "terminal.json") + state := Snapshot{Version: 1, Entries: []string{"newer", "selected"}, Selected: 1} + if err := save_history(path, state); err != nil { + t.Fatal(err) + } + update, err := apply_history(path, historyJob{action: "enable", state: Snapshot{Version: 1, Entries: []string{"retained"}}, limit: 10}) + if err != nil { + t.Fatal(err) + } + if got := update.State.Entries[update.State.Selected]; got != "selected" { + t.Fatalf("saved selection moved to %q", got) + } +} diff --git a/internal/clipboard/shared_history.go b/internal/clipboard/shared_history.go index 67d663f0..88671fdb 100644 --- a/internal/clipboard/shared_history.go +++ b/internal/clipboard/shared_history.go @@ -134,6 +134,7 @@ func (p *Persistence) run(path string, watching bool) { defer tick.Stop() last := HistoryUpdate{State: p.Initial, Enabled: p.InitialEnabled} var signature string + var retryLock bool var acknowledged uint64 for { select { @@ -167,7 +168,7 @@ func (p *Persistence) run(path string, watching bool) { continue } nextSignature := history_signature(path) - if nextSignature == signature { + if nextSignature == signature && !retryLock { continue } file, err := acquire_history(path, false) @@ -182,16 +183,21 @@ func (p *Persistence) run(path string, watching bool) { } } if err != nil { - // Report a stable failure once, and retry after a file changes. + // Report a stable failure once. Lock contention can clear without + // changing either file, so it must also be retried on the next tick. if signature != nextSignature { p.report(err) } } else { + if retryLock { + p.report(nil) + } if !reflect.DeepEqual(update, last) { p.publish(update) last = update } } + retryLock = errors.Is(err, syscall.EWOULDBLOCK) signature = nextSignature } } @@ -294,6 +300,7 @@ func apply_history(path string, job historyJob) (HistoryUpdate, error) { } if !found { state.Entries = append([]string{text}, state.Entries...) + state.Selected++ } } }