detached 49f309d

fix(client): repaint the picker after terminal resize

commit
49f309d8ea46d946736f12671dc8572fa2e6dd54
author
brice · · 2026-09-19 23:23 UTC
parent
113b229
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
8 files · +1450 -88
M docs/architecture.md +4 -0
@@ -98,6 +98,10 @@ Input ownership is exclusive for the interaction lifetime. `pickerConsumer` in `
9898
9999 A preview is data, not a daemon paint: the client asks for the row its cursor rests on with a debounced `PickerPreviewRequest` (80 ms, one request in flight per interaction), and the serving daemon answers with a bounded styled-cell `PickerPreview`. The daemon captures local rows through the navigation preview facts it already owns (`previewTarget` plus `snapshotPickerPreview`), captures remote rows through its own remote viewport client and cache, and registers the requested row as that viewer's render subscription, so a row that keeps changing keeps publishing while a superseded request publishes nothing. Unchanged or retired rows answer with a status-only preview, and the client clears its viewport rather than showing another row's content. The client composes the viewport into its own modal; the daemon never writes it.
100100
101+### Resize repaint fence
102+
103+A terminal resize reaches the client as one coalesced invalidation owned by the attachment geometry collector in `internal/usecase/client`, and the supervisor's serialized waits (connect attempt, established ready loop, retry cadence, and non-retryable picker wait) consume it and repaint. The fence is exactly `PickerPresentation`, which is `PresentPicker`: the composition samples `Terminal.Geometry` at render time, so a repaint always observes the latest size. The pre-attachment `PresentConnecting` transition is deliberately refused, because `Begin` has already admitted the attachment foreground that owns the same terminal writer before `MarkAttached` and the initial publication; painting there would overwrite session output. For the same reason the attachment settlement wait never consumes the invalidation, so it stays coalesced and buffered through connecting and attached and is consumed with a repaint only after the attachment returns to the picker. The invalidation is independent of the attachment's own geometry sequence/wakeup, which still drives the typed resize to the live session.
104+
101105 ### Attention and notifications
102106
103107 Attention is a daemon fact, not an overlay: a bell raised by a PTY, by a status-line notification, or by an agent run marks the owning tab, and the daemon publishes it in the frames its attachments receive. The status bar carries it as a pulsing glyph, the picker session header carries it inline, and `PickerLine.Attention` carries it on the tab row the client renders. Because a client-owned modal suppresses daemon paints, the attention pulse republishes the picker source for every attachment whose interaction the client presents, so a bell raised while the picker is open still reaches the displayed rows.
@@ -98,6 +98,10 @@ Input ownership is exclusive for the interaction lifetime. `pickerConsumer` in `
98 98
99 A preview is data, not a daemon paint: the client asks for the row its cursor rests on with a debounced `PickerPreviewRequest` (80 ms, one request in flight per interaction), and the serving daemon answers with a bounded styled-cell `PickerPreview`. The daemon captures local rows through the navigation preview facts it already owns (`previewTarget` plus `snapshotPickerPreview`), captures remote rows through its own remote viewport client and cache, and registers the requested row as that viewer's render subscription, so a row that keeps changing keeps publishing while a superseded request publishes nothing. Unchanged or retired rows answer with a status-only preview, and the client clears its viewport rather than showing another row's content. The client composes the viewport into its own modal; the daemon never writes it.99 A preview is data, not a daemon paint: the client asks for the row its cursor rests on with a debounced `PickerPreviewRequest` (80 ms, one request in flight per interaction), and the serving daemon answers with a bounded styled-cell `PickerPreview`. The daemon captures local rows through the navigation preview facts it already owns (`previewTarget` plus `snapshotPickerPreview`), captures remote rows through its own remote viewport client and cache, and registers the requested row as that viewer's render subscription, so a row that keeps changing keeps publishing while a superseded request publishes nothing. Unchanged or retired rows answer with a status-only preview, and the client clears its viewport rather than showing another row's content. The client composes the viewport into its own modal; the daemon never writes it.
100 100
101+### Resize repaint fence
102+
103+A terminal resize reaches the client as one coalesced invalidation owned by the attachment geometry collector in `internal/usecase/client`, and the supervisor's serialized waits (connect attempt, established ready loop, retry cadence, and non-retryable picker wait) consume it and repaint. The fence is exactly `PickerPresentation`, which is `PresentPicker`: the composition samples `Terminal.Geometry` at render time, so a repaint always observes the latest size. The pre-attachment `PresentConnecting` transition is deliberately refused, because `Begin` has already admitted the attachment foreground that owns the same terminal writer before `MarkAttached` and the initial publication; painting there would overwrite session output. For the same reason the attachment settlement wait never consumes the invalidation, so it stays coalesced and buffered through connecting and attached and is consumed with a repaint only after the attachment returns to the picker. The invalidation is independent of the attachment's own geometry sequence/wakeup, which still drives the typed resize to the live session.
104+
101 ### Attention and notifications105 ### Attention and notifications
102 106
103 Attention is a daemon fact, not an overlay: a bell raised by a PTY, by a status-line notification, or by an agent run marks the owning tab, and the daemon publishes it in the frames its attachments receive. The status bar carries it as a pulsing glyph, the picker session header carries it inline, and `PickerLine.Attention` carries it on the tab row the client renders. Because a client-owned modal suppresses daemon paints, the attention pulse republishes the picker source for every attachment whose interaction the client presents, so a bell raised while the picker is open still reaches the displayed rows.107 Attention is a daemon fact, not an overlay: a bell raised by a PTY, by a status-line notification, or by an agent run marks the owning tab, and the daemon publishes it in the frames its attachments receive. The status bar carries it as a pulsing glyph, the picker session header carries it inline, and `PickerLine.Attention` carries it on the tab row the client renders. Because a client-owned modal suppresses daemon paints, the attention pulse republishes the picker source for every attachment whose interaction the client presents, so a bell raised while the picker is open still reaches the displayed rows.
M internal/app/broker_offline_client.go +45 -30
@@ -105,17 +105,57 @@ func runOfflineClient(ctx context.Context, socket string, terminal ports.Termina
105105 }
106106 clk := clock.New()
107107 picker := client.NewPicker(clk, 0)
108+ navigation := client.InitialNavigationCreateEphemeral
109+ supervisor, err := client.NewSupervisor(client.SupervisorConfig{
110+ Connector: brokeripc.NewConnector(socket, brokeripc.Config{}),
111+ Terminal: terminal,
112+ Clock: clk,
113+ UI: ui,
114+ Picker: picker,
115+ InitialNavigation: &navigation,
116+ AttachmentEnvironment: client.AttachmentEnvironment{
117+ TermEnv: os.Getenv("TERM"), Cwd: currentWorkingDirectory(), TrueColor: client.DetectTrueColor(os.Getenv("TERM"), os.Getenv("COLORTERM"), os.Environ()),
118+ },
119+ Render: offlineClientRender(terminal, picker, onState),
120+ Notify: func(_ client.State, err error) {
121+ if onFailure != nil {
122+ onFailure(err)
123+ }
124+ },
125+ })
126+ if err != nil {
127+ return err
128+ }
129+ if log != nil {
130+ log.Debug("broker_offline_client", "socket", socket)
131+ }
132+ return supervisor.Run(ctx)
133+}
134+
135+// offlineClientRender is the production offline render callback. Every state
136+// reaches the optional observer; the terminal is written only while the shared
137+// client.PickerPresentation fence admits the presentation, which is exactly
138+// PresentPicker. That is what lets a resize invalidation repaint the picker at
139+// its current geometry while refusing the pre-attachment connecting transition,
140+// whose admitted foreground already owns the same terminal writer.
141+//
142+// ResizeEvents has exactly one consumer for the whole supervisor run: its
143+// attachment geometry collector. Picker rendering samples Geometry here at
144+// render time instead of competing for that single event channel, so a repaint
145+// always observes the latest size.
146+//
147+// The fence is exactly PickerPresentation. An attached foreground owns the
148+// terminal writer and a terminating process is leaving it, so neither is ever
149+// painted over from here.
150+func offlineClientRender(terminal ports.Terminal, picker *client.Picker, onState func(client.State)) func(client.State) {
108151 var writer sync.Mutex
109- render := func(state client.State) {
152+ return func(state client.State) {
110153 if onState != nil {
111154 onState(state)
112155 }
113- if state.Presentation != client.PresentPicker {
156+ if !client.PickerPresentation(state) {
114157 return
115158 }
116- // ResizeEvents has exactly one consumer for the whole supervisor run:
117- // its attachment geometry collector. Picker rendering samples Geometry
118- // here instead of competing for that single event channel.
119159 geometry, err := terminal.Geometry()
120160 if err != nil {
121161 return
@@ -135,31 +175,6 @@ func runOfflineClient(ctx context.Context, socket string, terminal ports.Termina
135175 }
136176 _ = terminal.Flush()
137177 }
138- navigation := client.InitialNavigationCreateEphemeral
139- supervisor, err := client.NewSupervisor(client.SupervisorConfig{
140- Connector: brokeripc.NewConnector(socket, brokeripc.Config{}),
141- Terminal: terminal,
142- Clock: clk,
143- UI: ui,
144- Picker: picker,
145- InitialNavigation: &navigation,
146- AttachmentEnvironment: client.AttachmentEnvironment{
147- TermEnv: os.Getenv("TERM"), Cwd: currentWorkingDirectory(), TrueColor: client.DetectTrueColor(os.Getenv("TERM"), os.Getenv("COLORTERM"), os.Environ()),
148- },
149- Render: render,
150- Notify: func(_ client.State, err error) {
151- if onFailure != nil {
152- onFailure(err)
153- }
154- },
155- })
156- if err != nil {
157- return err
158- }
159- if log != nil {
160- log.Debug("broker_offline_client", "socket", socket)
161- }
162- return supervisor.Run(ctx)
163178 }
164179
165180 func currentWorkingDirectory() string {
@@ -105,17 +105,57 @@ func runOfflineClient(ctx context.Context, socket string, terminal ports.Termina
105 }105 }
106 clk := clock.New()106 clk := clock.New()
107 picker := client.NewPicker(clk, 0)107 picker := client.NewPicker(clk, 0)
108+ navigation := client.InitialNavigationCreateEphemeral
109+ supervisor, err := client.NewSupervisor(client.SupervisorConfig{
110+ Connector: brokeripc.NewConnector(socket, brokeripc.Config{}),
111+ Terminal: terminal,
112+ Clock: clk,
113+ UI: ui,
114+ Picker: picker,
115+ InitialNavigation: &navigation,
116+ AttachmentEnvironment: client.AttachmentEnvironment{
117+ TermEnv: os.Getenv("TERM"), Cwd: currentWorkingDirectory(), TrueColor: client.DetectTrueColor(os.Getenv("TERM"), os.Getenv("COLORTERM"), os.Environ()),
118+ },
119+ Render: offlineClientRender(terminal, picker, onState),
120+ Notify: func(_ client.State, err error) {
121+ if onFailure != nil {
122+ onFailure(err)
123+ }
124+ },
125+ })
126+ if err != nil {
127+ return err
128+ }
129+ if log != nil {
130+ log.Debug("broker_offline_client", "socket", socket)
131+ }
132+ return supervisor.Run(ctx)
133+}
134+
135+// offlineClientRender is the production offline render callback. Every state
136+// reaches the optional observer; the terminal is written only while the shared
137+// client.PickerPresentation fence admits the presentation, which is exactly
138+// PresentPicker. That is what lets a resize invalidation repaint the picker at
139+// its current geometry while refusing the pre-attachment connecting transition,
140+// whose admitted foreground already owns the same terminal writer.
141+//
142+// ResizeEvents has exactly one consumer for the whole supervisor run: its
143+// attachment geometry collector. Picker rendering samples Geometry here at
144+// render time instead of competing for that single event channel, so a repaint
145+// always observes the latest size.
146+//
147+// The fence is exactly PickerPresentation. An attached foreground owns the
148+// terminal writer and a terminating process is leaving it, so neither is ever
149+// painted over from here.
150+func offlineClientRender(terminal ports.Terminal, picker *client.Picker, onState func(client.State)) func(client.State) {
108 var writer sync.Mutex151 var writer sync.Mutex
109- render := func(state client.State) {152+ return func(state client.State) {
110 if onState != nil {153 if onState != nil {
111 onState(state)154 onState(state)
112 }155 }
113- if state.Presentation != client.PresentPicker {156+ if !client.PickerPresentation(state) {
114 return157 return
115 }158 }
116- // ResizeEvents has exactly one consumer for the whole supervisor run:
117- // its attachment geometry collector. Picker rendering samples Geometry
118- // here instead of competing for that single event channel.
119 geometry, err := terminal.Geometry()159 geometry, err := terminal.Geometry()
120 if err != nil {160 if err != nil {
121 return161 return
@@ -135,31 +175,6 @@ func runOfflineClient(ctx context.Context, socket string, terminal ports.Termina
135 }175 }
136 _ = terminal.Flush()176 _ = terminal.Flush()
137 }177 }
138- navigation := client.InitialNavigationCreateEphemeral
139- supervisor, err := client.NewSupervisor(client.SupervisorConfig{
140- Connector: brokeripc.NewConnector(socket, brokeripc.Config{}),
141- Terminal: terminal,
142- Clock: clk,
143- UI: ui,
144- Picker: picker,
145- InitialNavigation: &navigation,
146- AttachmentEnvironment: client.AttachmentEnvironment{
147- TermEnv: os.Getenv("TERM"), Cwd: currentWorkingDirectory(), TrueColor: client.DetectTrueColor(os.Getenv("TERM"), os.Getenv("COLORTERM"), os.Environ()),
148- },
149- Render: render,
150- Notify: func(_ client.State, err error) {
151- if onFailure != nil {
152- onFailure(err)
153- }
154- },
155- })
156- if err != nil {
157- return err
158- }
159- if log != nil {
160- log.Debug("broker_offline_client", "socket", socket)
161- }
162- return supervisor.Run(ctx)
163 }178 }
164 179
165 func currentWorkingDirectory() string {180 func currentWorkingDirectory() string {
A internal/app/broker_offline_client_render_test.go +236 -0
@@ -0,0 +1,236 @@
1+package app
2+
3+import (
4+ "bytes"
5+ "io"
6+ "strings"
7+ "sync"
8+ "testing"
9+ "time"
10+
11+ "github.com/stretchr/testify/require"
12+
13+ "github.com/bnema/vev/internal/domain"
14+ "github.com/bnema/vev/internal/ports"
15+ "github.com/bnema/vev/internal/protocol"
16+ "github.com/bnema/vev/internal/protocol/catalogue"
17+ "github.com/bnema/vev/internal/usecase/client"
18+)
19+
20+// The resize presentation fence at the composition seam. The supervisor asks
21+// its renderer to repaint whenever a resize invalidation reaches a serialized
22+// picker wait, and the production offline composition paints only while the
23+// shared client.PickerPresentation fence admits the state. That fence is exactly
24+// PresentPicker: an admitted attachment foreground owns the terminal writer
25+// from Begin, before MarkAttached and the initial publication, so painting the
26+// pre-attachment connecting transition would overwrite session output with
27+// picker bytes. Attached and terminating presentations are refused for the same
28+// reason.
29+
30+// offlineRenderTerminal is the composition's terminal double: a mutable
31+// geometry and an output buffer, with the single resize channel the attachment
32+// geometry collector would consume.
33+type offlineRenderTerminal struct {
34+ mu sync.Mutex
35+ geometry domain.Geometry
36+ out bytes.Buffer
37+ flushes int
38+}
39+
40+func newOfflineRenderTerminal(geometry domain.Geometry) *offlineRenderTerminal {
41+ return &offlineRenderTerminal{geometry: geometry}
42+}
43+
44+func (t *offlineRenderTerminal) EnterRaw() (func() error, error) {
45+ return func() error { return nil }, nil
46+}
47+
48+func (t *offlineRenderTerminal) Geometry() (domain.Geometry, error) {
49+ t.mu.Lock()
50+ defer t.mu.Unlock()
51+ return t.geometry, nil
52+}
53+
54+func (t *offlineRenderTerminal) ResizeEvents() <-chan domain.Geometry {
55+ ch := make(chan domain.Geometry)
56+ close(ch)
57+ return ch
58+}
59+
60+func (t *offlineRenderTerminal) In() io.Reader { return strings.NewReader("") }
61+
62+func (t *offlineRenderTerminal) Out() io.Writer { return &offlineRenderTerminalWriter{terminal: t} }
63+
64+func (t *offlineRenderTerminal) Flush() error {
65+ t.mu.Lock()
66+ defer t.mu.Unlock()
67+ t.flushes++
68+ return nil
69+}
70+
71+func (t *offlineRenderTerminal) resize(geometry domain.Geometry) {
72+ t.mu.Lock()
73+ t.geometry = geometry
74+ t.mu.Unlock()
75+}
76+
77+// seed writes bytes exactly as an admitted attachment foreground would, so a
78+// test can prove the composition never paints over session output.
79+func (t *offlineRenderTerminal) seed(data string) {
80+ t.mu.Lock()
81+ defer t.mu.Unlock()
82+ t.out.WriteString(data)
83+}
84+
85+// written returns the bytes the composition actually wrote and the flush count.
86+func (t *offlineRenderTerminal) written() (string, int) {
87+ t.mu.Lock()
88+ defer t.mu.Unlock()
89+ return t.out.String(), t.flushes
90+}
91+
92+// offlineRenderTerminalWriter serializes writes through the terminal's own lock
93+// so a test reads a consistent buffer, exactly as the composition's writer mutex
94+// does in production.
95+type offlineRenderTerminalWriter struct{ terminal *offlineRenderTerminal }
96+
97+func (w *offlineRenderTerminalWriter) Write(data []byte) (int, error) {
98+ w.terminal.mu.Lock()
99+ defer w.terminal.mu.Unlock()
100+ return w.terminal.out.Write(data)
101+}
102+
103+// offlineRenderPicker builds the production picker over one reachable local
104+// daemon with a single session, so Render produces a real frame at any size.
105+func offlineRenderPicker(t *testing.T) *client.Picker {
106+ t.Helper()
107+ picker := client.NewPicker(nil, 0)
108+ picker.ApplySnapshot(ports.BrokerSnapshot{
109+ Epoch: 1,
110+ Revision: 1,
111+ Daemons: []ports.BrokerDaemonObservation{{
112+ Local: true,
113+ DisplayOrigin: "local",
114+ Policy: ports.BrokerPolicy{
115+ ProtocolVersion: protocol.Version,
116+ CatalogSchemaVersion: catalogue.RemoteCatalogSchemaVersion,
117+ EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned,
118+ Transport: "quic",
119+ Trust: "offline-render-trust",
120+ Launch: "offline-render-launch",
121+ Isolation: "offline-render-isolation",
122+ },
123+ Identity: "local-daemon",
124+ Incarnation: ports.BrokerDaemonIncarnation{1},
125+ ProtocolVersion: protocol.Version,
126+ Availability: domain.RemoteAvailabilityReachable,
127+ LastSuccess: time.Now(),
128+ InventoryKnown: true,
129+ Sessions: []catalogue.RemoteCatalogSession{{
130+ LifecycleID: domain.SessionLifecycleID{1},
131+ Name: "offline-render-session",
132+ State: catalogue.RemoteCatalogSessionUp,
133+ }},
134+ }},
135+ })
136+ return picker
137+}
138+
139+// TestOfflineClientRenderRefusesNonPickerPresentations proves the composition
140+// fence refuses every state the shared predicate refuses, even after a resize
141+// moved the terminal to a new geometry. The buffer already holds the session
142+// frame an admitted foreground wrote, so a connecting repaint would overwrite
143+// it. If client.PickerPresentation admitted PresentConnecting, this test fails
144+// on overpainted session output.
145+func TestOfflineClientRenderRefusesNonPickerPresentations(t *testing.T) {
146+ small := domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}}
147+ large := domain.Geometry{Size: domain.Size{Cols: 132, Rows: 43}}
148+ const session = "\x1b[Hsession output"
149+
150+ terminal := newOfflineRenderTerminal(small)
151+ terminal.seed(session)
152+ picker := offlineRenderPicker(t)
153+
154+ observed := make(chan client.State, 8)
155+ render := offlineClientRender(terminal, picker, func(state client.State) {
156+ select {
157+ case observed <- state:
158+ default:
159+ }
160+ })
161+
162+ terminal.resize(large)
163+ for _, presentation := range []client.Presentation{client.PresentConnecting, client.PresentAttached, client.PresentTerminating} {
164+ t.Run(presentation.String()+" is never painted over", func(t *testing.T) {
165+ state := client.State{Presentation: presentation, Connectivity: client.ConnectivityReady, Generation: 1}
166+ render(state)
167+ written, flushes := terminal.written()
168+ require.Equal(t, session, written, "%s must leave the session output byte for byte", presentation)
169+ require.Zero(t, flushes, "%s must not flush the terminal", presentation)
170+ select {
171+ case seen := <-observed:
172+ require.Equal(t, state, seen, "the composition observer still sees the state")
173+ default:
174+ t.Fatalf("the composition never reported the %s state", presentation)
175+ }
176+ })
177+ }
178+}
179+
180+// TestOfflineClientRenderFenceIsPickerPresentation pins the composition to the
181+// shared predicate: it writes picker bytes for exactly the states
182+// client.PickerPresentation admits, so the composition can never widen or
183+// narrow the fence locally.
184+func TestOfflineClientRenderFenceIsPickerPresentation(t *testing.T) {
185+ small := domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}}
186+ states := []client.State{
187+ {Presentation: client.PresentPicker, Connectivity: client.ConnectivityReady, Generation: 1},
188+ {Presentation: client.PresentConnecting, Connectivity: client.ConnectivityReady, Generation: 1},
189+ {Presentation: client.PresentAttached, Connectivity: client.ConnectivityReady, Generation: 1},
190+ {Presentation: client.PresentTerminating, Connectivity: client.ConnectivityDisconnected, Generation: 1},
191+ }
192+ for _, state := range states {
193+ t.Run(state.Presentation.String(), func(t *testing.T) {
194+ terminal := newOfflineRenderTerminal(small)
195+ render := offlineClientRender(terminal, offlineRenderPicker(t), nil)
196+
197+ render(state)
198+
199+ written, flushes := terminal.written()
200+ if client.PickerPresentation(state) {
201+ require.NotEmpty(t, written, "a picker-visible state must paint the picker")
202+ require.Equal(t, 1, flushes, "a picker-visible state flushes exactly once")
203+ return
204+ }
205+ require.Empty(t, written, "a refused state must write nothing")
206+ require.Zero(t, flushes, "a refused state must not flush")
207+ })
208+ }
209+}
210+
211+// TestOfflineClientRenderPaintsPickerResize pins the picker-visible half of the
212+// fence: a resize invalidation repaints the picker at the terminal's current
213+// geometry, and the repaint appends after the previous frame instead of
214+// rewriting it.
215+func TestOfflineClientRenderPaintsPickerResize(t *testing.T) {
216+ small := domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}}
217+ large := domain.Geometry{Size: domain.Size{Cols: 132, Rows: 43}}
218+
219+ terminal := newOfflineRenderTerminal(small)
220+ picker := offlineRenderPicker(t)
221+ render := offlineClientRender(terminal, picker, nil)
222+
223+ render(client.State{Presentation: client.PresentPicker, Connectivity: client.ConnectivityReady, Generation: 1})
224+ initial, flushes := terminal.written()
225+ require.NotEmpty(t, initial, "the initial picker paint writes the frame at the reported geometry")
226+ require.Contains(t, initial, "offline-render-session")
227+ require.Equal(t, 1, flushes)
228+
229+ terminal.resize(large)
230+ render(client.State{Presentation: client.PresentPicker, Connectivity: client.ConnectivityReady, Generation: 1})
231+ resized, flushes := terminal.written()
232+ require.True(t, strings.HasPrefix(resized, initial), "the resize repaint appends after the initial frame, never rewrites it")
233+ require.Greater(t, len(resized), len(initial), "the picker resize repaint writes the frame at the new geometry")
234+ require.Contains(t, strings.TrimPrefix(resized, initial), "offline-render-session", "the appended repaint is the picker frame at the new size")
235+ require.Equal(t, 2, flushes)
236+}
@@ -0,0 +1,236 @@
1+package app
2+
3+import (
4+ "bytes"
5+ "io"
6+ "strings"
7+ "sync"
8+ "testing"
9+ "time"
10+
11+ "github.com/stretchr/testify/require"
12+
13+ "github.com/bnema/vev/internal/domain"
14+ "github.com/bnema/vev/internal/ports"
15+ "github.com/bnema/vev/internal/protocol"
16+ "github.com/bnema/vev/internal/protocol/catalogue"
17+ "github.com/bnema/vev/internal/usecase/client"
18+)
19+
20+// The resize presentation fence at the composition seam. The supervisor asks
21+// its renderer to repaint whenever a resize invalidation reaches a serialized
22+// picker wait, and the production offline composition paints only while the
23+// shared client.PickerPresentation fence admits the state. That fence is exactly
24+// PresentPicker: an admitted attachment foreground owns the terminal writer
25+// from Begin, before MarkAttached and the initial publication, so painting the
26+// pre-attachment connecting transition would overwrite session output with
27+// picker bytes. Attached and terminating presentations are refused for the same
28+// reason.
29+
30+// offlineRenderTerminal is the composition's terminal double: a mutable
31+// geometry and an output buffer, with the single resize channel the attachment
32+// geometry collector would consume.
33+type offlineRenderTerminal struct {
34+ mu sync.Mutex
35+ geometry domain.Geometry
36+ out bytes.Buffer
37+ flushes int
38+}
39+
40+func newOfflineRenderTerminal(geometry domain.Geometry) *offlineRenderTerminal {
41+ return &offlineRenderTerminal{geometry: geometry}
42+}
43+
44+func (t *offlineRenderTerminal) EnterRaw() (func() error, error) {
45+ return func() error { return nil }, nil
46+}
47+
48+func (t *offlineRenderTerminal) Geometry() (domain.Geometry, error) {
49+ t.mu.Lock()
50+ defer t.mu.Unlock()
51+ return t.geometry, nil
52+}
53+
54+func (t *offlineRenderTerminal) ResizeEvents() <-chan domain.Geometry {
55+ ch := make(chan domain.Geometry)
56+ close(ch)
57+ return ch
58+}
59+
60+func (t *offlineRenderTerminal) In() io.Reader { return strings.NewReader("") }
61+
62+func (t *offlineRenderTerminal) Out() io.Writer { return &offlineRenderTerminalWriter{terminal: t} }
63+
64+func (t *offlineRenderTerminal) Flush() error {
65+ t.mu.Lock()
66+ defer t.mu.Unlock()
67+ t.flushes++
68+ return nil
69+}
70+
71+func (t *offlineRenderTerminal) resize(geometry domain.Geometry) {
72+ t.mu.Lock()
73+ t.geometry = geometry
74+ t.mu.Unlock()
75+}
76+
77+// seed writes bytes exactly as an admitted attachment foreground would, so a
78+// test can prove the composition never paints over session output.
79+func (t *offlineRenderTerminal) seed(data string) {
80+ t.mu.Lock()
81+ defer t.mu.Unlock()
82+ t.out.WriteString(data)
83+}
84+
85+// written returns the bytes the composition actually wrote and the flush count.
86+func (t *offlineRenderTerminal) written() (string, int) {
87+ t.mu.Lock()
88+ defer t.mu.Unlock()
89+ return t.out.String(), t.flushes
90+}
91+
92+// offlineRenderTerminalWriter serializes writes through the terminal's own lock
93+// so a test reads a consistent buffer, exactly as the composition's writer mutex
94+// does in production.
95+type offlineRenderTerminalWriter struct{ terminal *offlineRenderTerminal }
96+
97+func (w *offlineRenderTerminalWriter) Write(data []byte) (int, error) {
98+ w.terminal.mu.Lock()
99+ defer w.terminal.mu.Unlock()
100+ return w.terminal.out.Write(data)
101+}
102+
103+// offlineRenderPicker builds the production picker over one reachable local
104+// daemon with a single session, so Render produces a real frame at any size.
105+func offlineRenderPicker(t *testing.T) *client.Picker {
106+ t.Helper()
107+ picker := client.NewPicker(nil, 0)
108+ picker.ApplySnapshot(ports.BrokerSnapshot{
109+ Epoch: 1,
110+ Revision: 1,
111+ Daemons: []ports.BrokerDaemonObservation{{
112+ Local: true,
113+ DisplayOrigin: "local",
114+ Policy: ports.BrokerPolicy{
115+ ProtocolVersion: protocol.Version,
116+ CatalogSchemaVersion: catalogue.RemoteCatalogSchemaVersion,
117+ EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned,
118+ Transport: "quic",
119+ Trust: "offline-render-trust",
120+ Launch: "offline-render-launch",
121+ Isolation: "offline-render-isolation",
122+ },
123+ Identity: "local-daemon",
124+ Incarnation: ports.BrokerDaemonIncarnation{1},
125+ ProtocolVersion: protocol.Version,
126+ Availability: domain.RemoteAvailabilityReachable,
127+ LastSuccess: time.Now(),
128+ InventoryKnown: true,
129+ Sessions: []catalogue.RemoteCatalogSession{{
130+ LifecycleID: domain.SessionLifecycleID{1},
131+ Name: "offline-render-session",
132+ State: catalogue.RemoteCatalogSessionUp,
133+ }},
134+ }},
135+ })
136+ return picker
137+}
138+
139+// TestOfflineClientRenderRefusesNonPickerPresentations proves the composition
140+// fence refuses every state the shared predicate refuses, even after a resize
141+// moved the terminal to a new geometry. The buffer already holds the session
142+// frame an admitted foreground wrote, so a connecting repaint would overwrite
143+// it. If client.PickerPresentation admitted PresentConnecting, this test fails
144+// on overpainted session output.
145+func TestOfflineClientRenderRefusesNonPickerPresentations(t *testing.T) {
146+ small := domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}}
147+ large := domain.Geometry{Size: domain.Size{Cols: 132, Rows: 43}}
148+ const session = "\x1b[Hsession output"
149+
150+ terminal := newOfflineRenderTerminal(small)
151+ terminal.seed(session)
152+ picker := offlineRenderPicker(t)
153+
154+ observed := make(chan client.State, 8)
155+ render := offlineClientRender(terminal, picker, func(state client.State) {
156+ select {
157+ case observed <- state:
158+ default:
159+ }
160+ })
161+
162+ terminal.resize(large)
163+ for _, presentation := range []client.Presentation{client.PresentConnecting, client.PresentAttached, client.PresentTerminating} {
164+ t.Run(presentation.String()+" is never painted over", func(t *testing.T) {
165+ state := client.State{Presentation: presentation, Connectivity: client.ConnectivityReady, Generation: 1}
166+ render(state)
167+ written, flushes := terminal.written()
168+ require.Equal(t, session, written, "%s must leave the session output byte for byte", presentation)
169+ require.Zero(t, flushes, "%s must not flush the terminal", presentation)
170+ select {
171+ case seen := <-observed:
172+ require.Equal(t, state, seen, "the composition observer still sees the state")
173+ default:
174+ t.Fatalf("the composition never reported the %s state", presentation)
175+ }
176+ })
177+ }
178+}
179+
180+// TestOfflineClientRenderFenceIsPickerPresentation pins the composition to the
181+// shared predicate: it writes picker bytes for exactly the states
182+// client.PickerPresentation admits, so the composition can never widen or
183+// narrow the fence locally.
184+func TestOfflineClientRenderFenceIsPickerPresentation(t *testing.T) {
185+ small := domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}}
186+ states := []client.State{
187+ {Presentation: client.PresentPicker, Connectivity: client.ConnectivityReady, Generation: 1},
188+ {Presentation: client.PresentConnecting, Connectivity: client.ConnectivityReady, Generation: 1},
189+ {Presentation: client.PresentAttached, Connectivity: client.ConnectivityReady, Generation: 1},
190+ {Presentation: client.PresentTerminating, Connectivity: client.ConnectivityDisconnected, Generation: 1},
191+ }
192+ for _, state := range states {
193+ t.Run(state.Presentation.String(), func(t *testing.T) {
194+ terminal := newOfflineRenderTerminal(small)
195+ render := offlineClientRender(terminal, offlineRenderPicker(t), nil)
196+
197+ render(state)
198+
199+ written, flushes := terminal.written()
200+ if client.PickerPresentation(state) {
201+ require.NotEmpty(t, written, "a picker-visible state must paint the picker")
202+ require.Equal(t, 1, flushes, "a picker-visible state flushes exactly once")
203+ return
204+ }
205+ require.Empty(t, written, "a refused state must write nothing")
206+ require.Zero(t, flushes, "a refused state must not flush")
207+ })
208+ }
209+}
210+
211+// TestOfflineClientRenderPaintsPickerResize pins the picker-visible half of the
212+// fence: a resize invalidation repaints the picker at the terminal's current
213+// geometry, and the repaint appends after the previous frame instead of
214+// rewriting it.
215+func TestOfflineClientRenderPaintsPickerResize(t *testing.T) {
216+ small := domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}}
217+ large := domain.Geometry{Size: domain.Size{Cols: 132, Rows: 43}}
218+
219+ terminal := newOfflineRenderTerminal(small)
220+ picker := offlineRenderPicker(t)
221+ render := offlineClientRender(terminal, picker, nil)
222+
223+ render(client.State{Presentation: client.PresentPicker, Connectivity: client.ConnectivityReady, Generation: 1})
224+ initial, flushes := terminal.written()
225+ require.NotEmpty(t, initial, "the initial picker paint writes the frame at the reported geometry")
226+ require.Contains(t, initial, "offline-render-session")
227+ require.Equal(t, 1, flushes)
228+
229+ terminal.resize(large)
230+ render(client.State{Presentation: client.PresentPicker, Connectivity: client.ConnectivityReady, Generation: 1})
231+ resized, flushes := terminal.written()
232+ require.True(t, strings.HasPrefix(resized, initial), "the resize repaint appends after the initial frame, never rewrites it")
233+ require.Greater(t, len(resized), len(initial), "the picker resize repaint writes the frame at the new geometry")
234+ require.Contains(t, strings.TrimPrefix(resized, initial), "offline-render-session", "the appended repaint is the picker frame at the new size")
235+ require.Equal(t, 2, flushes)
236+}
M internal/usecase/client/attachment_worker.go +27 -17
@@ -381,14 +381,15 @@ type attachmentHost struct {
381381 onAttach func(AttachmentToken)
382382 authority attachmentAuthority
383383
384- geometryMu sync.Mutex
385- geometry domain.Geometry
386- geometryValid bool
387- geometrySeq uint64
388- geometryUpdate chan struct{}
389- geometryCancel context.CancelFunc
390- geometryDone chan struct{}
391- ownerBoundary bool
384+ geometryMu sync.Mutex
385+ geometry domain.Geometry
386+ geometryValid bool
387+ geometrySeq uint64
388+ geometryUpdate chan struct{}
389+ presentationUpdate chan struct{}
390+ geometryCancel context.CancelFunc
391+ geometryDone chan struct{}
392+ ownerBoundary bool
392393 }
393394
394395 // newAttachmentHost builds the reusable foreground host. A nil clock falls back
@@ -408,15 +409,16 @@ func newAttachmentHost(cfg attachmentHostConfig) *attachmentHost {
408409 resizes = cfg.Terminal.ResizeEvents()
409410 }
410411 return &attachmentHost{
411- term: cfg.Terminal,
412- clock: clock,
413- input: cfg.Input,
414- ui: ui,
415- actionUI: cfg.ActionUI,
416- resizes: resizes,
417- actions: cfg.Actions,
418- onAttach: cfg.OnAttached,
419- geometryUpdate: make(chan struct{}, 1),
412+ term: cfg.Terminal,
413+ clock: clock,
414+ input: cfg.Input,
415+ ui: ui,
416+ actionUI: cfg.ActionUI,
417+ resizes: resizes,
418+ actions: cfg.Actions,
419+ onAttach: cfg.OnAttached,
420+ geometryUpdate: make(chan struct{}, 1),
421+ presentationUpdate: make(chan struct{}, 1),
420422 }
421423 }
422424
@@ -528,6 +530,14 @@ func (h *attachmentHost) startGeometry(ctx context.Context) {
528530 case h.geometryUpdate <- struct{}{}:
529531 default:
530532 }
533+ // Presentation invalidation is deliberately independent from the
534+ // attachment geometry sequence/wakeup. The supervisor consumes this
535+ // coalesced signal and decides whether a picker presentation may be
536+ // repainted; this collector never renders directly.
537+ select {
538+ case h.presentationUpdate <- struct{}{}:
539+ default:
540+ }
531541 case <-geometryCtx.Done():
532542 return
533543 }
@@ -381,14 +381,15 @@ type attachmentHost struct {
381 onAttach func(AttachmentToken)381 onAttach func(AttachmentToken)
382 authority attachmentAuthority382 authority attachmentAuthority
383 383
384- geometryMu sync.Mutex384+ geometryMu sync.Mutex
385- geometry domain.Geometry385+ geometry domain.Geometry
386- geometryValid bool386+ geometryValid bool
387- geometrySeq uint64387+ geometrySeq uint64
388- geometryUpdate chan struct{}388+ geometryUpdate chan struct{}
389- geometryCancel context.CancelFunc389+ presentationUpdate chan struct{}
390- geometryDone chan struct{}390+ geometryCancel context.CancelFunc
391- ownerBoundary bool391+ geometryDone chan struct{}
392+ ownerBoundary bool
392 }393 }
393 394
394 // newAttachmentHost builds the reusable foreground host. A nil clock falls back395 // newAttachmentHost builds the reusable foreground host. A nil clock falls back
@@ -408,15 +409,16 @@ func newAttachmentHost(cfg attachmentHostConfig) *attachmentHost {
408 resizes = cfg.Terminal.ResizeEvents()409 resizes = cfg.Terminal.ResizeEvents()
409 }410 }
410 return &attachmentHost{411 return &attachmentHost{
411- term: cfg.Terminal,412+ term: cfg.Terminal,
412- clock: clock,413+ clock: clock,
413- input: cfg.Input,414+ input: cfg.Input,
414- ui: ui,415+ ui: ui,
415- actionUI: cfg.ActionUI,416+ actionUI: cfg.ActionUI,
416- resizes: resizes,417+ resizes: resizes,
417- actions: cfg.Actions,418+ actions: cfg.Actions,
418- onAttach: cfg.OnAttached,419+ onAttach: cfg.OnAttached,
419- geometryUpdate: make(chan struct{}, 1),420+ geometryUpdate: make(chan struct{}, 1),
421+ presentationUpdate: make(chan struct{}, 1),
420 }422 }
421 }423 }
422 424
@@ -528,6 +530,14 @@ func (h *attachmentHost) startGeometry(ctx context.Context) {
528 case h.geometryUpdate <- struct{}{}:530 case h.geometryUpdate <- struct{}{}:
529 default:531 default:
530 }532 }
533+ // Presentation invalidation is deliberately independent from the
534+ // attachment geometry sequence/wakeup. The supervisor consumes this
535+ // coalesced signal and decides whether a picker presentation may be
536+ // repainted; this collector never renders directly.
537+ select {
538+ case h.presentationUpdate <- struct{}{}:
539+ default:
540+ }
531 case <-geometryCtx.Done():541 case <-geometryCtx.Done():
532 return542 return
533 }543 }
M internal/usecase/client/picker_composition.go +12 -3
@@ -21,9 +21,10 @@ import (
2121 // invariants or consume a pending user decision before the supervisor handles
2222 // it.
2323 //
24-// Painting is allowed only while the supervisor reports PresentPicker. A
25-// composition must not paint picker bytes during an attachment: the attachment
26-// writes through the same terminal, and nothing serializes those two writers.
24+// Painting is allowed only while PickerPresentation reports true. In
25+// particular, PresentConnecting is not safe: once the attachment foreground is
26+// admitted it owns the same terminal writer before MarkAttached or the initial
27+// publication. Nothing serializes picker painting with that foreground.
2728 // The picker deliberately neither owns a terminal writer nor consumes the
2829 // terminal resize channel; the composition supplies its current geometry when
2930 // rendering.
@@ -35,6 +36,14 @@ type Picker struct {
3536 // SupervisorConfig.Picker.
3637 var _ pickerHost = (*Picker)(nil)
3738
39+// PickerPresentation reports whether composition-owned picker bytes may be
40+// written for state. This is intentionally narrower than "not attached": an
41+// admitted foreground can write while the public presentation is still
42+// PresentConnecting.
43+func PickerPresentation(state State) bool {
44+ return state.Presentation == PresentPicker
45+}
46+
3847 // NewPicker composes one client-owned picker. A nil clock uses the system
3948 // clock, and a zero freshness uses the catalogue default.
4049 func NewPicker(clock ports.Clock, freshness time.Duration) *Picker {
@@ -21,9 +21,10 @@ import (
21 // invariants or consume a pending user decision before the supervisor handles21 // invariants or consume a pending user decision before the supervisor handles
22 // it.22 // it.
23 //23 //
24-// Painting is allowed only while the supervisor reports PresentPicker. A24+// Painting is allowed only while PickerPresentation reports true. In
25-// composition must not paint picker bytes during an attachment: the attachment25+// particular, PresentConnecting is not safe: once the attachment foreground is
26-// writes through the same terminal, and nothing serializes those two writers.26+// admitted it owns the same terminal writer before MarkAttached or the initial
27+// publication. Nothing serializes picker painting with that foreground.
27 // The picker deliberately neither owns a terminal writer nor consumes the28 // The picker deliberately neither owns a terminal writer nor consumes the
28 // terminal resize channel; the composition supplies its current geometry when29 // terminal resize channel; the composition supplies its current geometry when
29 // rendering.30 // rendering.
@@ -35,6 +36,14 @@ type Picker struct {
35 // SupervisorConfig.Picker.36 // SupervisorConfig.Picker.
36 var _ pickerHost = (*Picker)(nil)37 var _ pickerHost = (*Picker)(nil)
37 38
39+// PickerPresentation reports whether composition-owned picker bytes may be
40+// written for state. This is intentionally narrower than "not attached": an
41+// admitted foreground can write while the public presentation is still
42+// PresentConnecting.
43+func PickerPresentation(state State) bool {
44+ return state.Presentation == PresentPicker
45+}
46+
38 // NewPicker composes one client-owned picker. A nil clock uses the system47 // NewPicker composes one client-owned picker. A nil clock uses the system
39 // clock, and a zero freshness uses the catalogue default.48 // clock, and a zero freshness uses the catalogue default.
40 func NewPicker(clock ports.Clock, freshness time.Duration) *Picker {49 func NewPicker(clock ports.Clock, freshness time.Duration) *Picker {
A internal/usecase/client/resize_presentation_test.go +1022 -0
@@ -0,0 +1,1022 @@
1+package client
2+
3+import (
4+ "context"
5+ "io"
6+ "strings"
7+ "sync"
8+ "testing"
9+ "time"
10+
11+ "github.com/stretchr/testify/require"
12+
13+ "github.com/bnema/vev/internal/domain"
14+ "github.com/bnema/vev/internal/ports"
15+ "github.com/bnema/vev/internal/protocol"
16+)
17+
18+// Resize presentation-invalidation tests (Plan 001, uncommitted slice).
19+//
20+// The mechanism under test is deliberately split across two owners:
21+//
22+// - the attachment host's geometry collector records the latest valid
23+// Terminal.Geometry and emits two independent coalesced wakeups: the
24+// attachment sequence wakeup (geometryUpdate) that a granted foreground
25+// consumes through Resize, and the presentation-invalidation wakeup
26+// (presentationUpdate) that only the supervisor's serialized control path
27+// consumes;
28+// - the supervisor consumes presentationUpdate in every wait it owns
29+// (attempt, ready, termination, backoff) and repaints only a picker
30+// presentation (PickerPresentation), sampling Terminal.Geometry at render
31+// time. The attachment settlement wait never consumes it: Begin has already
32+// admitted a foreground that can write session output through the same
33+// terminal before MarkAttached and the initial publication, so the
34+// invalidation stays buffered for the next picker state instead of being
35+// painted over it.
36+//
37+// Every test below is deterministic: a fake clock, scripted fakes, bounded
38+// waits, and channel signals instead of sleeps. The resize channel is pushed
39+// by the test itself, so no ordering is hoped for.
40+
41+// resizeTestTerminal is the supervisor-owned terminal double with a
42+// test-driven ResizeEvents channel and a Geometry that reports the latest
43+// pushed size. It embeds the attachment-supervisor terminal double so it also
44+// satisfies the UI publication seam, and only overrides geometry.
45+type resizeTestTerminal struct {
46+ *attachTestTerminal
47+ resizes chan domain.Geometry
48+ geometryMu sync.Mutex
49+ geometry domain.Geometry
50+}
51+
52+func newResizeTestTerminal(in io.Reader, geometry domain.Geometry) *resizeTestTerminal {
53+ return &resizeTestTerminal{
54+ attachTestTerminal: &attachTestTerminal{in: in},
55+ resizes: make(chan domain.Geometry, 8),
56+ geometry: geometry,
57+ }
58+}
59+
60+func (t *resizeTestTerminal) ResizeEvents() <-chan domain.Geometry { return t.resizes }
61+
62+func (t *resizeTestTerminal) Geometry() (domain.Geometry, error) {
63+ t.geometryMu.Lock()
64+ defer t.geometryMu.Unlock()
65+ return t.geometry, nil
66+}
67+
68+// resize records the new size as the terminal's current geometry and then
69+// publishes the event, exactly like a physical terminal whose size query and
70+// resize event agree.
71+func (t *resizeTestTerminal) resize(geometry domain.Geometry) {
72+ t.geometryMu.Lock()
73+ t.geometry = geometry
74+ t.geometryMu.Unlock()
75+ t.resizes <- geometry
76+}
77+
78+func (t *resizeTestTerminal) closeResizeEvents() { close(t.resizes) }
79+
80+// setWritten replaces the terminal's committed output, so a test can seed the
81+// bytes an admitted foreground would have written and then prove no picker
82+// repaint overpainted them.
83+func (t *resizeTestTerminal) setWritten(data string) {
84+ t.attachTestTerminal.mu.Lock()
85+ defer t.attachTestTerminal.mu.Unlock()
86+ t.attachTestTerminal.out.Reset()
87+ t.attachTestTerminal.out.WriteString(data)
88+}
89+
90+// resizeRenderSample is one observed Render call: the exact state handed to the
91+// renderer plus the terminal geometry the renderer would have sampled, exactly
92+// as the offline composition does when it paints the picker.
93+type resizeRenderSample struct {
94+ state State
95+ geometry domain.Geometry
96+}
97+
98+// resizeRenderRecorder records every Render call both in a channel (for
99+// ordered waits) and in a slice (for counting), so a test can prove that a
100+// repaint happened, in what state, and against which geometry.
101+type resizeRenderRecorder struct {
102+ terminal *resizeTestTerminal
103+ renders chan resizeRenderSample
104+
105+ mu sync.Mutex
106+ all []resizeRenderSample
107+}
108+
109+func newResizeRenderRecorder(terminal *resizeTestTerminal) *resizeRenderRecorder {
110+ return &resizeRenderRecorder{terminal: terminal, renders: make(chan resizeRenderSample, 256)}
111+}
112+
113+// resizeRenderPaint is the picker frame marker the recording renderer writes.
114+// It stands for the production composition's picker bytes: a test can prove the
115+// renderer ran and that its output never landed over session output.
116+const resizeRenderPaint = "<picker-frame>"
117+
118+func (r *resizeRenderRecorder) render(state State) {
119+ geometry, _ := r.terminal.Geometry()
120+ sample := resizeRenderSample{state: state, geometry: geometry}
121+ r.mu.Lock()
122+ r.all = append(r.all, sample)
123+ r.mu.Unlock()
124+ // Exactly like the production offline composition, the recording renderer
125+ // writes its frame only while the shared fence admits the state. A mutation
126+ // that widened the fence would therefore land bytes a test can observe.
127+ if PickerPresentation(state) {
128+ _, _ = r.terminal.Out().Write([]byte(resizeRenderPaint))
129+ }
130+ select {
131+ case r.renders <- sample:
132+ default:
133+ }
134+}
135+
136+// drain discards every render already delivered to the ordered channel; the
137+// counted slice is retained so a caller can still take a baseline count.
138+func (r *resizeRenderRecorder) drain() {
139+ for {
140+ select {
141+ case <-r.renders:
142+ default:
143+ return
144+ }
145+ }
146+}
147+
148+func (r *resizeRenderRecorder) count() int {
149+ r.mu.Lock()
150+ defer r.mu.Unlock()
151+ return len(r.all)
152+}
153+
154+// last returns the most recent render sample, or the zero sample when nothing
155+// was rendered.
156+func (r *resizeRenderRecorder) last() resizeRenderSample {
157+ r.mu.Lock()
158+ defer r.mu.Unlock()
159+ if len(r.all) == 0 {
160+ return resizeRenderSample{}
161+ }
162+ return r.all[len(r.all)-1]
163+}
164+
165+// samples returns every recorded render sample in call order.
166+func (r *resizeRenderRecorder) samples() []resizeRenderSample {
167+ r.mu.Lock()
168+ defer r.mu.Unlock()
169+ return append([]resizeRenderSample(nil), r.all...)
170+}
171+
172+// awaitResizeRender waits for the next render matching match, bounded.
173+func awaitResizeRender(t *testing.T, recorder *resizeRenderRecorder, match func(resizeRenderSample) bool) resizeRenderSample {
174+ t.Helper()
175+ timeout := time.After(5 * time.Second)
176+ for {
177+ select {
178+ case sample := <-recorder.renders:
179+ if match(sample) {
180+ return sample
181+ }
182+ case <-timeout:
183+ t.Fatal("the supervisor never repainted the presentation for the resize")
184+ return resizeRenderSample{}
185+ }
186+ }
187+}
188+
189+// pendingPresentationSignals reports how many coalesced presentation
190+// invalidations are buffered. Reading len of a channel is synchronized, so it
191+// is safe from the test goroutine while the collector runs.
192+func (h *attachmentHost) pendingPresentationSignals() int { return len(h.presentationUpdate) }
193+
194+// pendingGeometrySignals reports how many attachment geometry wakeups are
195+// buffered, so a test can prove the two signals are independent.
196+func (h *attachmentHost) pendingGeometrySignals() int { return len(h.geometryUpdate) }
197+
198+// geometryProgress reports how many valid resizes the collector has recorded.
199+func (h *attachmentHost) geometryProgress() uint64 {
200+ h.geometryMu.Lock()
201+ defer h.geometryMu.Unlock()
202+ return h.geometrySeq
203+}
204+
205+// resizeSupervisorHarness is one running supervisor with its fakes: a
206+// test-driven resize channel, a recording renderer, and a fake clock.
207+type resizeSupervisorHarness struct {
208+ sup *Supervisor
209+ service *supervisorTestService
210+ clock *supervisorTestClock
211+ terminal *resizeTestTerminal
212+ reader *attachTestReader
213+ connector *supervisorTestConnector
214+ recorder *resizeRenderRecorder
215+
216+ cancel context.CancelFunc
217+ runDone chan struct{}
218+
219+ runMu sync.Mutex
220+ runErr error
221+}
222+
223+type resizeHarnessOptions struct {
224+ // picker is the supervisor's picker seam; nil runs the picker-less driver.
225+ picker pickerHost
226+ // geometry is the terminal's initial reported geometry.
227+ geometry domain.Geometry
228+ // closeResizes hands the supervisor an already closed ResizeEvents channel.
229+ closeResizes bool
230+ // connect scripts the broker attempts; nil returns the harness service.
231+ connect func(ctx context.Context, call int) (ports.BrokerService, error)
232+}
233+
234+// startResizeHarness builds and runs one supervisor over the resize-aware
235+// fakes. It returns as soon as Run is started, so a test can park on any wait.
236+func startResizeHarness(t *testing.T, options resizeHarnessOptions) *resizeSupervisorHarness {
237+ t.Helper()
238+ ctx, cancel := context.WithCancel(context.Background())
239+ clock := newSupervisorTestClock()
240+ reader := newAttachTestReader()
241+ geometry := options.geometry
242+ if geometry == (domain.Geometry{}) {
243+ geometry = domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}}
244+ }
245+ terminal := newResizeTestTerminal(reader, geometry)
246+ if options.closeResizes {
247+ terminal.closeResizeEvents()
248+ }
249+ service := newSupervisorTestService(ports.BrokerConnectionID{1})
250+ service.publish(3, 1)
251+ connect := options.connect
252+ if connect == nil {
253+ connect = func(context.Context, int) (ports.BrokerService, error) { return service, nil }
254+ }
255+ connector := newSupervisorTestConnector(connect)
256+ recorder := newResizeRenderRecorder(terminal)
257+ sup := mustSupervisor(t, SupervisorConfig{
258+ Connector: connector,
259+ Terminal: terminal,
260+ Clock: clock,
261+ Jitter: func() float64 { return 0 },
262+ Picker: options.picker,
263+ Render: recorder.render,
264+ })
265+
266+ harness := &resizeSupervisorHarness{
267+ sup: sup,
268+ service: service,
269+ clock: clock,
270+ terminal: terminal,
271+ reader: reader,
272+ connector: connector,
273+ recorder: recorder,
274+ cancel: cancel,
275+ runDone: make(chan struct{}),
276+ }
277+ go func() {
278+ err := sup.Run(ctx)
279+ harness.runMu.Lock()
280+ harness.runErr = err
281+ harness.runMu.Unlock()
282+ close(harness.runDone)
283+ }()
284+ t.Cleanup(func() {
285+ cancel()
286+ select {
287+ case <-harness.runDone:
288+ case <-time.After(5 * time.Second):
289+ t.Error("the supervisor did not stop")
290+ }
291+ // Release the single input lifetime's reader so the pump goroutine that
292+ // outlives Run, exactly like a real stdin that never reaches EOF, ends.
293+ reader.close()
294+ })
295+ return harness
296+}
297+
298+func (h *resizeSupervisorHarness) waitRun(t *testing.T) error {
299+ t.Helper()
300+ select {
301+ case <-h.runDone:
302+ case <-time.After(5 * time.Second):
303+ t.Fatal("the supervisor did not stop")
304+ }
305+ h.runMu.Lock()
306+ defer h.runMu.Unlock()
307+ return h.runErr
308+}
309+
310+// resizeStreamRegistry admits one fresh scripted logical stream per open call
311+// and lets a test reach the stream of a specific attachment attempt.
312+type resizeStreamRegistry struct {
313+ mu sync.Mutex
314+ streams []*sessionTestStream
315+}
316+
317+func (r *resizeStreamRegistry) open(context.Context, ports.BrokerOpenStreamRequest) (ports.BrokerLogicalConnection, error) {
318+ stream := newSessionTestStream()
319+ r.mu.Lock()
320+ r.streams = append(r.streams, stream)
321+ r.mu.Unlock()
322+ return stream, nil
323+}
324+
325+// stream waits until the index-th stream was admitted and returns it.
326+func (r *resizeStreamRegistry) stream(t *testing.T, index int) *sessionTestStream {
327+ t.Helper()
328+ require.Eventually(t, func() bool {
329+ r.mu.Lock()
330+ defer r.mu.Unlock()
331+ return len(r.streams) > index
332+ }, 5*time.Second, time.Millisecond, "the supervisor never admitted stream %d", index)
333+ r.mu.Lock()
334+ defer r.mu.Unlock()
335+ return r.streams[index]
336+}
337+
338+// streamResizes returns the typed resize messages one attachment stream sent.
339+func streamResizes(stream *sessionTestStream) []protocol.Resize {
340+ var resizes []protocol.Resize
341+ for _, message := range stream.messages() {
342+ if resize, ok := message.(protocol.Resize); ok {
343+ resizes = append(resizes, resize)
344+ }
345+ }
346+ return resizes
347+}
348+
349+// awaitStreamResizes waits until the stream sent at least want resize messages.
350+func awaitStreamResizes(t *testing.T, stream *sessionTestStream, want int) []protocol.Resize {
351+ t.Helper()
352+ require.Eventually(t, func() bool { return len(streamResizes(stream)) >= want }, 5*time.Second, time.Millisecond,
353+ "the attachment stream never sent %d resize messages", want)
354+ return streamResizes(stream)
355+}
356+
357+// TestSupervisorResizeBurstCoalescesAndRenderObservesLatestGeometry holds the
358+// supervisor's state lock across a whole resize burst, so the collector records
359+// every geometry while the repaint consumer cannot observe a partially applied
360+// burst. The burst must coalesce into at most one pending invalidation and the
361+// repaint that follows must sample the latest Terminal.Geometry: no render may
362+// observe an intermediate size.
363+func TestSupervisorResizeBurstCoalescesAndRenderObservesLatestGeometry(t *testing.T) {
364+ picker := newAttachTestPicker()
365+ harness := startResizeHarness(t, resizeHarnessOptions{picker: picker})
366+ require.Eventually(t, func() bool { return harness.sup.State().Connectivity == ConnectivityReady }, 5*time.Second, time.Millisecond)
367+ harness.recorder.drain()
368+ baseline := harness.recorder.count()
369+
370+ burst := []domain.Geometry{
371+ {Size: domain.Size{Cols: 90, Rows: 30}},
372+ {Size: domain.Size{Cols: 100, Rows: 35}},
373+ {Size: domain.Size{Cols: 120, Rows: 40}},
374+ }
375+ var pending int
376+ func() {
377+ harness.sup.mu.Lock()
378+ defer harness.sup.mu.Unlock()
379+ for _, geometry := range burst {
380+ harness.terminal.resize(geometry)
381+ }
382+ require.Eventually(t, func() bool {
383+ return harness.sup.attachments.geometryProgress() == uint64(len(burst))
384+ }, 5*time.Second, time.Millisecond, "the collector records every geometry of the burst")
385+ pending = harness.sup.attachments.pendingPresentationSignals()
386+ }()
387+ require.Equal(t, 1, pending,
388+ "a resize burst coalesces into exactly one pending presentation invalidation")
389+
390+ latest := burst[len(burst)-1]
391+ require.Eventually(t, func() bool { return harness.recorder.count() > baseline }, 5*time.Second, time.Millisecond,
392+ "the coalesced burst produces a repaint")
393+ for _, sample := range harness.recorder.samples()[baseline:] {
394+ require.Equal(t, PresentPicker, sample.state.Presentation)
395+ require.Equal(t, latest.Size, sample.geometry.Size,
396+ "every repaint after the burst observes the latest Terminal.Geometry")
397+ }
398+ require.Equal(t, latest.Size, harness.recorder.last().geometry.Size)
399+}
400+
401+// TestSupervisorIdlePickerRepaintsOnResizeWithoutInputOrPublication proves the
402+// core idle behavior: a resize wakes the established-connection wait and
403+// repaints the picker even though no terminal input arrived and no broker
404+// snapshot was published.
405+func TestSupervisorIdlePickerRepaintsOnResizeWithoutInputOrPublication(t *testing.T) {
406+ picker := newAttachTestPicker()
407+ harness := startResizeHarness(t, resizeHarnessOptions{picker: picker})
408+ require.Eventually(t, func() bool { return harness.sup.State().Connectivity == ConnectivityReady }, 5*time.Second, time.Millisecond)
409+
410+ revision := harness.service.hub.current().Revision
411+ harness.recorder.drain()
412+
413+ resized := domain.Geometry{Size: domain.Size{Cols: 132, Rows: 43}}
414+ harness.terminal.resize(resized)
415+
416+ sample := awaitResizeRender(t, harness.recorder, func(sample resizeRenderSample) bool {
417+ return sample.state.Presentation == PresentPicker && sample.state.Connectivity == ConnectivityReady
418+ })
419+ require.Equal(t, State{Presentation: PresentPicker, Connectivity: ConnectivityReady, Generation: 1}, sample.state,
420+ "a resize repaint reports the untouched ready state")
421+ require.Equal(t, resized.Size, sample.geometry.Size, "the render samples the latest terminal geometry")
422+ require.Equal(t, revision, harness.service.hub.current().Revision, "a resize repaint must not need a broker publication")
423+ require.Zero(t, picker.consumedCount(), "a resize repaint must not consume terminal input")
424+ require.Zero(t, harness.sup.attachments.pendingPresentationSignals(), "the coalesced invalidation was consumed")
425+}
426+
427+// TestSupervisorResizeRepaintsConnectingRetryAndNonRetryableWaiting proves the
428+// resize invalidation reaches every pre-attachment waiting state the supervisor
429+// parks in: an in-flight broker connect, the retry cadence, and the
430+// non-retryable picker wait.
431+func TestSupervisorResizeRepaintsConnectingRetryAndNonRetryableWaiting(t *testing.T) {
432+ unavailable := ports.BrokerError{Code: ports.BrokerErrorUnavailable, Text: "broker down"}
433+ incompatible := ports.BrokerError{Code: ports.BrokerErrorIncompatible, Text: "protocol"}
434+
435+ tests := []struct {
436+ name string
437+ // start parks one supervisor on the wait under test and returns the state
438+ // it must be presenting when the resize arrives.
439+ start func(t *testing.T) (*resizeSupervisorHarness, State)
440+ }{
441+ {
442+ name: "broker connect in flight",
443+ start: func(t *testing.T) (*resizeSupervisorHarness, State) {
444+ harness := startResizeHarness(t, resizeHarnessOptions{
445+ connect: func(ctx context.Context, _ int) (ports.BrokerService, error) {
446+ <-ctx.Done()
447+ return nil, ctx.Err()
448+ },
449+ })
450+ require.Equal(t, 1, harness.connector.awaitStart(t))
451+ return harness, State{Presentation: PresentPicker, Connectivity: ConnectivityConnectingBroker, Generation: 1}
452+ },
453+ },
454+ {
455+ name: "retry cadence wait",
456+ start: func(t *testing.T) (*resizeSupervisorHarness, State) {
457+ harness := startResizeHarness(t, resizeHarnessOptions{
458+ connect: func(context.Context, int) (ports.BrokerService, error) { return nil, unavailable },
459+ })
460+ harness.clock.awaitTimer(t)
461+ // The cadence exists because the first attempt already failed, so its
462+ // connector-start notification is still queued. Consume it now; the
463+ // retry assertion below must observe the next attempt's notification,
464+ // not this initial one.
465+ require.Equal(t, 1, harness.connector.awaitStart(t))
466+ return harness, State{Presentation: PresentPicker, Connectivity: ConnectivityRetryWait, Attempt: 1, Generation: 1, Err: unavailable}
467+ },
468+ },
469+ {
470+ name: "non-retryable picker wait",
471+ start: func(t *testing.T) (*resizeSupervisorHarness, State) {
472+ harness := startResizeHarness(t, resizeHarnessOptions{
473+ connect: func(context.Context, int) (ports.BrokerService, error) { return nil, incompatible },
474+ })
475+ require.Eventually(t, func() bool {
476+ state := harness.sup.State()
477+ return state.Connectivity == ConnectivityDisconnected && state.Err != nil
478+ }, 5*time.Second, time.Millisecond)
479+ return harness, State{Presentation: PresentPicker, Connectivity: ConnectivityDisconnected, Generation: 1, Err: incompatible}
480+ },
481+ },
482+ }
483+
484+ for _, tt := range tests {
485+ t.Run(tt.name, func(t *testing.T) {
486+ harness, want := tt.start(t)
487+ require.Equal(t, want, harness.sup.State(), "the supervisor is parked exactly on the wait under test")
488+ harness.recorder.drain()
489+
490+ var retryTimer *supervisorTestTimer
491+ if tt.name == "retry cadence wait" {
492+ retryTimer = harness.clock.snapshotTimers()[0]
493+ require.Equal(t, 1, harness.clock.timerCount())
494+ require.Equal(t, 1, harness.clock.liveTimers())
495+ }
496+
497+ resized := domain.Geometry{Size: domain.Size{Cols: 111, Rows: 41}}
498+ harness.terminal.resize(resized)
499+
500+ sample := awaitResizeRender(t, harness.recorder, func(sample resizeRenderSample) bool {
501+ return sample.state.Presentation == PresentPicker
502+ })
503+ require.Equal(t, want, sample.state, "a resize repaint reports the parked state unchanged")
504+ require.Equal(t, resized.Size, sample.geometry.Size, "the render samples the latest terminal geometry")
505+ require.Zero(t, harness.sup.attachments.pendingPresentationSignals(), "the coalesced invalidation was consumed")
506+ require.Equal(t, want.Connectivity, harness.sup.State().Connectivity, "a repaint never advances connectivity")
507+ if retryTimer != nil {
508+ require.Equal(t, 1, harness.clock.timerCount(), "resize must not replace the retry timer")
509+ require.Equal(t, 1, harness.clock.liveTimers(), "the original retry timer remains live")
510+ retryTimer.fire()
511+ require.Equal(t, 2, harness.connector.awaitStart(t), "the original timer advances the next attempt")
512+ }
513+ })
514+ }
515+}
516+
517+// TestAttachmentHostResizeBurstCoalescesAndKeepsGeometryWakeupsIndependent pins
518+// the collector itself: a burst of valid resizes is coalesced into exactly one
519+// buffered presentation invalidation while the attachment geometry sequence
520+// still advances to the latest geometry, and draining the presentation signal
521+// cannot steal the attachment's own geometry wakeup.
522+func TestAttachmentHostResizeBurstCoalescesAndKeepsGeometryWakeupsIndependent(t *testing.T) {
523+ resizes := make(chan domain.Geometry, 8)
524+ host := newAttachmentHost(attachmentHostConfig{})
525+ host.resizes = resizes
526+ host.ownerBoundary = true
527+ ctx, cancel := context.WithCancel(context.Background())
528+ defer cancel()
529+ host.startGeometry(ctx)
530+ defer host.stopGeometry()
531+
532+ fg := host.newForeground(AttachmentToken{Generation: 1, Attempt: 1}, newSessionTestStream())
533+ require.True(t, host.authority.grant(fg))
534+
535+ burst := []domain.Geometry{
536+ {Size: domain.Size{Cols: 90, Rows: 30}},
537+ {Size: domain.Size{Cols: 100, Rows: 35}},
538+ {Size: domain.Size{Cols: 120, Rows: 40}},
539+ }
540+ for _, geometry := range burst {
541+ resizes <- geometry
542+ }
543+ require.Eventually(t, func() bool {
544+ return host.geometryProgress() == uint64(len(burst)) && host.pendingPresentationSignals() == 1
545+ }, 5*time.Second, time.Millisecond, "a resize burst must coalesce into one presentation invalidation")
546+ require.Equal(t, 1, host.pendingGeometrySignals(), "the attachment geometry wakeup coalesces independently")
547+
548+ latest, ok := host.latestGeometry()
549+ require.True(t, ok)
550+ require.Equal(t, burst[len(burst)-1].Size, latest.Size, "the latest geometry wins the coalesced burst")
551+
552+ // Draining the presentation invalidation must not touch the attachment's
553+ // geometry wakeup: the foreground still receives the latest geometry.
554+ <-host.presentationUpdate
555+ geometry, ok := fg.Resize(context.Background())
556+ require.True(t, ok)
557+ require.Equal(t, burst[len(burst)-1].Size, geometry.Size)
558+
559+ next := domain.Geometry{Size: domain.Size{Cols: 132, Rows: 43}}
560+ resizes <- next
561+ geometry, ok = fg.Resize(context.Background())
562+ require.True(t, ok)
563+ require.Equal(t, next.Size, geometry.Size)
564+ require.Equal(t, 1, host.pendingPresentationSignals(), "every resize still raises a coalesced invalidation")
565+ fg.finish()
566+}
567+
568+// TestSupervisorResizeDuringAttachmentSettlementPreservesSessionOutput pins the
569+// safe boundary this slice was narrowed to. Once Begin has admitted the
570+// foreground it owns the shared terminal writer before MarkAttached and the
571+// initial publication, so a resize invalidation that lands in the settlement
572+// wait must not be consumed there: the composition's renderer would write picker
573+// bytes over the session output the foreground is writing. The invalidation
574+// stays coalesced and buffered through connecting and attached, and is consumed
575+// with a repaint at the latest geometry only once the attachment returns to the
576+// picker.
577+func TestSupervisorResizeDuringAttachmentSettlementPreservesSessionOutput(t *testing.T) {
578+ picker := newAttachTestPicker()
579+ harness := startResizeHarness(t, resizeHarnessOptions{picker: picker})
580+ registry := &resizeStreamRegistry{}
581+ harness.service.setOpenStream(registry.open)
582+
583+ picker.commit(sessionTestRequest(true))
584+ stream := registry.stream(t, 0)
585+ require.Eventually(t, func() bool {
586+ return harness.sup.State().Presentation == PresentConnecting
587+ }, 5*time.Second, time.Millisecond, "attachment settlement exposes connecting presentation")
588+ harness.recorder.drain()
589+
590+ resized := domain.Geometry{Size: domain.Size{Cols: 109, Rows: 39}}
591+ harness.terminal.resize(resized)
592+ require.Eventually(t, func() bool { return harness.sup.attachments.pendingPresentationSignals() == 1 },
593+ 5*time.Second, time.Millisecond, "the collector still records the resize while connecting")
594+
595+ // The admitted foreground commits its initial publication through the same
596+ // terminal. The supervisor must not have painted over it, and the resize must
597+ // not have driven a connecting repaint at the new geometry.
598+ deliverReadyStream(t, stream)
599+ awaitAttachedState(t, harness.sup)
600+ written := harness.terminal.written()
601+ at := strings.Index(written, "\x1b[Hready")
602+ require.GreaterOrEqual(t, at, 0, "the admitted foreground committed its initial publication")
603+ require.Equal(t, "\x1b[Hready", written[at:],
604+ "a resize during settlement must not append picker bytes over session output")
605+ for _, sample := range harness.recorder.samples() {
606+ require.False(t, sample.state.Presentation == PresentConnecting && sample.geometry.Size == resized.Size,
607+ "a resize during settlement must not invoke the picker renderer for the connecting presentation")
608+ }
609+ require.Equal(t, 1, harness.sup.attachments.pendingPresentationSignals(),
610+ "the invalidation stays buffered through connecting and attached")
611+
612+ // Back at the picker the preserved invalidation is consumed and repaints at
613+ // the latest geometry. The transition back to the picker renders once; the
614+ // consumed invalidation renders once more, so exactly two picker renders at
615+ // the resized geometry follow the attachment. A run that never emitted the
616+ // invalidation cannot reach this point, and a run that consumed it without a
617+ // repaint leaves only one.
618+ stream.deliver(protocol.Detached{})
619+ awaitPickerState(t, harness.sup)
620+ pickerAtResize := func(sample resizeRenderSample) bool {
621+ return sample.state.Presentation == PresentPicker && sample.geometry.Size == resized.Size
622+ }
623+ awaitResizeRender(t, harness.recorder, pickerAtResize)
624+ awaitResizeRender(t, harness.recorder, pickerAtResize)
625+ require.Eventually(t, func() bool { return harness.sup.attachments.pendingPresentationSignals() == 0 },
626+ 5*time.Second, time.Millisecond, "the picker wait consumes the preserved invalidation")
627+}
628+
629+// TestSupervisorResizeConnectingBurstStaysCoalescedAndBuffered proves the
630+// invalidation a settlement wait must not consume is still coalesced: a burst of
631+// resizes while the connecting presentation holds an admitted foreground leaves
632+// exactly one pending signal, and the whole burst is then consumed by the picker
633+// wait after the attachment ends without any intermediate repaint.
634+func TestSupervisorResizeConnectingBurstStaysCoalescedAndBuffered(t *testing.T) {
635+ picker := newAttachTestPicker()
636+ harness := startResizeHarness(t, resizeHarnessOptions{picker: picker})
637+ registry := &resizeStreamRegistry{}
638+ harness.service.setOpenStream(registry.open)
639+
640+ picker.commit(sessionTestRequest(true))
641+ stream := registry.stream(t, 0)
642+ require.Eventually(t, func() bool {
643+ return harness.sup.State().Presentation == PresentConnecting
644+ }, 5*time.Second, time.Millisecond)
645+
646+ burst := []domain.Geometry{
647+ {Size: domain.Size{Cols: 90, Rows: 30}},
648+ {Size: domain.Size{Cols: 100, Rows: 35}},
649+ {Size: domain.Size{Cols: 120, Rows: 40}},
650+ }
651+ for _, geometry := range burst {
652+ harness.terminal.resize(geometry)
653+ }
654+ require.Eventually(t, func() bool {
655+ return harness.sup.attachments.geometryProgress() == uint64(len(burst))
656+ }, 5*time.Second, time.Millisecond, "the collector records every geometry of the burst")
657+ require.Eventually(t, func() bool { return harness.sup.attachments.pendingPresentationSignals() == 1 },
658+ 5*time.Second, time.Millisecond, "a connecting resize burst coalesces into one buffered invalidation")
659+
660+ deliverReadyStream(t, stream)
661+ awaitAttachedState(t, harness.sup)
662+ require.Equal(t, 1, harness.sup.attachments.pendingPresentationSignals(),
663+ "the coalesced burst is preserved through connecting and attached")
664+
665+ stream.deliver(protocol.Detached{})
666+ awaitPickerState(t, harness.sup)
667+ pickerAtLatest := func(sample resizeRenderSample) bool {
668+ return sample.state.Presentation == PresentPicker && sample.geometry.Size == burst[len(burst)-1].Size
669+ }
670+ awaitResizeRender(t, harness.recorder, pickerAtLatest)
671+ awaitResizeRender(t, harness.recorder, pickerAtLatest)
672+ require.Eventually(t, func() bool { return harness.sup.attachments.pendingPresentationSignals() == 0 },
673+ 5*time.Second, time.Millisecond, "the coalesced burst is consumed by the picker wait, once")
674+}
675+
676+// awaitAttachedSettlement waits until the supervisor's live attachment has
677+// claimed the attached transition and the settlement wait has drained every
678+// earlier invalidation. It is the unambiguous boundary the attached-resize
679+// assertion needs: once the current foreground reports attached, the
680+// settlement wait has disarmed its presentation arm, so a resize pushed after
681+// this point must stay buffered for the picker instead of being consumed.
682+func awaitAttachedSettlement(t *testing.T, harness *resizeSupervisorHarness) {
683+ t.Helper()
684+ require.Eventually(t, func() bool {
685+ harness.sup.mu.Lock()
686+ attachedPresentation := harness.sup.state.Presentation == PresentAttached
687+ harness.sup.mu.Unlock()
688+ foreground := harness.sup.attachments.authority.foreground()
689+ return attachedPresentation && foreground != nil && foreground.Attached()
690+ }, 5*time.Second, time.Millisecond, "the attachment never claimed the attached boundary")
691+ require.Eventually(t, func() bool { return harness.sup.attachments.pendingPresentationSignals() == 0 },
692+ 5*time.Second, time.Millisecond, "a pre-resize invalidation was not drained before the boundary")
693+}
694+
695+// TestSupervisorAttachedResizeKeepsGeometryWakeupAndPreservesRepaint proves an
696+// attached foreground is not painted over and its geometry wakeup is not
697+// stolen: the live attachment receives the typed resize while the coalesced
698+// presentation invalidation stays buffered for the picker, and is then consumed
699+// by the supervisor's next picker wait.
700+func TestSupervisorAttachedResizeKeepsGeometryWakeupAndPreservesRepaint(t *testing.T) {
701+ picker := newAttachTestPicker()
702+ harness := startResizeHarness(t, resizeHarnessOptions{picker: picker})
703+ registry := &resizeStreamRegistry{}
704+ harness.service.setOpenStream(registry.open)
705+
706+ picker.commit(sessionTestRequest(true))
707+ stream := registry.stream(t, 0)
708+ deliverReadyStream(t, stream)
709+ // Settle the attached boundary before the resize: the presentation is
710+ // attached and the live foreground has claimed attached, and every earlier
711+ // invalidation is drained, so the resize below lands unambiguously on an
712+ // attached settlement wait.
713+ awaitAttachedSettlement(t, harness)
714+ harness.recorder.drain()
715+ baseline := harness.recorder.count()
716+
717+ resized := domain.Geometry{Size: domain.Size{Cols: 100, Rows: 40}}
718+ harness.terminal.resize(resized)
719+
720+ resizes := awaitStreamResizes(t, stream, 1)
721+ require.Equal(t, resized.Size, resizes[0].Size, "the live attachment still receives its geometry wakeup")
722+ // The attachment geometry wakeup and the presentation invalidation are
723+ // pushed by the collector independently, and the worker can observe the new
724+ // geometry through the recorded sequence before the invalidation is pushed.
725+ // Wait for the coalesced token to be buffered; a supervisor that wrongly
726+ // consumed it while attached could never make this count return to one, so
727+ // this stays a behavioral check rather than a sleep.
728+ require.Eventually(t, func() bool { return harness.sup.attachments.pendingPresentationSignals() == 1 },
729+ 5*time.Second, time.Millisecond, "the supervisor does not consume the invalidation while attached")
730+ require.Equal(t, baseline, harness.recorder.count(), "an attached presentation is never painted over")
731+ require.Equal(t, 1, harness.sup.attachments.pendingPresentationSignals(), "the invalidation stays buffered until the picker wait")
732+
733+ stream.deliver(protocol.Detached{})
734+ awaitPickerState(t, harness.sup)
735+ require.Eventually(t, func() bool { return harness.sup.attachments.pendingPresentationSignals() == 0 }, 5*time.Second, time.Millisecond,
736+ "the preserved invalidation is consumed by the picker wait")
737+}
738+
739+// TestSupervisorResizeAfterDetachReachesOnlyReplacementAttachment proves a
740+// resize is delivered to the valid replacement attachment token only. A resize
741+// that lands in the detach/reattach gap reaches no stream at all: it repaints the
742+// picker, the retired token's stream keeps exactly its own earlier resize, and
743+// the replacement inherits the gap geometry and receives only its own later
744+// resize under its own (generation, attempt) identity.
745+func TestSupervisorResizeAfterDetachReachesOnlyReplacementAttachment(t *testing.T) {
746+ picker := newAttachTestPicker()
747+ harness := startResizeHarness(t, resizeHarnessOptions{picker: picker})
748+ registry := &resizeStreamRegistry{}
749+ harness.service.setOpenStream(registry.open)
750+
751+ picker.commit(sessionTestRequest(true))
752+ first := registry.stream(t, 0)
753+ deliverReadyStream(t, first)
754+ awaitAttachedState(t, harness.sup)
755+ require.Equal(t, AttachmentToken{Generation: 1, Attempt: 1}, harness.sup.attachments.authority.currentToken())
756+
757+ firstResize := domain.Geometry{Size: domain.Size{Cols: 101, Rows: 41}}
758+ harness.terminal.resize(firstResize)
759+ firstResizes := awaitStreamResizes(t, first, 1)
760+ require.Equal(t, firstResize.Size, firstResizes[0].Size)
761+
762+ // The attachment detaches: its generation is retired before any replacement
763+ // exists.
764+ first.deliver(protocol.Detached{})
765+ awaitPickerState(t, harness.sup)
766+ require.True(t, harness.sup.attachments.authority.currentToken().IsZero(), "the retired token no longer owns the foreground")
767+
768+ // A resize in the detach/reattach gap: no attachment owns the foreground, so
769+ // the retired stream must not receive it, only the picker repaints, and the
770+ // signal is consumed by the picker wait.
771+ harness.recorder.drain()
772+ gapResize := domain.Geometry{Size: domain.Size{Cols: 110, Rows: 45}}
773+ harness.terminal.resize(gapResize)
774+ gapSample := awaitResizeRender(t, harness.recorder, func(sample resizeRenderSample) bool {
775+ return sample.state.Presentation == PresentPicker && sample.geometry.Size == gapResize.Size
776+ })
777+ require.Equal(t, gapResize.Size, gapSample.geometry.Size, "a gap resize repaints the picker")
778+ require.Eventually(t, func() bool { return harness.sup.attachments.pendingPresentationSignals() == 0 }, 5*time.Second, time.Millisecond,
779+ "the gap invalidation is consumed by the picker wait")
780+ require.Len(t, streamResizes(first), 1, "the retired attachment token must not receive a gap resize")
781+
782+ // A replacement attachment claims the next attempt identity. It carries the
783+ // latest geometry in its own session state, which is the gap resize, so the
784+ // only stream that ever observes it is the valid replacement.
785+ picker.commit(sessionTestRequest(true))
786+ second := registry.stream(t, 1)
787+ deliverReadyStream(t, second)
788+ awaitAttachedState(t, harness.sup)
789+ require.Equal(t, AttachmentToken{Generation: 1, Attempt: 2}, harness.sup.attachments.authority.currentToken())
790+ inherited := awaitStreamResizes(t, second, 1)
791+ require.Equal(t, gapResize.Size, inherited[0].Size, "the replacement inherits the latest geometry")
792+
793+ // The replacement's own resize reaches it and nothing else.
794+ secondResize := domain.Geometry{Size: domain.Size{Cols: 120, Rows: 50}}
795+ harness.terminal.resize(secondResize)
796+ replacement := awaitStreamResizes(t, second, 2)
797+ require.Equal(t, secondResize.Size, replacement[1].Size, "only the replacement token receives its resize")
798+ require.Len(t, streamResizes(first), 1, "a retired attachment token must never receive a replacement's resize")
799+}
800+
801+// TestResizeCollectorAndSupervisorJoinOnClosedEventsAndCancellation proves the
802+// collector and the supervisor's owned waits end cleanly when ResizeEvents is
803+// already closed and when the process context is cancelled: no path blocks on a
804+// closed or abandoned channel.
805+func TestResizeCollectorAndSupervisorJoinOnClosedEventsAndCancellation(t *testing.T) {
806+ t.Run("collector joins a closed ResizeEvents channel", func(t *testing.T) {
807+ closed := make(chan domain.Geometry)
808+ close(closed)
809+ host := newAttachmentHost(attachmentHostConfig{})
810+ host.resizes = closed
811+ ctx, cancel := context.WithCancel(context.Background())
812+ defer cancel()
813+ host.startGeometry(ctx)
814+ select {
815+ case <-host.geometryDone:
816+ case <-time.After(5 * time.Second):
817+ t.Fatal("the collector did not end on closed resize events")
818+ }
819+ host.stopGeometry()
820+ require.Nil(t, host.geometryDone)
821+ require.Nil(t, host.geometryCancel)
822+
823+ // The host can be started again with a live channel and joined by
824+ // cancellation, even with a coalesced invalidation still pending.
825+ resizes := make(chan domain.Geometry, 2)
826+ host.resizes = resizes
827+ host.startGeometry(ctx)
828+ resizes <- domain.Geometry{Size: domain.Size{Cols: 90, Rows: 30}}
829+ resizes <- domain.Geometry{Size: domain.Size{Cols: 91, Rows: 31}}
830+ require.Eventually(t, func() bool { return host.pendingPresentationSignals() == 1 }, 5*time.Second, time.Millisecond)
831+ cancel()
832+ select {
833+ case <-host.geometryDone:
834+ case <-time.After(5 * time.Second):
835+ t.Fatal("the collector did not end on cancellation")
836+ }
837+ host.stopGeometry()
838+ require.Nil(t, host.geometryDone)
839+ })
840+
841+ t.Run("supervisor run joins on closed ResizeEvents and cancellation", func(t *testing.T) {
842+ harness := startResizeHarness(t, resizeHarnessOptions{
843+ closeResizes: true,
844+ connect: func(context.Context, int) (ports.BrokerService, error) {
845+ return nil, ports.BrokerError{Code: ports.BrokerErrorUnavailable, Text: "down"}
846+ },
847+ })
848+ require.Equal(t, 1, harness.connector.awaitStart(t))
849+ harness.cancel()
850+ require.ErrorIs(t, harness.waitRun(t), context.Canceled)
851+ _, _, restores := harness.terminal.counts()
852+ require.Equal(t, 1, restores, "raw mode is restored exactly once")
853+ require.Nil(t, harness.sup.attachments.geometryDone, "Run joined the geometry collector")
854+ require.Nil(t, harness.sup.attachments.geometryCancel)
855+ })
856+}
857+
858+// TestSupervisorResizeInvalidationRendersOnlyPickerPresentations pins the
859+// presentation fence directly: only PresentPicker may be repainted by a resize,
860+// so the pre-attachment connecting transition, an attached foreground, and a
861+// terminating process are never painted over. It also proves the fence is
862+// behavioral, not just counted: with the session byte already in the terminal,
863+// a connecting repaint would change the renderer count and append picker bytes
864+// over the session output. A supervisor without a renderer is a no-op.
865+func TestSupervisorResizeInvalidationRendersOnlyPickerPresentations(t *testing.T) {
866+ terminal := newResizeTestTerminal(newAttachTestReader(), domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}})
867+ recorder := newResizeRenderRecorder(terminal)
868+ sup := mustSupervisor(t, SupervisorConfig{
869+ Connector: newSupervisorTestConnector(nil),
870+ Terminal: terminal,
871+ Clock: newSupervisorTestClock(),
872+ Render: recorder.render,
873+ })
874+
875+ tests := []struct {
876+ name string
877+ presentation Presentation
878+ wantRender bool
879+ }{
880+ {name: "picker is repainted", presentation: PresentPicker, wantRender: true},
881+ {name: "connecting is never painted over", presentation: PresentConnecting},
882+ {name: "attached is never painted over", presentation: PresentAttached},
883+ {name: "terminating is never repainted", presentation: PresentTerminating},
884+ }
885+ for _, tt := range tests {
886+ t.Run(tt.name, func(t *testing.T) {
887+ // A session frame as an admitted foreground would have written it, so
888+ // any picker repaint for a refused presentation is observable.
889+ const session = "\x1b[Hsession output"
890+ terminal.setWritten(session)
891+ sup.mu.Lock()
892+ sup.state = State{Presentation: tt.presentation, Connectivity: ConnectivityReady}
893+ sup.mu.Unlock()
894+ recorder.drain()
895+ baseline := recorder.count()
896+
897+ sup.renderResizeInvalidation()
898+
899+ if tt.wantRender {
900+ sample := awaitResizeRender(t, recorder, func(resizeRenderSample) bool { return true })
901+ require.Equal(t, tt.presentation, sample.state.Presentation)
902+ return
903+ }
904+ require.Equal(t, baseline, recorder.count(), "presentation %s must not be repainted by a resize", tt.presentation)
905+ require.Equal(t, session, terminal.written(),
906+ "presentation %s must never overpaint session output", tt.presentation)
907+ })
908+ }
909+
910+ // A supervisor with no renderer configured through the internal zero value
911+ // never panics, including on a nil receiver.
912+ (&Supervisor{}).renderResizeInvalidation()
913+ var nilSupervisor *Supervisor
914+ nilSupervisor.renderResizeInvalidation()
915+}
916+
917+// TestSupervisorPresentationInvalidationNilHostIsSafe pins the nil-host
918+// contract of the presentation invalidation helper and of a wait that selects
919+// on it. A supervisor whose attachment host is absent (the zero value, or a run
920+// whose host was detached) must expose a nil signal and still park on its
921+// remaining signals instead of dereferencing a nil host.
922+func TestSupervisorPresentationInvalidationNilHostIsSafe(t *testing.T) {
923+ t.Run("helper returns nil without a host", func(t *testing.T) {
924+ var nilSupervisor *Supervisor
925+ require.Nil(t, nilSupervisor.presentationInvalidation(), "a nil receiver exposes no invalidation")
926+
927+ zero := &Supervisor{}
928+ require.Nil(t, zero.presentationInvalidation(), "a zero-value supervisor exposes no invalidation")
929+
930+ // A fully built supervisor exposes its host's signal; once the host is
931+ // gone the helper must fall back to nil rather than panic.
932+ terminal := newResizeTestTerminal(newAttachTestReader(), domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}})
933+ sup := mustSupervisor(t, SupervisorConfig{
934+ Connector: newSupervisorTestConnector(nil),
935+ Terminal: terminal,
936+ Clock: newSupervisorTestClock(),
937+ })
938+ require.NotNil(t, sup.presentationInvalidation(), "a live host exposes its coalesced invalidation")
939+ sup.attachments = nil
940+ require.Nil(t, sup.presentationInvalidation(), "a detached host exposes no invalidation")
941+ })
942+
943+ t.Run("a wait with a nil host still settles", func(t *testing.T) {
944+ terminal := newResizeTestTerminal(newAttachTestReader(), domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}})
945+ clock := newSupervisorTestClock()
946+ sup := mustSupervisor(t, SupervisorConfig{
947+ Connector: newSupervisorTestConnector(nil),
948+ Terminal: terminal,
949+ Clock: clock,
950+ Jitter: func() float64 { return 0 },
951+ })
952+ sup.attachments = nil
953+ require.Nil(t, sup.presentationInvalidation())
954+ // One transient failure makes the cadence non-zero, so waitBackoff
955+ // arms a real timer.
956+ sup.transition(supervisorEvent{kind: supervisorTransientFailure})
957+
958+ // The reader never yields, so the EOF arm stays closed and the timer is
959+ // the only signal that can settle this wait.
960+ reader := newAttachTestReader()
961+ lifetime := startTerminalInputLifetime(reader, nil)
962+ t.Cleanup(func() {
963+ reader.close()
964+ lifetime.stop()
965+ })
966+
967+ settled := make(chan struct{})
968+ var terminated bool
969+ var waitErr error
970+ go func() {
971+ defer close(settled)
972+ terminated, waitErr = sup.waitBackoff(context.Background(), lifetime)
973+ }()
974+ timer := clock.awaitTimer(t)
975+ timer.fire()
976+ select {
977+ case <-settled:
978+ case <-time.After(5 * time.Second):
979+ t.Fatal("a nil-host backoff wait never settled on its timer")
980+ }
981+ require.False(t, terminated)
982+ require.NoError(t, waitErr)
983+ })
984+
985+ t.Run("a wait with a nil host settles on cancellation", func(t *testing.T) {
986+ terminal := newResizeTestTerminal(newAttachTestReader(), domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}})
987+ clock := newSupervisorTestClock()
988+ sup := mustSupervisor(t, SupervisorConfig{
989+ Connector: newSupervisorTestConnector(nil),
990+ Terminal: terminal,
991+ Clock: clock,
992+ Jitter: func() float64 { return 0 },
993+ })
994+ sup.attachments = nil
995+ sup.transition(supervisorEvent{kind: supervisorTransientFailure})
996+
997+ reader := newAttachTestReader()
998+ lifetime := startTerminalInputLifetime(reader, nil)
999+ t.Cleanup(func() {
1000+ reader.close()
1001+ lifetime.stop()
1002+ })
1003+
1004+ ctx, cancel := context.WithCancel(context.Background())
1005+ settled := make(chan struct{})
1006+ var terminated bool
1007+ var waitErr error
1008+ go func() {
1009+ defer close(settled)
1010+ terminated, waitErr = sup.waitBackoff(ctx, lifetime)
1011+ }()
1012+ clock.awaitTimer(t)
1013+ cancel()
1014+ select {
1015+ case <-settled:
1016+ case <-time.After(5 * time.Second):
1017+ t.Fatal("a nil-host backoff wait never settled on cancellation")
1018+ }
1019+ require.True(t, terminated)
1020+ require.ErrorIs(t, waitErr, context.Canceled)
1021+ })
1022+}
@@ -0,0 +1,1022 @@
1+package client
2+
3+import (
4+ "context"
5+ "io"
6+ "strings"
7+ "sync"
8+ "testing"
9+ "time"
10+
11+ "github.com/stretchr/testify/require"
12+
13+ "github.com/bnema/vev/internal/domain"
14+ "github.com/bnema/vev/internal/ports"
15+ "github.com/bnema/vev/internal/protocol"
16+)
17+
18+// Resize presentation-invalidation tests (Plan 001, uncommitted slice).
19+//
20+// The mechanism under test is deliberately split across two owners:
21+//
22+// - the attachment host's geometry collector records the latest valid
23+// Terminal.Geometry and emits two independent coalesced wakeups: the
24+// attachment sequence wakeup (geometryUpdate) that a granted foreground
25+// consumes through Resize, and the presentation-invalidation wakeup
26+// (presentationUpdate) that only the supervisor's serialized control path
27+// consumes;
28+// - the supervisor consumes presentationUpdate in every wait it owns
29+// (attempt, ready, termination, backoff) and repaints only a picker
30+// presentation (PickerPresentation), sampling Terminal.Geometry at render
31+// time. The attachment settlement wait never consumes it: Begin has already
32+// admitted a foreground that can write session output through the same
33+// terminal before MarkAttached and the initial publication, so the
34+// invalidation stays buffered for the next picker state instead of being
35+// painted over it.
36+//
37+// Every test below is deterministic: a fake clock, scripted fakes, bounded
38+// waits, and channel signals instead of sleeps. The resize channel is pushed
39+// by the test itself, so no ordering is hoped for.
40+
41+// resizeTestTerminal is the supervisor-owned terminal double with a
42+// test-driven ResizeEvents channel and a Geometry that reports the latest
43+// pushed size. It embeds the attachment-supervisor terminal double so it also
44+// satisfies the UI publication seam, and only overrides geometry.
45+type resizeTestTerminal struct {
46+ *attachTestTerminal
47+ resizes chan domain.Geometry
48+ geometryMu sync.Mutex
49+ geometry domain.Geometry
50+}
51+
52+func newResizeTestTerminal(in io.Reader, geometry domain.Geometry) *resizeTestTerminal {
53+ return &resizeTestTerminal{
54+ attachTestTerminal: &attachTestTerminal{in: in},
55+ resizes: make(chan domain.Geometry, 8),
56+ geometry: geometry,
57+ }
58+}
59+
60+func (t *resizeTestTerminal) ResizeEvents() <-chan domain.Geometry { return t.resizes }
61+
62+func (t *resizeTestTerminal) Geometry() (domain.Geometry, error) {
63+ t.geometryMu.Lock()
64+ defer t.geometryMu.Unlock()
65+ return t.geometry, nil
66+}
67+
68+// resize records the new size as the terminal's current geometry and then
69+// publishes the event, exactly like a physical terminal whose size query and
70+// resize event agree.
71+func (t *resizeTestTerminal) resize(geometry domain.Geometry) {
72+ t.geometryMu.Lock()
73+ t.geometry = geometry
74+ t.geometryMu.Unlock()
75+ t.resizes <- geometry
76+}
77+
78+func (t *resizeTestTerminal) closeResizeEvents() { close(t.resizes) }
79+
80+// setWritten replaces the terminal's committed output, so a test can seed the
81+// bytes an admitted foreground would have written and then prove no picker
82+// repaint overpainted them.
83+func (t *resizeTestTerminal) setWritten(data string) {
84+ t.attachTestTerminal.mu.Lock()
85+ defer t.attachTestTerminal.mu.Unlock()
86+ t.attachTestTerminal.out.Reset()
87+ t.attachTestTerminal.out.WriteString(data)
88+}
89+
90+// resizeRenderSample is one observed Render call: the exact state handed to the
91+// renderer plus the terminal geometry the renderer would have sampled, exactly
92+// as the offline composition does when it paints the picker.
93+type resizeRenderSample struct {
94+ state State
95+ geometry domain.Geometry
96+}
97+
98+// resizeRenderRecorder records every Render call both in a channel (for
99+// ordered waits) and in a slice (for counting), so a test can prove that a
100+// repaint happened, in what state, and against which geometry.
101+type resizeRenderRecorder struct {
102+ terminal *resizeTestTerminal
103+ renders chan resizeRenderSample
104+
105+ mu sync.Mutex
106+ all []resizeRenderSample
107+}
108+
109+func newResizeRenderRecorder(terminal *resizeTestTerminal) *resizeRenderRecorder {
110+ return &resizeRenderRecorder{terminal: terminal, renders: make(chan resizeRenderSample, 256)}
111+}
112+
113+// resizeRenderPaint is the picker frame marker the recording renderer writes.
114+// It stands for the production composition's picker bytes: a test can prove the
115+// renderer ran and that its output never landed over session output.
116+const resizeRenderPaint = "<picker-frame>"
117+
118+func (r *resizeRenderRecorder) render(state State) {
119+ geometry, _ := r.terminal.Geometry()
120+ sample := resizeRenderSample{state: state, geometry: geometry}
121+ r.mu.Lock()
122+ r.all = append(r.all, sample)
123+ r.mu.Unlock()
124+ // Exactly like the production offline composition, the recording renderer
125+ // writes its frame only while the shared fence admits the state. A mutation
126+ // that widened the fence would therefore land bytes a test can observe.
127+ if PickerPresentation(state) {
128+ _, _ = r.terminal.Out().Write([]byte(resizeRenderPaint))
129+ }
130+ select {
131+ case r.renders <- sample:
132+ default:
133+ }
134+}
135+
136+// drain discards every render already delivered to the ordered channel; the
137+// counted slice is retained so a caller can still take a baseline count.
138+func (r *resizeRenderRecorder) drain() {
139+ for {
140+ select {
141+ case <-r.renders:
142+ default:
143+ return
144+ }
145+ }
146+}
147+
148+func (r *resizeRenderRecorder) count() int {
149+ r.mu.Lock()
150+ defer r.mu.Unlock()
151+ return len(r.all)
152+}
153+
154+// last returns the most recent render sample, or the zero sample when nothing
155+// was rendered.
156+func (r *resizeRenderRecorder) last() resizeRenderSample {
157+ r.mu.Lock()
158+ defer r.mu.Unlock()
159+ if len(r.all) == 0 {
160+ return resizeRenderSample{}
161+ }
162+ return r.all[len(r.all)-1]
163+}
164+
165+// samples returns every recorded render sample in call order.
166+func (r *resizeRenderRecorder) samples() []resizeRenderSample {
167+ r.mu.Lock()
168+ defer r.mu.Unlock()
169+ return append([]resizeRenderSample(nil), r.all...)
170+}
171+
172+// awaitResizeRender waits for the next render matching match, bounded.
173+func awaitResizeRender(t *testing.T, recorder *resizeRenderRecorder, match func(resizeRenderSample) bool) resizeRenderSample {
174+ t.Helper()
175+ timeout := time.After(5 * time.Second)
176+ for {
177+ select {
178+ case sample := <-recorder.renders:
179+ if match(sample) {
180+ return sample
181+ }
182+ case <-timeout:
183+ t.Fatal("the supervisor never repainted the presentation for the resize")
184+ return resizeRenderSample{}
185+ }
186+ }
187+}
188+
189+// pendingPresentationSignals reports how many coalesced presentation
190+// invalidations are buffered. Reading len of a channel is synchronized, so it
191+// is safe from the test goroutine while the collector runs.
192+func (h *attachmentHost) pendingPresentationSignals() int { return len(h.presentationUpdate) }
193+
194+// pendingGeometrySignals reports how many attachment geometry wakeups are
195+// buffered, so a test can prove the two signals are independent.
196+func (h *attachmentHost) pendingGeometrySignals() int { return len(h.geometryUpdate) }
197+
198+// geometryProgress reports how many valid resizes the collector has recorded.
199+func (h *attachmentHost) geometryProgress() uint64 {
200+ h.geometryMu.Lock()
201+ defer h.geometryMu.Unlock()
202+ return h.geometrySeq
203+}
204+
205+// resizeSupervisorHarness is one running supervisor with its fakes: a
206+// test-driven resize channel, a recording renderer, and a fake clock.
207+type resizeSupervisorHarness struct {
208+ sup *Supervisor
209+ service *supervisorTestService
210+ clock *supervisorTestClock
211+ terminal *resizeTestTerminal
212+ reader *attachTestReader
213+ connector *supervisorTestConnector
214+ recorder *resizeRenderRecorder
215+
216+ cancel context.CancelFunc
217+ runDone chan struct{}
218+
219+ runMu sync.Mutex
220+ runErr error
221+}
222+
223+type resizeHarnessOptions struct {
224+ // picker is the supervisor's picker seam; nil runs the picker-less driver.
225+ picker pickerHost
226+ // geometry is the terminal's initial reported geometry.
227+ geometry domain.Geometry
228+ // closeResizes hands the supervisor an already closed ResizeEvents channel.
229+ closeResizes bool
230+ // connect scripts the broker attempts; nil returns the harness service.
231+ connect func(ctx context.Context, call int) (ports.BrokerService, error)
232+}
233+
234+// startResizeHarness builds and runs one supervisor over the resize-aware
235+// fakes. It returns as soon as Run is started, so a test can park on any wait.
236+func startResizeHarness(t *testing.T, options resizeHarnessOptions) *resizeSupervisorHarness {
237+ t.Helper()
238+ ctx, cancel := context.WithCancel(context.Background())
239+ clock := newSupervisorTestClock()
240+ reader := newAttachTestReader()
241+ geometry := options.geometry
242+ if geometry == (domain.Geometry{}) {
243+ geometry = domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}}
244+ }
245+ terminal := newResizeTestTerminal(reader, geometry)
246+ if options.closeResizes {
247+ terminal.closeResizeEvents()
248+ }
249+ service := newSupervisorTestService(ports.BrokerConnectionID{1})
250+ service.publish(3, 1)
251+ connect := options.connect
252+ if connect == nil {
253+ connect = func(context.Context, int) (ports.BrokerService, error) { return service, nil }
254+ }
255+ connector := newSupervisorTestConnector(connect)
256+ recorder := newResizeRenderRecorder(terminal)
257+ sup := mustSupervisor(t, SupervisorConfig{
258+ Connector: connector,
259+ Terminal: terminal,
260+ Clock: clock,
261+ Jitter: func() float64 { return 0 },
262+ Picker: options.picker,
263+ Render: recorder.render,
264+ })
265+
266+ harness := &resizeSupervisorHarness{
267+ sup: sup,
268+ service: service,
269+ clock: clock,
270+ terminal: terminal,
271+ reader: reader,
272+ connector: connector,
273+ recorder: recorder,
274+ cancel: cancel,
275+ runDone: make(chan struct{}),
276+ }
277+ go func() {
278+ err := sup.Run(ctx)
279+ harness.runMu.Lock()
280+ harness.runErr = err
281+ harness.runMu.Unlock()
282+ close(harness.runDone)
283+ }()
284+ t.Cleanup(func() {
285+ cancel()
286+ select {
287+ case <-harness.runDone:
288+ case <-time.After(5 * time.Second):
289+ t.Error("the supervisor did not stop")
290+ }
291+ // Release the single input lifetime's reader so the pump goroutine that
292+ // outlives Run, exactly like a real stdin that never reaches EOF, ends.
293+ reader.close()
294+ })
295+ return harness
296+}
297+
298+func (h *resizeSupervisorHarness) waitRun(t *testing.T) error {
299+ t.Helper()
300+ select {
301+ case <-h.runDone:
302+ case <-time.After(5 * time.Second):
303+ t.Fatal("the supervisor did not stop")
304+ }
305+ h.runMu.Lock()
306+ defer h.runMu.Unlock()
307+ return h.runErr
308+}
309+
310+// resizeStreamRegistry admits one fresh scripted logical stream per open call
311+// and lets a test reach the stream of a specific attachment attempt.
312+type resizeStreamRegistry struct {
313+ mu sync.Mutex
314+ streams []*sessionTestStream
315+}
316+
317+func (r *resizeStreamRegistry) open(context.Context, ports.BrokerOpenStreamRequest) (ports.BrokerLogicalConnection, error) {
318+ stream := newSessionTestStream()
319+ r.mu.Lock()
320+ r.streams = append(r.streams, stream)
321+ r.mu.Unlock()
322+ return stream, nil
323+}
324+
325+// stream waits until the index-th stream was admitted and returns it.
326+func (r *resizeStreamRegistry) stream(t *testing.T, index int) *sessionTestStream {
327+ t.Helper()
328+ require.Eventually(t, func() bool {
329+ r.mu.Lock()
330+ defer r.mu.Unlock()
331+ return len(r.streams) > index
332+ }, 5*time.Second, time.Millisecond, "the supervisor never admitted stream %d", index)
333+ r.mu.Lock()
334+ defer r.mu.Unlock()
335+ return r.streams[index]
336+}
337+
338+// streamResizes returns the typed resize messages one attachment stream sent.
339+func streamResizes(stream *sessionTestStream) []protocol.Resize {
340+ var resizes []protocol.Resize
341+ for _, message := range stream.messages() {
342+ if resize, ok := message.(protocol.Resize); ok {
343+ resizes = append(resizes, resize)
344+ }
345+ }
346+ return resizes
347+}
348+
349+// awaitStreamResizes waits until the stream sent at least want resize messages.
350+func awaitStreamResizes(t *testing.T, stream *sessionTestStream, want int) []protocol.Resize {
351+ t.Helper()
352+ require.Eventually(t, func() bool { return len(streamResizes(stream)) >= want }, 5*time.Second, time.Millisecond,
353+ "the attachment stream never sent %d resize messages", want)
354+ return streamResizes(stream)
355+}
356+
357+// TestSupervisorResizeBurstCoalescesAndRenderObservesLatestGeometry holds the
358+// supervisor's state lock across a whole resize burst, so the collector records
359+// every geometry while the repaint consumer cannot observe a partially applied
360+// burst. The burst must coalesce into at most one pending invalidation and the
361+// repaint that follows must sample the latest Terminal.Geometry: no render may
362+// observe an intermediate size.
363+func TestSupervisorResizeBurstCoalescesAndRenderObservesLatestGeometry(t *testing.T) {
364+ picker := newAttachTestPicker()
365+ harness := startResizeHarness(t, resizeHarnessOptions{picker: picker})
366+ require.Eventually(t, func() bool { return harness.sup.State().Connectivity == ConnectivityReady }, 5*time.Second, time.Millisecond)
367+ harness.recorder.drain()
368+ baseline := harness.recorder.count()
369+
370+ burst := []domain.Geometry{
371+ {Size: domain.Size{Cols: 90, Rows: 30}},
372+ {Size: domain.Size{Cols: 100, Rows: 35}},
373+ {Size: domain.Size{Cols: 120, Rows: 40}},
374+ }
375+ var pending int
376+ func() {
377+ harness.sup.mu.Lock()
378+ defer harness.sup.mu.Unlock()
379+ for _, geometry := range burst {
380+ harness.terminal.resize(geometry)
381+ }
382+ require.Eventually(t, func() bool {
383+ return harness.sup.attachments.geometryProgress() == uint64(len(burst))
384+ }, 5*time.Second, time.Millisecond, "the collector records every geometry of the burst")
385+ pending = harness.sup.attachments.pendingPresentationSignals()
386+ }()
387+ require.Equal(t, 1, pending,
388+ "a resize burst coalesces into exactly one pending presentation invalidation")
389+
390+ latest := burst[len(burst)-1]
391+ require.Eventually(t, func() bool { return harness.recorder.count() > baseline }, 5*time.Second, time.Millisecond,
392+ "the coalesced burst produces a repaint")
393+ for _, sample := range harness.recorder.samples()[baseline:] {
394+ require.Equal(t, PresentPicker, sample.state.Presentation)
395+ require.Equal(t, latest.Size, sample.geometry.Size,
396+ "every repaint after the burst observes the latest Terminal.Geometry")
397+ }
398+ require.Equal(t, latest.Size, harness.recorder.last().geometry.Size)
399+}
400+
401+// TestSupervisorIdlePickerRepaintsOnResizeWithoutInputOrPublication proves the
402+// core idle behavior: a resize wakes the established-connection wait and
403+// repaints the picker even though no terminal input arrived and no broker
404+// snapshot was published.
405+func TestSupervisorIdlePickerRepaintsOnResizeWithoutInputOrPublication(t *testing.T) {
406+ picker := newAttachTestPicker()
407+ harness := startResizeHarness(t, resizeHarnessOptions{picker: picker})
408+ require.Eventually(t, func() bool { return harness.sup.State().Connectivity == ConnectivityReady }, 5*time.Second, time.Millisecond)
409+
410+ revision := harness.service.hub.current().Revision
411+ harness.recorder.drain()
412+
413+ resized := domain.Geometry{Size: domain.Size{Cols: 132, Rows: 43}}
414+ harness.terminal.resize(resized)
415+
416+ sample := awaitResizeRender(t, harness.recorder, func(sample resizeRenderSample) bool {
417+ return sample.state.Presentation == PresentPicker && sample.state.Connectivity == ConnectivityReady
418+ })
419+ require.Equal(t, State{Presentation: PresentPicker, Connectivity: ConnectivityReady, Generation: 1}, sample.state,
420+ "a resize repaint reports the untouched ready state")
421+ require.Equal(t, resized.Size, sample.geometry.Size, "the render samples the latest terminal geometry")
422+ require.Equal(t, revision, harness.service.hub.current().Revision, "a resize repaint must not need a broker publication")
423+ require.Zero(t, picker.consumedCount(), "a resize repaint must not consume terminal input")
424+ require.Zero(t, harness.sup.attachments.pendingPresentationSignals(), "the coalesced invalidation was consumed")
425+}
426+
427+// TestSupervisorResizeRepaintsConnectingRetryAndNonRetryableWaiting proves the
428+// resize invalidation reaches every pre-attachment waiting state the supervisor
429+// parks in: an in-flight broker connect, the retry cadence, and the
430+// non-retryable picker wait.
431+func TestSupervisorResizeRepaintsConnectingRetryAndNonRetryableWaiting(t *testing.T) {
432+ unavailable := ports.BrokerError{Code: ports.BrokerErrorUnavailable, Text: "broker down"}
433+ incompatible := ports.BrokerError{Code: ports.BrokerErrorIncompatible, Text: "protocol"}
434+
435+ tests := []struct {
436+ name string
437+ // start parks one supervisor on the wait under test and returns the state
438+ // it must be presenting when the resize arrives.
439+ start func(t *testing.T) (*resizeSupervisorHarness, State)
440+ }{
441+ {
442+ name: "broker connect in flight",
443+ start: func(t *testing.T) (*resizeSupervisorHarness, State) {
444+ harness := startResizeHarness(t, resizeHarnessOptions{
445+ connect: func(ctx context.Context, _ int) (ports.BrokerService, error) {
446+ <-ctx.Done()
447+ return nil, ctx.Err()
448+ },
449+ })
450+ require.Equal(t, 1, harness.connector.awaitStart(t))
451+ return harness, State{Presentation: PresentPicker, Connectivity: ConnectivityConnectingBroker, Generation: 1}
452+ },
453+ },
454+ {
455+ name: "retry cadence wait",
456+ start: func(t *testing.T) (*resizeSupervisorHarness, State) {
457+ harness := startResizeHarness(t, resizeHarnessOptions{
458+ connect: func(context.Context, int) (ports.BrokerService, error) { return nil, unavailable },
459+ })
460+ harness.clock.awaitTimer(t)
461+ // The cadence exists because the first attempt already failed, so its
462+ // connector-start notification is still queued. Consume it now; the
463+ // retry assertion below must observe the next attempt's notification,
464+ // not this initial one.
465+ require.Equal(t, 1, harness.connector.awaitStart(t))
466+ return harness, State{Presentation: PresentPicker, Connectivity: ConnectivityRetryWait, Attempt: 1, Generation: 1, Err: unavailable}
467+ },
468+ },
469+ {
470+ name: "non-retryable picker wait",
471+ start: func(t *testing.T) (*resizeSupervisorHarness, State) {
472+ harness := startResizeHarness(t, resizeHarnessOptions{
473+ connect: func(context.Context, int) (ports.BrokerService, error) { return nil, incompatible },
474+ })
475+ require.Eventually(t, func() bool {
476+ state := harness.sup.State()
477+ return state.Connectivity == ConnectivityDisconnected && state.Err != nil
478+ }, 5*time.Second, time.Millisecond)
479+ return harness, State{Presentation: PresentPicker, Connectivity: ConnectivityDisconnected, Generation: 1, Err: incompatible}
480+ },
481+ },
482+ }
483+
484+ for _, tt := range tests {
485+ t.Run(tt.name, func(t *testing.T) {
486+ harness, want := tt.start(t)
487+ require.Equal(t, want, harness.sup.State(), "the supervisor is parked exactly on the wait under test")
488+ harness.recorder.drain()
489+
490+ var retryTimer *supervisorTestTimer
491+ if tt.name == "retry cadence wait" {
492+ retryTimer = harness.clock.snapshotTimers()[0]
493+ require.Equal(t, 1, harness.clock.timerCount())
494+ require.Equal(t, 1, harness.clock.liveTimers())
495+ }
496+
497+ resized := domain.Geometry{Size: domain.Size{Cols: 111, Rows: 41}}
498+ harness.terminal.resize(resized)
499+
500+ sample := awaitResizeRender(t, harness.recorder, func(sample resizeRenderSample) bool {
501+ return sample.state.Presentation == PresentPicker
502+ })
503+ require.Equal(t, want, sample.state, "a resize repaint reports the parked state unchanged")
504+ require.Equal(t, resized.Size, sample.geometry.Size, "the render samples the latest terminal geometry")
505+ require.Zero(t, harness.sup.attachments.pendingPresentationSignals(), "the coalesced invalidation was consumed")
506+ require.Equal(t, want.Connectivity, harness.sup.State().Connectivity, "a repaint never advances connectivity")
507+ if retryTimer != nil {
508+ require.Equal(t, 1, harness.clock.timerCount(), "resize must not replace the retry timer")
509+ require.Equal(t, 1, harness.clock.liveTimers(), "the original retry timer remains live")
510+ retryTimer.fire()
511+ require.Equal(t, 2, harness.connector.awaitStart(t), "the original timer advances the next attempt")
512+ }
513+ })
514+ }
515+}
516+
517+// TestAttachmentHostResizeBurstCoalescesAndKeepsGeometryWakeupsIndependent pins
518+// the collector itself: a burst of valid resizes is coalesced into exactly one
519+// buffered presentation invalidation while the attachment geometry sequence
520+// still advances to the latest geometry, and draining the presentation signal
521+// cannot steal the attachment's own geometry wakeup.
522+func TestAttachmentHostResizeBurstCoalescesAndKeepsGeometryWakeupsIndependent(t *testing.T) {
523+ resizes := make(chan domain.Geometry, 8)
524+ host := newAttachmentHost(attachmentHostConfig{})
525+ host.resizes = resizes
526+ host.ownerBoundary = true
527+ ctx, cancel := context.WithCancel(context.Background())
528+ defer cancel()
529+ host.startGeometry(ctx)
530+ defer host.stopGeometry()
531+
532+ fg := host.newForeground(AttachmentToken{Generation: 1, Attempt: 1}, newSessionTestStream())
533+ require.True(t, host.authority.grant(fg))
534+
535+ burst := []domain.Geometry{
536+ {Size: domain.Size{Cols: 90, Rows: 30}},
537+ {Size: domain.Size{Cols: 100, Rows: 35}},
538+ {Size: domain.Size{Cols: 120, Rows: 40}},
539+ }
540+ for _, geometry := range burst {
541+ resizes <- geometry
542+ }
543+ require.Eventually(t, func() bool {
544+ return host.geometryProgress() == uint64(len(burst)) && host.pendingPresentationSignals() == 1
545+ }, 5*time.Second, time.Millisecond, "a resize burst must coalesce into one presentation invalidation")
546+ require.Equal(t, 1, host.pendingGeometrySignals(), "the attachment geometry wakeup coalesces independently")
547+
548+ latest, ok := host.latestGeometry()
549+ require.True(t, ok)
550+ require.Equal(t, burst[len(burst)-1].Size, latest.Size, "the latest geometry wins the coalesced burst")
551+
552+ // Draining the presentation invalidation must not touch the attachment's
553+ // geometry wakeup: the foreground still receives the latest geometry.
554+ <-host.presentationUpdate
555+ geometry, ok := fg.Resize(context.Background())
556+ require.True(t, ok)
557+ require.Equal(t, burst[len(burst)-1].Size, geometry.Size)
558+
559+ next := domain.Geometry{Size: domain.Size{Cols: 132, Rows: 43}}
560+ resizes <- next
561+ geometry, ok = fg.Resize(context.Background())
562+ require.True(t, ok)
563+ require.Equal(t, next.Size, geometry.Size)
564+ require.Equal(t, 1, host.pendingPresentationSignals(), "every resize still raises a coalesced invalidation")
565+ fg.finish()
566+}
567+
568+// TestSupervisorResizeDuringAttachmentSettlementPreservesSessionOutput pins the
569+// safe boundary this slice was narrowed to. Once Begin has admitted the
570+// foreground it owns the shared terminal writer before MarkAttached and the
571+// initial publication, so a resize invalidation that lands in the settlement
572+// wait must not be consumed there: the composition's renderer would write picker
573+// bytes over the session output the foreground is writing. The invalidation
574+// stays coalesced and buffered through connecting and attached, and is consumed
575+// with a repaint at the latest geometry only once the attachment returns to the
576+// picker.
577+func TestSupervisorResizeDuringAttachmentSettlementPreservesSessionOutput(t *testing.T) {
578+ picker := newAttachTestPicker()
579+ harness := startResizeHarness(t, resizeHarnessOptions{picker: picker})
580+ registry := &resizeStreamRegistry{}
581+ harness.service.setOpenStream(registry.open)
582+
583+ picker.commit(sessionTestRequest(true))
584+ stream := registry.stream(t, 0)
585+ require.Eventually(t, func() bool {
586+ return harness.sup.State().Presentation == PresentConnecting
587+ }, 5*time.Second, time.Millisecond, "attachment settlement exposes connecting presentation")
588+ harness.recorder.drain()
589+
590+ resized := domain.Geometry{Size: domain.Size{Cols: 109, Rows: 39}}
591+ harness.terminal.resize(resized)
592+ require.Eventually(t, func() bool { return harness.sup.attachments.pendingPresentationSignals() == 1 },
593+ 5*time.Second, time.Millisecond, "the collector still records the resize while connecting")
594+
595+ // The admitted foreground commits its initial publication through the same
596+ // terminal. The supervisor must not have painted over it, and the resize must
597+ // not have driven a connecting repaint at the new geometry.
598+ deliverReadyStream(t, stream)
599+ awaitAttachedState(t, harness.sup)
600+ written := harness.terminal.written()
601+ at := strings.Index(written, "\x1b[Hready")
602+ require.GreaterOrEqual(t, at, 0, "the admitted foreground committed its initial publication")
603+ require.Equal(t, "\x1b[Hready", written[at:],
604+ "a resize during settlement must not append picker bytes over session output")
605+ for _, sample := range harness.recorder.samples() {
606+ require.False(t, sample.state.Presentation == PresentConnecting && sample.geometry.Size == resized.Size,
607+ "a resize during settlement must not invoke the picker renderer for the connecting presentation")
608+ }
609+ require.Equal(t, 1, harness.sup.attachments.pendingPresentationSignals(),
610+ "the invalidation stays buffered through connecting and attached")
611+
612+ // Back at the picker the preserved invalidation is consumed and repaints at
613+ // the latest geometry. The transition back to the picker renders once; the
614+ // consumed invalidation renders once more, so exactly two picker renders at
615+ // the resized geometry follow the attachment. A run that never emitted the
616+ // invalidation cannot reach this point, and a run that consumed it without a
617+ // repaint leaves only one.
618+ stream.deliver(protocol.Detached{})
619+ awaitPickerState(t, harness.sup)
620+ pickerAtResize := func(sample resizeRenderSample) bool {
621+ return sample.state.Presentation == PresentPicker && sample.geometry.Size == resized.Size
622+ }
623+ awaitResizeRender(t, harness.recorder, pickerAtResize)
624+ awaitResizeRender(t, harness.recorder, pickerAtResize)
625+ require.Eventually(t, func() bool { return harness.sup.attachments.pendingPresentationSignals() == 0 },
626+ 5*time.Second, time.Millisecond, "the picker wait consumes the preserved invalidation")
627+}
628+
629+// TestSupervisorResizeConnectingBurstStaysCoalescedAndBuffered proves the
630+// invalidation a settlement wait must not consume is still coalesced: a burst of
631+// resizes while the connecting presentation holds an admitted foreground leaves
632+// exactly one pending signal, and the whole burst is then consumed by the picker
633+// wait after the attachment ends without any intermediate repaint.
634+func TestSupervisorResizeConnectingBurstStaysCoalescedAndBuffered(t *testing.T) {
635+ picker := newAttachTestPicker()
636+ harness := startResizeHarness(t, resizeHarnessOptions{picker: picker})
637+ registry := &resizeStreamRegistry{}
638+ harness.service.setOpenStream(registry.open)
639+
640+ picker.commit(sessionTestRequest(true))
641+ stream := registry.stream(t, 0)
642+ require.Eventually(t, func() bool {
643+ return harness.sup.State().Presentation == PresentConnecting
644+ }, 5*time.Second, time.Millisecond)
645+
646+ burst := []domain.Geometry{
647+ {Size: domain.Size{Cols: 90, Rows: 30}},
648+ {Size: domain.Size{Cols: 100, Rows: 35}},
649+ {Size: domain.Size{Cols: 120, Rows: 40}},
650+ }
651+ for _, geometry := range burst {
652+ harness.terminal.resize(geometry)
653+ }
654+ require.Eventually(t, func() bool {
655+ return harness.sup.attachments.geometryProgress() == uint64(len(burst))
656+ }, 5*time.Second, time.Millisecond, "the collector records every geometry of the burst")
657+ require.Eventually(t, func() bool { return harness.sup.attachments.pendingPresentationSignals() == 1 },
658+ 5*time.Second, time.Millisecond, "a connecting resize burst coalesces into one buffered invalidation")
659+
660+ deliverReadyStream(t, stream)
661+ awaitAttachedState(t, harness.sup)
662+ require.Equal(t, 1, harness.sup.attachments.pendingPresentationSignals(),
663+ "the coalesced burst is preserved through connecting and attached")
664+
665+ stream.deliver(protocol.Detached{})
666+ awaitPickerState(t, harness.sup)
667+ pickerAtLatest := func(sample resizeRenderSample) bool {
668+ return sample.state.Presentation == PresentPicker && sample.geometry.Size == burst[len(burst)-1].Size
669+ }
670+ awaitResizeRender(t, harness.recorder, pickerAtLatest)
671+ awaitResizeRender(t, harness.recorder, pickerAtLatest)
672+ require.Eventually(t, func() bool { return harness.sup.attachments.pendingPresentationSignals() == 0 },
673+ 5*time.Second, time.Millisecond, "the coalesced burst is consumed by the picker wait, once")
674+}
675+
676+// awaitAttachedSettlement waits until the supervisor's live attachment has
677+// claimed the attached transition and the settlement wait has drained every
678+// earlier invalidation. It is the unambiguous boundary the attached-resize
679+// assertion needs: once the current foreground reports attached, the
680+// settlement wait has disarmed its presentation arm, so a resize pushed after
681+// this point must stay buffered for the picker instead of being consumed.
682+func awaitAttachedSettlement(t *testing.T, harness *resizeSupervisorHarness) {
683+ t.Helper()
684+ require.Eventually(t, func() bool {
685+ harness.sup.mu.Lock()
686+ attachedPresentation := harness.sup.state.Presentation == PresentAttached
687+ harness.sup.mu.Unlock()
688+ foreground := harness.sup.attachments.authority.foreground()
689+ return attachedPresentation && foreground != nil && foreground.Attached()
690+ }, 5*time.Second, time.Millisecond, "the attachment never claimed the attached boundary")
691+ require.Eventually(t, func() bool { return harness.sup.attachments.pendingPresentationSignals() == 0 },
692+ 5*time.Second, time.Millisecond, "a pre-resize invalidation was not drained before the boundary")
693+}
694+
695+// TestSupervisorAttachedResizeKeepsGeometryWakeupAndPreservesRepaint proves an
696+// attached foreground is not painted over and its geometry wakeup is not
697+// stolen: the live attachment receives the typed resize while the coalesced
698+// presentation invalidation stays buffered for the picker, and is then consumed
699+// by the supervisor's next picker wait.
700+func TestSupervisorAttachedResizeKeepsGeometryWakeupAndPreservesRepaint(t *testing.T) {
701+ picker := newAttachTestPicker()
702+ harness := startResizeHarness(t, resizeHarnessOptions{picker: picker})
703+ registry := &resizeStreamRegistry{}
704+ harness.service.setOpenStream(registry.open)
705+
706+ picker.commit(sessionTestRequest(true))
707+ stream := registry.stream(t, 0)
708+ deliverReadyStream(t, stream)
709+ // Settle the attached boundary before the resize: the presentation is
710+ // attached and the live foreground has claimed attached, and every earlier
711+ // invalidation is drained, so the resize below lands unambiguously on an
712+ // attached settlement wait.
713+ awaitAttachedSettlement(t, harness)
714+ harness.recorder.drain()
715+ baseline := harness.recorder.count()
716+
717+ resized := domain.Geometry{Size: domain.Size{Cols: 100, Rows: 40}}
718+ harness.terminal.resize(resized)
719+
720+ resizes := awaitStreamResizes(t, stream, 1)
721+ require.Equal(t, resized.Size, resizes[0].Size, "the live attachment still receives its geometry wakeup")
722+ // The attachment geometry wakeup and the presentation invalidation are
723+ // pushed by the collector independently, and the worker can observe the new
724+ // geometry through the recorded sequence before the invalidation is pushed.
725+ // Wait for the coalesced token to be buffered; a supervisor that wrongly
726+ // consumed it while attached could never make this count return to one, so
727+ // this stays a behavioral check rather than a sleep.
728+ require.Eventually(t, func() bool { return harness.sup.attachments.pendingPresentationSignals() == 1 },
729+ 5*time.Second, time.Millisecond, "the supervisor does not consume the invalidation while attached")
730+ require.Equal(t, baseline, harness.recorder.count(), "an attached presentation is never painted over")
731+ require.Equal(t, 1, harness.sup.attachments.pendingPresentationSignals(), "the invalidation stays buffered until the picker wait")
732+
733+ stream.deliver(protocol.Detached{})
734+ awaitPickerState(t, harness.sup)
735+ require.Eventually(t, func() bool { return harness.sup.attachments.pendingPresentationSignals() == 0 }, 5*time.Second, time.Millisecond,
736+ "the preserved invalidation is consumed by the picker wait")
737+}
738+
739+// TestSupervisorResizeAfterDetachReachesOnlyReplacementAttachment proves a
740+// resize is delivered to the valid replacement attachment token only. A resize
741+// that lands in the detach/reattach gap reaches no stream at all: it repaints the
742+// picker, the retired token's stream keeps exactly its own earlier resize, and
743+// the replacement inherits the gap geometry and receives only its own later
744+// resize under its own (generation, attempt) identity.
745+func TestSupervisorResizeAfterDetachReachesOnlyReplacementAttachment(t *testing.T) {
746+ picker := newAttachTestPicker()
747+ harness := startResizeHarness(t, resizeHarnessOptions{picker: picker})
748+ registry := &resizeStreamRegistry{}
749+ harness.service.setOpenStream(registry.open)
750+
751+ picker.commit(sessionTestRequest(true))
752+ first := registry.stream(t, 0)
753+ deliverReadyStream(t, first)
754+ awaitAttachedState(t, harness.sup)
755+ require.Equal(t, AttachmentToken{Generation: 1, Attempt: 1}, harness.sup.attachments.authority.currentToken())
756+
757+ firstResize := domain.Geometry{Size: domain.Size{Cols: 101, Rows: 41}}
758+ harness.terminal.resize(firstResize)
759+ firstResizes := awaitStreamResizes(t, first, 1)
760+ require.Equal(t, firstResize.Size, firstResizes[0].Size)
761+
762+ // The attachment detaches: its generation is retired before any replacement
763+ // exists.
764+ first.deliver(protocol.Detached{})
765+ awaitPickerState(t, harness.sup)
766+ require.True(t, harness.sup.attachments.authority.currentToken().IsZero(), "the retired token no longer owns the foreground")
767+
768+ // A resize in the detach/reattach gap: no attachment owns the foreground, so
769+ // the retired stream must not receive it, only the picker repaints, and the
770+ // signal is consumed by the picker wait.
771+ harness.recorder.drain()
772+ gapResize := domain.Geometry{Size: domain.Size{Cols: 110, Rows: 45}}
773+ harness.terminal.resize(gapResize)
774+ gapSample := awaitResizeRender(t, harness.recorder, func(sample resizeRenderSample) bool {
775+ return sample.state.Presentation == PresentPicker && sample.geometry.Size == gapResize.Size
776+ })
777+ require.Equal(t, gapResize.Size, gapSample.geometry.Size, "a gap resize repaints the picker")
778+ require.Eventually(t, func() bool { return harness.sup.attachments.pendingPresentationSignals() == 0 }, 5*time.Second, time.Millisecond,
779+ "the gap invalidation is consumed by the picker wait")
780+ require.Len(t, streamResizes(first), 1, "the retired attachment token must not receive a gap resize")
781+
782+ // A replacement attachment claims the next attempt identity. It carries the
783+ // latest geometry in its own session state, which is the gap resize, so the
784+ // only stream that ever observes it is the valid replacement.
785+ picker.commit(sessionTestRequest(true))
786+ second := registry.stream(t, 1)
787+ deliverReadyStream(t, second)
788+ awaitAttachedState(t, harness.sup)
789+ require.Equal(t, AttachmentToken{Generation: 1, Attempt: 2}, harness.sup.attachments.authority.currentToken())
790+ inherited := awaitStreamResizes(t, second, 1)
791+ require.Equal(t, gapResize.Size, inherited[0].Size, "the replacement inherits the latest geometry")
792+
793+ // The replacement's own resize reaches it and nothing else.
794+ secondResize := domain.Geometry{Size: domain.Size{Cols: 120, Rows: 50}}
795+ harness.terminal.resize(secondResize)
796+ replacement := awaitStreamResizes(t, second, 2)
797+ require.Equal(t, secondResize.Size, replacement[1].Size, "only the replacement token receives its resize")
798+ require.Len(t, streamResizes(first), 1, "a retired attachment token must never receive a replacement's resize")
799+}
800+
801+// TestResizeCollectorAndSupervisorJoinOnClosedEventsAndCancellation proves the
802+// collector and the supervisor's owned waits end cleanly when ResizeEvents is
803+// already closed and when the process context is cancelled: no path blocks on a
804+// closed or abandoned channel.
805+func TestResizeCollectorAndSupervisorJoinOnClosedEventsAndCancellation(t *testing.T) {
806+ t.Run("collector joins a closed ResizeEvents channel", func(t *testing.T) {
807+ closed := make(chan domain.Geometry)
808+ close(closed)
809+ host := newAttachmentHost(attachmentHostConfig{})
810+ host.resizes = closed
811+ ctx, cancel := context.WithCancel(context.Background())
812+ defer cancel()
813+ host.startGeometry(ctx)
814+ select {
815+ case <-host.geometryDone:
816+ case <-time.After(5 * time.Second):
817+ t.Fatal("the collector did not end on closed resize events")
818+ }
819+ host.stopGeometry()
820+ require.Nil(t, host.geometryDone)
821+ require.Nil(t, host.geometryCancel)
822+
823+ // The host can be started again with a live channel and joined by
824+ // cancellation, even with a coalesced invalidation still pending.
825+ resizes := make(chan domain.Geometry, 2)
826+ host.resizes = resizes
827+ host.startGeometry(ctx)
828+ resizes <- domain.Geometry{Size: domain.Size{Cols: 90, Rows: 30}}
829+ resizes <- domain.Geometry{Size: domain.Size{Cols: 91, Rows: 31}}
830+ require.Eventually(t, func() bool { return host.pendingPresentationSignals() == 1 }, 5*time.Second, time.Millisecond)
831+ cancel()
832+ select {
833+ case <-host.geometryDone:
834+ case <-time.After(5 * time.Second):
835+ t.Fatal("the collector did not end on cancellation")
836+ }
837+ host.stopGeometry()
838+ require.Nil(t, host.geometryDone)
839+ })
840+
841+ t.Run("supervisor run joins on closed ResizeEvents and cancellation", func(t *testing.T) {
842+ harness := startResizeHarness(t, resizeHarnessOptions{
843+ closeResizes: true,
844+ connect: func(context.Context, int) (ports.BrokerService, error) {
845+ return nil, ports.BrokerError{Code: ports.BrokerErrorUnavailable, Text: "down"}
846+ },
847+ })
848+ require.Equal(t, 1, harness.connector.awaitStart(t))
849+ harness.cancel()
850+ require.ErrorIs(t, harness.waitRun(t), context.Canceled)
851+ _, _, restores := harness.terminal.counts()
852+ require.Equal(t, 1, restores, "raw mode is restored exactly once")
853+ require.Nil(t, harness.sup.attachments.geometryDone, "Run joined the geometry collector")
854+ require.Nil(t, harness.sup.attachments.geometryCancel)
855+ })
856+}
857+
858+// TestSupervisorResizeInvalidationRendersOnlyPickerPresentations pins the
859+// presentation fence directly: only PresentPicker may be repainted by a resize,
860+// so the pre-attachment connecting transition, an attached foreground, and a
861+// terminating process are never painted over. It also proves the fence is
862+// behavioral, not just counted: with the session byte already in the terminal,
863+// a connecting repaint would change the renderer count and append picker bytes
864+// over the session output. A supervisor without a renderer is a no-op.
865+func TestSupervisorResizeInvalidationRendersOnlyPickerPresentations(t *testing.T) {
866+ terminal := newResizeTestTerminal(newAttachTestReader(), domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}})
867+ recorder := newResizeRenderRecorder(terminal)
868+ sup := mustSupervisor(t, SupervisorConfig{
869+ Connector: newSupervisorTestConnector(nil),
870+ Terminal: terminal,
871+ Clock: newSupervisorTestClock(),
872+ Render: recorder.render,
873+ })
874+
875+ tests := []struct {
876+ name string
877+ presentation Presentation
878+ wantRender bool
879+ }{
880+ {name: "picker is repainted", presentation: PresentPicker, wantRender: true},
881+ {name: "connecting is never painted over", presentation: PresentConnecting},
882+ {name: "attached is never painted over", presentation: PresentAttached},
883+ {name: "terminating is never repainted", presentation: PresentTerminating},
884+ }
885+ for _, tt := range tests {
886+ t.Run(tt.name, func(t *testing.T) {
887+ // A session frame as an admitted foreground would have written it, so
888+ // any picker repaint for a refused presentation is observable.
889+ const session = "\x1b[Hsession output"
890+ terminal.setWritten(session)
891+ sup.mu.Lock()
892+ sup.state = State{Presentation: tt.presentation, Connectivity: ConnectivityReady}
893+ sup.mu.Unlock()
894+ recorder.drain()
895+ baseline := recorder.count()
896+
897+ sup.renderResizeInvalidation()
898+
899+ if tt.wantRender {
900+ sample := awaitResizeRender(t, recorder, func(resizeRenderSample) bool { return true })
901+ require.Equal(t, tt.presentation, sample.state.Presentation)
902+ return
903+ }
904+ require.Equal(t, baseline, recorder.count(), "presentation %s must not be repainted by a resize", tt.presentation)
905+ require.Equal(t, session, terminal.written(),
906+ "presentation %s must never overpaint session output", tt.presentation)
907+ })
908+ }
909+
910+ // A supervisor with no renderer configured through the internal zero value
911+ // never panics, including on a nil receiver.
912+ (&Supervisor{}).renderResizeInvalidation()
913+ var nilSupervisor *Supervisor
914+ nilSupervisor.renderResizeInvalidation()
915+}
916+
917+// TestSupervisorPresentationInvalidationNilHostIsSafe pins the nil-host
918+// contract of the presentation invalidation helper and of a wait that selects
919+// on it. A supervisor whose attachment host is absent (the zero value, or a run
920+// whose host was detached) must expose a nil signal and still park on its
921+// remaining signals instead of dereferencing a nil host.
922+func TestSupervisorPresentationInvalidationNilHostIsSafe(t *testing.T) {
923+ t.Run("helper returns nil without a host", func(t *testing.T) {
924+ var nilSupervisor *Supervisor
925+ require.Nil(t, nilSupervisor.presentationInvalidation(), "a nil receiver exposes no invalidation")
926+
927+ zero := &Supervisor{}
928+ require.Nil(t, zero.presentationInvalidation(), "a zero-value supervisor exposes no invalidation")
929+
930+ // A fully built supervisor exposes its host's signal; once the host is
931+ // gone the helper must fall back to nil rather than panic.
932+ terminal := newResizeTestTerminal(newAttachTestReader(), domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}})
933+ sup := mustSupervisor(t, SupervisorConfig{
934+ Connector: newSupervisorTestConnector(nil),
935+ Terminal: terminal,
936+ Clock: newSupervisorTestClock(),
937+ })
938+ require.NotNil(t, sup.presentationInvalidation(), "a live host exposes its coalesced invalidation")
939+ sup.attachments = nil
940+ require.Nil(t, sup.presentationInvalidation(), "a detached host exposes no invalidation")
941+ })
942+
943+ t.Run("a wait with a nil host still settles", func(t *testing.T) {
944+ terminal := newResizeTestTerminal(newAttachTestReader(), domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}})
945+ clock := newSupervisorTestClock()
946+ sup := mustSupervisor(t, SupervisorConfig{
947+ Connector: newSupervisorTestConnector(nil),
948+ Terminal: terminal,
949+ Clock: clock,
950+ Jitter: func() float64 { return 0 },
951+ })
952+ sup.attachments = nil
953+ require.Nil(t, sup.presentationInvalidation())
954+ // One transient failure makes the cadence non-zero, so waitBackoff
955+ // arms a real timer.
956+ sup.transition(supervisorEvent{kind: supervisorTransientFailure})
957+
958+ // The reader never yields, so the EOF arm stays closed and the timer is
959+ // the only signal that can settle this wait.
960+ reader := newAttachTestReader()
961+ lifetime := startTerminalInputLifetime(reader, nil)
962+ t.Cleanup(func() {
963+ reader.close()
964+ lifetime.stop()
965+ })
966+
967+ settled := make(chan struct{})
968+ var terminated bool
969+ var waitErr error
970+ go func() {
971+ defer close(settled)
972+ terminated, waitErr = sup.waitBackoff(context.Background(), lifetime)
973+ }()
974+ timer := clock.awaitTimer(t)
975+ timer.fire()
976+ select {
977+ case <-settled:
978+ case <-time.After(5 * time.Second):
979+ t.Fatal("a nil-host backoff wait never settled on its timer")
980+ }
981+ require.False(t, terminated)
982+ require.NoError(t, waitErr)
983+ })
984+
985+ t.Run("a wait with a nil host settles on cancellation", func(t *testing.T) {
986+ terminal := newResizeTestTerminal(newAttachTestReader(), domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}})
987+ clock := newSupervisorTestClock()
988+ sup := mustSupervisor(t, SupervisorConfig{
989+ Connector: newSupervisorTestConnector(nil),
990+ Terminal: terminal,
991+ Clock: clock,
992+ Jitter: func() float64 { return 0 },
993+ })
994+ sup.attachments = nil
995+ sup.transition(supervisorEvent{kind: supervisorTransientFailure})
996+
997+ reader := newAttachTestReader()
998+ lifetime := startTerminalInputLifetime(reader, nil)
999+ t.Cleanup(func() {
1000+ reader.close()
1001+ lifetime.stop()
1002+ })
1003+
1004+ ctx, cancel := context.WithCancel(context.Background())
1005+ settled := make(chan struct{})
1006+ var terminated bool
1007+ var waitErr error
1008+ go func() {
1009+ defer close(settled)
1010+ terminated, waitErr = sup.waitBackoff(ctx, lifetime)
1011+ }()
1012+ clock.awaitTimer(t)
1013+ cancel()
1014+ select {
1015+ case <-settled:
1016+ case <-time.After(5 * time.Second):
1017+ t.Fatal("a nil-host backoff wait never settled on cancellation")
1018+ }
1019+ require.True(t, terminated)
1020+ require.ErrorIs(t, waitErr, context.Canceled)
1021+ })
1022+}
M internal/usecase/client/supervisor.go +77 -22
@@ -266,9 +266,17 @@ type SupervisorConfig struct {
266266 // clock.
267267 Clock ports.Clock
268268 // Render asks the composition to paint the current state. It is called for
269- // visible transitions and after picker catalogue publications; while the
270- // state reports PresentPicker, the composition paints from Picker.Render
271- // and Picker.RenderNotice. Optional; the default renders nothing.
269+ // visible transitions, after picker catalogue publications, and for every
270+ // resize invalidation the supervisor's serialized waits consume. The
271+ // composition paints only for the states PickerPresentation admits, which is
272+ // exactly PresentPicker, using Picker.Render and Picker.RenderNotice and
273+ // sampling Terminal.Geometry at render time so a resize repaint observes the
274+ // latest size. PresentConnecting is deliberately not admitted: an admitted
275+ // attachment foreground owns the terminal writer from Begin, before the
276+ // attached marker and the initial publication, so painting then would
277+ // overwrite session output. An attached foreground still owns the terminal
278+ // and a terminating process is leaving it, so neither is ever painted over
279+ // by a resize invalidation. Optional; the default renders nothing.
272280 Render func(State)
273281 // Notify surfaces a connectivity failure without leaving the picker.
274282 // Optional; the default notifies nothing.
@@ -693,13 +701,17 @@ func (s *Supervisor) connectAttempt(ctx context.Context, generation uint64) supe
693701 // awaitAttempt waits for the in-flight attempt to settle, or for the run to be
694702 // terminated first.
695703 func (s *Supervisor) awaitAttempt(ctx context.Context, input *terminalInputLifetime, outcome <-chan supervisorAttempt) (supervisorAttempt, bool, error) {
696- select {
697- case result := <-outcome:
698- return result, false, nil
699- case <-ctx.Done():
700- return supervisorAttempt{}, true, ctx.Err()
701- case err := <-input.EOF():
702- return supervisorAttempt{}, true, terminalReadCause(err)
704+ for {
705+ select {
706+ case result := <-outcome:
707+ return result, false, nil
708+ case <-ctx.Done():
709+ return supervisorAttempt{}, true, ctx.Err()
710+ case err := <-input.EOF():
711+ return supervisorAttempt{}, true, terminalReadCause(err)
712+ case <-s.presentationInvalidation():
713+ s.renderResizeInvalidation()
714+ }
703715 }
704716 }
705717
@@ -749,6 +761,8 @@ func (s *Supervisor) awaitReady(ctx context.Context, input *terminalInputLifetim
749761 return readyOutcome{terminated: true, termErr: ctx.Err()}
750762 case err := <-input.EOF():
751763 return readyOutcome{terminated: true, termErr: terminalReadCause(err)}
764+ case <-s.presentationInvalidation():
765+ s.renderResizeInvalidation()
752766 }
753767 }
754768 }
@@ -773,11 +787,15 @@ func (s *Supervisor) takeCommittedKey() (string, bool) {
773787 // failure until the terminal ends or the process is cancelled. A clean terminal
774788 // EOF returns nil; every other cause is reported.
775789 func (s *Supervisor) awaitTermination(ctx context.Context, input *terminalInputLifetime) error {
776- select {
777- case <-ctx.Done():
778- return ctx.Err()
779- case err := <-input.EOF():
780- return terminalReadCause(err)
790+ for {
791+ select {
792+ case <-ctx.Done():
793+ return ctx.Err()
794+ case err := <-input.EOF():
795+ return terminalReadCause(err)
796+ case <-s.presentationInvalidation():
797+ s.renderResizeInvalidation()
798+ }
781799 }
782800 }
783801
@@ -791,13 +809,17 @@ func (s *Supervisor) waitBackoff(ctx context.Context, input *terminalInputLifeti
791809 }
792810 timer := s.cfg.Clock.NewTimer(delay)
793811 defer stopSupervisorTimer(timer)
794- select {
795- case <-timer.C():
796- return false, nil
797- case <-ctx.Done():
798- return true, ctx.Err()
799- case err := <-input.EOF():
800- return true, terminalReadCause(err)
812+ for {
813+ select {
814+ case <-timer.C():
815+ return false, nil
816+ case <-ctx.Done():
817+ return true, ctx.Err()
818+ case err := <-input.EOF():
819+ return true, terminalReadCause(err)
820+ case <-s.presentationInvalidation():
821+ s.renderResizeInvalidation()
822+ }
801823 }
802824 }
803825
@@ -932,6 +954,39 @@ func (s *Supervisor) renderCurrent() {
932954 s.cfg.Render(s.State())
933955 }
934956
957+// presentationInvalidation returns the coalesced resize invalidation the
958+// supervisor's serialized waits select on, or nil when there is no attachment
959+// host to collect resizes. A nil signal is simply never ready, so a supervisor
960+// with no host (the zero value, or one whose host was detached) parks on its
961+// remaining arms instead of dereferencing a nil host. Selecting on a nil
962+// channel is safe in both directions. The returned channel is receive-only: the
963+// supervisor only ever consumes an invalidation, and the attachment settlement
964+// wait deliberately does not select on it at all, so an invalidation raised
965+// while a foreground is admitted stays buffered for the next picker state.
966+func (s *Supervisor) presentationInvalidation() <-chan struct{} {
967+ if s == nil || s.attachments == nil {
968+ return nil
969+ }
970+ return s.attachments.presentationUpdate
971+}
972+
973+// renderResizeInvalidation runs only on the supervisor's serialized control
974+// path, so resize collection cannot write the terminal and an attached
975+// foreground is never painted over. The composition's renderer is invoked only
976+// for a presentation PickerPresentation admits, which is exactly PresentPicker;
977+// it samples Terminal.Geometry at render time. A pre-attachment connecting
978+// presentation is refused here for the same reason as an attached one: the
979+// admitted foreground already owns the same terminal writer.
980+func (s *Supervisor) renderResizeInvalidation() {
981+ if s == nil || s.cfg.Render == nil {
982+ return
983+ }
984+ state := s.State()
985+ if PickerPresentation(state) {
986+ s.cfg.Render(state)
987+ }
988+}
989+
935990 // terminalInputLifetime owns the single read of the controlling terminal. It is
936991 // started once and never replaced: a bare io.Reader cannot be interrupted, so
937992 // the goroutine may outlive Run when stdin never reaches EOF, exactly like the
@@ -266,9 +266,17 @@ type SupervisorConfig struct {
266 // clock.266 // clock.
267 Clock ports.Clock267 Clock ports.Clock
268 // Render asks the composition to paint the current state. It is called for268 // Render asks the composition to paint the current state. It is called for
269- // visible transitions and after picker catalogue publications; while the269+ // visible transitions, after picker catalogue publications, and for every
270- // state reports PresentPicker, the composition paints from Picker.Render270+ // resize invalidation the supervisor's serialized waits consume. The
271- // and Picker.RenderNotice. Optional; the default renders nothing.271+ // composition paints only for the states PickerPresentation admits, which is
272+ // exactly PresentPicker, using Picker.Render and Picker.RenderNotice and
273+ // sampling Terminal.Geometry at render time so a resize repaint observes the
274+ // latest size. PresentConnecting is deliberately not admitted: an admitted
275+ // attachment foreground owns the terminal writer from Begin, before the
276+ // attached marker and the initial publication, so painting then would
277+ // overwrite session output. An attached foreground still owns the terminal
278+ // and a terminating process is leaving it, so neither is ever painted over
279+ // by a resize invalidation. Optional; the default renders nothing.
272 Render func(State)280 Render func(State)
273 // Notify surfaces a connectivity failure without leaving the picker.281 // Notify surfaces a connectivity failure without leaving the picker.
274 // Optional; the default notifies nothing.282 // Optional; the default notifies nothing.
@@ -693,13 +701,17 @@ func (s *Supervisor) connectAttempt(ctx context.Context, generation uint64) supe
693 // awaitAttempt waits for the in-flight attempt to settle, or for the run to be701 // awaitAttempt waits for the in-flight attempt to settle, or for the run to be
694 // terminated first.702 // terminated first.
695 func (s *Supervisor) awaitAttempt(ctx context.Context, input *terminalInputLifetime, outcome <-chan supervisorAttempt) (supervisorAttempt, bool, error) {703 func (s *Supervisor) awaitAttempt(ctx context.Context, input *terminalInputLifetime, outcome <-chan supervisorAttempt) (supervisorAttempt, bool, error) {
696- select {704+ for {
697- case result := <-outcome:705+ select {
698- return result, false, nil706+ case result := <-outcome:
699- case <-ctx.Done():707+ return result, false, nil
700- return supervisorAttempt{}, true, ctx.Err()708+ case <-ctx.Done():
701- case err := <-input.EOF():709+ return supervisorAttempt{}, true, ctx.Err()
702- return supervisorAttempt{}, true, terminalReadCause(err)710+ case err := <-input.EOF():
711+ return supervisorAttempt{}, true, terminalReadCause(err)
712+ case <-s.presentationInvalidation():
713+ s.renderResizeInvalidation()
714+ }
703 }715 }
704 }716 }
705 717
@@ -749,6 +761,8 @@ func (s *Supervisor) awaitReady(ctx context.Context, input *terminalInputLifetim
749 return readyOutcome{terminated: true, termErr: ctx.Err()}761 return readyOutcome{terminated: true, termErr: ctx.Err()}
750 case err := <-input.EOF():762 case err := <-input.EOF():
751 return readyOutcome{terminated: true, termErr: terminalReadCause(err)}763 return readyOutcome{terminated: true, termErr: terminalReadCause(err)}
764+ case <-s.presentationInvalidation():
765+ s.renderResizeInvalidation()
752 }766 }
753 }767 }
754 }768 }
@@ -773,11 +787,15 @@ func (s *Supervisor) takeCommittedKey() (string, bool) {
773 // failure until the terminal ends or the process is cancelled. A clean terminal787 // failure until the terminal ends or the process is cancelled. A clean terminal
774 // EOF returns nil; every other cause is reported.788 // EOF returns nil; every other cause is reported.
775 func (s *Supervisor) awaitTermination(ctx context.Context, input *terminalInputLifetime) error {789 func (s *Supervisor) awaitTermination(ctx context.Context, input *terminalInputLifetime) error {
776- select {790+ for {
777- case <-ctx.Done():791+ select {
778- return ctx.Err()792+ case <-ctx.Done():
779- case err := <-input.EOF():793+ return ctx.Err()
780- return terminalReadCause(err)794+ case err := <-input.EOF():
795+ return terminalReadCause(err)
796+ case <-s.presentationInvalidation():
797+ s.renderResizeInvalidation()
798+ }
781 }799 }
782 }800 }
783 801
@@ -791,13 +809,17 @@ func (s *Supervisor) waitBackoff(ctx context.Context, input *terminalInputLifeti
791 }809 }
792 timer := s.cfg.Clock.NewTimer(delay)810 timer := s.cfg.Clock.NewTimer(delay)
793 defer stopSupervisorTimer(timer)811 defer stopSupervisorTimer(timer)
794- select {812+ for {
795- case <-timer.C():813+ select {
796- return false, nil814+ case <-timer.C():
797- case <-ctx.Done():815+ return false, nil
798- return true, ctx.Err()816+ case <-ctx.Done():
799- case err := <-input.EOF():817+ return true, ctx.Err()
800- return true, terminalReadCause(err)818+ case err := <-input.EOF():
819+ return true, terminalReadCause(err)
820+ case <-s.presentationInvalidation():
821+ s.renderResizeInvalidation()
822+ }
801 }823 }
802 }824 }
803 825
@@ -932,6 +954,39 @@ func (s *Supervisor) renderCurrent() {
932 s.cfg.Render(s.State())954 s.cfg.Render(s.State())
933 }955 }
934 956
957+// presentationInvalidation returns the coalesced resize invalidation the
958+// supervisor's serialized waits select on, or nil when there is no attachment
959+// host to collect resizes. A nil signal is simply never ready, so a supervisor
960+// with no host (the zero value, or one whose host was detached) parks on its
961+// remaining arms instead of dereferencing a nil host. Selecting on a nil
962+// channel is safe in both directions. The returned channel is receive-only: the
963+// supervisor only ever consumes an invalidation, and the attachment settlement
964+// wait deliberately does not select on it at all, so an invalidation raised
965+// while a foreground is admitted stays buffered for the next picker state.
966+func (s *Supervisor) presentationInvalidation() <-chan struct{} {
967+ if s == nil || s.attachments == nil {
968+ return nil
969+ }
970+ return s.attachments.presentationUpdate
971+}
972+
973+// renderResizeInvalidation runs only on the supervisor's serialized control
974+// path, so resize collection cannot write the terminal and an attached
975+// foreground is never painted over. The composition's renderer is invoked only
976+// for a presentation PickerPresentation admits, which is exactly PresentPicker;
977+// it samples Terminal.Geometry at render time. A pre-attachment connecting
978+// presentation is refused here for the same reason as an attached one: the
979+// admitted foreground already owns the same terminal writer.
980+func (s *Supervisor) renderResizeInvalidation() {
981+ if s == nil || s.cfg.Render == nil {
982+ return
983+ }
984+ state := s.State()
985+ if PickerPresentation(state) {
986+ s.cfg.Render(state)
987+ }
988+}
989+
935 // terminalInputLifetime owns the single read of the controlling terminal. It is990 // terminalInputLifetime owns the single read of the controlling terminal. It is
936 // started once and never replaced: a bare io.Reader cannot be interrupted, so991 // started once and never replaced: a bare io.Reader cannot be interrupted, so
937 // the goroutine may outlive Run when stdin never reaches EOF, exactly like the992 // the goroutine may outlive Run when stdin never reaches EOF, exactly like the
M internal/usecase/client/supervisor_attach.go +27 -16
@@ -375,22 +375,33 @@ func (s *Supervisor) settleAttachment(ctx context.Context, input *terminalInputL
375375 var brokerLost bool
376376 var terminated bool
377377 var termErr error
378- select {
379- case result = <-settled:
380- case <-service.Done():
381- brokerLost = true
382- // The broker connection is gone; the supervisor cancels the run and
383- // lets the ready loop observe the loss and retire the connection.
384- run.Cancel()
385- result = <-settled
386- case <-ctx.Done():
387- run.Cancel()
388- result = <-settled
389- terminated, termErr = true, ctx.Err()
390- case err := <-input.EOF():
391- run.Cancel()
392- result = <-settled
393- terminated, termErr = true, terminalReadCause(err)
378+settlement:
379+ for {
380+ // Begin has admitted the foreground, which can write before MarkAttached
381+ // and before the initial publication. Keep presentation invalidation
382+ // buffered for the next picker state; consuming it here could overlay
383+ // session output with a second terminal writer.
384+ select {
385+ case result = <-settled:
386+ break settlement
387+ case <-service.Done():
388+ brokerLost = true
389+ // The broker connection is gone; the supervisor cancels the run and
390+ // lets the ready loop observe the loss and retire the connection.
391+ run.Cancel()
392+ result = <-settled
393+ break settlement
394+ case <-ctx.Done():
395+ run.Cancel()
396+ result = <-settled
397+ terminated, termErr = true, ctx.Err()
398+ break settlement
399+ case err := <-input.EOF():
400+ run.Cancel()
401+ result = <-settled
402+ terminated, termErr = true, terminalReadCause(err)
403+ break settlement
404+ }
394405 }
395406
396407 if terminated {
@@ -375,22 +375,33 @@ func (s *Supervisor) settleAttachment(ctx context.Context, input *terminalInputL
375 var brokerLost bool375 var brokerLost bool
376 var terminated bool376 var terminated bool
377 var termErr error377 var termErr error
378- select {378+settlement:
379- case result = <-settled:379+ for {
380- case <-service.Done():380+ // Begin has admitted the foreground, which can write before MarkAttached
381- brokerLost = true381+ // and before the initial publication. Keep presentation invalidation
382- // The broker connection is gone; the supervisor cancels the run and382+ // buffered for the next picker state; consuming it here could overlay
383- // lets the ready loop observe the loss and retire the connection.383+ // session output with a second terminal writer.
384- run.Cancel()384+ select {
385- result = <-settled385+ case result = <-settled:
386- case <-ctx.Done():386+ break settlement
387- run.Cancel()387+ case <-service.Done():
388- result = <-settled388+ brokerLost = true
389- terminated, termErr = true, ctx.Err()389+ // The broker connection is gone; the supervisor cancels the run and
390- case err := <-input.EOF():390+ // lets the ready loop observe the loss and retire the connection.
391- run.Cancel()391+ run.Cancel()
392- result = <-settled392+ result = <-settled
393- terminated, termErr = true, terminalReadCause(err)393+ break settlement
394+ case <-ctx.Done():
395+ run.Cancel()
396+ result = <-settled
397+ terminated, termErr = true, ctx.Err()
398+ break settlement
399+ case err := <-input.EOF():
400+ run.Cancel()
401+ result = <-settled
402+ terminated, termErr = true, terminalReadCause(err)
403+ break settlement
404+ }
394 }405 }
395 406
396 if terminated {407 if terminated {

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