detached 8f34248

fix(daemon): serialize detached output state

commit
8f342486e252370828dd635a8f9241cb14af4eee
author
brice · · 2026-08-04 06:34 UTC
committer
brice · · 2026-08-04 06:36 UTC
parent
509f5fa
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
5 files · +55 -33
M internal/usecase/daemon/attachment_registry.go +9 -2
@@ -48,9 +48,16 @@ func (ac *attachedClient) publishView(view attachmentView) {
4848 ac.viewMu.Lock()
4949 // Rebase while viewMu is held, before the new revision becomes visible.
5050 // Output fences take the same lock, so no side effect can pair the new
51- // ViewRevision with the previous output epoch.
51+ // ViewRevision with the previous output epoch. Test/headless fixtures may
52+ // leave output.attachment unset; use the stream-local lock for those.
5253 if ac.output != nil && ac.view.revision != view.revision {
53- ac.output.rebaseLocked()
54+ if ac.output.attachment == ac {
55+ ac.output.rebaseLocked()
56+ } else {
57+ ac.output.stateMu.Lock()
58+ ac.output.rebaseLocked()
59+ ac.output.stateMu.Unlock()
60+ }
5461 }
5562 ac.view = view
5663 ac.viewMu.Unlock()
@@ -48,9 +48,16 @@ func (ac *attachedClient) publishView(view attachmentView) {
48 ac.viewMu.Lock()48 ac.viewMu.Lock()
49 // Rebase while viewMu is held, before the new revision becomes visible.49 // Rebase while viewMu is held, before the new revision becomes visible.
50 // Output fences take the same lock, so no side effect can pair the new50 // Output fences take the same lock, so no side effect can pair the new
51- // ViewRevision with the previous output epoch.51+ // ViewRevision with the previous output epoch. Test/headless fixtures may
52+ // leave output.attachment unset; use the stream-local lock for those.
52 if ac.output != nil && ac.view.revision != view.revision {53 if ac.output != nil && ac.view.revision != view.revision {
53- ac.output.rebaseLocked()54+ if ac.output.attachment == ac {
55+ ac.output.rebaseLocked()
56+ } else {
57+ ac.output.stateMu.Lock()
58+ ac.output.rebaseLocked()
59+ ac.output.stateMu.Unlock()
60+ }
54 }61 }
55 ac.view = view62 ac.view = view
56 ac.viewMu.Unlock()63 ac.viewMu.Unlock()
M internal/usecase/daemon/client.go +6 -6
@@ -356,8 +356,8 @@ func (ac *attachedClient) sendExpectedSideEffectForAttachment(expected transport
356356 if err := ac.beginExpectedTransportSendLocked(expected, ticket); err != nil {
357357 return errAttachmentTransition
358358 }
359- unlockView := ac.output.lockView()
360- defer unlockView()
359+ ac.output.lockView()
360+ defer ac.output.unlockView()
361361 frame, err := ac.output.sideEffectLocked(data, echoAck)
362362 if err == nil {
363363 err = expected.transport.Send(frame)
@@ -408,8 +408,8 @@ func (d *Daemon) boundedSendOutputErrTransport(ac *attachedClient, b []byte) (po
408408 if !ac.transportSnapshotCurrent(expected) {
409409 return expected.transport, errTransportReplaced
410410 }
411- unlockView := ac.output.lockView()
412- defer unlockView()
411+ ac.output.lockView()
412+ defer ac.output.unlockView()
413413 frame, err := ac.output.sideEffectLocked(b, ac.echoAck.Load())
414414 if err == nil {
415415 err = owned.SendSynchronous(frame)
@@ -422,8 +422,8 @@ func (d *Daemon) boundedSendOutputErrTransport(ac *attachedClient, b []byte) (po
422422 if !ac.transportSnapshotCurrent(expected) {
423423 return errTransportReplaced
424424 }
425- unlockView := ac.output.lockView()
426- defer unlockView()
425+ ac.output.lockView()
426+ defer ac.output.unlockView()
427427 frame, err := ac.output.sideEffectLocked(b, ac.echoAck.Load())
428428 if err != nil {
429429 return err
@@ -356,8 +356,8 @@ func (ac *attachedClient) sendExpectedSideEffectForAttachment(expected transport
356 if err := ac.beginExpectedTransportSendLocked(expected, ticket); err != nil {356 if err := ac.beginExpectedTransportSendLocked(expected, ticket); err != nil {
357 return errAttachmentTransition357 return errAttachmentTransition
358 }358 }
359- unlockView := ac.output.lockView()359+ ac.output.lockView()
360- defer unlockView()360+ defer ac.output.unlockView()
361 frame, err := ac.output.sideEffectLocked(data, echoAck)361 frame, err := ac.output.sideEffectLocked(data, echoAck)
362 if err == nil {362 if err == nil {
363 err = expected.transport.Send(frame)363 err = expected.transport.Send(frame)
@@ -408,8 +408,8 @@ func (d *Daemon) boundedSendOutputErrTransport(ac *attachedClient, b []byte) (po
408 if !ac.transportSnapshotCurrent(expected) {408 if !ac.transportSnapshotCurrent(expected) {
409 return expected.transport, errTransportReplaced409 return expected.transport, errTransportReplaced
410 }410 }
411- unlockView := ac.output.lockView()411+ ac.output.lockView()
412- defer unlockView()412+ defer ac.output.unlockView()
413 frame, err := ac.output.sideEffectLocked(b, ac.echoAck.Load())413 frame, err := ac.output.sideEffectLocked(b, ac.echoAck.Load())
414 if err == nil {414 if err == nil {
415 err = owned.SendSynchronous(frame)415 err = owned.SendSynchronous(frame)
@@ -422,8 +422,8 @@ func (d *Daemon) boundedSendOutputErrTransport(ac *attachedClient, b []byte) (po
422 if !ac.transportSnapshotCurrent(expected) {422 if !ac.transportSnapshotCurrent(expected) {
423 return errTransportReplaced423 return errTransportReplaced
424 }424 }
425- unlockView := ac.output.lockView()425+ ac.output.lockView()
426- defer unlockView()426+ defer ac.output.unlockView()
427 frame, err := ac.output.sideEffectLocked(b, ac.echoAck.Load())427 frame, err := ac.output.sideEffectLocked(b, ac.echoAck.Load())
428 if err != nil {428 if err != nil {
429 return err429 return err
M internal/usecase/daemon/clipboard.go +2 -2
@@ -271,8 +271,8 @@ func (d *Daemon) boundedSendClipboardForward(item clipboardForward, ticket *atta
271271 if !beginClipboardOwnerSend(item.owner, ticket, expected) {
272272 return errAttachmentTransition
273273 }
274- unlockView := ac.output.lockView()
275- defer unlockView()
274+ ac.output.lockView()
275+ defer ac.output.unlockView()
276276 frame, err := ac.output.sideEffectLocked(item.seq, ac.echoAck.Load())
277277 if err == nil {
278278 if owned {
@@ -271,8 +271,8 @@ func (d *Daemon) boundedSendClipboardForward(item clipboardForward, ticket *atta
271 if !beginClipboardOwnerSend(item.owner, ticket, expected) {271 if !beginClipboardOwnerSend(item.owner, ticket, expected) {
272 return errAttachmentTransition272 return errAttachmentTransition
273 }273 }
274- unlockView := ac.output.lockView()274+ ac.output.lockView()
275- defer unlockView()275+ defer ac.output.unlockView()
276 frame, err := ac.output.sideEffectLocked(item.seq, ac.echoAck.Load())276 frame, err := ac.output.sideEffectLocked(item.seq, ac.echoAck.Load())
277 if err == nil {277 if err == nil {
278 if owned {278 if owned {
M internal/usecase/daemon/daemon_test.go +0 -1
@@ -401,7 +401,6 @@ func newManualSessionWithPTYsClockCleanup(t testing.TB, clock ports.Clock, regis
401401 d := newTestDaemonWithCleanup(t, nil, clock, registerCleanup)
402402 tr, sends := newCapturingTransport(t)
403403 ac := &attachedClient{tr: tr, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}}
404- ac.output.attachment = ac
405404 ac.initOverlays()
406405 sctx, cancel := context.WithCancel(d.serveCtx)
407406 tabs := make([]*tab, 0, len(ptys))
@@ -401,7 +401,6 @@ func newManualSessionWithPTYsClockCleanup(t testing.TB, clock ports.Clock, regis
401 d := newTestDaemonWithCleanup(t, nil, clock, registerCleanup)401 d := newTestDaemonWithCleanup(t, nil, clock, registerCleanup)
402 tr, sends := newCapturingTransport(t)402 tr, sends := newCapturingTransport(t)
403 ac := &attachedClient{tr: tr, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}}403 ac := &attachedClient{tr: tr, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}}
404- ac.output.attachment = ac
405 ac.initOverlays()404 ac.initOverlays()
406 sctx, cancel := context.WithCancel(d.serveCtx)405 sctx, cancel := context.WithCancel(d.serveCtx)
407 tabs := make([]*tab, 0, len(ptys))406 tabs := make([]*tab, 0, len(ptys))
M internal/usecase/daemon/output_state_stream.go +38 -22
@@ -2,6 +2,7 @@ package daemon
22
33 import (
44 "errors"
5+ "sync"
56 "sync/atomic"
67
78 "github.com/bnema/vev/internal/domain"
@@ -30,18 +31,33 @@ type outputStateStream struct {
3031 forceSnapshot bool
3132 initialized bool
3233 attachment *attachedClient
34+ stateMu sync.Mutex
3335 }
3436
3537 // lockView serializes an attachment's view publication with every output
3638 // state transition. It deliberately does not take sendMu: callers that hold
3739 // architecture locks can publish a view without reversing the paint path's
3840 // sendMu -> session lock order.
39-func (s *outputStateStream) lockView() func() {
40- if s == nil || s.attachment == nil {
41- return func() {}
41+func (s *outputStateStream) lockView() {
42+ if s == nil {
43+ return
44+ }
45+ if s.attachment == nil {
46+ s.stateMu.Lock()
47+ return
4248 }
4349 s.attachment.viewMu.Lock()
44- return s.attachment.viewMu.Unlock
50+}
51+
52+func (s *outputStateStream) unlockView() {
53+ if s == nil {
54+ return
55+ }
56+ if s.attachment == nil {
57+ s.stateMu.Unlock()
58+ return
59+ }
60+ s.attachment.viewMu.Unlock()
4561 }
4662
4763 func newOutputStateStream(windowSize ...uint8) *outputStateStream {
@@ -76,8 +92,8 @@ type preparedOutput struct {
7692 }
7793
7894 func (s *outputStateStream) prepare(frame renderer.Frame, damage []renderer.Damage, reset bool) (*preparedOutput, error) {
79- unlockView := s.lockView()
80- defer unlockView()
95+ s.lockView()
96+ defer s.unlockView()
8197 if s.epoch == 0 {
8298 s.epoch = 1
8399 }
@@ -113,8 +129,8 @@ func (p *preparedOutput) send(data []byte, echoAck uint64, send func(ports.Frame
113129 if p == nil || p.stream == nil || p.attempted {
114130 return nil
115131 }
116- unlockView := p.stream.lockView()
117- defer unlockView()
132+ p.stream.lockView()
133+ defer p.stream.unlockView()
118134 p.attempted = true
119135 if !p.currentLocked() {
120136 return nil
@@ -148,8 +164,8 @@ func (p *preparedOutput) commitNoSend() {
148164 if p == nil || p.stream == nil || p.attempted {
149165 return
150166 }
151- unlockView := p.stream.lockView()
152- defer unlockView()
167+ p.stream.lockView()
168+ defer p.stream.unlockView()
153169 p.attempted = true
154170 if !p.currentLocked() || len(p.data) != 0 {
155171 return
@@ -164,8 +180,8 @@ func (p *preparedOutput) commit() {
164180 }
165181
166182 func (s *outputStateStream) currentEpoch() uint64 {
167- unlockView := s.lockView()
168- defer unlockView()
183+ s.lockView()
184+ defer s.unlockView()
169185 return s.currentEpochLocked()
170186 }
171187
@@ -218,8 +234,8 @@ func marshalOutputState(data []byte, epoch, base, next, echoAck, viewRevision ui
218234 // an attachment is present, callers must hold its sendMu; attachment-bound
219235 // sends use sideEffectLocked after validating their transport incarnation.
220236 func (s *outputStateStream) sideEffect(data []byte, echoAck uint64) (ports.Frame, error) {
221- unlockView := s.lockView()
222- defer unlockView()
237+ s.lockView()
238+ defer s.unlockView()
223239 return s.sideEffectLocked(data, echoAck)
224240 }
225241
@@ -239,8 +255,8 @@ func (s *outputStateStream) ack(epoch, state uint64) {
239255 if s == nil {
240256 return
241257 }
242- unlockView := s.lockView()
243- defer unlockView()
258+ s.lockView()
259+ defer s.unlockView()
244260 if epoch != s.currentEpochLocked() || state > s.next || state <= s.acked {
245261 return
246262 }
@@ -252,8 +268,8 @@ func (s *outputStateStream) rebase() {
252268 if s == nil {
253269 return
254270 }
255- unlockView := s.lockView()
256- defer unlockView()
271+ s.lockView()
272+ defer s.unlockView()
257273 s.rebaseLocked()
258274 }
259275
@@ -291,8 +307,8 @@ func (s *outputStateStream) syncCapacityLocked() {
291307 if s == nil {
292308 return
293309 }
294- unlockView := s.lockView()
295- defer unlockView()
310+ s.lockView()
311+ defer s.unlockView()
296312 s.maxOutstandingAtomic.Store(s.maxOutstanding)
297313 s.publishOutstanding()
298314 }
@@ -308,7 +324,7 @@ func (s *outputStateStream) outstanding() uint64 {
308324 if s == nil {
309325 return 0
310326 }
311- unlockView := s.lockView()
312- defer unlockView()
327+ s.lockView()
328+ defer s.unlockView()
313329 return s.next - s.acked
314330 }
@@ -2,6 +2,7 @@ package daemon
2 2
3 import (3 import (
4 "errors"4 "errors"
5+ "sync"
5 "sync/atomic"6 "sync/atomic"
6 7
7 "github.com/bnema/vev/internal/domain"8 "github.com/bnema/vev/internal/domain"
@@ -30,18 +31,33 @@ type outputStateStream struct {
30 forceSnapshot bool31 forceSnapshot bool
31 initialized bool32 initialized bool
32 attachment *attachedClient33 attachment *attachedClient
34+ stateMu sync.Mutex
33 }35 }
34 36
35 // lockView serializes an attachment's view publication with every output37 // lockView serializes an attachment's view publication with every output
36 // state transition. It deliberately does not take sendMu: callers that hold38 // state transition. It deliberately does not take sendMu: callers that hold
37 // architecture locks can publish a view without reversing the paint path's39 // architecture locks can publish a view without reversing the paint path's
38 // sendMu -> session lock order.40 // sendMu -> session lock order.
39-func (s *outputStateStream) lockView() func() {41+func (s *outputStateStream) lockView() {
40- if s == nil || s.attachment == nil {42+ if s == nil {
41- return func() {}43+ return
44+ }
45+ if s.attachment == nil {
46+ s.stateMu.Lock()
47+ return
42 }48 }
43 s.attachment.viewMu.Lock()49 s.attachment.viewMu.Lock()
44- return s.attachment.viewMu.Unlock50+}
51+
52+func (s *outputStateStream) unlockView() {
53+ if s == nil {
54+ return
55+ }
56+ if s.attachment == nil {
57+ s.stateMu.Unlock()
58+ return
59+ }
60+ s.attachment.viewMu.Unlock()
45 }61 }
46 62
47 func newOutputStateStream(windowSize ...uint8) *outputStateStream {63 func newOutputStateStream(windowSize ...uint8) *outputStateStream {
@@ -76,8 +92,8 @@ type preparedOutput struct {
76 }92 }
77 93
78 func (s *outputStateStream) prepare(frame renderer.Frame, damage []renderer.Damage, reset bool) (*preparedOutput, error) {94 func (s *outputStateStream) prepare(frame renderer.Frame, damage []renderer.Damage, reset bool) (*preparedOutput, error) {
79- unlockView := s.lockView()95+ s.lockView()
80- defer unlockView()96+ defer s.unlockView()
81 if s.epoch == 0 {97 if s.epoch == 0 {
82 s.epoch = 198 s.epoch = 1
83 }99 }
@@ -113,8 +129,8 @@ func (p *preparedOutput) send(data []byte, echoAck uint64, send func(ports.Frame
113 if p == nil || p.stream == nil || p.attempted {129 if p == nil || p.stream == nil || p.attempted {
114 return nil130 return nil
115 }131 }
116- unlockView := p.stream.lockView()132+ p.stream.lockView()
117- defer unlockView()133+ defer p.stream.unlockView()
118 p.attempted = true134 p.attempted = true
119 if !p.currentLocked() {135 if !p.currentLocked() {
120 return nil136 return nil
@@ -148,8 +164,8 @@ func (p *preparedOutput) commitNoSend() {
148 if p == nil || p.stream == nil || p.attempted {164 if p == nil || p.stream == nil || p.attempted {
149 return165 return
150 }166 }
151- unlockView := p.stream.lockView()167+ p.stream.lockView()
152- defer unlockView()168+ defer p.stream.unlockView()
153 p.attempted = true169 p.attempted = true
154 if !p.currentLocked() || len(p.data) != 0 {170 if !p.currentLocked() || len(p.data) != 0 {
155 return171 return
@@ -164,8 +180,8 @@ func (p *preparedOutput) commit() {
164 }180 }
165 181
166 func (s *outputStateStream) currentEpoch() uint64 {182 func (s *outputStateStream) currentEpoch() uint64 {
167- unlockView := s.lockView()183+ s.lockView()
168- defer unlockView()184+ defer s.unlockView()
169 return s.currentEpochLocked()185 return s.currentEpochLocked()
170 }186 }
171 187
@@ -218,8 +234,8 @@ func marshalOutputState(data []byte, epoch, base, next, echoAck, viewRevision ui
218 // an attachment is present, callers must hold its sendMu; attachment-bound234 // an attachment is present, callers must hold its sendMu; attachment-bound
219 // sends use sideEffectLocked after validating their transport incarnation.235 // sends use sideEffectLocked after validating their transport incarnation.
220 func (s *outputStateStream) sideEffect(data []byte, echoAck uint64) (ports.Frame, error) {236 func (s *outputStateStream) sideEffect(data []byte, echoAck uint64) (ports.Frame, error) {
221- unlockView := s.lockView()237+ s.lockView()
222- defer unlockView()238+ defer s.unlockView()
223 return s.sideEffectLocked(data, echoAck)239 return s.sideEffectLocked(data, echoAck)
224 }240 }
225 241
@@ -239,8 +255,8 @@ func (s *outputStateStream) ack(epoch, state uint64) {
239 if s == nil {255 if s == nil {
240 return256 return
241 }257 }
242- unlockView := s.lockView()258+ s.lockView()
243- defer unlockView()259+ defer s.unlockView()
244 if epoch != s.currentEpochLocked() || state > s.next || state <= s.acked {260 if epoch != s.currentEpochLocked() || state > s.next || state <= s.acked {
245 return261 return
246 }262 }
@@ -252,8 +268,8 @@ func (s *outputStateStream) rebase() {
252 if s == nil {268 if s == nil {
253 return269 return
254 }270 }
255- unlockView := s.lockView()271+ s.lockView()
256- defer unlockView()272+ defer s.unlockView()
257 s.rebaseLocked()273 s.rebaseLocked()
258 }274 }
259 275
@@ -291,8 +307,8 @@ func (s *outputStateStream) syncCapacityLocked() {
291 if s == nil {307 if s == nil {
292 return308 return
293 }309 }
294- unlockView := s.lockView()310+ s.lockView()
295- defer unlockView()311+ defer s.unlockView()
296 s.maxOutstandingAtomic.Store(s.maxOutstanding)312 s.maxOutstandingAtomic.Store(s.maxOutstanding)
297 s.publishOutstanding()313 s.publishOutstanding()
298 }314 }
@@ -308,7 +324,7 @@ func (s *outputStateStream) outstanding() uint64 {
308 if s == nil {324 if s == nil {
309 return 0325 return 0
310 }326 }
311- unlockView := s.lockView()327+ s.lockView()
312- defer unlockView()328+ defer s.unlockView()
313 return s.next - s.acked329 return s.next - s.acked
314 }330 }

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 2

Tags 13

↑↓ moveenter openesc close