fix(client): repaint the picker after terminal resize
- commit
- 49f309d8ea46d946736f12671dc8572fa2e6dd54
- author
- brice · · 2026-09-19 23:23 UTC
- parent
- 113b229
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 ` | |||
| 98 | 98 | ||
| 99 | 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 | 105 | ### Attention and notifications | |
| 102 | 106 | ||
| 103 | 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. | |
| @@ -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 notifications | 105 | ### 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 | |||
| 105 | 105 | } | |
| 106 | 106 | clk := clock.New() | |
| 107 | 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 | 151 | var writer sync.Mutex | |
| 109 | - | render := func(state client.State) { | |
| 152 | + | return func(state client.State) { | |
| 110 | 153 | if onState != nil { | |
| 111 | 154 | onState(state) | |
| 112 | 155 | } | |
| 113 | - | if state.Presentation != client.PresentPicker { | |
| 156 | + | if !client.PickerPresentation(state) { | |
| 114 | 157 | 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 | 159 | geometry, err := terminal.Geometry() | |
| 120 | 160 | if err != nil { | |
| 121 | 161 | return | |
| @@ -135,31 +175,6 @@ func runOfflineClient(ctx context.Context, socket string, terminal ports.Termina | |||
| 135 | 175 | } | |
| 136 | 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 | 180 | 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.Mutex | 151 | 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 | return | 157 | 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 | return | 161 | 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 { | |||
| 381 | 381 | onAttach func(AttachmentToken) | |
| 382 | 382 | authority attachmentAuthority | |
| 383 | 383 | ||
| 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 | |
| 392 | 393 | } | |
| 393 | 394 | ||
| 394 | 395 | // newAttachmentHost builds the reusable foreground host. A nil clock falls back | |
| @@ -408,15 +409,16 @@ func newAttachmentHost(cfg attachmentHostConfig) *attachmentHost { | |||
| 408 | 409 | resizes = cfg.Terminal.ResizeEvents() | |
| 409 | 410 | } | |
| 410 | 411 | 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), | |
| 420 | 422 | } | |
| 421 | 423 | } | |
| 422 | 424 | ||
| @@ -528,6 +530,14 @@ func (h *attachmentHost) startGeometry(ctx context.Context) { | |||
| 528 | 530 | case h.geometryUpdate <- struct{}{}: | |
| 529 | 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 | 541 | case <-geometryCtx.Done(): | |
| 532 | 542 | return | |
| 533 | 543 | } | |
| @@ -381,14 +381,15 @@ type attachmentHost struct { | |||||
| 381 | onAttach func(AttachmentToken) | 381 | onAttach func(AttachmentToken) | ||
| 382 | authority attachmentAuthority | 382 | authority attachmentAuthority | ||
| 383 | 383 | ||||
| 384 | - | geometryMu sync.Mutex | 384 | + | geometryMu sync.Mutex |
| 385 | - | geometry domain.Geometry | 385 | + | geometry domain.Geometry |
| 386 | - | geometryValid bool | 386 | + | geometryValid bool |
| 387 | - | geometrySeq uint64 | 387 | + | geometrySeq uint64 |
| 388 | - | geometryUpdate chan struct{} | 388 | + | geometryUpdate chan struct{} |
| 389 | - | geometryCancel context.CancelFunc | 389 | + | presentationUpdate chan struct{} |
| 390 | - | geometryDone chan struct{} | 390 | + | geometryCancel context.CancelFunc |
| 391 | - | ownerBoundary bool | 391 | + | geometryDone chan struct{} |
| 392 | + | ownerBoundary bool | |||
| 392 | } | 393 | } | ||
| 393 | 394 | ||||
| 394 | // newAttachmentHost builds the reusable foreground host. A nil clock falls back | 395 | // 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 | return | 542 | return | ||
| 533 | } | 543 | } | ||
M internal/usecase/client/picker_composition.go +12 -3
| @@ -21,9 +21,10 @@ import ( | |||
| 21 | 21 | // invariants or consume a pending user decision before the supervisor handles | |
| 22 | 22 | // it. | |
| 23 | 23 | // | |
| 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. | |
| 27 | 28 | // The picker deliberately neither owns a terminal writer nor consumes the | |
| 28 | 29 | // terminal resize channel; the composition supplies its current geometry when | |
| 29 | 30 | // rendering. | |
| @@ -35,6 +36,14 @@ type Picker struct { | |||
| 35 | 36 | // SupervisorConfig.Picker. | |
| 36 | 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 | 47 | // NewPicker composes one client-owned picker. A nil clock uses the system | |
| 39 | 48 | // clock, and a zero freshness uses the catalogue default. | |
| 40 | 49 | 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 handles | 21 | // 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. A | 24 | + | // Painting is allowed only while PickerPresentation reports true. In |
| 25 | - | // composition must not paint picker bytes during an attachment: the attachment | 25 | + | // 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 the | 28 | // The picker deliberately neither owns a terminal writer nor consumes the | ||
| 28 | // terminal resize channel; the composition supplies its current geometry when | 29 | // 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 system | 47 | // 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 { | |||
| 266 | 266 | // clock. | |
| 267 | 267 | Clock ports.Clock | |
| 268 | 268 | // 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. | |
| 272 | 280 | Render func(State) | |
| 273 | 281 | // Notify surfaces a connectivity failure without leaving the picker. | |
| 274 | 282 | // Optional; the default notifies nothing. | |
| @@ -693,13 +701,17 @@ func (s *Supervisor) connectAttempt(ctx context.Context, generation uint64) supe | |||
| 693 | 701 | // awaitAttempt waits for the in-flight attempt to settle, or for the run to be | |
| 694 | 702 | // terminated first. | |
| 695 | 703 | 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 | + | } | |
| 703 | 715 | } | |
| 704 | 716 | } | |
| 705 | 717 | ||
| @@ -749,6 +761,8 @@ func (s *Supervisor) awaitReady(ctx context.Context, input *terminalInputLifetim | |||
| 749 | 761 | return readyOutcome{terminated: true, termErr: ctx.Err()} | |
| 750 | 762 | case err := <-input.EOF(): | |
| 751 | 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 | 787 | // failure until the terminal ends or the process is cancelled. A clean terminal | |
| 774 | 788 | // EOF returns nil; every other cause is reported. | |
| 775 | 789 | 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 | + | } | |
| 781 | 799 | } | |
| 782 | 800 | } | |
| 783 | 801 | ||
| @@ -791,13 +809,17 @@ func (s *Supervisor) waitBackoff(ctx context.Context, input *terminalInputLifeti | |||
| 791 | 809 | } | |
| 792 | 810 | timer := s.cfg.Clock.NewTimer(delay) | |
| 793 | 811 | 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 | + | } | |
| 801 | 823 | } | |
| 802 | 824 | } | |
| 803 | 825 | ||
| @@ -932,6 +954,39 @@ func (s *Supervisor) renderCurrent() { | |||
| 932 | 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 | 990 | // terminalInputLifetime owns the single read of the controlling terminal. It is | |
| 936 | 991 | // started once and never replaced: a bare io.Reader cannot be interrupted, so | |
| 937 | 992 | // 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.Clock | 267 | Clock ports.Clock | ||
| 268 | // Render asks the composition to paint the current state. It is called for | 268 | // Render asks the composition to paint the current state. It is called for | ||
| 269 | - | // visible transitions and after picker catalogue publications; while the | 269 | + | // visible transitions, after picker catalogue publications, and for every |
| 270 | - | // state reports PresentPicker, the composition paints from Picker.Render | 270 | + | // 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 be | 701 | // 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, nil | 706 | + | 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 terminal | 787 | // 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, nil | 814 | + | 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 is | 990 | // terminalInputLifetime owns the single read of the controlling terminal. It is | ||
| 936 | // started once and never replaced: a bare io.Reader cannot be interrupted, so | 991 | // 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 the | 992 | // 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 | |||
| 375 | 375 | var brokerLost bool | |
| 376 | 376 | var terminated bool | |
| 377 | 377 | 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 | + | } | |
| 394 | 405 | } | |
| 395 | 406 | ||
| 396 | 407 | if terminated { | |
| @@ -375,22 +375,33 @@ func (s *Supervisor) settleAttachment(ctx context.Context, input *terminalInputL | |||||
| 375 | var brokerLost bool | 375 | var brokerLost bool | ||
| 376 | var terminated bool | 376 | var terminated bool | ||
| 377 | var termErr error | 377 | 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 = true | 381 | + | // and before the initial publication. Keep presentation invalidation |
| 382 | - | // The broker connection is gone; the supervisor cancels the run and | 382 | + | // 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 = <-settled | 385 | + | case result = <-settled: |
| 386 | - | case <-ctx.Done(): | 386 | + | break settlement |
| 387 | - | run.Cancel() | 387 | + | case <-service.Done(): |
| 388 | - | result = <-settled | 388 | + | 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 = <-settled | 392 | + | 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 { | ||