detached ba3714c

fix(obs): decouple runtime reporting from ACK progress

commit
ba3714c57a183570f1fe81c432a7ef1c1aa3388e
author
brice · · 2026-07-13 09:25 UTC
parent
c10e22a
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
5 files · +312 -23
M internal/ports/runtime_observer.go +132 -0
@@ -2,6 +2,7 @@ package ports
22
33 import (
44 "errors"
5+ "sync"
56 "sync/atomic"
67 )
78
@@ -61,6 +62,137 @@ type RuntimeMark struct {
6162 // a producer provide a timestamp in another clock domain.
6263 type RuntimeObserver interface{ ObserveRuntime(RuntimeMark) }
6364
65+// SerializedRuntimeObserver accepts marks without waiting for observer I/O.
66+// Flush and Close wait until every accepted mark has reached Observer. Close
67+// is idempotent and rejects later marks.
68+type SerializedRuntimeObserver interface {
69+ RuntimeObserver
70+ Flush()
71+ Close()
72+}
73+
74+// NewSerializedRuntimeObserver isolates producers from a potentially slow
75+// observer with one ordered worker. capacity bounds queued marks. If the queue
76+// fills, dropped marks are represented by one ordered diagnostic mark before
77+// reporting resumes, so a trace can never silently claim completeness.
78+func NewSerializedRuntimeObserver(observer RuntimeObserver, capacity int) SerializedRuntimeObserver {
79+ if observer == nil {
80+ return nil
81+ }
82+ if capacity < 1 {
83+ capacity = 1
84+ }
85+ o := &serializedRuntimeObserver{observer: observer, capacity: capacity, done: make(chan struct{})}
86+ o.cond = sync.NewCond(&o.mu)
87+ go o.run()
88+ return o
89+}
90+
91+type serializedRuntimeObserver struct {
92+ observer RuntimeObserver
93+ capacity int
94+
95+ mu sync.Mutex
96+ cond *sync.Cond
97+ marks []RuntimeMark
98+ dropped uint64
99+ closed bool
100+ active bool
101+ done chan struct{}
102+}
103+
104+func (o *serializedRuntimeObserver) ObserveRuntime(mark RuntimeMark) {
105+ if o == nil {
106+ return
107+ }
108+ o.mu.Lock()
109+ defer o.mu.Unlock()
110+ if o.closed {
111+ return
112+ }
113+ if o.dropped != 0 {
114+ // Reserve space for both the gap marker and the first post-gap mark.
115+ // This preserves the producer order around a bounded-queue loss.
116+ if len(o.marks)+2 > o.capacity {
117+ o.dropped++
118+ return
119+ }
120+ o.marks = append(o.marks, runtimeObserverGapMark(o.dropped), mark)
121+ o.dropped = 0
122+ o.cond.Signal()
123+ return
124+ }
125+ if len(o.marks) == o.capacity {
126+ o.dropped = 1
127+ return
128+ }
129+ o.marks = append(o.marks, mark)
130+ o.cond.Signal()
131+}
132+
133+func runtimeObserverGapMark(dropped uint64) RuntimeMark {
134+ return NewRuntimeMark("observer", RuntimeTransportDiagnostic, dropped, false)
135+}
136+
137+func (o *serializedRuntimeObserver) Flush() {
138+ if o == nil {
139+ return
140+ }
141+ o.mu.Lock()
142+ for len(o.marks) != 0 || o.active || o.dropped != 0 {
143+ o.cond.Signal()
144+ o.cond.Wait()
145+ }
146+ closed := o.closed
147+ o.mu.Unlock()
148+ if closed {
149+ <-o.done
150+ }
151+}
152+
153+func (o *serializedRuntimeObserver) Close() {
154+ if o == nil {
155+ return
156+ }
157+ o.mu.Lock()
158+ if !o.closed {
159+ o.closed = true
160+ o.cond.Broadcast()
161+ }
162+ o.mu.Unlock()
163+ <-o.done
164+}
165+
166+func (o *serializedRuntimeObserver) run() {
167+ defer close(o.done)
168+ for {
169+ o.mu.Lock()
170+ for len(o.marks) == 0 && o.dropped == 0 && !o.closed {
171+ o.cond.Wait()
172+ }
173+ if len(o.marks) == 0 && o.dropped != 0 {
174+ o.marks = append(o.marks, runtimeObserverGapMark(o.dropped))
175+ o.dropped = 0
176+ }
177+ if len(o.marks) == 0 && o.closed {
178+ o.cond.Broadcast()
179+ o.mu.Unlock()
180+ return
181+ }
182+ mark := o.marks[0]
183+ o.marks = o.marks[1:]
184+ o.active = true
185+ o.mu.Unlock()
186+
187+ o.observer.ObserveRuntime(mark)
188+
189+ o.mu.Lock()
190+ o.active = false
191+ o.cond.Broadcast()
192+ o.mu.Unlock()
193+ }
194+}
195+
64196 // RuntimeMarkValid reports whether a mark has all correlation fields required
65197 // by the JSONL schema. Tick is checked separately by the timestamp owner.
66198 func RuntimeMarkValid(mark RuntimeMark) bool {
@@ -2,6 +2,7 @@ package ports
2 2
3 import (3 import (
4 "errors"4 "errors"
5+ "sync"
5 "sync/atomic"6 "sync/atomic"
6 )7 )
7 8
@@ -61,6 +62,137 @@ type RuntimeMark struct {
61 // a producer provide a timestamp in another clock domain.62 // a producer provide a timestamp in another clock domain.
62 type RuntimeObserver interface{ ObserveRuntime(RuntimeMark) }63 type RuntimeObserver interface{ ObserveRuntime(RuntimeMark) }
63 64
65+// SerializedRuntimeObserver accepts marks without waiting for observer I/O.
66+// Flush and Close wait until every accepted mark has reached Observer. Close
67+// is idempotent and rejects later marks.
68+type SerializedRuntimeObserver interface {
69+ RuntimeObserver
70+ Flush()
71+ Close()
72+}
73+
74+// NewSerializedRuntimeObserver isolates producers from a potentially slow
75+// observer with one ordered worker. capacity bounds queued marks. If the queue
76+// fills, dropped marks are represented by one ordered diagnostic mark before
77+// reporting resumes, so a trace can never silently claim completeness.
78+func NewSerializedRuntimeObserver(observer RuntimeObserver, capacity int) SerializedRuntimeObserver {
79+ if observer == nil {
80+ return nil
81+ }
82+ if capacity < 1 {
83+ capacity = 1
84+ }
85+ o := &serializedRuntimeObserver{observer: observer, capacity: capacity, done: make(chan struct{})}
86+ o.cond = sync.NewCond(&o.mu)
87+ go o.run()
88+ return o
89+}
90+
91+type serializedRuntimeObserver struct {
92+ observer RuntimeObserver
93+ capacity int
94+
95+ mu sync.Mutex
96+ cond *sync.Cond
97+ marks []RuntimeMark
98+ dropped uint64
99+ closed bool
100+ active bool
101+ done chan struct{}
102+}
103+
104+func (o *serializedRuntimeObserver) ObserveRuntime(mark RuntimeMark) {
105+ if o == nil {
106+ return
107+ }
108+ o.mu.Lock()
109+ defer o.mu.Unlock()
110+ if o.closed {
111+ return
112+ }
113+ if o.dropped != 0 {
114+ // Reserve space for both the gap marker and the first post-gap mark.
115+ // This preserves the producer order around a bounded-queue loss.
116+ if len(o.marks)+2 > o.capacity {
117+ o.dropped++
118+ return
119+ }
120+ o.marks = append(o.marks, runtimeObserverGapMark(o.dropped), mark)
121+ o.dropped = 0
122+ o.cond.Signal()
123+ return
124+ }
125+ if len(o.marks) == o.capacity {
126+ o.dropped = 1
127+ return
128+ }
129+ o.marks = append(o.marks, mark)
130+ o.cond.Signal()
131+}
132+
133+func runtimeObserverGapMark(dropped uint64) RuntimeMark {
134+ return NewRuntimeMark("observer", RuntimeTransportDiagnostic, dropped, false)
135+}
136+
137+func (o *serializedRuntimeObserver) Flush() {
138+ if o == nil {
139+ return
140+ }
141+ o.mu.Lock()
142+ for len(o.marks) != 0 || o.active || o.dropped != 0 {
143+ o.cond.Signal()
144+ o.cond.Wait()
145+ }
146+ closed := o.closed
147+ o.mu.Unlock()
148+ if closed {
149+ <-o.done
150+ }
151+}
152+
153+func (o *serializedRuntimeObserver) Close() {
154+ if o == nil {
155+ return
156+ }
157+ o.mu.Lock()
158+ if !o.closed {
159+ o.closed = true
160+ o.cond.Broadcast()
161+ }
162+ o.mu.Unlock()
163+ <-o.done
164+}
165+
166+func (o *serializedRuntimeObserver) run() {
167+ defer close(o.done)
168+ for {
169+ o.mu.Lock()
170+ for len(o.marks) == 0 && o.dropped == 0 && !o.closed {
171+ o.cond.Wait()
172+ }
173+ if len(o.marks) == 0 && o.dropped != 0 {
174+ o.marks = append(o.marks, runtimeObserverGapMark(o.dropped))
175+ o.dropped = 0
176+ }
177+ if len(o.marks) == 0 && o.closed {
178+ o.cond.Broadcast()
179+ o.mu.Unlock()
180+ return
181+ }
182+ mark := o.marks[0]
183+ o.marks = o.marks[1:]
184+ o.active = true
185+ o.mu.Unlock()
186+
187+ o.observer.ObserveRuntime(mark)
188+
189+ o.mu.Lock()
190+ o.active = false
191+ o.cond.Broadcast()
192+ o.mu.Unlock()
193+ }
194+}
195+
64 // RuntimeMarkValid reports whether a mark has all correlation fields required196 // RuntimeMarkValid reports whether a mark has all correlation fields required
65 // by the JSONL schema. Tick is checked separately by the timestamp owner.197 // by the JSONL schema. Tick is checked separately by the timestamp owner.
66 func RuntimeMarkValid(mark RuntimeMark) bool {198 func RuntimeMarkValid(mark RuntimeMark) bool {
M internal/ports/runtime_observer_test.go +57 -0
@@ -2,7 +2,9 @@ package ports
22
33 import (
44 "reflect"
5+ "sync"
56 "testing"
7+ "time"
68 )
79
810 func TestRuntimeObserverContract(t *testing.T) {
@@ -42,6 +44,61 @@ func TestRuntimeObserverContract(t *testing.T) {
4244 var _ RuntimeObserver = runtimeObserverFunc(func(RuntimeMark) {})
4345 }
4446
47+func TestSerializedRuntimeObserverFlushesInOrderAndCloses(t *testing.T) {
48+ var mu sync.Mutex
49+ var got []RuntimeMark
50+ observer := NewSerializedRuntimeObserver(runtimeObserverFunc(func(mark RuntimeMark) {
51+ mu.Lock()
52+ got = append(got, mark)
53+ mu.Unlock()
54+ }), 2)
55+ observer.ObserveRuntime(NewRuntimeMark("client", RuntimeEmitStart, 1, true))
56+ observer.ObserveRuntime(NewRuntimeMark("client", RuntimeEmitEnd, 1, true))
57+ observer.Flush()
58+ observer.Close()
59+ observer.Close()
60+
61+ mu.Lock()
62+ defer mu.Unlock()
63+ if len(got) != 2 || got[0].Kind != RuntimeEmitStart || got[1].Kind != RuntimeEmitEnd {
64+ t.Fatalf("serialized marks = %#v", got)
65+ }
66+}
67+
68+func TestSerializedRuntimeObserverReportsBoundedQueueLoss(t *testing.T) {
69+ entered := make(chan struct{})
70+ release := make(chan struct{})
71+ var mu sync.Mutex
72+ var got []RuntimeMark
73+ observer := NewSerializedRuntimeObserver(runtimeObserverFunc(func(mark RuntimeMark) {
74+ if mark.Kind == RuntimeEmitStart {
75+ close(entered)
76+ <-release
77+ }
78+ mu.Lock()
79+ got = append(got, mark)
80+ mu.Unlock()
81+ }), 2)
82+ observer.ObserveRuntime(NewRuntimeMark("client", RuntimeEmitStart, 1, true))
83+ select {
84+ case <-entered:
85+ case <-time.After(time.Second):
86+ t.Fatal("observer did not start")
87+ }
88+ observer.ObserveRuntime(NewRuntimeMark("client", RuntimeComposeStart, 1, true))
89+ observer.ObserveRuntime(NewRuntimeMark("client", RuntimeComposeEnd, 1, true))
90+ observer.ObserveRuntime(NewRuntimeMark("client", RuntimeEmitEnd, 1, true)) // bounded loss
91+ close(release)
92+ observer.Flush()
93+ observer.Close()
94+
95+ mu.Lock()
96+ defer mu.Unlock()
97+ if len(got) != 4 || got[0].Kind != RuntimeEmitStart || got[1].Kind != RuntimeComposeStart || got[2].Kind != RuntimeComposeEnd || got[3].Kind != RuntimeTransportDiagnostic || got[3].Bytes != 1 || got[3].Valid {
98+ t.Fatalf("bounded-loss marks = %#v", got)
99+ }
100+}
101+
45102 func TestRuntimeObserverRequiredSpanKinds(t *testing.T) {
46103 pairs := []struct {
47104 start RuntimeMarkKind
@@ -2,7 +2,9 @@ package ports
2 2
3 import (3 import (
4 "reflect"4 "reflect"
5+ "sync"
5 "testing"6 "testing"
7+ "time"
6 )8 )
7 9
8 func TestRuntimeObserverContract(t *testing.T) {10 func TestRuntimeObserverContract(t *testing.T) {
@@ -42,6 +44,61 @@ func TestRuntimeObserverContract(t *testing.T) {
42 var _ RuntimeObserver = runtimeObserverFunc(func(RuntimeMark) {})44 var _ RuntimeObserver = runtimeObserverFunc(func(RuntimeMark) {})
43 }45 }
44 46
47+func TestSerializedRuntimeObserverFlushesInOrderAndCloses(t *testing.T) {
48+ var mu sync.Mutex
49+ var got []RuntimeMark
50+ observer := NewSerializedRuntimeObserver(runtimeObserverFunc(func(mark RuntimeMark) {
51+ mu.Lock()
52+ got = append(got, mark)
53+ mu.Unlock()
54+ }), 2)
55+ observer.ObserveRuntime(NewRuntimeMark("client", RuntimeEmitStart, 1, true))
56+ observer.ObserveRuntime(NewRuntimeMark("client", RuntimeEmitEnd, 1, true))
57+ observer.Flush()
58+ observer.Close()
59+ observer.Close()
60+
61+ mu.Lock()
62+ defer mu.Unlock()
63+ if len(got) != 2 || got[0].Kind != RuntimeEmitStart || got[1].Kind != RuntimeEmitEnd {
64+ t.Fatalf("serialized marks = %#v", got)
65+ }
66+}
67+
68+func TestSerializedRuntimeObserverReportsBoundedQueueLoss(t *testing.T) {
69+ entered := make(chan struct{})
70+ release := make(chan struct{})
71+ var mu sync.Mutex
72+ var got []RuntimeMark
73+ observer := NewSerializedRuntimeObserver(runtimeObserverFunc(func(mark RuntimeMark) {
74+ if mark.Kind == RuntimeEmitStart {
75+ close(entered)
76+ <-release
77+ }
78+ mu.Lock()
79+ got = append(got, mark)
80+ mu.Unlock()
81+ }), 2)
82+ observer.ObserveRuntime(NewRuntimeMark("client", RuntimeEmitStart, 1, true))
83+ select {
84+ case <-entered:
85+ case <-time.After(time.Second):
86+ t.Fatal("observer did not start")
87+ }
88+ observer.ObserveRuntime(NewRuntimeMark("client", RuntimeComposeStart, 1, true))
89+ observer.ObserveRuntime(NewRuntimeMark("client", RuntimeComposeEnd, 1, true))
90+ observer.ObserveRuntime(NewRuntimeMark("client", RuntimeEmitEnd, 1, true)) // bounded loss
91+ close(release)
92+ observer.Flush()
93+ observer.Close()
94+
95+ mu.Lock()
96+ defer mu.Unlock()
97+ if len(got) != 4 || got[0].Kind != RuntimeEmitStart || got[1].Kind != RuntimeComposeStart || got[2].Kind != RuntimeComposeEnd || got[3].Kind != RuntimeTransportDiagnostic || got[3].Bytes != 1 || got[3].Valid {
98+ t.Fatalf("bounded-loss marks = %#v", got)
99+ }
100+}
101+
45 func TestRuntimeObserverRequiredSpanKinds(t *testing.T) {102 func TestRuntimeObserverRequiredSpanKinds(t *testing.T) {
46 pairs := []struct {103 pairs := []struct {
47 start RuntimeMarkKind104 start RuntimeMarkKind
M internal/usecase/client/client.go +9 -0
@@ -102,6 +102,10 @@ const stdinBufSize = 4096
102102 // goroutine, decoupling the input/resize pumps from transport back-pressure.
103103 const sendQueueDepth = 64
104104
105+// runtimeObserverQueueDepth bounds marks awaiting the one reporting worker.
106+// A full queue records an ordered diagnostic gap rather than blocking ACKs.
107+const runtimeObserverQueueDepth = 64
108+
105109 var reconnectSleep = sleepReconnect
106110 var reconnectSleepWithResize = sleepReconnectWithResizeEvents
107111
@@ -141,6 +145,11 @@ func RunWithRuntimeObserver(ctx context.Context, dialer ports.Dialer, term ports
141145 }
142146
143147 func run(ctx context.Context, dialer ports.Dialer, term ports.Terminal, clk ports.Clock, intent uint8, name string, remote bool, clipboard ports.ClipboardReader, log *slog.Logger, observer ports.RuntimeObserver) (retErr error) {
148+ if observer != nil {
149+ reporter := ports.NewSerializedRuntimeObserver(observer, runtimeObserverQueueDepth)
150+ defer reporter.Close()
151+ observer = reporter
152+ }
144153 if log == nil {
145154 log = slog.Default()
146155 }
@@ -102,6 +102,10 @@ const stdinBufSize = 4096
102 // goroutine, decoupling the input/resize pumps from transport back-pressure.102 // goroutine, decoupling the input/resize pumps from transport back-pressure.
103 const sendQueueDepth = 64103 const sendQueueDepth = 64
104 104
105+// runtimeObserverQueueDepth bounds marks awaiting the one reporting worker.
106+// A full queue records an ordered diagnostic gap rather than blocking ACKs.
107+const runtimeObserverQueueDepth = 64
108+
105 var reconnectSleep = sleepReconnect109 var reconnectSleep = sleepReconnect
106 var reconnectSleepWithResize = sleepReconnectWithResizeEvents110 var reconnectSleepWithResize = sleepReconnectWithResizeEvents
107 111
@@ -141,6 +145,11 @@ func RunWithRuntimeObserver(ctx context.Context, dialer ports.Dialer, term ports
141 }145 }
142 146
143 func run(ctx context.Context, dialer ports.Dialer, term ports.Terminal, clk ports.Clock, intent uint8, name string, remote bool, clipboard ports.ClipboardReader, log *slog.Logger, observer ports.RuntimeObserver) (retErr error) {147 func run(ctx context.Context, dialer ports.Dialer, term ports.Terminal, clk ports.Clock, intent uint8, name string, remote bool, clipboard ports.ClipboardReader, log *slog.Logger, observer ports.RuntimeObserver) (retErr error) {
148+ if observer != nil {
149+ reporter := ports.NewSerializedRuntimeObserver(observer, runtimeObserverQueueDepth)
150+ defer reporter.Close()
151+ observer = reporter
152+ }
144 if log == nil {153 if log == nil {
145 log = slog.Default()154 log = slog.Default()
146 }155 }
M internal/usecase/client/transport_observability_test.go +70 -0
@@ -6,6 +6,7 @@ import (
66 "sync"
77 "sync/atomic"
88 "testing"
9+ "time"
910
1011 "github.com/stretchr/testify/require"
1112
@@ -15,6 +16,53 @@ import (
1516 "github.com/bnema/vev/internal/usecase/client"
1617 )
1718
19+func TestBlockingRuntimeObserverDoesNotDelayTerminalFlushOrACK(t *testing.T) {
20+ var out bytes.Buffer
21+ var restores atomic.Int32
22+ resizeCh := make(chan domain.Size)
23+ flushed := make(chan struct{})
24+ term := portsmocks.NewMockTerminal(t)
25+ term.EXPECT().Size().Return(domain.Size{Cols: 80, Rows: 24}, nil).Once()
26+ term.EXPECT().EnterRaw().Return(func() error { restores.Add(1); return nil }, nil).Once()
27+ in := newBlockingReader()
28+ defer in.unblock()
29+ term.EXPECT().In().Return(in).Maybe()
30+ term.EXPECT().Out().Return(&out).Maybe()
31+ term.EXPECT().Flush().Run(func() { close(flushed) }).Return(nil).Once()
32+ term.EXPECT().ResizeEvents().Return(resizeCh).Maybe()
33+
34+ observer := &blockingClientRuntimeObserver{entered: make(chan struct{}), release: make(chan struct{})}
35+ acked := make(chan struct{})
36+ transport := portsmocks.NewMockTransport(t)
37+ transport.EXPECT().Send(isType(ports.MsgHello)).Return(nil).Once()
38+ transport.EXPECT().Send(isType(ports.MsgAck)).Run(func(ports.Frame) { close(acked) }).Return(nil).Once()
39+ unblock := scriptRecv(transport,
40+ recvItem{f: frameOf(ports.MsgWelcome, ports.MarshalWelcome(ports.Welcome{SessionID: "s1"}))},
41+ recvItem{f: frameOf(ports.MsgOutput, ports.MarshalOutput(ports.Output{Data: []byte("flush-before-observe"), NewStateNum: 3}))},
42+ )
43+ defer unblock()
44+ transport.EXPECT().Close().Return(nil).Once()
45+
46+ ctx, cancel := context.WithCancel(context.Background())
47+ result := make(chan error, 1)
48+ go func() {
49+ result <- client.Attach(ctx, transport, term, realClock{}, ports.IntentEphemeral, "", client.WithRuntimeObserver(observer))
50+ }()
51+ awaitClientRuntime(t, observer.entered, "blocking observer")
52+ awaitClientRuntime(t, flushed, "terminal flush")
53+ awaitClientRuntime(t, acked, "ACK")
54+ cancel()
55+ close(observer.release)
56+ select {
57+ case err := <-result:
58+ require.NoError(t, err)
59+ case <-time.After(time.Second):
60+ t.Fatal("Attach did not complete after observer release")
61+ }
62+ require.Equal(t, "flush-before-observe", out.String())
63+ require.Equal(t, int32(1), restores.Load())
64+}
65+
1866 func TestTerminalFlushBoundaryTransportObservability(t *testing.T) {
1967 var out bytes.Buffer
2068 var restores atomic.Int32
@@ -43,6 +91,28 @@ func TestTerminalFlushBoundaryTransportObservability(t *testing.T) {
4391 observer.requireOrdered(t, ports.RuntimeTerminalFlushed)
4492 }
4593
94+type blockingClientRuntimeObserver struct {
95+ entered chan struct{}
96+ release chan struct{}
97+ once sync.Once
98+}
99+
100+func (o *blockingClientRuntimeObserver) ObserveRuntime(ports.RuntimeMark) {
101+ o.once.Do(func() {
102+ close(o.entered)
103+ <-o.release
104+ })
105+}
106+
107+func awaitClientRuntime(t *testing.T, ch <-chan struct{}, what string) {
108+ t.Helper()
109+ select {
110+ case <-ch:
111+ case <-time.After(time.Second):
112+ t.Fatalf("timed out waiting for %s", what)
113+ }
114+}
115+
46116 type clientRuntimeObserver struct {
47117 mu sync.Mutex
48118 marks []ports.RuntimeMark
@@ -6,6 +6,7 @@ import (
6 "sync"6 "sync"
7 "sync/atomic"7 "sync/atomic"
8 "testing"8 "testing"
9+ "time"
9 10
10 "github.com/stretchr/testify/require"11 "github.com/stretchr/testify/require"
11 12
@@ -15,6 +16,53 @@ import (
15 "github.com/bnema/vev/internal/usecase/client"16 "github.com/bnema/vev/internal/usecase/client"
16 )17 )
17 18
19+func TestBlockingRuntimeObserverDoesNotDelayTerminalFlushOrACK(t *testing.T) {
20+ var out bytes.Buffer
21+ var restores atomic.Int32
22+ resizeCh := make(chan domain.Size)
23+ flushed := make(chan struct{})
24+ term := portsmocks.NewMockTerminal(t)
25+ term.EXPECT().Size().Return(domain.Size{Cols: 80, Rows: 24}, nil).Once()
26+ term.EXPECT().EnterRaw().Return(func() error { restores.Add(1); return nil }, nil).Once()
27+ in := newBlockingReader()
28+ defer in.unblock()
29+ term.EXPECT().In().Return(in).Maybe()
30+ term.EXPECT().Out().Return(&out).Maybe()
31+ term.EXPECT().Flush().Run(func() { close(flushed) }).Return(nil).Once()
32+ term.EXPECT().ResizeEvents().Return(resizeCh).Maybe()
33+
34+ observer := &blockingClientRuntimeObserver{entered: make(chan struct{}), release: make(chan struct{})}
35+ acked := make(chan struct{})
36+ transport := portsmocks.NewMockTransport(t)
37+ transport.EXPECT().Send(isType(ports.MsgHello)).Return(nil).Once()
38+ transport.EXPECT().Send(isType(ports.MsgAck)).Run(func(ports.Frame) { close(acked) }).Return(nil).Once()
39+ unblock := scriptRecv(transport,
40+ recvItem{f: frameOf(ports.MsgWelcome, ports.MarshalWelcome(ports.Welcome{SessionID: "s1"}))},
41+ recvItem{f: frameOf(ports.MsgOutput, ports.MarshalOutput(ports.Output{Data: []byte("flush-before-observe"), NewStateNum: 3}))},
42+ )
43+ defer unblock()
44+ transport.EXPECT().Close().Return(nil).Once()
45+
46+ ctx, cancel := context.WithCancel(context.Background())
47+ result := make(chan error, 1)
48+ go func() {
49+ result <- client.Attach(ctx, transport, term, realClock{}, ports.IntentEphemeral, "", client.WithRuntimeObserver(observer))
50+ }()
51+ awaitClientRuntime(t, observer.entered, "blocking observer")
52+ awaitClientRuntime(t, flushed, "terminal flush")
53+ awaitClientRuntime(t, acked, "ACK")
54+ cancel()
55+ close(observer.release)
56+ select {
57+ case err := <-result:
58+ require.NoError(t, err)
59+ case <-time.After(time.Second):
60+ t.Fatal("Attach did not complete after observer release")
61+ }
62+ require.Equal(t, "flush-before-observe", out.String())
63+ require.Equal(t, int32(1), restores.Load())
64+}
65+
18 func TestTerminalFlushBoundaryTransportObservability(t *testing.T) {66 func TestTerminalFlushBoundaryTransportObservability(t *testing.T) {
19 var out bytes.Buffer67 var out bytes.Buffer
20 var restores atomic.Int3268 var restores atomic.Int32
@@ -43,6 +91,28 @@ func TestTerminalFlushBoundaryTransportObservability(t *testing.T) {
43 observer.requireOrdered(t, ports.RuntimeTerminalFlushed)91 observer.requireOrdered(t, ports.RuntimeTerminalFlushed)
44 }92 }
45 93
94+type blockingClientRuntimeObserver struct {
95+ entered chan struct{}
96+ release chan struct{}
97+ once sync.Once
98+}
99+
100+func (o *blockingClientRuntimeObserver) ObserveRuntime(ports.RuntimeMark) {
101+ o.once.Do(func() {
102+ close(o.entered)
103+ <-o.release
104+ })
105+}
106+
107+func awaitClientRuntime(t *testing.T, ch <-chan struct{}, what string) {
108+ t.Helper()
109+ select {
110+ case <-ch:
111+ case <-time.After(time.Second):
112+ t.Fatalf("timed out waiting for %s", what)
113+ }
114+}
115+
46 type clientRuntimeObserver struct {116 type clientRuntimeObserver struct {
47 mu sync.Mutex117 mu sync.Mutex
48 marks []ports.RuntimeMark118 marks []ports.RuntimeMark
M internal/usecase/daemon/transport_observability_test.go +44 -23
@@ -6,30 +6,64 @@ import (
66 "testing"
77 "time"
88
9+ "github.com/bnema/vev/internal/domain"
910 "github.com/bnema/vev/internal/ports"
1011 )
1112
12-// This acceptance boundary deliberately configures the daemon with an
13-// observer only. The clock remains the daemon's existing behavior clock;
14-// timestamp ownership belongs to the concrete process observer.
13+// This acceptance boundary exercises the attached resize, render, and ACK
14+// path rather than merely listing the marks the implementation should emit.
1515 func TestTransportObservabilityDaemonBoundaries(t *testing.T) {
1616 observer := &daemonRuntimeObserver{}
17- d := New(nil, daemonRuntimeClock{now: time.Unix(0, 101)}, nil, WithRuntimeObserver(observer))
18- if d == nil {
19- t.Fatal("New() returned nil")
17+ d, sess, ac, sends := newManualSessionWithPTYs(t, &quietPTY{})
18+ d.runtimeObserver = observer
19+ // One in-flight state makes the second public resize take the ACK-blocked
20+ // path. The real coordinator owns both renders and the ACK wake.
21+ ac.output = newOutputStateStream(1)
22+ rc := d.attachCoordinator(sess, nil, ac, true)
23+
24+ if !d.resizeForFirstPaint(sess, ac, domain.Size{Cols: 90, Rows: 25}) {
25+ t.Fatal("first resize was not accepted")
26+ }
27+ first := <-sends
28+ if first.Type != ports.MsgOutput {
29+ t.Fatalf("first frame = %v, want output", first.Type)
30+ }
31+ output, err := ports.UnmarshalOutput(first.Payload)
32+ if err != nil {
33+ t.Fatalf("decode first output: %v", err)
34+ }
35+ if !d.resizeForFirstPaint(sess, ac, domain.Size{Cols: 100, Rows: 26}) {
36+ t.Fatal("second resize was not accepted")
37+ }
38+ ac.ackOutputState(output.NewStateNum)
39+ rc.notifyAck()
40+ second := <-sends
41+ if second.Type != ports.MsgOutput {
42+ t.Fatalf("second frame = %v, want output", second.Type)
2043 }
2144
2245 want := []ports.RuntimeMarkKind{
2346 ports.RuntimeResizeRequested, ports.RuntimeResizeCommitted,
47+ ports.RuntimeQueueEnqueued, ports.RuntimeQueueDequeued,
2448 ports.RuntimeCaptureStart, ports.RuntimeCaptureEnd,
2549 ports.RuntimeComposeStart, ports.RuntimeComposeEnd,
2650 ports.RuntimeDiffStart, ports.RuntimeDiffEnd,
27- ports.RuntimeQueueEnqueued, ports.RuntimeQueueDequeued,
28- ports.RuntimeACKBlockedStart, ports.RuntimeACKBlockedEnd,
2951 ports.RuntimeEmitStart, ports.RuntimeEmitEnd,
52+ ports.RuntimeResizeRequested, ports.RuntimeResizeCommitted,
53+ ports.RuntimeQueueEnqueued, ports.RuntimeACKBlockedStart,
54+ ports.RuntimeACKBlockedEnd, ports.RuntimeQueueDequeued,
55+ ports.RuntimeCaptureStart, ports.RuntimeCaptureEnd,
56+ ports.RuntimeComposeStart, ports.RuntimeComposeEnd,
57+ ports.RuntimeDiffStart, ports.RuntimeDiffEnd,
58+ ports.RuntimeEmitStart, ports.RuntimeEmitEnd,
59+ }
60+ if len(observer.marks) != len(want) {
61+ t.Fatalf("runtime marks = %#v, want kinds %#v", observer.marks, want)
3062 }
31- if len(want) != 14 {
32- t.Fatalf("daemon boundary mark inventory changed: %d", len(want))
63+ for i, kind := range want {
64+ if observer.marks[i].Kind != kind {
65+ t.Fatalf("runtime mark %d = %q, want %q; all=%#v", i, observer.marks[i].Kind, kind, observer.marks)
66+ }
3367 }
3468 }
3569
@@ -39,19 +73,6 @@ func (o *daemonRuntimeObserver) ObserveRuntime(mark ports.RuntimeMark) {
3973 o.marks = append(o.marks, mark)
4074 }
4175
42-type daemonRuntimeClock struct{ now time.Time }
43-
44-func (c daemonRuntimeClock) Now() time.Time { return c.now }
45-func (c daemonRuntimeClock) NewTimer(d time.Duration) ports.Timer {
46- return daemonRuntimeTimer{timer: time.NewTimer(d)}
47-}
48-
49-type daemonRuntimeTimer struct{ timer *time.Timer }
50-
51-func (t daemonRuntimeTimer) C() <-chan time.Time { return t.timer.C }
52-func (t daemonRuntimeTimer) Reset(d time.Duration) bool { return t.timer.Reset(d) }
53-func (t daemonRuntimeTimer) Stop() bool { return t.timer.Stop() }
54-
5576 type blockingDaemonRuntimeObserver struct {
5677 mu sync.Mutex
5778 entered chan struct{}
@@ -6,30 +6,64 @@ import (
6 "testing"6 "testing"
7 "time"7 "time"
8 8
9+ "github.com/bnema/vev/internal/domain"
9 "github.com/bnema/vev/internal/ports"10 "github.com/bnema/vev/internal/ports"
10 )11 )
11 12
12-// This acceptance boundary deliberately configures the daemon with an13+// This acceptance boundary exercises the attached resize, render, and ACK
13-// observer only. The clock remains the daemon's existing behavior clock;14+// path rather than merely listing the marks the implementation should emit.
14-// timestamp ownership belongs to the concrete process observer.
15 func TestTransportObservabilityDaemonBoundaries(t *testing.T) {15 func TestTransportObservabilityDaemonBoundaries(t *testing.T) {
16 observer := &daemonRuntimeObserver{}16 observer := &daemonRuntimeObserver{}
17- d := New(nil, daemonRuntimeClock{now: time.Unix(0, 101)}, nil, WithRuntimeObserver(observer))17+ d, sess, ac, sends := newManualSessionWithPTYs(t, &quietPTY{})
18- if d == nil {18+ d.runtimeObserver = observer
19- t.Fatal("New() returned nil")19+ // One in-flight state makes the second public resize take the ACK-blocked
20+ // path. The real coordinator owns both renders and the ACK wake.
21+ ac.output = newOutputStateStream(1)
22+ rc := d.attachCoordinator(sess, nil, ac, true)
23+
24+ if !d.resizeForFirstPaint(sess, ac, domain.Size{Cols: 90, Rows: 25}) {
25+ t.Fatal("first resize was not accepted")
26+ }
27+ first := <-sends
28+ if first.Type != ports.MsgOutput {
29+ t.Fatalf("first frame = %v, want output", first.Type)
30+ }
31+ output, err := ports.UnmarshalOutput(first.Payload)
32+ if err != nil {
33+ t.Fatalf("decode first output: %v", err)
34+ }
35+ if !d.resizeForFirstPaint(sess, ac, domain.Size{Cols: 100, Rows: 26}) {
36+ t.Fatal("second resize was not accepted")
37+ }
38+ ac.ackOutputState(output.NewStateNum)
39+ rc.notifyAck()
40+ second := <-sends
41+ if second.Type != ports.MsgOutput {
42+ t.Fatalf("second frame = %v, want output", second.Type)
20 }43 }
21 44
22 want := []ports.RuntimeMarkKind{45 want := []ports.RuntimeMarkKind{
23 ports.RuntimeResizeRequested, ports.RuntimeResizeCommitted,46 ports.RuntimeResizeRequested, ports.RuntimeResizeCommitted,
47+ ports.RuntimeQueueEnqueued, ports.RuntimeQueueDequeued,
24 ports.RuntimeCaptureStart, ports.RuntimeCaptureEnd,48 ports.RuntimeCaptureStart, ports.RuntimeCaptureEnd,
25 ports.RuntimeComposeStart, ports.RuntimeComposeEnd,49 ports.RuntimeComposeStart, ports.RuntimeComposeEnd,
26 ports.RuntimeDiffStart, ports.RuntimeDiffEnd,50 ports.RuntimeDiffStart, ports.RuntimeDiffEnd,
27- ports.RuntimeQueueEnqueued, ports.RuntimeQueueDequeued,
28- ports.RuntimeACKBlockedStart, ports.RuntimeACKBlockedEnd,
29 ports.RuntimeEmitStart, ports.RuntimeEmitEnd,51 ports.RuntimeEmitStart, ports.RuntimeEmitEnd,
52+ ports.RuntimeResizeRequested, ports.RuntimeResizeCommitted,
53+ ports.RuntimeQueueEnqueued, ports.RuntimeACKBlockedStart,
54+ ports.RuntimeACKBlockedEnd, ports.RuntimeQueueDequeued,
55+ ports.RuntimeCaptureStart, ports.RuntimeCaptureEnd,
56+ ports.RuntimeComposeStart, ports.RuntimeComposeEnd,
57+ ports.RuntimeDiffStart, ports.RuntimeDiffEnd,
58+ ports.RuntimeEmitStart, ports.RuntimeEmitEnd,
59+ }
60+ if len(observer.marks) != len(want) {
61+ t.Fatalf("runtime marks = %#v, want kinds %#v", observer.marks, want)
30 }62 }
31- if len(want) != 14 {63+ for i, kind := range want {
32- t.Fatalf("daemon boundary mark inventory changed: %d", len(want))64+ if observer.marks[i].Kind != kind {
65+ t.Fatalf("runtime mark %d = %q, want %q; all=%#v", i, observer.marks[i].Kind, kind, observer.marks)
66+ }
33 }67 }
34 }68 }
35 69
@@ -39,19 +73,6 @@ func (o *daemonRuntimeObserver) ObserveRuntime(mark ports.RuntimeMark) {
39 o.marks = append(o.marks, mark)73 o.marks = append(o.marks, mark)
40 }74 }
41 75
42-type daemonRuntimeClock struct{ now time.Time }
43-
44-func (c daemonRuntimeClock) Now() time.Time { return c.now }
45-func (c daemonRuntimeClock) NewTimer(d time.Duration) ports.Timer {
46- return daemonRuntimeTimer{timer: time.NewTimer(d)}
47-}
48-
49-type daemonRuntimeTimer struct{ timer *time.Timer }
50-
51-func (t daemonRuntimeTimer) C() <-chan time.Time { return t.timer.C }
52-func (t daemonRuntimeTimer) Reset(d time.Duration) bool { return t.timer.Reset(d) }
53-func (t daemonRuntimeTimer) Stop() bool { return t.timer.Stop() }
54-
55 type blockingDaemonRuntimeObserver struct {76 type blockingDaemonRuntimeObserver struct {
56 mu sync.Mutex77 mu sync.Mutex
57 entered chan struct{}78 entered chan struct{}

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 12

Tags 11

↑↓ moveenter openesc close