detached edaaa7c

Merge pull request #154 from bnema/fix/session-jsonl-concurrent-write

fix(core): serialize concurrent session writes
commit
edaaa7c5f9cfaf69df3dc15f304b1ae986f732f1
author
bnema · · 2026-08-21 02:11 UTC
committer
GitHub · · 2026-08-21 02:11 UTC
merge of
7021aa7 + 0da46cd · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
refs
tag: v0.23.0
2 files · +118 -21
M packages/core/session.go +43 -21
@@ -34,6 +34,7 @@ type Session struct {
3434 ExtensionState map[string]json.RawMessage
3535 writer *os.File
3636 buf *bufio.Writer
37+ writerMu sync.Mutex
3738
3839 // freshFile is true when the file was created by NewSession (this
3940 // process owns it) and false when OpenSession reopened an existing
@@ -1295,11 +1296,7 @@ func (s *Session) AppendMessage(m provider.Message) error {
12951296 if len(m.Content) == 0 {
12961297 return errors.New("message has no content")
12971298 }
1298- if err := s.writeLine(sessionLine{Type: "message", Message: &m}); err != nil {
1299- return err
1300- }
1301- s.messagesAppended++
1302- return nil
1299+ return s.writeCountedLine(sessionLine{Type: "message", Message: &m})
13031300 }
13041301
13051302 // Sync flushes the session file's bytes through the filesystem. Interactive
@@ -1309,6 +1306,8 @@ func (s *Session) Sync() error {
13091306 if s == nil || s.writer == nil {
13101307 return nil
13111308 }
1309+ s.writerMu.Lock()
1310+ defer s.writerMu.Unlock()
13121311 if err := s.buf.Flush(); err != nil {
13131312 return err
13141313 }
@@ -1324,14 +1323,10 @@ func (s *Session) AppendCompaction(messages []provider.Message) error {
13241323 return nil
13251324 }
13261325 compactionMessages := messages
1327- if err := s.writeLine(sessionLine{Type: "compaction", Messages: &compactionMessages}); err != nil {
1328- return err
1329- }
13301326 // The compaction row itself is meaningful even when it replaces the
13311327 // transcript with an empty or nil slice. Count the append operation so a
13321328 // fresh session containing an empty checkpoint is not deleted on Close.
1333- s.messagesAppended++
1334- return nil
1329+ return s.writeCountedLine(sessionLine{Type: "compaction", Messages: &compactionMessages})
13351330 }
13361331
13371332 // AppendCompactionWithUsage atomically records a replacement transcript and
@@ -1343,16 +1338,12 @@ func (s *Session) AppendCompactionWithUsage(messages []provider.Message, cumulat
13431338 return nil
13441339 }
13451340 compactionMessages := messages
1346- if err := s.writeUsageCheckpoint(sessionLine{
1341+ return s.writeUsageCheckpointCounted(sessionLine{
13471342 Type: "compaction",
13481343 Messages: &compactionMessages,
13491344 Usage: &cumulative,
13501345 Cumulative: &cumulative,
1351- }); err != nil {
1352- return err
1353- }
1354- s.messagesAppended++
1355- return nil
1346+ })
13561347 }
13571348
13581349 // UpdateModel records a provider/model switch in the session file.
@@ -1620,24 +1611,32 @@ func (s *Session) FlushRetryLifecycle(u, cum provider.Usage) error {
16201611 if len(s.pendingRetryLifecycle) == 0 {
16211612 return nil
16221613 }
1623- return s.writeUsageCheckpointLocked(sessionLine{Type: "usage", Usage: &u, Cumulative: &cum})
1614+ return s.writeUsageCheckpointLocked(sessionLine{Type: "usage", Usage: &u, Cumulative: &cum}, false)
16241615 }
16251616
16261617 func (s *Session) writeUsageCheckpoint(row sessionLine) error {
16271618 s.retryMu.Lock()
16281619 defer s.retryMu.Unlock()
1629- return s.writeUsageCheckpointLocked(row)
1620+ return s.writeUsageCheckpointLocked(row, false)
16301621 }
16311622
1632-func (s *Session) writeUsageCheckpointLocked(row sessionLine) error {
1623+func (s *Session) writeUsageCheckpointCounted(row sessionLine) error {
1624+ s.retryMu.Lock()
1625+ defer s.retryMu.Unlock()
1626+ return s.writeUsageCheckpointLocked(row, true)
1627+}
1628+
1629+func (s *Session) writeUsageCheckpointLocked(row sessionLine, counted bool) error {
16331630 if len(s.pendingRetryLifecycle) > 0 {
16341631 row.RetryLifecycle = append([]RetryLifecycleRecord(nil), s.pendingRetryLifecycle...)
16351632 }
1633+ s.writerMu.Lock()
1634+ defer s.writerMu.Unlock()
16361635 if row.Type == "usage" && len(row.RetryLifecycle) == 0 && row.Usage != nil && row.Cumulative != nil &&
16371636 s.lastWriteWasUsage && *row.Usage == s.lastUsage && *row.Cumulative == s.lastCumulativeUsage {
16381637 return nil
16391638 }
1640- if err := s.writeLine(row); err != nil {
1639+ if err := s.writeLineLocked(row); err != nil {
16411640 return err
16421641 }
16431642 if row.Type == "usage" && row.Usage != nil && row.Cumulative != nil {
@@ -1645,6 +1644,9 @@ func (s *Session) writeUsageCheckpointLocked(row sessionLine) error {
16451644 s.lastUsage = *row.Usage
16461645 s.lastCumulativeUsage = *row.Cumulative
16471646 }
1647+ if counted {
1648+ s.messagesAppended++
1649+ }
16481650 s.pendingRetryLifecycle = nil
16491651 return nil
16501652 }
@@ -1719,6 +1721,8 @@ func (s *Session) Flush() error {
17191721 if s == nil {
17201722 return nil
17211723 }
1724+ s.writerMu.Lock()
1725+ defer s.writerMu.Unlock()
17221726 return s.buf.Flush()
17231727 }
17241728
@@ -1731,7 +1735,9 @@ func (s *Session) Close() error {
17311735 if s == nil {
17321736 return nil
17331737 }
1734- flushErr := s.Flush()
1738+ s.writerMu.Lock()
1739+ defer s.writerMu.Unlock()
1740+ flushErr := s.buf.Flush()
17351741 closeErr := s.writer.Close()
17361742 if s.freshFile && s.messagesAppended == 0 && len(s.ExtensionState) == 0 && len(s.Meta.CompactHandoff) == 0 && s.Meta.Mission == nil && s.Meta.Goal == nil && len(s.Meta.GoalHistory) == 0 {
17371743 // Best-effort cleanup. We deliberately don't propagate the
@@ -1746,6 +1752,22 @@ func (s *Session) Close() error {
17461752 }
17471753
17481754 func (s *Session) writeLine(row sessionLine) error {
1755+ s.writerMu.Lock()
1756+ defer s.writerMu.Unlock()
1757+ return s.writeLineLocked(row)
1758+}
1759+
1760+func (s *Session) writeCountedLine(row sessionLine) error {
1761+ s.writerMu.Lock()
1762+ defer s.writerMu.Unlock()
1763+ if err := s.writeLineLocked(row); err != nil {
1764+ return err
1765+ }
1766+ s.messagesAppended++
1767+ return nil
1768+}
1769+
1770+func (s *Session) writeLineLocked(row sessionLine) error {
17491771 s.lastWriteWasUsage = false
17501772 b, err := json.Marshal(row)
17511773 if err != nil {
@@ -34,6 +34,7 @@ type Session struct {
34 ExtensionState map[string]json.RawMessage34 ExtensionState map[string]json.RawMessage
35 writer *os.File35 writer *os.File
36 buf *bufio.Writer36 buf *bufio.Writer
37+ writerMu sync.Mutex
37 38
38 // freshFile is true when the file was created by NewSession (this39 // freshFile is true when the file was created by NewSession (this
39 // process owns it) and false when OpenSession reopened an existing40 // process owns it) and false when OpenSession reopened an existing
@@ -1295,11 +1296,7 @@ func (s *Session) AppendMessage(m provider.Message) error {
1295 if len(m.Content) == 0 {1296 if len(m.Content) == 0 {
1296 return errors.New("message has no content")1297 return errors.New("message has no content")
1297 }1298 }
1298- if err := s.writeLine(sessionLine{Type: "message", Message: &m}); err != nil {1299+ return s.writeCountedLine(sessionLine{Type: "message", Message: &m})
1299- return err
1300- }
1301- s.messagesAppended++
1302- return nil
1303 }1300 }
1304 1301
1305 // Sync flushes the session file's bytes through the filesystem. Interactive1302 // Sync flushes the session file's bytes through the filesystem. Interactive
@@ -1309,6 +1306,8 @@ func (s *Session) Sync() error {
1309 if s == nil || s.writer == nil {1306 if s == nil || s.writer == nil {
1310 return nil1307 return nil
1311 }1308 }
1309+ s.writerMu.Lock()
1310+ defer s.writerMu.Unlock()
1312 if err := s.buf.Flush(); err != nil {1311 if err := s.buf.Flush(); err != nil {
1313 return err1312 return err
1314 }1313 }
@@ -1324,14 +1323,10 @@ func (s *Session) AppendCompaction(messages []provider.Message) error {
1324 return nil1323 return nil
1325 }1324 }
1326 compactionMessages := messages1325 compactionMessages := messages
1327- if err := s.writeLine(sessionLine{Type: "compaction", Messages: &compactionMessages}); err != nil {
1328- return err
1329- }
1330 // The compaction row itself is meaningful even when it replaces the1326 // The compaction row itself is meaningful even when it replaces the
1331 // transcript with an empty or nil slice. Count the append operation so a1327 // transcript with an empty or nil slice. Count the append operation so a
1332 // fresh session containing an empty checkpoint is not deleted on Close.1328 // fresh session containing an empty checkpoint is not deleted on Close.
1333- s.messagesAppended++1329+ return s.writeCountedLine(sessionLine{Type: "compaction", Messages: &compactionMessages})
1334- return nil
1335 }1330 }
1336 1331
1337 // AppendCompactionWithUsage atomically records a replacement transcript and1332 // AppendCompactionWithUsage atomically records a replacement transcript and
@@ -1343,16 +1338,12 @@ func (s *Session) AppendCompactionWithUsage(messages []provider.Message, cumulat
1343 return nil1338 return nil
1344 }1339 }
1345 compactionMessages := messages1340 compactionMessages := messages
1346- if err := s.writeUsageCheckpoint(sessionLine{1341+ return s.writeUsageCheckpointCounted(sessionLine{
1347 Type: "compaction",1342 Type: "compaction",
1348 Messages: &compactionMessages,1343 Messages: &compactionMessages,
1349 Usage: &cumulative,1344 Usage: &cumulative,
1350 Cumulative: &cumulative,1345 Cumulative: &cumulative,
1351- }); err != nil {1346+ })
1352- return err
1353- }
1354- s.messagesAppended++
1355- return nil
1356 }1347 }
1357 1348
1358 // UpdateModel records a provider/model switch in the session file.1349 // UpdateModel records a provider/model switch in the session file.
@@ -1620,24 +1611,32 @@ func (s *Session) FlushRetryLifecycle(u, cum provider.Usage) error {
1620 if len(s.pendingRetryLifecycle) == 0 {1611 if len(s.pendingRetryLifecycle) == 0 {
1621 return nil1612 return nil
1622 }1613 }
1623- return s.writeUsageCheckpointLocked(sessionLine{Type: "usage", Usage: &u, Cumulative: &cum})1614+ return s.writeUsageCheckpointLocked(sessionLine{Type: "usage", Usage: &u, Cumulative: &cum}, false)
1624 }1615 }
1625 1616
1626 func (s *Session) writeUsageCheckpoint(row sessionLine) error {1617 func (s *Session) writeUsageCheckpoint(row sessionLine) error {
1627 s.retryMu.Lock()1618 s.retryMu.Lock()
1628 defer s.retryMu.Unlock()1619 defer s.retryMu.Unlock()
1629- return s.writeUsageCheckpointLocked(row)1620+ return s.writeUsageCheckpointLocked(row, false)
1630 }1621 }
1631 1622
1632-func (s *Session) writeUsageCheckpointLocked(row sessionLine) error {1623+func (s *Session) writeUsageCheckpointCounted(row sessionLine) error {
1624+ s.retryMu.Lock()
1625+ defer s.retryMu.Unlock()
1626+ return s.writeUsageCheckpointLocked(row, true)
1627+}
1628+
1629+func (s *Session) writeUsageCheckpointLocked(row sessionLine, counted bool) error {
1633 if len(s.pendingRetryLifecycle) > 0 {1630 if len(s.pendingRetryLifecycle) > 0 {
1634 row.RetryLifecycle = append([]RetryLifecycleRecord(nil), s.pendingRetryLifecycle...)1631 row.RetryLifecycle = append([]RetryLifecycleRecord(nil), s.pendingRetryLifecycle...)
1635 }1632 }
1633+ s.writerMu.Lock()
1634+ defer s.writerMu.Unlock()
1636 if row.Type == "usage" && len(row.RetryLifecycle) == 0 && row.Usage != nil && row.Cumulative != nil &&1635 if row.Type == "usage" && len(row.RetryLifecycle) == 0 && row.Usage != nil && row.Cumulative != nil &&
1637 s.lastWriteWasUsage && *row.Usage == s.lastUsage && *row.Cumulative == s.lastCumulativeUsage {1636 s.lastWriteWasUsage && *row.Usage == s.lastUsage && *row.Cumulative == s.lastCumulativeUsage {
1638 return nil1637 return nil
1639 }1638 }
1640- if err := s.writeLine(row); err != nil {1639+ if err := s.writeLineLocked(row); err != nil {
1641 return err1640 return err
1642 }1641 }
1643 if row.Type == "usage" && row.Usage != nil && row.Cumulative != nil {1642 if row.Type == "usage" && row.Usage != nil && row.Cumulative != nil {
@@ -1645,6 +1644,9 @@ func (s *Session) writeUsageCheckpointLocked(row sessionLine) error {
1645 s.lastUsage = *row.Usage1644 s.lastUsage = *row.Usage
1646 s.lastCumulativeUsage = *row.Cumulative1645 s.lastCumulativeUsage = *row.Cumulative
1647 }1646 }
1647+ if counted {
1648+ s.messagesAppended++
1649+ }
1648 s.pendingRetryLifecycle = nil1650 s.pendingRetryLifecycle = nil
1649 return nil1651 return nil
1650 }1652 }
@@ -1719,6 +1721,8 @@ func (s *Session) Flush() error {
1719 if s == nil {1721 if s == nil {
1720 return nil1722 return nil
1721 }1723 }
1724+ s.writerMu.Lock()
1725+ defer s.writerMu.Unlock()
1722 return s.buf.Flush()1726 return s.buf.Flush()
1723 }1727 }
1724 1728
@@ -1731,7 +1735,9 @@ func (s *Session) Close() error {
1731 if s == nil {1735 if s == nil {
1732 return nil1736 return nil
1733 }1737 }
1734- flushErr := s.Flush()1738+ s.writerMu.Lock()
1739+ defer s.writerMu.Unlock()
1740+ flushErr := s.buf.Flush()
1735 closeErr := s.writer.Close()1741 closeErr := s.writer.Close()
1736 if s.freshFile && s.messagesAppended == 0 && len(s.ExtensionState) == 0 && len(s.Meta.CompactHandoff) == 0 && s.Meta.Mission == nil && s.Meta.Goal == nil && len(s.Meta.GoalHistory) == 0 {1742 if s.freshFile && s.messagesAppended == 0 && len(s.ExtensionState) == 0 && len(s.Meta.CompactHandoff) == 0 && s.Meta.Mission == nil && s.Meta.Goal == nil && len(s.Meta.GoalHistory) == 0 {
1737 // Best-effort cleanup. We deliberately don't propagate the1743 // Best-effort cleanup. We deliberately don't propagate the
@@ -1746,6 +1752,22 @@ func (s *Session) Close() error {
1746 }1752 }
1747 1753
1748 func (s *Session) writeLine(row sessionLine) error {1754 func (s *Session) writeLine(row sessionLine) error {
1755+ s.writerMu.Lock()
1756+ defer s.writerMu.Unlock()
1757+ return s.writeLineLocked(row)
1758+}
1759+
1760+func (s *Session) writeCountedLine(row sessionLine) error {
1761+ s.writerMu.Lock()
1762+ defer s.writerMu.Unlock()
1763+ if err := s.writeLineLocked(row); err != nil {
1764+ return err
1765+ }
1766+ s.messagesAppended++
1767+ return nil
1768+}
1769+
1770+func (s *Session) writeLineLocked(row sessionLine) error {
1749 s.lastWriteWasUsage = false1771 s.lastWriteWasUsage = false
1750 b, err := json.Marshal(row)1772 b, err := json.Marshal(row)
1751 if err != nil {1773 if err != nil {
A packages/core/session_concurrency_test.go +75 -0
@@ -0,0 +1,75 @@
1+package core
2+
3+import (
4+ "bufio"
5+ "encoding/json"
6+ "os"
7+ "strings"
8+ "sync"
9+ "testing"
10+
11+ "github.com/bnema/zut/packages/provider"
12+)
13+
14+func TestSessionConcurrentAppendsRemainValidJSONL(t *testing.T) {
15+ session, err := NewSession(t.TempDir(), "/workspace", "provider", "model", "test")
16+ if err != nil {
17+ t.Fatal(err)
18+ }
19+
20+ const (
21+ writers = 8
22+ rows = 20
23+ )
24+ payload := strings.Repeat("x", 8*1024)
25+ start := make(chan struct{})
26+ errs := make(chan error, writers)
27+ var wg sync.WaitGroup
28+ for writer := 0; writer < writers; writer++ {
29+ wg.Add(1)
30+ go func() {
31+ defer wg.Done()
32+ <-start
33+ for i := 0; i < rows; i++ {
34+ if err := session.AppendMessage(provider.Message{
35+ Role: provider.RoleAssistant,
36+ Content: []provider.Content{provider.TextBlock{Text: payload}},
37+ }); err != nil {
38+ errs <- err
39+ return
40+ }
41+ }
42+ }()
43+ }
44+ close(start)
45+ wg.Wait()
46+ close(errs)
47+ for err := range errs {
48+ t.Errorf("append message: %v", err)
49+ }
50+ if err := session.Close(); err != nil {
51+ t.Fatal(err)
52+ }
53+
54+ file, err := os.Open(session.Path)
55+ if err != nil {
56+ t.Fatal(err)
57+ }
58+ defer file.Close()
59+
60+ lineCount := 0
61+ scanner := bufio.NewScanner(file)
62+ scanner.Buffer(make([]byte, 1024), 32*1024)
63+ for scanner.Scan() {
64+ lineCount++
65+ if !json.Valid(scanner.Bytes()) {
66+ t.Fatalf("line %d is not valid JSON", lineCount)
67+ }
68+ }
69+ if err := scanner.Err(); err != nil {
70+ t.Fatal(err)
71+ }
72+ if want := 1 + writers*rows; lineCount != want {
73+ t.Fatalf("line count = %d, want %d", lineCount, want)
74+ }
75+}
@@ -0,0 +1,75 @@
1+package core
2+
3+import (
4+ "bufio"
5+ "encoding/json"
6+ "os"
7+ "strings"
8+ "sync"
9+ "testing"
10+
11+ "github.com/bnema/zut/packages/provider"
12+)
13+
14+func TestSessionConcurrentAppendsRemainValidJSONL(t *testing.T) {
15+ session, err := NewSession(t.TempDir(), "/workspace", "provider", "model", "test")
16+ if err != nil {
17+ t.Fatal(err)
18+ }
19+
20+ const (
21+ writers = 8
22+ rows = 20
23+ )
24+ payload := strings.Repeat("x", 8*1024)
25+ start := make(chan struct{})
26+ errs := make(chan error, writers)
27+ var wg sync.WaitGroup
28+ for writer := 0; writer < writers; writer++ {
29+ wg.Add(1)
30+ go func() {
31+ defer wg.Done()
32+ <-start
33+ for i := 0; i < rows; i++ {
34+ if err := session.AppendMessage(provider.Message{
35+ Role: provider.RoleAssistant,
36+ Content: []provider.Content{provider.TextBlock{Text: payload}},
37+ }); err != nil {
38+ errs <- err
39+ return
40+ }
41+ }
42+ }()
43+ }
44+ close(start)
45+ wg.Wait()
46+ close(errs)
47+ for err := range errs {
48+ t.Errorf("append message: %v", err)
49+ }
50+ if err := session.Close(); err != nil {
51+ t.Fatal(err)
52+ }
53+
54+ file, err := os.Open(session.Path)
55+ if err != nil {
56+ t.Fatal(err)
57+ }
58+ defer file.Close()
59+
60+ lineCount := 0
61+ scanner := bufio.NewScanner(file)
62+ scanner.Buffer(make([]byte, 1024), 32*1024)
63+ for scanner.Scan() {
64+ lineCount++
65+ if !json.Valid(scanner.Bytes()) {
66+ t.Fatalf("line %d is not valid JSON", lineCount)
67+ }
68+ }
69+ if err := scanner.Err(); err != nil {
70+ t.Fatal(err)
71+ }
72+ if want := 1 + writers*rows; lineCount != want {
73+ t.Fatalf("line count = %d, want %d", lineCount, want)
74+ }
75+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close