From 4dd74128e44c55a99e23c06e7e741f06323ef59b Mon Sep 17 00:00:00 2001 From: David Schovanec Date: Wed, 25 Feb 2026 21:20:29 +0100 Subject: [PATCH] fix: make meta updates atomic to prevent cursor position races MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Concurrent LoadMeta → modify → SaveMeta cycles could overwrite each other's changes. When captureOutput cleanup saved session state, it could clobber cursor positions written by handleRead, causing reads to restart from position 0. - Add UpdateMeta to OutputStorage interface (atomic read-modify-write under lock) - Implement in MemoryStorage (mutates in-place under lock) and FileStorage (load-mutate-save under lock) - Replace all LoadMeta/SaveMeta pairs in server.go with UpdateMeta calls - Affects: captureOutput cleanup, handleRead, handleReadTUI, handleStop, handleResize --- internal/daemon/server.go | 51 ++++++++++++++++--------------- internal/daemon/storage.go | 1 + internal/daemon/storage_file.go | 12 ++++++++ internal/daemon/storage_memory.go | 12 ++++++++ 4 files changed, 51 insertions(+), 25 deletions(-) diff --git a/internal/daemon/server.go b/internal/daemon/server.go index 6abdf51..8ae29b4 100644 --- a/internal/daemon/server.go +++ b/internal/daemon/server.go @@ -499,11 +499,10 @@ func (s *Server) captureOutput(name string, h *sessionHandle) { now := time.Now() h.stoppedAt = &now - if meta, err := s.storage.LoadMeta(name); err == nil { + s.storage.UpdateMeta(name, func(meta *SessionMeta) { meta.State = StateStopped meta.StoppedAt = &now - s.storage.SaveMeta(name, meta) - } + }) }() buf := make([]byte, ReadBufferSize) @@ -622,15 +621,16 @@ func (s *Server) handleRead(req Request) Response { result = string(output) } - if req.Cursor != "" { - if meta.Cursors == nil { - meta.Cursors = make(map[string]int64) + storage.UpdateMeta(req.Name, func(m *SessionMeta) { + if req.Cursor != "" { + if m.Cursors == nil { + m.Cursors = make(map[string]int64) + } + m.Cursors[req.Cursor] = totalLen + } else { + m.ReadPos = totalLen } - meta.Cursors[req.Cursor] = totalLen - } else { - meta.ReadPos = totalLen - } - storage.SaveMeta(req.Name, meta) + }) default: output, err := storage.ReadAll(req.Name) if err != nil { @@ -682,15 +682,16 @@ func (s *Server) handleReadTUI(req Request, h *sessionHandle, screen *vterm.Scre result = screen.Render() } - if req.Cursor != "" { - if meta.Cursors == nil { - meta.Cursors = make(map[string]int64) + s.storage.UpdateMeta(req.Name, func(m *SessionMeta) { + if req.Cursor != "" { + if m.Cursors == nil { + m.Cursors = make(map[string]int64) + } + m.Cursors[req.Cursor] = currentVersion + } else { + m.ReadPos = currentVersion } - meta.Cursors[req.Cursor] = currentVersion - } else { - meta.ReadPos = currentVersion - } - s.storage.SaveMeta(req.Name, meta) + }) default: result = screen.Render() } @@ -924,11 +925,10 @@ func (s *Server) handleStop(req Request) Response { now := time.Now() h.stoppedAt = &now - if meta, err := s.storage.LoadMeta(req.Name); err == nil { + s.storage.UpdateMeta(req.Name, func(meta *SessionMeta) { meta.State = StateStopped meta.StoppedAt = &now - s.storage.SaveMeta(req.Name, meta) - } + }) return Response{Success: true} } @@ -1188,9 +1188,10 @@ func (s *Server) handleResize(req Request) Response { } s.mu.Unlock() - meta.Cols = cols - meta.Rows = rows - if err := storage.SaveMeta(req.Name, meta); err != nil { + if err := storage.UpdateMeta(req.Name, func(m *SessionMeta) { + m.Cols = cols + m.Rows = rows + }); err != nil { return Response{Success: false, Error: fmt.Sprintf("save meta: %v", err)} } diff --git a/internal/daemon/storage.go b/internal/daemon/storage.go index d593807..3384916 100644 --- a/internal/daemon/storage.go +++ b/internal/daemon/storage.go @@ -36,5 +36,6 @@ type OutputStorage interface { LoadMeta(session string) (*SessionMeta, error) SaveMeta(session string, meta *SessionMeta) error + UpdateMeta(session string, fn func(meta *SessionMeta)) error ListSessions() ([]string, error) } diff --git a/internal/daemon/storage_file.go b/internal/daemon/storage_file.go index 20d082d..d15bb96 100644 --- a/internal/daemon/storage_file.go +++ b/internal/daemon/storage_file.go @@ -207,6 +207,18 @@ func (s *FileStorage) saveMetaLocked(session string, meta *SessionMeta) error { return nil } +func (s *FileStorage) UpdateMeta(session string, fn func(meta *SessionMeta)) error { + s.mu.Lock() + defer s.mu.Unlock() + + meta, err := s.loadMetaLocked(session) + if err != nil { + return err + } + fn(meta) + return s.saveMetaLocked(session, meta) +} + func (s *FileStorage) ListSessions() ([]string, error) { s.mu.RLock() defer s.mu.RUnlock() diff --git a/internal/daemon/storage_memory.go b/internal/daemon/storage_memory.go index 63a4d2e..6ba0a15 100644 --- a/internal/daemon/storage_memory.go +++ b/internal/daemon/storage_memory.go @@ -162,6 +162,18 @@ func (s *MemoryStorage) SaveMeta(session string, meta *SessionMeta) error { return nil } +func (s *MemoryStorage) UpdateMeta(session string, fn func(meta *SessionMeta)) error { + s.mu.Lock() + defer s.mu.Unlock() + + meta, exists := s.metas[session] + if !exists { + return fmt.Errorf("session %q not found", session) + } + fn(meta) + return nil +} + func (s *MemoryStorage) ListSessions() ([]string, error) { s.mu.RLock() defer s.mu.RUnlock()