detached 76d7fa2

perf: quieter idle broker, compressed snapshots, faster copy search, shared floating shell (#319)

* perf(copy): scan history text without cell copies and refine extended queries

Search walks HistoryView.RangeText instead of materializing cell rows,
and visual search narrows previous matches when the query is extended.

BenchmarkFindMatches12K (12k rows, benchstat -count=6):
  hot pages:  193 ms -> 13.6 ms
  cold pages: 276 ms -> 109 ms

* perf(broker): publish routine refreshes once and skip timestamp-only persistence

A scheduled refresh of a reachable daemon no longer publishes a transient
Checking snapshot before its result, halving idle publications per watched
host. Explicit probes and unconfirmed hosts still show Checking.

The durable writer skips a store write (and its fsync) when only the
revision and LastAttempt/LastSuccess/NextDue changed since the last
successful write.

Server snapshot publication encodes each part once and sends the same bytes
it measured against the staged ceiling, instead of encoding twice.

* perf(snapshot): zlib-compress snapshot objects

VEVO objects now store the payload as a BestSpeed zlib stream (object
version 2, manifest version 5). Encoding stays deterministic, so content
addressing is unchanged. Older checkpoints fail with ErrBadVersion and go
through the existing incompatible-checkpoint reset.

BenchmarkDaemonIncrementalSnapshotRepository (benchstat -count=6):
  initial 10k x 120 objects: 10.97 MB -> 0.21 MB (-98%)
  byte-limited 400 columns:  28.3 MB  -> 0.41 MB (-98.5%)
  tail-only: 17.6 KB -> 0.6 KB; transcript-only: 40.5 KB -> 1.1 KB
  initial B/op: 70.4 MiB -> 42.7 MiB; time unchanged (~)

* perf(uiterm): convert UI mirror snapshots on read

A flush now captures the committed screen and bumps the revision; the
conversion to UI cells runs on the first Snapshot read of that revision.
An interactive client with UI control no longer converts every frame
nobody reads. Context-only publications reuse the committed frame.

BenchmarkTerminalMirrorFlush (200x50, benchstat -count=6):
  385.7 us -> 20.6 us (-95%), 673.5 KiB -> 162.4 KiB, 10009 -> 8 allocs

* perf(daemon): share one pre-started floating shell per session

Tabs no longer prewarm their own hidden floating shell. Each session keeps
one spare; the first tab that opens its floating pane claims it (resized to
that tab's geometry before its reader starts) and the session starts the
next spare in the background. A spare started in another directory or for
another floating.command is closed and the claim launches a fresh shell.
The spare is owned by the session context, is never persisted, and is
closed on session teardown.

Shell count per session: panes + one hidden shell per tab -> panes + 1.

* perf(client): read only the UI boundary on each committed frame

UI.published ran on every committed frame and called Snapshot, which
converted every cell even though only the revision and context were used.
UIState gains Boundary(), which returns the latest publication's revision
and context under the same availability rules without converting cells.

Sandbox, 200x55 client, copy-mode wheel scroll for 10 s:
  --ui-control client CPU: 38.2% -> 2.9% (plain client: 1.8%, unchanged)

* docs(performance): document focused benchmarks and idle cost rules

* chore(deps): bump vev-vt to v0.7.0
commit
76d7fa2b4cb3d56e2dd8152bc735d7363ed3af50
author
bnema · · 2026-10-07 19:23 UTC
committer
GitHub · · 2026-10-07 19:23 UTC
parent
c7eae95
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
39 files · +1182 -149
M docs/configuration.md +2 -1
@@ -25,7 +25,7 @@ theme.accent = auto
2525 # bottom-left, bottom, or bottom-right.
2626 palette.anchor = center
2727
28-# One prewarmed floating terminal per tab. An empty command uses the normal shell.
28+# One floating terminal per tab. An empty command uses the normal shell.
2929 floating.command =
3030 floating.width = 80%
3131 floating.height = 80%
@@ -287,6 +287,7 @@ Below 80 columns, popups (palette, picker, floating terminal, prompts) become fu
287287 - `floating.command` runs through your shell. Changes apply on the next launch.
288288 - Below 80 columns, the terminal takes the full width and keeps `floating.height`.
289289 - Floating terminals are not restored after a daemon restart.
290+- Each session keeps one pre-started shell ready. The first tab that opens its floating terminal takes it, and the session starts the next one in the background. A shell started in another directory is replaced by a fresh one in the tab's directory.
290291
291292 ## Bar anchors
292293
@@ -25,7 +25,7 @@ theme.accent = auto
25 # bottom-left, bottom, or bottom-right.25 # bottom-left, bottom, or bottom-right.
26 palette.anchor = center26 palette.anchor = center
27 27
28-# One prewarmed floating terminal per tab. An empty command uses the normal shell.28+# One floating terminal per tab. An empty command uses the normal shell.
29 floating.command =29 floating.command =
30 floating.width = 80%30 floating.width = 80%
31 floating.height = 80%31 floating.height = 80%
@@ -287,6 +287,7 @@ Below 80 columns, popups (palette, picker, floating terminal, prompts) become fu
287 - `floating.command` runs through your shell. Changes apply on the next launch.287 - `floating.command` runs through your shell. Changes apply on the next launch.
288 - Below 80 columns, the terminal takes the full width and keeps `floating.height`.288 - Below 80 columns, the terminal takes the full width and keeps `floating.height`.
289 - Floating terminals are not restored after a daemon restart.289 - Floating terminals are not restored after a daemon restart.
290+- Each session keeps one pre-started shell ready. The first tab that opens its floating terminal takes it, and the session starts the next one in the background. A shell started in another directory is replaced by a fresh one in the tab's directory.
290 291
291 ## Bar anchors292 ## Bar anchors
292 293
M docs/performance.md +20 -0
@@ -192,6 +192,26 @@ go test github.com/bnema/vev-vt/ansi github.com/bnema/vev-vt ./internal/adapters
192192 go test ./internal/usecase/daemon -run '^$' -bench '^BenchmarkComposeCapturedFloatingFrameCached$' -benchmem
193193 ```
194194
195+Focused benchmarks for specific paths:
196+
197+| Path | Command |
198+|---|---|
199+| Copy-mode search over 12k rows | `go test ./internal/usecase/copy -run '^$' -bench '^BenchmarkFindMatches12K$' -benchmem` |
200+| Snapshot publication bytes and time | `go test ./internal/usecase/daemon -run '^$' -bench '^BenchmarkDaemonIncrementalSnapshotRepository$' -benchmem` |
201+| UI mirror flush with `--ui-observe`/`--ui-control` | `go test ./internal/adapters/uiterm -run '^$' -bench '^BenchmarkTerminalMirrorFlush$' -benchmem` |
202+
203+## Idle cost
204+
205+An idle session should cost almost nothing:
206+
207+- The broker rewrites `state.json` only when durable state changes, not when
208+ an observation only refreshes its timestamps.
209+- A routine refresh of a reachable daemon publishes one snapshot, not a
210+ transient "checking" snapshot first.
211+- A session keeps one pre-started floating shell, not one per tab, so its
212+ process count is its panes plus one.
213+- The UI mirror converts a frame to cells only when something reads it.
214+
195215 ## Mouse scroll rendering
196216
197217 Wheel input moves the immutable copy viewport, rather than walking the keyboard
@@ -192,6 +192,26 @@ go test github.com/bnema/vev-vt/ansi github.com/bnema/vev-vt ./internal/adapters
192 go test ./internal/usecase/daemon -run '^$' -bench '^BenchmarkComposeCapturedFloatingFrameCached$' -benchmem192 go test ./internal/usecase/daemon -run '^$' -bench '^BenchmarkComposeCapturedFloatingFrameCached$' -benchmem
193 ```193 ```
194 194
195+Focused benchmarks for specific paths:
196+
197+| Path | Command |
198+|---|---|
199+| Copy-mode search over 12k rows | `go test ./internal/usecase/copy -run '^$' -bench '^BenchmarkFindMatches12K$' -benchmem` |
200+| Snapshot publication bytes and time | `go test ./internal/usecase/daemon -run '^$' -bench '^BenchmarkDaemonIncrementalSnapshotRepository$' -benchmem` |
201+| UI mirror flush with `--ui-observe`/`--ui-control` | `go test ./internal/adapters/uiterm -run '^$' -bench '^BenchmarkTerminalMirrorFlush$' -benchmem` |
202+
203+## Idle cost
204+
205+An idle session should cost almost nothing:
206+
207+- The broker rewrites `state.json` only when durable state changes, not when
208+ an observation only refreshes its timestamps.
209+- A routine refresh of a reachable daemon publishes one snapshot, not a
210+ transient "checking" snapshot first.
211+- A session keeps one pre-started floating shell, not one per tab, so its
212+ process count is its panes plus one.
213+- The UI mirror converts a frame to cells only when something reads it.
214+
195 ## Mouse scroll rendering215 ## Mouse scroll rendering
196 216
197 Wheel input moves the immutable copy viewport, rather than walking the keyboard217 Wheel input moves the immutable copy viewport, rather than walking the keyboard
M go.mod +1 -1
@@ -3,7 +3,7 @@ module github.com/bnema/vev
33 go 1.27
44
55 require (
6- github.com/bnema/vev-vt v0.6.0
6+ github.com/bnema/vev-vt v0.7.0
77 github.com/coder/websocket v1.8.15
88 github.com/quic-go/quic-go v0.62.0
99 github.com/stretchr/testify v1.12.1
@@ -3,7 +3,7 @@ module github.com/bnema/vev
3 go 1.273 go 1.27
4 4
5 require (5 require (
6- github.com/bnema/vev-vt v0.6.06+ github.com/bnema/vev-vt v0.7.0
7 github.com/coder/websocket v1.8.157 github.com/coder/websocket v1.8.15
8 github.com/quic-go/quic-go v0.62.08 github.com/quic-go/quic-go v0.62.0
9 github.com/stretchr/testify v1.12.19 github.com/stretchr/testify v1.12.1
M go.sum +2 -2 generated file: collapsed
M internal/adapters/brokeripc/emit.go +8 -6
@@ -107,17 +107,19 @@ func cloneCatalogSession(session catalogue.RemoteCatalogSession) catalogue.Remot
107107 return out
108108 }
109109
110-// snapshotTransferBytes sums the encoded size of one prepared transfer so the
111-// publisher can refuse a publication above the staged ceiling before the peer
112-// aborts it.
113-func snapshotTransferBytes(parts []brokerwire.SnapshotPart, maxEnvelopeBytes, maxChunkBytes uint64) (uint64, error) {
110+// encodeSnapshotTransfer encodes every part of one prepared transfer once and
111+// sums the encoded size, so the publisher can refuse a publication above the
112+// staged ceiling before the peer aborts it and then send the same bytes.
113+func encodeSnapshotTransfer(parts []brokerwire.SnapshotPart, maxEnvelopeBytes, maxChunkBytes uint64) ([][]byte, uint64, error) {
114+ payloads := make([][]byte, 0, len(parts))
114115 var total uint64
115116 for _, part := range parts {
116117 payload, err := brokerwire.EncodeServer(part, maxEnvelopeBytes, maxChunkBytes)
117118 if err != nil {
118- return 0, err
119+ return nil, 0, err
119120 }
121+ payloads = append(payloads, payload)
120122 total += uint64(len(payload))
121123 }
122- return total, nil
124+ return payloads, total, nil
123125 }
@@ -107,17 +107,19 @@ func cloneCatalogSession(session catalogue.RemoteCatalogSession) catalogue.Remot
107 return out107 return out
108 }108 }
109 109
110-// snapshotTransferBytes sums the encoded size of one prepared transfer so the110+// encodeSnapshotTransfer encodes every part of one prepared transfer once and
111-// publisher can refuse a publication above the staged ceiling before the peer111+// sums the encoded size, so the publisher can refuse a publication above the
112-// aborts it.112+// staged ceiling before the peer aborts it and then send the same bytes.
113-func snapshotTransferBytes(parts []brokerwire.SnapshotPart, maxEnvelopeBytes, maxChunkBytes uint64) (uint64, error) {113+func encodeSnapshotTransfer(parts []brokerwire.SnapshotPart, maxEnvelopeBytes, maxChunkBytes uint64) ([][]byte, uint64, error) {
114+ payloads := make([][]byte, 0, len(parts))
114 var total uint64115 var total uint64
115 for _, part := range parts {116 for _, part := range parts {
116 payload, err := brokerwire.EncodeServer(part, maxEnvelopeBytes, maxChunkBytes)117 payload, err := brokerwire.EncodeServer(part, maxEnvelopeBytes, maxChunkBytes)
117 if err != nil {118 if err != nil {
118- return 0, err119+ return nil, 0, err
119 }120 }
121+ payloads = append(payloads, payload)
120 total += uint64(len(payload))122 total += uint64(len(payload))
121 }123 }
122- return total, nil124+ return payloads, total, nil
123 }125 }
M internal/adapters/brokeripc/mutation.go +3 -3
@@ -111,15 +111,15 @@ func (s *serverSession) publishSnapshot(generation brokerwire.SubscriptionGenera
111111 if err != nil {
112112 return err
113113 }
114- total, err := snapshotTransferBytes(parts, s.ceilings.MaxReceiveEnvelopeBytes, s.ceilings.StreamChunkLimit)
114+ payloads, total, err := encodeSnapshotTransfer(parts, s.ceilings.MaxReceiveEnvelopeBytes, s.ceilings.StreamChunkLimit)
115115 if err != nil {
116116 return err
117117 }
118118 if total > uint64(brokerwire.MaxSnapshotStagedBytes) {
119119 return errors.New("brokeripc: snapshot publication exceeds the staged ceiling")
120120 }
121- for _, part := range parts {
122- if err := s.send(part); err != nil {
121+ for _, payload := range payloads {
122+ if err := s.sendPayload(payload); err != nil {
123123 return err
124124 }
125125 }
@@ -111,15 +111,15 @@ func (s *serverSession) publishSnapshot(generation brokerwire.SubscriptionGenera
111 if err != nil {111 if err != nil {
112 return err112 return err
113 }113 }
114- total, err := snapshotTransferBytes(parts, s.ceilings.MaxReceiveEnvelopeBytes, s.ceilings.StreamChunkLimit)114+ payloads, total, err := encodeSnapshotTransfer(parts, s.ceilings.MaxReceiveEnvelopeBytes, s.ceilings.StreamChunkLimit)
115 if err != nil {115 if err != nil {
116 return err116 return err
117 }117 }
118 if total > uint64(brokerwire.MaxSnapshotStagedBytes) {118 if total > uint64(brokerwire.MaxSnapshotStagedBytes) {
119 return errors.New("brokeripc: snapshot publication exceeds the staged ceiling")119 return errors.New("brokeripc: snapshot publication exceeds the staged ceiling")
120 }120 }
121- for _, part := range parts {121+ for _, payload := range payloads {
122- if err := s.send(part); err != nil {122+ if err := s.sendPayload(payload); err != nil {
123 return err123 return err
124 }124 }
125 }125 }
M internal/adapters/brokeripc/server.go +5 -0
@@ -815,6 +815,11 @@ func (s *serverSession) send(message brokerwire.ServerMessage) error {
815815 if err != nil {
816816 return err
817817 }
818+ return s.sendPayload(payload)
819+}
820+
821+// sendPayload writes one already encoded server envelope.
822+func (s *serverSession) sendPayload(payload []byte) error {
818823 if err := s.transport.Send(wire.Envelope{Payload: payload}); err != nil {
819824 return transportFailure(err)
820825 }
@@ -815,6 +815,11 @@ func (s *serverSession) send(message brokerwire.ServerMessage) error {
815 if err != nil {815 if err != nil {
816 return err816 return err
817 }817 }
818+ return s.sendPayload(payload)
819+}
820+
821+// sendPayload writes one already encoded server envelope.
822+func (s *serverSession) sendPayload(payload []byte) error {
818 if err := s.transport.Send(wire.Envelope{Payload: payload}); err != nil {823 if err := s.transport.Send(wire.Envelope{Payload: payload}); err != nil {
819 return transportFailure(err)824 return transportFailure(err)
820 }825 }
M internal/adapters/snapshot/publish.go +4 -4
@@ -283,8 +283,8 @@ func validateSuppliedObject(objects []ports.SnapshotObject, digest ports.Snapsho
283283 return ports.SnapshotObject{}, fmt.Errorf("conflicting object")
284284 }
285285 }
286- kind, payload, err := codec.PreflightObject(object.Data)
287- if err != nil || kind != ref.Kind || len(object.Data) != int(ref.Size) || len(payload) == 0 {
286+ kind, err := codec.PreflightObject(object.Data)
287+ if err != nil || kind != ref.Kind || len(object.Data) != int(ref.Size) {
288288 return ports.SnapshotObject{}, fmt.Errorf("invalid object envelope")
289289 }
290290 return ports.SnapshotObject{Digest: digest, Data: append([]byte(nil), object.Data...)}, nil
@@ -329,8 +329,8 @@ func manifestRefs(manifest codec.Manifest) map[ports.SnapshotDigest]codec.Object
329329 }
330330
331331 func validObject(data []byte, ref codec.ObjectRef) bool {
332- kind, payload, err := codec.PreflightObject(data)
333- return err == nil && kind == ref.Kind && len(payload) > 0 && len(data) == int(ref.Size)
332+ kind, err := codec.PreflightObject(data)
333+ return err == nil && kind == ref.Kind && len(data) == int(ref.Size)
334334 }
335335
336336 func (r *Repository) objectDigest(data []byte) ports.SnapshotDigest {
@@ -283,8 +283,8 @@ func validateSuppliedObject(objects []ports.SnapshotObject, digest ports.Snapsho
283 return ports.SnapshotObject{}, fmt.Errorf("conflicting object")283 return ports.SnapshotObject{}, fmt.Errorf("conflicting object")
284 }284 }
285 }285 }
286- kind, payload, err := codec.PreflightObject(object.Data)286+ kind, err := codec.PreflightObject(object.Data)
287- if err != nil || kind != ref.Kind || len(object.Data) != int(ref.Size) || len(payload) == 0 {287+ if err != nil || kind != ref.Kind || len(object.Data) != int(ref.Size) {
288 return ports.SnapshotObject{}, fmt.Errorf("invalid object envelope")288 return ports.SnapshotObject{}, fmt.Errorf("invalid object envelope")
289 }289 }
290 return ports.SnapshotObject{Digest: digest, Data: append([]byte(nil), object.Data...)}, nil290 return ports.SnapshotObject{Digest: digest, Data: append([]byte(nil), object.Data...)}, nil
@@ -329,8 +329,8 @@ func manifestRefs(manifest codec.Manifest) map[ports.SnapshotDigest]codec.Object
329 }329 }
330 330
331 func validObject(data []byte, ref codec.ObjectRef) bool {331 func validObject(data []byte, ref codec.ObjectRef) bool {
332- kind, payload, err := codec.PreflightObject(data)332+ kind, err := codec.PreflightObject(data)
333- return err == nil && kind == ref.Kind && len(payload) > 0 && len(data) == int(ref.Size)333+ return err == nil && kind == ref.Kind && len(data) == int(ref.Size)
334 }334 }
335 335
336 func (r *Repository) objectDigest(data []byte) ports.SnapshotDigest {336 func (r *Repository) objectDigest(data []byte) ports.SnapshotDigest {
M internal/adapters/uiterm/terminal.go +39 -10
@@ -35,7 +35,10 @@ type Terminal struct {
3535 mirror bool
3636 geometry domain.Geometry
3737 context ports.UIContext
38- latest ports.UISnapshot
38+ published vt.ScreenSnapshot // committed screen of the latest revision
39+ publishedGeom domain.Geometry
40+ publishedCtx ports.UIContext
41+ latest ports.UISnapshot // conversion cache, built on the first read
3942 revision uint64
4043 available bool
4144 captureTooLarge bool
@@ -257,21 +260,44 @@ func (t *Terminal) completeContextLocked(context ports.UIContext) ports.UIContex
257260 return context
258261 }
259262
260-func (t *Terminal) Snapshot() (ports.UISnapshot, error) {
261- t.mu.Lock()
262- defer t.mu.Unlock()
263+// capturableLocked applies the shared availability rules for a read of the
264+// latest publication. The caller must hold t.mu.
265+func (t *Terminal) capturableLocked() error {
263266 if !t.available {
264- return ports.UISnapshot{}, ports.ErrUIUnavailable
267+ return ports.ErrUIUnavailable
265268 }
266269 if t.captureTooLarge {
267- return ports.UISnapshot{}, &ports.UIError{Code: ports.UIErrCaptureTooLarge}
270+ return &ports.UIError{Code: ports.UIErrCaptureTooLarge}
268271 }
269- if t.latest.Revision == 0 {
270- return ports.UISnapshot{}, ports.ErrUIUnavailable
272+ if t.revision == 0 {
273+ return ports.ErrUIUnavailable
274+ }
275+ return nil
276+}
277+
278+func (t *Terminal) Snapshot() (ports.UISnapshot, error) {
279+ t.mu.Lock()
280+ defer t.mu.Unlock()
281+ if err := t.capturableLocked(); err != nil {
282+ return ports.UISnapshot{}, err
283+ }
284+ if t.latest.Revision != t.revision {
285+ t.latest = convertSnapshot(t.published, t.publishedGeom, t.publishedCtx, t.revision)
271286 }
272287 return cloneSnapshot(t.latest), nil
273288 }
274289
290+// Boundary reports the latest publication's revision and context without
291+// converting its cells.
292+func (t *Terminal) Boundary() (ports.UIActionResult, error) {
293+ t.mu.Lock()
294+ defer t.mu.Unlock()
295+ if err := t.capturableLocked(); err != nil {
296+ return ports.UIActionResult{}, err
297+ }
298+ return ports.UIActionResult{Revision: t.revision, Context: t.publishedCtx}, nil
299+}
300+
275301 func (t *Terminal) Changes() <-chan struct{} { return t.changes }
276302
277303 func (t *Terminal) ObserveTerminalWrite(data []byte) {
@@ -349,9 +375,12 @@ func (t *Terminal) publishLocked() {
349375 if !t.available || t.captureTooLarge || t.screen == nil {
350376 return
351377 }
352- screen := t.screen.Snapshot()
378+ if t.dirty || t.revision == 0 {
379+ t.published = t.screen.Snapshot()
380+ }
381+ t.publishedGeom = t.geometry
382+ t.publishedCtx = t.context
353383 t.revision++
354- t.latest = convertSnapshot(screen, t.geometry, t.context, t.revision)
355384 t.dirty = false
356385 t.signalChange()
357386 }
@@ -35,7 +35,10 @@ type Terminal struct {
35 mirror bool35 mirror bool
36 geometry domain.Geometry36 geometry domain.Geometry
37 context ports.UIContext37 context ports.UIContext
38- latest ports.UISnapshot38+ published vt.ScreenSnapshot // committed screen of the latest revision
39+ publishedGeom domain.Geometry
40+ publishedCtx ports.UIContext
41+ latest ports.UISnapshot // conversion cache, built on the first read
39 revision uint6442 revision uint64
40 available bool43 available bool
41 captureTooLarge bool44 captureTooLarge bool
@@ -257,21 +260,44 @@ func (t *Terminal) completeContextLocked(context ports.UIContext) ports.UIContex
257 return context260 return context
258 }261 }
259 262
260-func (t *Terminal) Snapshot() (ports.UISnapshot, error) {263+// capturableLocked applies the shared availability rules for a read of the
261- t.mu.Lock()264+// latest publication. The caller must hold t.mu.
262- defer t.mu.Unlock()265+func (t *Terminal) capturableLocked() error {
263 if !t.available {266 if !t.available {
264- return ports.UISnapshot{}, ports.ErrUIUnavailable267+ return ports.ErrUIUnavailable
265 }268 }
266 if t.captureTooLarge {269 if t.captureTooLarge {
267- return ports.UISnapshot{}, &ports.UIError{Code: ports.UIErrCaptureTooLarge}270+ return &ports.UIError{Code: ports.UIErrCaptureTooLarge}
268 }271 }
269- if t.latest.Revision == 0 {272+ if t.revision == 0 {
270- return ports.UISnapshot{}, ports.ErrUIUnavailable273+ return ports.ErrUIUnavailable
274+ }
275+ return nil
276+}
277+
278+func (t *Terminal) Snapshot() (ports.UISnapshot, error) {
279+ t.mu.Lock()
280+ defer t.mu.Unlock()
281+ if err := t.capturableLocked(); err != nil {
282+ return ports.UISnapshot{}, err
283+ }
284+ if t.latest.Revision != t.revision {
285+ t.latest = convertSnapshot(t.published, t.publishedGeom, t.publishedCtx, t.revision)
271 }286 }
272 return cloneSnapshot(t.latest), nil287 return cloneSnapshot(t.latest), nil
273 }288 }
274 289
290+// Boundary reports the latest publication's revision and context without
291+// converting its cells.
292+func (t *Terminal) Boundary() (ports.UIActionResult, error) {
293+ t.mu.Lock()
294+ defer t.mu.Unlock()
295+ if err := t.capturableLocked(); err != nil {
296+ return ports.UIActionResult{}, err
297+ }
298+ return ports.UIActionResult{Revision: t.revision, Context: t.publishedCtx}, nil
299+}
300+
275 func (t *Terminal) Changes() <-chan struct{} { return t.changes }301 func (t *Terminal) Changes() <-chan struct{} { return t.changes }
276 302
277 func (t *Terminal) ObserveTerminalWrite(data []byte) {303 func (t *Terminal) ObserveTerminalWrite(data []byte) {
@@ -349,9 +375,12 @@ func (t *Terminal) publishLocked() {
349 if !t.available || t.captureTooLarge || t.screen == nil {375 if !t.available || t.captureTooLarge || t.screen == nil {
350 return376 return
351 }377 }
352- screen := t.screen.Snapshot()378+ if t.dirty || t.revision == 0 {
379+ t.published = t.screen.Snapshot()
380+ }
381+ t.publishedGeom = t.geometry
382+ t.publishedCtx = t.context
353 t.revision++383 t.revision++
354- t.latest = convertSnapshot(screen, t.geometry, t.context, t.revision)
355 t.dirty = false384 t.dirty = false
356 t.signalChange()385 t.signalChange()
357 }386 }
A internal/adapters/uiterm/terminal_lazy_test.go +112 -0
@@ -0,0 +1,112 @@
1+package uiterm
2+
3+import (
4+ "bytes"
5+ "context"
6+ "testing"
7+
8+ "github.com/bnema/vev/internal/domain"
9+ "github.com/bnema/vev/internal/ports"
10+)
11+
12+// TestTerminalSnapshotReadsCommittedRevision pins the lazy conversion: a
13+// Snapshot read after later uncommitted writes still returns the committed
14+// screen of the latest revision, and each revision converts its own state.
15+func TestTerminalSnapshotReadsCommittedRevision(t *testing.T) {
16+ terminal := newTestTerminal(t, 4, 1)
17+ for _, tc := range []struct {
18+ write string
19+ flush bool
20+ picker bool
21+ revision uint64
22+ first string
23+ }{
24+ {write: "A", flush: true, revision: 1, first: "A"},
25+ {write: "\rB", revision: 1, first: "A"},
26+ {flush: true, revision: 2, first: "B"},
27+ {picker: true, revision: 3, first: "B"},
28+ {write: "\rC", revision: 3, first: "B"},
29+ {flush: true, revision: 4, first: "C"},
30+ } {
31+ if tc.write != "" {
32+ if _, err := terminal.Write([]byte(tc.write)); err != nil {
33+ t.Fatal(err)
34+ }
35+ }
36+ if tc.flush {
37+ if err := terminal.Flush(); err != nil {
38+ t.Fatal(err)
39+ }
40+ }
41+ if tc.picker {
42+ // A context-only publication reuses the committed frame.
43+ if err := terminal.PublishContext(ports.UIContext{AttachmentHandle: "attachment", Status: ports.UIStatusPicker}); err != nil {
44+ t.Fatal(err)
45+ }
46+ }
47+ snapshot, err := terminal.Snapshot()
48+ if err != nil {
49+ t.Fatal(err)
50+ }
51+ if snapshot.Revision != tc.revision || snapshot.Cells[0].Text != tc.first {
52+ t.Fatalf("after %+v: revision %d first %q, want %d %q", tc, snapshot.Revision, snapshot.Cells[0].Text, tc.revision, tc.first)
53+ }
54+ if tc.revision >= 3 && snapshot.Context.Status != ports.UIStatusPicker {
55+ t.Fatalf("after %+v: context %+v lost the picker publication", tc, snapshot.Context)
56+ }
57+ }
58+}
59+
60+// TestTerminalBoundaryMatchesSnapshotWithoutConverting pins the per-frame
61+// bookkeeping read: Boundary reports the same revision and context as
62+// Snapshot, follows the same availability rules, and never builds cells.
63+func TestTerminalBoundaryMatchesSnapshotWithoutConverting(t *testing.T) {
64+ terminal := newTestTerminal(t, 4, 1)
65+ if _, err := terminal.Boundary(); err == nil {
66+ t.Fatal("boundary before the first publication must be unavailable")
67+ }
68+ terminal.BeginOutput(ports.UIContext{AttachmentHandle: "attachment", Generation: 3, Status: ports.UIStatusAttached})
69+ if _, err := terminal.Write([]byte("A")); err != nil {
70+ t.Fatal(err)
71+ }
72+ terminal.EndOutput(true)
73+
74+ boundary, err := terminal.Boundary()
75+ if err != nil {
76+ t.Fatal(err)
77+ }
78+ terminal.mu.Lock()
79+ converted := terminal.latest.Revision
80+ terminal.mu.Unlock()
81+ if converted != 0 {
82+ t.Fatalf("Boundary converted revision %d", converted)
83+ }
84+ snapshot, err := terminal.Snapshot()
85+ if err != nil {
86+ t.Fatal(err)
87+ }
88+ if boundary.Revision != snapshot.Revision || boundary.Context != snapshot.Context {
89+ t.Fatalf("boundary %+v, snapshot revision %d context %+v", boundary, snapshot.Revision, snapshot.Context)
90+ }
91+
92+ terminal.InvalidateTerminalObservation()
93+ if _, err := terminal.Boundary(); err == nil {
94+ t.Fatal("boundary of an invalidated observation must be unavailable")
95+ }
96+}
97+
98+// BenchmarkTerminalMirrorFlush measures one mirrored flush that nobody reads,
99+// the steady state of an interactive client with UI control enabled.
100+func BenchmarkTerminalMirrorFlush(b *testing.B) {
101+ terminal, err := NewMirror(context.Background(), domain.Geometry{Size: domain.Size{Cols: 200, Rows: 50}}, "attachment")
102+ if err != nil {
103+ b.Fatal(err)
104+ }
105+ defer terminal.Close()
106+ line := append(bytes.Repeat([]byte("x"), 199), '\n')
107+ b.ReportAllocs()
108+ for b.Loop() {
109+ terminal.ObserveTerminalWrite(line)
110+ terminal.ObserveTerminalFlush()
111+ }
112+}
@@ -0,0 +1,112 @@
1+package uiterm
2+
3+import (
4+ "bytes"
5+ "context"
6+ "testing"
7+
8+ "github.com/bnema/vev/internal/domain"
9+ "github.com/bnema/vev/internal/ports"
10+)
11+
12+// TestTerminalSnapshotReadsCommittedRevision pins the lazy conversion: a
13+// Snapshot read after later uncommitted writes still returns the committed
14+// screen of the latest revision, and each revision converts its own state.
15+func TestTerminalSnapshotReadsCommittedRevision(t *testing.T) {
16+ terminal := newTestTerminal(t, 4, 1)
17+ for _, tc := range []struct {
18+ write string
19+ flush bool
20+ picker bool
21+ revision uint64
22+ first string
23+ }{
24+ {write: "A", flush: true, revision: 1, first: "A"},
25+ {write: "\rB", revision: 1, first: "A"},
26+ {flush: true, revision: 2, first: "B"},
27+ {picker: true, revision: 3, first: "B"},
28+ {write: "\rC", revision: 3, first: "B"},
29+ {flush: true, revision: 4, first: "C"},
30+ } {
31+ if tc.write != "" {
32+ if _, err := terminal.Write([]byte(tc.write)); err != nil {
33+ t.Fatal(err)
34+ }
35+ }
36+ if tc.flush {
37+ if err := terminal.Flush(); err != nil {
38+ t.Fatal(err)
39+ }
40+ }
41+ if tc.picker {
42+ // A context-only publication reuses the committed frame.
43+ if err := terminal.PublishContext(ports.UIContext{AttachmentHandle: "attachment", Status: ports.UIStatusPicker}); err != nil {
44+ t.Fatal(err)
45+ }
46+ }
47+ snapshot, err := terminal.Snapshot()
48+ if err != nil {
49+ t.Fatal(err)
50+ }
51+ if snapshot.Revision != tc.revision || snapshot.Cells[0].Text != tc.first {
52+ t.Fatalf("after %+v: revision %d first %q, want %d %q", tc, snapshot.Revision, snapshot.Cells[0].Text, tc.revision, tc.first)
53+ }
54+ if tc.revision >= 3 && snapshot.Context.Status != ports.UIStatusPicker {
55+ t.Fatalf("after %+v: context %+v lost the picker publication", tc, snapshot.Context)
56+ }
57+ }
58+}
59+
60+// TestTerminalBoundaryMatchesSnapshotWithoutConverting pins the per-frame
61+// bookkeeping read: Boundary reports the same revision and context as
62+// Snapshot, follows the same availability rules, and never builds cells.
63+func TestTerminalBoundaryMatchesSnapshotWithoutConverting(t *testing.T) {
64+ terminal := newTestTerminal(t, 4, 1)
65+ if _, err := terminal.Boundary(); err == nil {
66+ t.Fatal("boundary before the first publication must be unavailable")
67+ }
68+ terminal.BeginOutput(ports.UIContext{AttachmentHandle: "attachment", Generation: 3, Status: ports.UIStatusAttached})
69+ if _, err := terminal.Write([]byte("A")); err != nil {
70+ t.Fatal(err)
71+ }
72+ terminal.EndOutput(true)
73+
74+ boundary, err := terminal.Boundary()
75+ if err != nil {
76+ t.Fatal(err)
77+ }
78+ terminal.mu.Lock()
79+ converted := terminal.latest.Revision
80+ terminal.mu.Unlock()
81+ if converted != 0 {
82+ t.Fatalf("Boundary converted revision %d", converted)
83+ }
84+ snapshot, err := terminal.Snapshot()
85+ if err != nil {
86+ t.Fatal(err)
87+ }
88+ if boundary.Revision != snapshot.Revision || boundary.Context != snapshot.Context {
89+ t.Fatalf("boundary %+v, snapshot revision %d context %+v", boundary, snapshot.Revision, snapshot.Context)
90+ }
91+
92+ terminal.InvalidateTerminalObservation()
93+ if _, err := terminal.Boundary(); err == nil {
94+ t.Fatal("boundary of an invalidated observation must be unavailable")
95+ }
96+}
97+
98+// BenchmarkTerminalMirrorFlush measures one mirrored flush that nobody reads,
99+// the steady state of an interactive client with UI control enabled.
100+func BenchmarkTerminalMirrorFlush(b *testing.B) {
101+ terminal, err := NewMirror(context.Background(), domain.Geometry{Size: domain.Size{Cols: 200, Rows: 50}}, "attachment")
102+ if err != nil {
103+ b.Fatal(err)
104+ }
105+ defer terminal.Close()
106+ line := append(bytes.Repeat([]byte("x"), 199), '\n')
107+ b.ReportAllocs()
108+ for b.Loop() {
109+ terminal.ObserveTerminalWrite(line)
110+ terminal.ObserveTerminalFlush()
111+ }
112+}
M internal/ports/mocks/ui_mock.go +53 -0
@@ -625,6 +625,59 @@ func (_m *MockUIState) EXPECT() *MockUIState_Expecter {
625625 return &MockUIState_Expecter{mock: &_m.Mock}
626626 }
627627
628+// Boundary provides a mock function for the type MockUIState
629+func (_mock *MockUIState) Boundary() (ports.UIActionResult, error) {
630+ ret := _mock.Called()
631+
632+ if len(ret) == 0 {
633+ panic("no return value specified for Boundary")
634+ }
635+
636+ var r0 ports.UIActionResult
637+ var r1 error
638+ if returnFunc, ok := ret.Get(0).(func() (ports.UIActionResult, error)); ok {
639+ return returnFunc()
640+ }
641+ if returnFunc, ok := ret.Get(0).(func() ports.UIActionResult); ok {
642+ r0 = returnFunc()
643+ } else {
644+ r0 = ret.Get(0).(ports.UIActionResult)
645+ }
646+ if returnFunc, ok := ret.Get(1).(func() error); ok {
647+ r1 = returnFunc()
648+ } else {
649+ r1 = ret.Error(1)
650+ }
651+ return r0, r1
652+}
653+
654+// MockUIState_Boundary_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Boundary'
655+type MockUIState_Boundary_Call struct {
656+ *mock.Call
657+}
658+
659+// Boundary is a helper method to define mock.On call
660+func (_e *MockUIState_Expecter) Boundary() *MockUIState_Boundary_Call {
661+ return &MockUIState_Boundary_Call{Call: _e.mock.On("Boundary")}
662+}
663+
664+func (_c *MockUIState_Boundary_Call) Run(run func()) *MockUIState_Boundary_Call {
665+ _c.Call.Run(func(args mock.Arguments) {
666+ run()
667+ })
668+ return _c
669+}
670+
671+func (_c *MockUIState_Boundary_Call) Return(uIActionResult ports.UIActionResult, err error) *MockUIState_Boundary_Call {
672+ _c.Call.Return(uIActionResult, err)
673+ return _c
674+}
675+
676+func (_c *MockUIState_Boundary_Call) RunAndReturn(run func() (ports.UIActionResult, error)) *MockUIState_Boundary_Call {
677+ _c.Call.Return(run)
678+ return _c
679+}
680+
628681 // Changes provides a mock function for the type MockUIState
629682 func (_mock *MockUIState) Changes() <-chan struct{} {
630683 ret := _mock.Called()
@@ -625,6 +625,59 @@ func (_m *MockUIState) EXPECT() *MockUIState_Expecter {
625 return &MockUIState_Expecter{mock: &_m.Mock}625 return &MockUIState_Expecter{mock: &_m.Mock}
626 }626 }
627 627
628+// Boundary provides a mock function for the type MockUIState
629+func (_mock *MockUIState) Boundary() (ports.UIActionResult, error) {
630+ ret := _mock.Called()
631+
632+ if len(ret) == 0 {
633+ panic("no return value specified for Boundary")
634+ }
635+
636+ var r0 ports.UIActionResult
637+ var r1 error
638+ if returnFunc, ok := ret.Get(0).(func() (ports.UIActionResult, error)); ok {
639+ return returnFunc()
640+ }
641+ if returnFunc, ok := ret.Get(0).(func() ports.UIActionResult); ok {
642+ r0 = returnFunc()
643+ } else {
644+ r0 = ret.Get(0).(ports.UIActionResult)
645+ }
646+ if returnFunc, ok := ret.Get(1).(func() error); ok {
647+ r1 = returnFunc()
648+ } else {
649+ r1 = ret.Error(1)
650+ }
651+ return r0, r1
652+}
653+
654+// MockUIState_Boundary_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Boundary'
655+type MockUIState_Boundary_Call struct {
656+ *mock.Call
657+}
658+
659+// Boundary is a helper method to define mock.On call
660+func (_e *MockUIState_Expecter) Boundary() *MockUIState_Boundary_Call {
661+ return &MockUIState_Boundary_Call{Call: _e.mock.On("Boundary")}
662+}
663+
664+func (_c *MockUIState_Boundary_Call) Run(run func()) *MockUIState_Boundary_Call {
665+ _c.Call.Run(func(args mock.Arguments) {
666+ run()
667+ })
668+ return _c
669+}
670+
671+func (_c *MockUIState_Boundary_Call) Return(uIActionResult ports.UIActionResult, err error) *MockUIState_Boundary_Call {
672+ _c.Call.Return(uIActionResult, err)
673+ return _c
674+}
675+
676+func (_c *MockUIState_Boundary_Call) RunAndReturn(run func() (ports.UIActionResult, error)) *MockUIState_Boundary_Call {
677+ _c.Call.Return(run)
678+ return _c
679+}
680+
628 // Changes provides a mock function for the type MockUIState681 // Changes provides a mock function for the type MockUIState
629 func (_mock *MockUIState) Changes() <-chan struct{} {682 func (_mock *MockUIState) Changes() <-chan struct{} {
630 ret := _mock.Called()683 ret := _mock.Called()
M internal/ports/ui.go +4 -0
@@ -229,7 +229,11 @@ type UIOutputTransaction interface {
229229
230230 // UIState exposes the latest immutable publication and a coalesced change
231231 // signal. Snapshot returns ErrUIUnavailable rather than stale state.
232+// Boundary reports the same publication's revision and context without its
233+// cells, under the same availability rules, for per-frame bookkeeping that
234+// must not pay for a full capture.
232235 type UIState interface {
233236 Snapshot() (UISnapshot, error)
237+ Boundary() (UIActionResult, error)
234238 Changes() <-chan struct{}
235239 }
@@ -229,7 +229,11 @@ type UIOutputTransaction interface {
229 229
230 // UIState exposes the latest immutable publication and a coalesced change230 // UIState exposes the latest immutable publication and a coalesced change
231 // signal. Snapshot returns ErrUIUnavailable rather than stale state.231 // signal. Snapshot returns ErrUIUnavailable rather than stale state.
232+// Boundary reports the same publication's revision and context without its
233+// cells, under the same availability rules, for per-frame bookkeeping that
234+// must not pay for a full capture.
232 type UIState interface {235 type UIState interface {
233 Snapshot() (UISnapshot, error)236 Snapshot() (UISnapshot, error)
237+ Boundary() (UIActionResult, error)
234 Changes() <-chan struct{}238 Changes() <-chan struct{}
235 }239 }
M internal/usecase/broker/registry.go +50 -7
@@ -951,7 +951,8 @@ func (r *Registry) dispatch(ctx context.Context, now time.Time, results chan<- p
951951 if _, inflight := r.inflight[endpoint]; inflight {
952952 continue
953953 }
954- if !r.pending[endpoint] && (r.demand == 0 || !host.NextDue.IsZero() && now.Before(host.NextDue)) {
954+ explicit := r.pending[endpoint]
955+ if !explicit && (r.demand == 0 || !host.NextDue.IsZero() && now.Before(host.NextDue)) {
955956 continue
956957 }
957958 delete(r.pending, endpoint)
@@ -959,8 +960,10 @@ func (r *Registry) dispatch(ctx context.Context, now time.Time, results chan<- p
959960 attempt := r.attempts
960961 probeCtx, cancel := context.WithCancel(ctx)
961962 r.inflight[endpoint] = &probeAttempt{token: attempt, cancel: cancel}
962- started = true
963- host.Checking = true
963+ if visibleCheck(host, explicit) {
964+ started = true
965+ host.Checking = true
966+ }
964967 host.LastAttempt = now
965968 r.hosts[endpoint] = host
966969 registration := host.Registration
@@ -982,9 +985,10 @@ func (r *Registry) dispatch(ctx context.Context, now time.Time, results chan<- p
982985 }
983986 }(endpoint, registration, attempt, probeCtx)
984987 }
985- // Only an observation that actually started changes the projection; an
986- // idle dispatch must not bump the revision or wake subscribers. The
987- // in-flight projection is never persisted: Checking is transient.
988+ // Only an observation that visibly started changes the projection; an
989+ // idle dispatch or a routine refresh must not bump the revision or wake
990+ // subscribers. The in-flight projection is never persisted: Checking is
991+ // transient.
988992 if r.dispatchLocalLocked(ctx, now, results) {
989993 started = true
990994 }
@@ -994,6 +998,15 @@ func (r *Registry) dispatch(ctx context.Context, now time.Time, results chan<- p
994998 r.mu.Unlock()
995999 }
9961000
1001+// visibleCheck reports whether starting an observation publishes the transient
1002+// Checking state. A routine scheduled refresh of a reachable daemon stays
1003+// silent until its result lands, so an idle watched broker publishes once per
1004+// observation instead of twice; an explicit request or a daemon that is not
1005+// confirmed reachable still shows that it is being checked.
1006+func visibleCheck(host ports.BrokerDaemonObservation, explicit bool) bool {
1007+ return explicit || host.Availability != domain.RemoteAvailabilityReachable
1008+}
1009+
9971010 // observeBounded runs one observation under its attempt deadline. retire is the
9981011 // attempt's retirement context: a replaced, removed, or settled attempt returns
9991012 // at once and its completion is fenced by the caller. The probe itself runs on
@@ -1420,6 +1433,10 @@ type snapshotWriter struct {
14201433 mu sync.Mutex
14211434 pending *ports.BrokerSnapshot
14221435 written ports.BrokerRevision
1436+ // last is the durable content of the newest successful write with its
1437+ // revision and observation timestamps cleared. Only the writer goroutine
1438+ // touches it.
1439+ last *ports.BrokerSnapshot
14231440 started bool
14241441 closed bool
14251442 wake chan struct{}
@@ -1496,10 +1513,20 @@ func (w *snapshotWriter) flush() {
14961513 if snapshot.Revision <= written {
14971514 continue
14981515 }
1499- if err := w.store.Store(durable(*snapshot)); err != nil {
1516+ next := durable(*snapshot)
1517+ key := durableContent(next)
1518+ if w.last != nil && reflect.DeepEqual(*w.last, key) {
1519+ // Only observation timestamps moved: the stored copy already
1520+ // carries the same durable state, so skip the rewrite and fsync.
1521+ // A restart re-observes every host before trusting freshness.
1522+ continue
1523+ }
1524+ if err := w.store.Store(next); err != nil {
1525+ w.last = nil
15001526 w.log.Error("broker: durable snapshot write failed", "revision", snapshot.Revision, "err", err)
15011527 continue
15021528 }
1529+ w.last = &key
15031530 w.mu.Lock()
15041531 if snapshot.Revision > w.written {
15051532 w.written = snapshot.Revision
@@ -1508,6 +1535,22 @@ func (w *snapshotWriter) flush() {
15081535 }
15091536 }
15101537
1538+// durableContent returns the durable projection without the fields that change
1539+// on every routine observation: the revision and the attempt, success, and
1540+// due timestamps.
1541+func durableContent(snapshot ports.BrokerSnapshot) ports.BrokerSnapshot {
1542+ out := snapshot
1543+ out.Revision = 0
1544+ out.Daemons = make([]ports.BrokerDaemonObservation, len(snapshot.Daemons))
1545+ for i, daemon := range snapshot.Daemons {
1546+ daemon.LastAttempt = time.Time{}
1547+ daemon.LastSuccess = time.Time{}
1548+ daemon.NextDue = time.Time{}
1549+ out.Daemons[i] = daemon
1550+ }
1551+ return out
1552+}
1553+
15111554 // close flushes the newest staged snapshot, stops the writer, and waits for
15121555 // the drain to finish. It never returns before the in-flight write completes.
15131556 func (w *snapshotWriter) close() {
@@ -951,7 +951,8 @@ func (r *Registry) dispatch(ctx context.Context, now time.Time, results chan<- p
951 if _, inflight := r.inflight[endpoint]; inflight {951 if _, inflight := r.inflight[endpoint]; inflight {
952 continue952 continue
953 }953 }
954- if !r.pending[endpoint] && (r.demand == 0 || !host.NextDue.IsZero() && now.Before(host.NextDue)) {954+ explicit := r.pending[endpoint]
955+ if !explicit && (r.demand == 0 || !host.NextDue.IsZero() && now.Before(host.NextDue)) {
955 continue956 continue
956 }957 }
957 delete(r.pending, endpoint)958 delete(r.pending, endpoint)
@@ -959,8 +960,10 @@ func (r *Registry) dispatch(ctx context.Context, now time.Time, results chan<- p
959 attempt := r.attempts960 attempt := r.attempts
960 probeCtx, cancel := context.WithCancel(ctx)961 probeCtx, cancel := context.WithCancel(ctx)
961 r.inflight[endpoint] = &probeAttempt{token: attempt, cancel: cancel}962 r.inflight[endpoint] = &probeAttempt{token: attempt, cancel: cancel}
962- started = true963+ if visibleCheck(host, explicit) {
963- host.Checking = true964+ started = true
965+ host.Checking = true
966+ }
964 host.LastAttempt = now967 host.LastAttempt = now
965 r.hosts[endpoint] = host968 r.hosts[endpoint] = host
966 registration := host.Registration969 registration := host.Registration
@@ -982,9 +985,10 @@ func (r *Registry) dispatch(ctx context.Context, now time.Time, results chan<- p
982 }985 }
983 }(endpoint, registration, attempt, probeCtx)986 }(endpoint, registration, attempt, probeCtx)
984 }987 }
985- // Only an observation that actually started changes the projection; an988+ // Only an observation that visibly started changes the projection; an
986- // idle dispatch must not bump the revision or wake subscribers. The989+ // idle dispatch or a routine refresh must not bump the revision or wake
987- // in-flight projection is never persisted: Checking is transient.990+ // subscribers. The in-flight projection is never persisted: Checking is
991+ // transient.
988 if r.dispatchLocalLocked(ctx, now, results) {992 if r.dispatchLocalLocked(ctx, now, results) {
989 started = true993 started = true
990 }994 }
@@ -994,6 +998,15 @@ func (r *Registry) dispatch(ctx context.Context, now time.Time, results chan<- p
994 r.mu.Unlock()998 r.mu.Unlock()
995 }999 }
996 1000
1001+// visibleCheck reports whether starting an observation publishes the transient
1002+// Checking state. A routine scheduled refresh of a reachable daemon stays
1003+// silent until its result lands, so an idle watched broker publishes once per
1004+// observation instead of twice; an explicit request or a daemon that is not
1005+// confirmed reachable still shows that it is being checked.
1006+func visibleCheck(host ports.BrokerDaemonObservation, explicit bool) bool {
1007+ return explicit || host.Availability != domain.RemoteAvailabilityReachable
1008+}
1009+
997 // observeBounded runs one observation under its attempt deadline. retire is the1010 // observeBounded runs one observation under its attempt deadline. retire is the
998 // attempt's retirement context: a replaced, removed, or settled attempt returns1011 // attempt's retirement context: a replaced, removed, or settled attempt returns
999 // at once and its completion is fenced by the caller. The probe itself runs on1012 // at once and its completion is fenced by the caller. The probe itself runs on
@@ -1420,6 +1433,10 @@ type snapshotWriter struct {
1420 mu sync.Mutex1433 mu sync.Mutex
1421 pending *ports.BrokerSnapshot1434 pending *ports.BrokerSnapshot
1422 written ports.BrokerRevision1435 written ports.BrokerRevision
1436+ // last is the durable content of the newest successful write with its
1437+ // revision and observation timestamps cleared. Only the writer goroutine
1438+ // touches it.
1439+ last *ports.BrokerSnapshot
1423 started bool1440 started bool
1424 closed bool1441 closed bool
1425 wake chan struct{}1442 wake chan struct{}
@@ -1496,10 +1513,20 @@ func (w *snapshotWriter) flush() {
1496 if snapshot.Revision <= written {1513 if snapshot.Revision <= written {
1497 continue1514 continue
1498 }1515 }
1499- if err := w.store.Store(durable(*snapshot)); err != nil {1516+ next := durable(*snapshot)
1517+ key := durableContent(next)
1518+ if w.last != nil && reflect.DeepEqual(*w.last, key) {
1519+ // Only observation timestamps moved: the stored copy already
1520+ // carries the same durable state, so skip the rewrite and fsync.
1521+ // A restart re-observes every host before trusting freshness.
1522+ continue
1523+ }
1524+ if err := w.store.Store(next); err != nil {
1525+ w.last = nil
1500 w.log.Error("broker: durable snapshot write failed", "revision", snapshot.Revision, "err", err)1526 w.log.Error("broker: durable snapshot write failed", "revision", snapshot.Revision, "err", err)
1501 continue1527 continue
1502 }1528 }
1529+ w.last = &key
1503 w.mu.Lock()1530 w.mu.Lock()
1504 if snapshot.Revision > w.written {1531 if snapshot.Revision > w.written {
1505 w.written = snapshot.Revision1532 w.written = snapshot.Revision
@@ -1508,6 +1535,22 @@ func (w *snapshotWriter) flush() {
1508 }1535 }
1509 }1536 }
1510 1537
1538+// durableContent returns the durable projection without the fields that change
1539+// on every routine observation: the revision and the attempt, success, and
1540+// due timestamps.
1541+func durableContent(snapshot ports.BrokerSnapshot) ports.BrokerSnapshot {
1542+ out := snapshot
1543+ out.Revision = 0
1544+ out.Daemons = make([]ports.BrokerDaemonObservation, len(snapshot.Daemons))
1545+ for i, daemon := range snapshot.Daemons {
1546+ daemon.LastAttempt = time.Time{}
1547+ daemon.LastSuccess = time.Time{}
1548+ daemon.NextDue = time.Time{}
1549+ out.Daemons[i] = daemon
1550+ }
1551+ return out
1552+}
1553+
1511 // close flushes the newest staged snapshot, stops the writer, and waits for1554 // close flushes the newest staged snapshot, stops the writer, and waits for
1512 // the drain to finish. It never returns before the in-flight write completes.1555 // the drain to finish. It never returns before the in-flight write completes.
1513 func (w *snapshotWriter) close() {1556 func (w *snapshotWriter) close() {
A internal/usecase/broker/registry_idle_test.go +103 -0
@@ -0,0 +1,103 @@
1+package broker
2+
3+import (
4+ "testing"
5+ "time"
6+
7+ "github.com/stretchr/testify/require"
8+
9+ "github.com/bnema/vev/internal/domain"
10+ "github.com/bnema/vev/internal/ports"
11+ "github.com/bnema/vev/internal/protocol/catalogue"
12+)
13+
14+// TestRegistryIdleRefreshPublishesOnceAndSkipsUnchangedPersistence pins the
15+// idle cost of a watched broker: a routine refresh of a reachable host
16+// publishes once (its result, no transient Checking publication), and a result
17+// whose durable content is unchanged does not rewrite the store.
18+func TestRegistryIdleRefreshPublishesOnceAndSkipsUnchangedPersistence(t *testing.T) {
19+ start := time.Unix(100, 0)
20+ clock := newManualClock(start)
21+ probe := newTestProbe(1)
22+ store := newTestStore()
23+ registry := newTestRegistry(t, 1, store, probe, clock)
24+ registry.demandFreshForRemote = time.Second
25+ reg := registration(t, "example.test", 1)
26+ require.NoError(t, registry.setHosts(hostRecords(reg)))
27+ startRegistry(t, registry)
28+
29+ answer := func(sessions []catalogue.RemoteCatalogSession) ports.BrokerDaemonObservation {
30+ t.Helper()
31+ var call probeCall
32+ require.Eventually(t, func() bool {
33+ select {
34+ case call = <-probe.calls:
35+ return true
36+ default:
37+ clock.Advance(registry.demandFreshForRemote)
38+ return false
39+ }
40+ }, registryTestWait, time.Millisecond)
41+ call.result <- probeAnswer{snapshot: ports.BrokerDaemonObservation{
42+ Availability: domain.RemoteAvailabilityReachable,
43+ InventoryKnown: true,
44+ Sessions: sessions,
45+ }}
46+ now := clock.Now()
47+ return waitHost(t, registry, reg.Endpoint, func(host ports.BrokerDaemonObservation) bool {
48+ return !host.Checking && host.LastSuccess.Equal(now)
49+ })
50+ }
51+
52+ first := []catalogue.RemoteCatalogSession{hostSession("work")}
53+ answer(first)
54+ stored := waitStored(t, store, func(snapshot ports.BrokerSnapshot) bool {
55+ host, ok := snapshot.Find(reg.Endpoint)
56+ return ok && len(host.Sessions) == 1
57+ })
58+ writes := store.writeCount()
59+ revision := registry.Snapshot().Revision
60+
61+ for range 2 {
62+ answer(first)
63+ require.Equal(t, revision+1, registry.Snapshot().Revision, "one publication per routine refresh")
64+ revision++
65+ }
66+
67+ // A durable change is still written, and it is the only write since the
68+ // first one: the idle refreshes never reached the store.
69+ changed := []catalogue.RemoteCatalogSession{hostSession("work"), hostSession("play")}
70+ answer(changed)
71+ stored = waitStored(t, store, func(snapshot ports.BrokerSnapshot) bool {
72+ host, ok := snapshot.Find(reg.Endpoint)
73+ return ok && len(host.Sessions) == 2
74+ })
75+ require.Equal(t, writes+1, store.writeCount())
76+ require.Equal(t, registry.Snapshot().Revision, stored.Revision)
77+}
78+
79+// TestRegistryExplicitProbeStillPublishesChecking keeps the visible refresh
80+// state for requested observations and for hosts not confirmed reachable.
81+func TestRegistryExplicitProbeStillPublishesChecking(t *testing.T) {
82+ clock := newManualClock(time.Unix(100, 0))
83+ probe := newTestProbe(1)
84+ registry := newTestRegistry(t, 1, newTestStore(), probe, clock)
85+ reg := registration(t, "example.test", 1)
86+ require.NoError(t, registry.setHosts(hostRecords(reg)))
87+ startRegistry(t, registry)
88+
89+ // Unknown availability: the first scheduled observation is visible.
90+ call := receiveCall(t, probe)
91+ waitHost(t, registry, reg.Endpoint, func(host ports.BrokerDaemonObservation) bool { return host.Checking })
92+ call.result <- probeAnswer{snapshot: ports.BrokerDaemonObservation{Availability: domain.RemoteAvailabilityReachable}}
93+ waitHost(t, registry, reg.Endpoint, func(host ports.BrokerDaemonObservation) bool {
94+ return !host.Checking && host.Availability == domain.RemoteAvailabilityReachable
95+ })
96+
97+ // Reachable host, explicit request: still visible.
98+ registry.RequestProbe(reg.Endpoint)
99+ call = receiveCall(t, probe)
100+ waitHost(t, registry, reg.Endpoint, func(host ports.BrokerDaemonObservation) bool { return host.Checking })
101+ call.result <- probeAnswer{snapshot: ports.BrokerDaemonObservation{Availability: domain.RemoteAvailabilityReachable}}
102+ waitHost(t, registry, reg.Endpoint, func(host ports.BrokerDaemonObservation) bool { return !host.Checking })
103+}
@@ -0,0 +1,103 @@
1+package broker
2+
3+import (
4+ "testing"
5+ "time"
6+
7+ "github.com/stretchr/testify/require"
8+
9+ "github.com/bnema/vev/internal/domain"
10+ "github.com/bnema/vev/internal/ports"
11+ "github.com/bnema/vev/internal/protocol/catalogue"
12+)
13+
14+// TestRegistryIdleRefreshPublishesOnceAndSkipsUnchangedPersistence pins the
15+// idle cost of a watched broker: a routine refresh of a reachable host
16+// publishes once (its result, no transient Checking publication), and a result
17+// whose durable content is unchanged does not rewrite the store.
18+func TestRegistryIdleRefreshPublishesOnceAndSkipsUnchangedPersistence(t *testing.T) {
19+ start := time.Unix(100, 0)
20+ clock := newManualClock(start)
21+ probe := newTestProbe(1)
22+ store := newTestStore()
23+ registry := newTestRegistry(t, 1, store, probe, clock)
24+ registry.demandFreshForRemote = time.Second
25+ reg := registration(t, "example.test", 1)
26+ require.NoError(t, registry.setHosts(hostRecords(reg)))
27+ startRegistry(t, registry)
28+
29+ answer := func(sessions []catalogue.RemoteCatalogSession) ports.BrokerDaemonObservation {
30+ t.Helper()
31+ var call probeCall
32+ require.Eventually(t, func() bool {
33+ select {
34+ case call = <-probe.calls:
35+ return true
36+ default:
37+ clock.Advance(registry.demandFreshForRemote)
38+ return false
39+ }
40+ }, registryTestWait, time.Millisecond)
41+ call.result <- probeAnswer{snapshot: ports.BrokerDaemonObservation{
42+ Availability: domain.RemoteAvailabilityReachable,
43+ InventoryKnown: true,
44+ Sessions: sessions,
45+ }}
46+ now := clock.Now()
47+ return waitHost(t, registry, reg.Endpoint, func(host ports.BrokerDaemonObservation) bool {
48+ return !host.Checking && host.LastSuccess.Equal(now)
49+ })
50+ }
51+
52+ first := []catalogue.RemoteCatalogSession{hostSession("work")}
53+ answer(first)
54+ stored := waitStored(t, store, func(snapshot ports.BrokerSnapshot) bool {
55+ host, ok := snapshot.Find(reg.Endpoint)
56+ return ok && len(host.Sessions) == 1
57+ })
58+ writes := store.writeCount()
59+ revision := registry.Snapshot().Revision
60+
61+ for range 2 {
62+ answer(first)
63+ require.Equal(t, revision+1, registry.Snapshot().Revision, "one publication per routine refresh")
64+ revision++
65+ }
66+
67+ // A durable change is still written, and it is the only write since the
68+ // first one: the idle refreshes never reached the store.
69+ changed := []catalogue.RemoteCatalogSession{hostSession("work"), hostSession("play")}
70+ answer(changed)
71+ stored = waitStored(t, store, func(snapshot ports.BrokerSnapshot) bool {
72+ host, ok := snapshot.Find(reg.Endpoint)
73+ return ok && len(host.Sessions) == 2
74+ })
75+ require.Equal(t, writes+1, store.writeCount())
76+ require.Equal(t, registry.Snapshot().Revision, stored.Revision)
77+}
78+
79+// TestRegistryExplicitProbeStillPublishesChecking keeps the visible refresh
80+// state for requested observations and for hosts not confirmed reachable.
81+func TestRegistryExplicitProbeStillPublishesChecking(t *testing.T) {
82+ clock := newManualClock(time.Unix(100, 0))
83+ probe := newTestProbe(1)
84+ registry := newTestRegistry(t, 1, newTestStore(), probe, clock)
85+ reg := registration(t, "example.test", 1)
86+ require.NoError(t, registry.setHosts(hostRecords(reg)))
87+ startRegistry(t, registry)
88+
89+ // Unknown availability: the first scheduled observation is visible.
90+ call := receiveCall(t, probe)
91+ waitHost(t, registry, reg.Endpoint, func(host ports.BrokerDaemonObservation) bool { return host.Checking })
92+ call.result <- probeAnswer{snapshot: ports.BrokerDaemonObservation{Availability: domain.RemoteAvailabilityReachable}}
93+ waitHost(t, registry, reg.Endpoint, func(host ports.BrokerDaemonObservation) bool {
94+ return !host.Checking && host.Availability == domain.RemoteAvailabilityReachable
95+ })
96+
97+ // Reachable host, explicit request: still visible.
98+ registry.RequestProbe(reg.Endpoint)
99+ call = receiveCall(t, probe)
100+ waitHost(t, registry, reg.Endpoint, func(host ports.BrokerDaemonObservation) bool { return host.Checking })
101+ call.result <- probeAnswer{snapshot: ports.BrokerDaemonObservation{Availability: domain.RemoteAvailabilityReachable}}
102+ waitHost(t, registry, reg.Endpoint, func(host ports.BrokerDaemonObservation) bool { return !host.Checking })
103+}
M internal/usecase/broker/registry_local.go +7 -5
@@ -81,13 +81,14 @@ func (l *LocalObservation) validate() error {
8181 // dispatchLocalLocked admits at most one local probe attempt when its schedule
8282 // says it is due, or immediately when localPending marks explicit demand for a
8383 // re-probe ahead of schedule. Callers must hold r.mu. It reports whether an
84-// attempt started, so dispatch publishes the transient Checking state exactly
85-// once.
84+// attempt started with a visible Checking state, so dispatch publishes it
85+// exactly once; a routine refresh of a reachable daemon stays silent.
8686 func (r *Registry) dispatchLocalLocked(ctx context.Context, now time.Time, results chan<- probeResult) bool {
8787 if r.local == nil || r.localAttempt != nil {
8888 return false
8989 }
90- if !r.localPending && (r.demand == 0 || !r.localHost.NextDue.IsZero() && now.Before(r.localHost.NextDue)) {
90+ explicit := r.localPending
91+ if !explicit && (r.demand == 0 || !r.localHost.NextDue.IsZero() && now.Before(r.localHost.NextDue)) {
9192 return false
9293 }
9394 r.localPending = false
@@ -95,7 +96,8 @@ func (r *Registry) dispatchLocalLocked(ctx context.Context, now time.Time, resul
9596 attempt := r.attempts
9697 probeCtx, cancel := context.WithCancel(ctx)
9798 r.localAttempt = &probeAttempt{token: attempt, cancel: cancel}
98- r.localHost.Checking = true
99+ visible := visibleCheck(r.localHost, explicit)
100+ r.localHost.Checking = visible
99101 r.localHost.LastAttempt = now
100102 deadline := r.clock.NewTimer(r.probeTimeout)
101103 go func(attempt uint64, probeCtx context.Context) {
@@ -109,7 +111,7 @@ func (r *Registry) dispatchLocalLocked(ctx context.Context, now time.Time, resul
109111 // queueing a result the run loop would only discard.
110112 }
111113 }(attempt, probeCtx)
112- return true
114+ return visible
113115 }
114116
115117 // applyLocal adopts one completed local probe. Callers must hold r.mu, exactly
@@ -81,13 +81,14 @@ func (l *LocalObservation) validate() error {
81 // dispatchLocalLocked admits at most one local probe attempt when its schedule81 // dispatchLocalLocked admits at most one local probe attempt when its schedule
82 // says it is due, or immediately when localPending marks explicit demand for a82 // says it is due, or immediately when localPending marks explicit demand for a
83 // re-probe ahead of schedule. Callers must hold r.mu. It reports whether an83 // re-probe ahead of schedule. Callers must hold r.mu. It reports whether an
84-// attempt started, so dispatch publishes the transient Checking state exactly84+// attempt started with a visible Checking state, so dispatch publishes it
85-// once.85+// exactly once; a routine refresh of a reachable daemon stays silent.
86 func (r *Registry) dispatchLocalLocked(ctx context.Context, now time.Time, results chan<- probeResult) bool {86 func (r *Registry) dispatchLocalLocked(ctx context.Context, now time.Time, results chan<- probeResult) bool {
87 if r.local == nil || r.localAttempt != nil {87 if r.local == nil || r.localAttempt != nil {
88 return false88 return false
89 }89 }
90- if !r.localPending && (r.demand == 0 || !r.localHost.NextDue.IsZero() && now.Before(r.localHost.NextDue)) {90+ explicit := r.localPending
91+ if !explicit && (r.demand == 0 || !r.localHost.NextDue.IsZero() && now.Before(r.localHost.NextDue)) {
91 return false92 return false
92 }93 }
93 r.localPending = false94 r.localPending = false
@@ -95,7 +96,8 @@ func (r *Registry) dispatchLocalLocked(ctx context.Context, now time.Time, resul
95 attempt := r.attempts96 attempt := r.attempts
96 probeCtx, cancel := context.WithCancel(ctx)97 probeCtx, cancel := context.WithCancel(ctx)
97 r.localAttempt = &probeAttempt{token: attempt, cancel: cancel}98 r.localAttempt = &probeAttempt{token: attempt, cancel: cancel}
98- r.localHost.Checking = true99+ visible := visibleCheck(r.localHost, explicit)
100+ r.localHost.Checking = visible
99 r.localHost.LastAttempt = now101 r.localHost.LastAttempt = now
100 deadline := r.clock.NewTimer(r.probeTimeout)102 deadline := r.clock.NewTimer(r.probeTimeout)
101 go func(attempt uint64, probeCtx context.Context) {103 go func(attempt uint64, probeCtx context.Context) {
@@ -109,7 +111,7 @@ func (r *Registry) dispatchLocalLocked(ctx context.Context, now time.Time, resul
109 // queueing a result the run loop would only discard.111 // queueing a result the run loop would only discard.
110 }112 }
111 }(attempt, probeCtx)113 }(attempt, probeCtx)
112- return true114+ return visible
113 }115 }
114 116
115 // applyLocal adopts one completed local probe. Callers must hold r.mu, exactly117 // applyLocal adopts one completed local probe. Callers must hold r.mu, exactly
M internal/usecase/broker/registry_test.go +5 -2
@@ -2345,10 +2345,13 @@ func TestRegistryRunIsSingleShotAndCleansUpInFlightState(t *testing.T) {
23452345 require.Zero(t, settled.Daemons[0].ConsecutiveFailures)
23462346
23472347 // Persistence is flushed before shutdown and no write outlives it.
2348+ // The settled projection only cleared transient Checking state, so the
2349+ // stored copy already holds its durable content and is not rewritten.
23482350 stored := waitStored(t, store, func(snapshot ports.BrokerSnapshot) bool {
2349- return snapshot.Revision >= settled.Revision
2351+ return snapshot.Revision > 0
23502352 })
2351- require.Equal(t, settled.Revision, stored.Revision)
2353+ require.LessOrEqual(t, stored.Revision, settled.Revision)
2354+ require.Equal(t, durableContent(durable(settled)), durableContent(stored))
23522355 require.False(t, stored.Daemons[0].Checking)
23532356 writes := store.writeCount()
23542357 require.NoError(t, registry.setHosts(nil))
@@ -2345,10 +2345,13 @@ func TestRegistryRunIsSingleShotAndCleansUpInFlightState(t *testing.T) {
2345 require.Zero(t, settled.Daemons[0].ConsecutiveFailures)2345 require.Zero(t, settled.Daemons[0].ConsecutiveFailures)
2346 2346
2347 // Persistence is flushed before shutdown and no write outlives it.2347 // Persistence is flushed before shutdown and no write outlives it.
2348+ // The settled projection only cleared transient Checking state, so the
2349+ // stored copy already holds its durable content and is not rewritten.
2348 stored := waitStored(t, store, func(snapshot ports.BrokerSnapshot) bool {2350 stored := waitStored(t, store, func(snapshot ports.BrokerSnapshot) bool {
2349- return snapshot.Revision >= settled.Revision2351+ return snapshot.Revision > 0
2350 })2352 })
2351- require.Equal(t, settled.Revision, stored.Revision)2353+ require.LessOrEqual(t, stored.Revision, settled.Revision)
2354+ require.Equal(t, durableContent(durable(settled)), durableContent(stored))
2352 require.False(t, stored.Daemons[0].Checking)2355 require.False(t, stored.Daemons[0].Checking)
2353 writes := store.writeCount()2356 writes := store.writeCount()
2354 require.NoError(t, registry.setHosts(nil))2357 require.NoError(t, registry.setHosts(nil))
M internal/usecase/client/supervisor_attach_test.go +5 -0
@@ -187,6 +187,11 @@ func (t *attachTestTerminal) Snapshot() (ports.UISnapshot, error) {
187187 return ports.UISnapshot{Revision: t.revision, Context: ctx}, nil
188188 }
189189
190+func (t *attachTestTerminal) Boundary() (ports.UIActionResult, error) {
191+ snapshot, err := t.Snapshot()
192+ return ports.UIActionResult{Revision: snapshot.Revision, Context: snapshot.Context}, err
193+}
194+
190195 func (t *attachTestTerminal) Changes() <-chan struct{} {
191196 t.mu.Lock()
192197 defer t.mu.Unlock()
@@ -187,6 +187,11 @@ func (t *attachTestTerminal) Snapshot() (ports.UISnapshot, error) {
187 return ports.UISnapshot{Revision: t.revision, Context: ctx}, nil187 return ports.UISnapshot{Revision: t.revision, Context: ctx}, nil
188 }188 }
189 189
190+func (t *attachTestTerminal) Boundary() (ports.UIActionResult, error) {
191+ snapshot, err := t.Snapshot()
192+ return ports.UIActionResult{Revision: snapshot.Revision, Context: snapshot.Context}, err
193+}
194+
190 func (t *attachTestTerminal) Changes() <-chan struct{} {195 func (t *attachTestTerminal) Changes() <-chan struct{} {
191 t.mu.Lock()196 t.mu.Lock()
192 defer t.mu.Unlock()197 defer t.mu.Unlock()
M internal/usecase/client/ui_actions.go +5 -4
@@ -148,18 +148,19 @@ func (u *UI) finishLocked(id uint64, status ports.UIActionStatus, boundary ports
148148 // published remembers only a committed output boundary, not receipt receive
149149 // time or an unrelated status-only revision.
150150 func (u *UI) published(generation uint64) {
151- snapshot, err := u.state.Snapshot()
151+ // Runs once per committed frame: read only the boundary, never the cells.
152+ boundary, err := u.state.Boundary()
152153 if err != nil {
153154 return
154155 }
155156 u.mu.Lock()
156157 defer u.mu.Unlock()
157- if generation != u.generation || snapshot.Context.Generation != generation {
158+ if generation != u.generation || boundary.Context.Generation != generation {
158159 return
159160 }
160- u.boundary = ports.UIActionResult{Revision: snapshot.Revision, Context: snapshot.Context}
161+ u.boundary = boundary
161162 u.suppressed = ports.UIContext{}
162- if u.handoff != nil && u.handoff.destinationGeneration == generation && snapshot.Context.Status == ports.UIStatusAttached {
163+ if u.handoff != nil && u.handoff.destinationGeneration == generation && boundary.Context.Status == ports.UIStatusAttached {
163164 u.handoff.boundary = u.boundary
164165 if u.dispatched[u.handoff.actionID] {
165166 u.finishLocked(u.handoff.actionID, ports.UIActionProcessed, u.boundary)
@@ -148,18 +148,19 @@ func (u *UI) finishLocked(id uint64, status ports.UIActionStatus, boundary ports
148 // published remembers only a committed output boundary, not receipt receive148 // published remembers only a committed output boundary, not receipt receive
149 // time or an unrelated status-only revision.149 // time or an unrelated status-only revision.
150 func (u *UI) published(generation uint64) {150 func (u *UI) published(generation uint64) {
151- snapshot, err := u.state.Snapshot()151+ // Runs once per committed frame: read only the boundary, never the cells.
152+ boundary, err := u.state.Boundary()
152 if err != nil {153 if err != nil {
153 return154 return
154 }155 }
155 u.mu.Lock()156 u.mu.Lock()
156 defer u.mu.Unlock()157 defer u.mu.Unlock()
157- if generation != u.generation || snapshot.Context.Generation != generation {158+ if generation != u.generation || boundary.Context.Generation != generation {
158 return159 return
159 }160 }
160- u.boundary = ports.UIActionResult{Revision: snapshot.Revision, Context: snapshot.Context}161+ u.boundary = boundary
161 u.suppressed = ports.UIContext{}162 u.suppressed = ports.UIContext{}
162- if u.handoff != nil && u.handoff.destinationGeneration == generation && snapshot.Context.Status == ports.UIStatusAttached {163+ if u.handoff != nil && u.handoff.destinationGeneration == generation && boundary.Context.Status == ports.UIStatusAttached {
163 u.handoff.boundary = u.boundary164 u.handoff.boundary = u.boundary
164 if u.dispatched[u.handoff.actionID] {165 if u.dispatched[u.handoff.actionID] {
165 u.finishLocked(u.handoff.actionID, ports.UIActionProcessed, u.boundary)166 u.finishLocked(u.handoff.actionID, ports.UIActionProcessed, u.boundary)
M internal/usecase/copy/mode.go +70 -35
@@ -209,25 +209,29 @@ func (s Snapshot) FindRowID(id vt.RowID) int {
209209 return -1
210210 }
211211
212-func (s Snapshot) rangeRows(yield func(int, []renderer.Cell) bool) error {
213- i := 0
212+// textRows streams every row as text: one rune per non-continuation cell
213+// (zero runes read as spaces) plus each rune's cell column and the row width.
214+// The slices are only valid during the callback.
215+type textRows func(yield func(row int, runes []rune, columns []int, width int) bool) error
216+
217+func (s Snapshot) rangeText(yield func(int, []rune, []int, int) bool) error {
214218 stopped := false
215- err := s.history.Range(func(r []renderer.Cell) bool {
216- if !yield(i, r) {
219+ err := s.history.RangeText(func(i int, runes []rune, columns []int, width int) bool {
220+ if !yield(i, runes, columns, width) {
217221 stopped = true
218222 return false
219223 }
220- i++
221224 return true
222225 })
223- if err != nil {
226+ if err != nil || stopped {
224227 return err
225228 }
226- if stopped {
227- return nil
228- }
229+ offset := s.history.Len()
230+ var runes []rune
231+ var columns []int
229232 for y := range s.screen.Height {
230- if !yield(i+y, s.screen.Row(y)) {
233+ runes, columns = s.screen.AppendRowText(runes[:0], columns[:0], y)
234+ if !yield(offset+y, runes, columns, s.screen.Width) {
231235 return nil
232236 }
233237 }
@@ -494,14 +498,60 @@ func FindMatches(doc *Document, query string) []SearchMatch {
494498 if doc == nil || query == "" {
495499 return nil
496500 }
497- return findMatches(doc, query, doc.Snapshot().rangeRows)
501+ return findMatches(query, doc.Snapshot().rangeText)
502+}
503+
504+// RefineMatches returns FindMatches(doc, query) given previous, the complete
505+// result for a query that query extends. Any row matching the longer query also
506+// matched the shorter one, so only rows listed in previous are scanned again.
507+func RefineMatches(doc *Document, previous []SearchMatch, query string) []SearchMatch {
508+ query = strings.TrimSpace(query)
509+ if doc == nil || query == "" {
510+ return nil
511+ }
512+ snapshot := doc.Snapshot()
513+ return findMatches(query, func(yield func(int, []rune, []int, int) bool) error {
514+ var cells []renderer.Cell
515+ var runes []rune
516+ var columns []int
517+ last := -1
518+ for _, match := range previous {
519+ if match.Row == last {
520+ continue
521+ }
522+ last = match.Row
523+ width := snapshot.RowWidth(match.Row)
524+ cells = slices.Grow(cells[:0], width)[:width]
525+ cells = cells[:snapshot.CopyRow(match.Row, cells)]
526+ runes, columns = runes[:0], columns[:0]
527+ for x, cell := range cells {
528+ if cell.Continuation {
529+ continue
530+ }
531+ r := cell.Rune
532+ if r == 0 {
533+ r = ' '
534+ }
535+ runes = append(runes, r)
536+ columns = append(columns, x)
537+ }
538+ if !yield(match.Row, runes, columns, len(cells)) {
539+ return nil
540+ }
541+ }
542+ return nil
543+ })
498544 }
499545
500-func findMatches(doc *Document, query string, rangeRows func(func(int, []renderer.Cell) bool) error) []SearchMatch {
546+func findMatches(query string, rows textRows) []SearchMatch {
501547 needle := lowerRunes(query)
502548 matches := []SearchMatch{}
503- err := rangeRows(func(row int, cells []renderer.Cell) bool {
504- hay, indexes := searchableCells(cells)
549+ var hay []rune
550+ err := rows(func(row int, runes []rune, columns []int, width int) bool {
551+ hay = hay[:0]
552+ for _, r := range runes {
553+ hay = append(hay, unicode.ToLower(r))
554+ }
505555 text := ""
506556 for start := 0; start+len(needle) <= len(hay); {
507557 if !slices.Equal(hay[start:start+len(needle)], needle) {
@@ -509,14 +559,15 @@ func findMatches(doc *Document, query string, rangeRows func(func(int, []rendere
509559 continue
510560 }
511561 if text == "" {
512- text = doc.LineText(row)
562+ // Same text as Document.LineText, built from the runes in hand.
563+ text = strings.TrimRight(string(runes), " ")
513564 }
514565 end := start + len(needle)
515- cellEnd := len(cells)
516- if end < len(indexes) {
517- cellEnd = indexes[end]
566+ cellEnd := width
567+ if end < len(columns) {
568+ cellEnd = columns[end]
518569 }
519- matches = append(matches, SearchMatch{Row: row, Start: indexes[start], End: cellEnd, Text: text})
570+ matches = append(matches, SearchMatch{Row: row, Start: columns[start], End: cellEnd, Text: text})
520571 start += len(needle)
521572 }
522573 return true
@@ -527,22 +578,6 @@ func findMatches(doc *Document, query string, rangeRows func(func(int, []rendere
527578 }
528579 return matches
529580 }
530-func searchableCells(cells []renderer.Cell) ([]rune, []int) {
531- rs := make([]rune, 0, len(cells))
532- is := make([]int, 0, len(cells))
533- for x, c := range cells {
534- if c.Continuation {
535- continue
536- }
537- r := c.Rune
538- if r == 0 {
539- r = ' '
540- }
541- rs = append(rs, unicode.ToLower(r))
542- is = append(is, x)
543- }
544- return rs, is
545-}
546581 func lowerRunes(s string) []rune {
547582 rs := []rune(s)
548583 for i, r := range rs {
@@ -209,25 +209,29 @@ func (s Snapshot) FindRowID(id vt.RowID) int {
209 return -1209 return -1
210 }210 }
211 211
212-func (s Snapshot) rangeRows(yield func(int, []renderer.Cell) bool) error {212+// textRows streams every row as text: one rune per non-continuation cell
213- i := 0213+// (zero runes read as spaces) plus each rune's cell column and the row width.
214+// The slices are only valid during the callback.
215+type textRows func(yield func(row int, runes []rune, columns []int, width int) bool) error
216+
217+func (s Snapshot) rangeText(yield func(int, []rune, []int, int) bool) error {
214 stopped := false218 stopped := false
215- err := s.history.Range(func(r []renderer.Cell) bool {219+ err := s.history.RangeText(func(i int, runes []rune, columns []int, width int) bool {
216- if !yield(i, r) {220+ if !yield(i, runes, columns, width) {
217 stopped = true221 stopped = true
218 return false222 return false
219 }223 }
220- i++
221 return true224 return true
222 })225 })
223- if err != nil {226+ if err != nil || stopped {
224 return err227 return err
225 }228 }
226- if stopped {229+ offset := s.history.Len()
227- return nil230+ var runes []rune
228- }231+ var columns []int
229 for y := range s.screen.Height {232 for y := range s.screen.Height {
230- if !yield(i+y, s.screen.Row(y)) {233+ runes, columns = s.screen.AppendRowText(runes[:0], columns[:0], y)
234+ if !yield(offset+y, runes, columns, s.screen.Width) {
231 return nil235 return nil
232 }236 }
233 }237 }
@@ -494,14 +498,60 @@ func FindMatches(doc *Document, query string) []SearchMatch {
494 if doc == nil || query == "" {498 if doc == nil || query == "" {
495 return nil499 return nil
496 }500 }
497- return findMatches(doc, query, doc.Snapshot().rangeRows)501+ return findMatches(query, doc.Snapshot().rangeText)
502+}
503+
504+// RefineMatches returns FindMatches(doc, query) given previous, the complete
505+// result for a query that query extends. Any row matching the longer query also
506+// matched the shorter one, so only rows listed in previous are scanned again.
507+func RefineMatches(doc *Document, previous []SearchMatch, query string) []SearchMatch {
508+ query = strings.TrimSpace(query)
509+ if doc == nil || query == "" {
510+ return nil
511+ }
512+ snapshot := doc.Snapshot()
513+ return findMatches(query, func(yield func(int, []rune, []int, int) bool) error {
514+ var cells []renderer.Cell
515+ var runes []rune
516+ var columns []int
517+ last := -1
518+ for _, match := range previous {
519+ if match.Row == last {
520+ continue
521+ }
522+ last = match.Row
523+ width := snapshot.RowWidth(match.Row)
524+ cells = slices.Grow(cells[:0], width)[:width]
525+ cells = cells[:snapshot.CopyRow(match.Row, cells)]
526+ runes, columns = runes[:0], columns[:0]
527+ for x, cell := range cells {
528+ if cell.Continuation {
529+ continue
530+ }
531+ r := cell.Rune
532+ if r == 0 {
533+ r = ' '
534+ }
535+ runes = append(runes, r)
536+ columns = append(columns, x)
537+ }
538+ if !yield(match.Row, runes, columns, len(cells)) {
539+ return nil
540+ }
541+ }
542+ return nil
543+ })
498 }544 }
499 545
500-func findMatches(doc *Document, query string, rangeRows func(func(int, []renderer.Cell) bool) error) []SearchMatch {546+func findMatches(query string, rows textRows) []SearchMatch {
501 needle := lowerRunes(query)547 needle := lowerRunes(query)
502 matches := []SearchMatch{}548 matches := []SearchMatch{}
503- err := rangeRows(func(row int, cells []renderer.Cell) bool {549+ var hay []rune
504- hay, indexes := searchableCells(cells)550+ err := rows(func(row int, runes []rune, columns []int, width int) bool {
551+ hay = hay[:0]
552+ for _, r := range runes {
553+ hay = append(hay, unicode.ToLower(r))
554+ }
505 text := ""555 text := ""
506 for start := 0; start+len(needle) <= len(hay); {556 for start := 0; start+len(needle) <= len(hay); {
507 if !slices.Equal(hay[start:start+len(needle)], needle) {557 if !slices.Equal(hay[start:start+len(needle)], needle) {
@@ -509,14 +559,15 @@ func findMatches(doc *Document, query string, rangeRows func(func(int, []rendere
509 continue559 continue
510 }560 }
511 if text == "" {561 if text == "" {
512- text = doc.LineText(row)562+ // Same text as Document.LineText, built from the runes in hand.
563+ text = strings.TrimRight(string(runes), " ")
513 }564 }
514 end := start + len(needle)565 end := start + len(needle)
515- cellEnd := len(cells)566+ cellEnd := width
516- if end < len(indexes) {567+ if end < len(columns) {
517- cellEnd = indexes[end]568+ cellEnd = columns[end]
518 }569 }
519- matches = append(matches, SearchMatch{Row: row, Start: indexes[start], End: cellEnd, Text: text})570+ matches = append(matches, SearchMatch{Row: row, Start: columns[start], End: cellEnd, Text: text})
520 start += len(needle)571 start += len(needle)
521 }572 }
522 return true573 return true
@@ -527,22 +578,6 @@ func findMatches(doc *Document, query string, rangeRows func(func(int, []rendere
527 }578 }
528 return matches579 return matches
529 }580 }
530-func searchableCells(cells []renderer.Cell) ([]rune, []int) {
531- rs := make([]rune, 0, len(cells))
532- is := make([]int, 0, len(cells))
533- for x, c := range cells {
534- if c.Continuation {
535- continue
536- }
537- r := c.Rune
538- if r == 0 {
539- r = ' '
540- }
541- rs = append(rs, unicode.ToLower(r))
542- is = append(is, x)
543- }
544- return rs, is
545-}
546 func lowerRunes(s string) []rune {581 func lowerRunes(s string) []rune {
547 rs := []rune(s)582 rs := []rune(s)
548 for i, r := range rs {583 for i, r := range rs {
A internal/usecase/copy/search_bench_test.go +52 -0
@@ -0,0 +1,52 @@
1+package copy
2+
3+import (
4+ "fmt"
5+ "strings"
6+ "testing"
7+
8+ vt "github.com/bnema/vev-vt"
9+ renderer "github.com/bnema/vev-vt/ansi"
10+)
11+
12+var benchmarkMatchesSink []SearchMatch
13+
14+// BenchmarkFindMatches12K measures one copy-search keystroke over a full
15+// default-sized scrollback (12k rows x 160 columns), with hot and idle
16+// (compressed) history pages.
17+func BenchmarkFindMatches12K(b *testing.B) {
18+ const rows, width = 12_000, 160
19+ for _, cold := range []bool{false, true} {
20+ b.Run(fmt.Sprintf("cold-%v", cold), func(b *testing.B) {
21+ history := vt.NewHistory(vt.HistoryConfig{MaxRows: rows, ChunkRows: 256})
22+ words := strings.Fields("build test vet lint daemon client render diff pane tab session snapshot")
23+ for i := range rows {
24+ var line strings.Builder
25+ fmt.Fprintf(&line, "line %06d", i)
26+ for j := 0; line.Len() < width-12; j++ {
27+ line.WriteString(" " + words[(i+j)%len(words)])
28+ }
29+ cells := row(line.String())
30+ if err := history.Append(cells, vt.LineBound{End: len(cells)}); err != nil {
31+ b.Fatal(err)
32+ }
33+ }
34+ if cold {
35+ history.SealAndView()
36+ for range 2 {
37+ if _, err := history.CompressIdle(rows); err != nil {
38+ b.Fatal(err)
39+ }
40+ }
41+ }
42+ doc := NewDocument(NewSnapshot(history, renderer.NewFrame(width, 45), nil, nil), "")
43+ b.ReportAllocs()
44+ for b.Loop() {
45+ benchmarkMatchesSink = FindMatches(doc, "snapsh")
46+ }
47+ if len(benchmarkMatchesSink) == 0 {
48+ b.Fatal("expected matches")
49+ }
50+ })
51+ }
52+}
@@ -0,0 +1,52 @@
1+package copy
2+
3+import (
4+ "fmt"
5+ "strings"
6+ "testing"
7+
8+ vt "github.com/bnema/vev-vt"
9+ renderer "github.com/bnema/vev-vt/ansi"
10+)
11+
12+var benchmarkMatchesSink []SearchMatch
13+
14+// BenchmarkFindMatches12K measures one copy-search keystroke over a full
15+// default-sized scrollback (12k rows x 160 columns), with hot and idle
16+// (compressed) history pages.
17+func BenchmarkFindMatches12K(b *testing.B) {
18+ const rows, width = 12_000, 160
19+ for _, cold := range []bool{false, true} {
20+ b.Run(fmt.Sprintf("cold-%v", cold), func(b *testing.B) {
21+ history := vt.NewHistory(vt.HistoryConfig{MaxRows: rows, ChunkRows: 256})
22+ words := strings.Fields("build test vet lint daemon client render diff pane tab session snapshot")
23+ for i := range rows {
24+ var line strings.Builder
25+ fmt.Fprintf(&line, "line %06d", i)
26+ for j := 0; line.Len() < width-12; j++ {
27+ line.WriteString(" " + words[(i+j)%len(words)])
28+ }
29+ cells := row(line.String())
30+ if err := history.Append(cells, vt.LineBound{End: len(cells)}); err != nil {
31+ b.Fatal(err)
32+ }
33+ }
34+ if cold {
35+ history.SealAndView()
36+ for range 2 {
37+ if _, err := history.CompressIdle(rows); err != nil {
38+ b.Fatal(err)
39+ }
40+ }
41+ }
42+ doc := NewDocument(NewSnapshot(history, renderer.NewFrame(width, 45), nil, nil), "")
43+ b.ReportAllocs()
44+ for b.Loop() {
45+ benchmarkMatchesSink = FindMatches(doc, "snapsh")
46+ }
47+ if len(benchmarkMatchesSink) == 0 {
48+ b.Fatal("expected matches")
49+ }
50+ })
51+ }
52+}
M internal/usecase/copy/search_error_test.go +2 -3
@@ -4,7 +4,6 @@ import (
44 "errors"
55 "testing"
66
7- renderer "github.com/bnema/vev-vt/ansi"
87 "github.com/stretchr/testify/require"
98 )
109
@@ -19,9 +18,9 @@ func TestFindMatchesDiscardsPartialResultsOnHistoryError(t *testing.T) {
1918 t.Run(tc.name, func(t *testing.T) {
2019 m := modeFor([]string{"match"}, 1)
2120 require.True(t, m.Search("match"))
22- matches := findMatches(m.document, "match", func(yield func(int, []renderer.Cell) bool) error {
21+ matches := findMatches("match", func(yield func(int, []rune, []int, int) bool) error {
2322 if tc.yielded {
24- require.True(t, yield(0, row("match")))
23+ require.True(t, yield(0, []rune("match"), []int{0, 1, 2, 3, 4}, 5))
2524 }
2625 return errors.New("corrupt cold history")
2726 })
@@ -4,7 +4,6 @@ import (
4 "errors"4 "errors"
5 "testing"5 "testing"
6 6
7- renderer "github.com/bnema/vev-vt/ansi"
8 "github.com/stretchr/testify/require"7 "github.com/stretchr/testify/require"
9 )8 )
10 9
@@ -19,9 +18,9 @@ func TestFindMatchesDiscardsPartialResultsOnHistoryError(t *testing.T) {
19 t.Run(tc.name, func(t *testing.T) {18 t.Run(tc.name, func(t *testing.T) {
20 m := modeFor([]string{"match"}, 1)19 m := modeFor([]string{"match"}, 1)
21 require.True(t, m.Search("match"))20 require.True(t, m.Search("match"))
22- matches := findMatches(m.document, "match", func(yield func(int, []renderer.Cell) bool) error {21+ matches := findMatches("match", func(yield func(int, []rune, []int, int) bool) error {
23 if tc.yielded {22 if tc.yielded {
24- require.True(t, yield(0, row("match")))23+ require.True(t, yield(0, []rune("match"), []int{0, 1, 2, 3, 4}, 5))
25 }24 }
26 return errors.New("corrupt cold history")25 return errors.New("corrupt cold history")
27 })26 })
M internal/usecase/daemon/attention_test.go +1 -1
@@ -443,7 +443,7 @@ func TestAltAJumpAttentionSelectsOldestLocalTab(t *testing.T) {
443443 d.handleInput(sess, ac, []byte("\x1ba"))
444444
445445 require.Equal(t, 2, testAttachmentTabIndex(sess))
446- requireFloatingInitialized(t, testAttachmentTab(sess))
446+ requireFloatingInitialized(t, sess)
447447 _ = mustOutputData(t, sends)
448448 }
449449
@@ -443,7 +443,7 @@ func TestAltAJumpAttentionSelectsOldestLocalTab(t *testing.T) {
443 d.handleInput(sess, ac, []byte("\x1ba"))443 d.handleInput(sess, ac, []byte("\x1ba"))
444 444
445 require.Equal(t, 2, testAttachmentTabIndex(sess))445 require.Equal(t, 2, testAttachmentTabIndex(sess))
446- requireFloatingInitialized(t, testAttachmentTab(sess))446+ requireFloatingInitialized(t, sess)
447 _ = mustOutputData(t, sends)447 _ = mustOutputData(t, sends)
448 }448 }
449 449
M internal/usecase/daemon/floating.go +32 -17
@@ -150,12 +150,10 @@ func (tb *tab) visibleFloatingSnapshotLocked(cfg domain.FloatingConfig) (*pane,
150150 return p, geometry, true
151151 }
152152
153-// ensureFloatingWarm starts the background prewarm exactly once for a tab.
153+// ensureFloatingWarm keeps the session's single pre-started floating shell
154+// ready. Tabs no longer prewarm their own hidden shell; see floating_spare.go.
154155 func (d *Daemon) ensureFloatingWarm(sess *session, tb *tab) {
155- if d == nil || sess == nil || tb == nil || d.ptys == nil {
156- return
157- }
158- d.startFloating(sess, tb, false)
156+ d.ensureFloatingSpare(sess, tb)
159157 }
160158
161159 // activateTab performs the work associated with making a tab the destination.
@@ -325,6 +323,9 @@ type floatingLaunchSpec struct {
325323 fallback string
326324 parentCtx context.Context
327325 userOpen bool
326+ // floatingCommand is the configured floating.command; a spare is only
327+ // claimed by a launch with the same command.
328+ floatingCommand string
328329 }
329330
330331 // launchFloating snapshots launch inputs, then accounts for a worker through
@@ -383,24 +384,38 @@ func (d *Daemon) newFloatingLaunchSpec(sess *session, tb *tab, cfg domain.Floati
383384 launch.args = []string{"-lc", cfg.Command}
384385 }
385386 return floatingLaunchSpec{
386- sessionName: name,
387- cwd: cwd,
388- size: size,
389- ptyGeometry: sess.geometry.paneGeometry(size),
390- geometry: geometry,
391- paneStableID: paneStableID,
392- env: childEnvFrom(env, name, tabStableID, paneStableID),
393- command: launch.command,
394- args: launch.args,
395- fallback: floatingCommandFallback(cfg.Command, launch.command),
396- parentCtx: tabCtx,
397- userOpen: userOpen,
387+ sessionName: name,
388+ cwd: cwd,
389+ size: size,
390+ ptyGeometry: sess.geometry.paneGeometry(size),
391+ geometry: geometry,
392+ paneStableID: paneStableID,
393+ env: childEnvFrom(env, name, tabStableID, paneStableID),
394+ command: launch.command,
395+ args: launch.args,
396+ fallback: floatingCommandFallback(cfg.Command, launch.command),
397+ parentCtx: tabCtx,
398+ userOpen: userOpen,
399+ floatingCommand: cfg.Command,
398400 }, nil
399401 }
400402
401403 // openAndInstallFloating runs entirely in the launch worker. No PTY operation
402404 // occurs under tb.mu; the pane is fully initialized before publication.
403405 func (d *Daemon) openAndInstallFloating(sess *session, tb *tab, spec floatingLaunchSpec, generation uint64) {
406+ if spec.userOpen {
407+ // Claim the session spare when it matches this launch; a missing or
408+ // mismatched spare falls through to a fresh launch. Once a spare was
409+ // used up, the session starts the next one in the background.
410+ p, had := sess.takeFloatingSpare(spec.cwd, spec.floatingCommand)
411+ if had {
412+ defer d.ensureFloatingSpare(sess, tb)
413+ }
414+ if p != nil {
415+ d.adoptFloatingSpare(sess, tb, p, spec, generation)
416+ return
417+ }
418+ }
404419 // Open is context-aware and is intentionally called without any daemon,
405420 // session, tab, or launch-ownership lock. Cancellation makes teardown
406421 // bounded even if the adapter is waiting to create the child.
@@ -150,12 +150,10 @@ func (tb *tab) visibleFloatingSnapshotLocked(cfg domain.FloatingConfig) (*pane,
150 return p, geometry, true150 return p, geometry, true
151 }151 }
152 152
153-// ensureFloatingWarm starts the background prewarm exactly once for a tab.153+// ensureFloatingWarm keeps the session's single pre-started floating shell
154+// ready. Tabs no longer prewarm their own hidden shell; see floating_spare.go.
154 func (d *Daemon) ensureFloatingWarm(sess *session, tb *tab) {155 func (d *Daemon) ensureFloatingWarm(sess *session, tb *tab) {
155- if d == nil || sess == nil || tb == nil || d.ptys == nil {156+ d.ensureFloatingSpare(sess, tb)
156- return
157- }
158- d.startFloating(sess, tb, false)
159 }157 }
160 158
161 // activateTab performs the work associated with making a tab the destination.159 // activateTab performs the work associated with making a tab the destination.
@@ -325,6 +323,9 @@ type floatingLaunchSpec struct {
325 fallback string323 fallback string
326 parentCtx context.Context324 parentCtx context.Context
327 userOpen bool325 userOpen bool
326+ // floatingCommand is the configured floating.command; a spare is only
327+ // claimed by a launch with the same command.
328+ floatingCommand string
328 }329 }
329 330
330 // launchFloating snapshots launch inputs, then accounts for a worker through331 // launchFloating snapshots launch inputs, then accounts for a worker through
@@ -383,24 +384,38 @@ func (d *Daemon) newFloatingLaunchSpec(sess *session, tb *tab, cfg domain.Floati
383 launch.args = []string{"-lc", cfg.Command}384 launch.args = []string{"-lc", cfg.Command}
384 }385 }
385 return floatingLaunchSpec{386 return floatingLaunchSpec{
386- sessionName: name,387+ sessionName: name,
387- cwd: cwd,388+ cwd: cwd,
388- size: size,389+ size: size,
389- ptyGeometry: sess.geometry.paneGeometry(size),390+ ptyGeometry: sess.geometry.paneGeometry(size),
390- geometry: geometry,391+ geometry: geometry,
391- paneStableID: paneStableID,392+ paneStableID: paneStableID,
392- env: childEnvFrom(env, name, tabStableID, paneStableID),393+ env: childEnvFrom(env, name, tabStableID, paneStableID),
393- command: launch.command,394+ command: launch.command,
394- args: launch.args,395+ args: launch.args,
395- fallback: floatingCommandFallback(cfg.Command, launch.command),396+ fallback: floatingCommandFallback(cfg.Command, launch.command),
396- parentCtx: tabCtx,397+ parentCtx: tabCtx,
397- userOpen: userOpen,398+ userOpen: userOpen,
399+ floatingCommand: cfg.Command,
398 }, nil400 }, nil
399 }401 }
400 402
401 // openAndInstallFloating runs entirely in the launch worker. No PTY operation403 // openAndInstallFloating runs entirely in the launch worker. No PTY operation
402 // occurs under tb.mu; the pane is fully initialized before publication.404 // occurs under tb.mu; the pane is fully initialized before publication.
403 func (d *Daemon) openAndInstallFloating(sess *session, tb *tab, spec floatingLaunchSpec, generation uint64) {405 func (d *Daemon) openAndInstallFloating(sess *session, tb *tab, spec floatingLaunchSpec, generation uint64) {
406+ if spec.userOpen {
407+ // Claim the session spare when it matches this launch; a missing or
408+ // mismatched spare falls through to a fresh launch. Once a spare was
409+ // used up, the session starts the next one in the background.
410+ p, had := sess.takeFloatingSpare(spec.cwd, spec.floatingCommand)
411+ if had {
412+ defer d.ensureFloatingSpare(sess, tb)
413+ }
414+ if p != nil {
415+ d.adoptFloatingSpare(sess, tb, p, spec, generation)
416+ return
417+ }
418+ }
404 // Open is context-aware and is intentionally called without any daemon,419 // Open is context-aware and is intentionally called without any daemon,
405 // session, tab, or launch-ownership lock. Cancellation makes teardown420 // session, tab, or launch-ownership lock. Cancellation makes teardown
406 // bounded even if the adapter is waiting to create the child.421 // bounded even if the adapter is waiting to create the child.
A internal/usecase/daemon/floating_spare.go +152 -0
@@ -0,0 +1,152 @@
1+package daemon
2+
3+import (
4+ "github.com/bnema/vev/internal/usecase/layout"
5+)
6+
7+// floatingSpare is one pre-started floating shell owned by a session rather
8+// than by a tab. The first tab that opens its floating pane claims it, and the
9+// session then starts a new spare in the background, so a session keeps at
10+// most one idle floating shell instead of one hidden shell per tab. A spare is
11+// never persisted or restored. Its reader starts only once a tab installs it;
12+// until then the kernel PTY buffer holds its first prompt. Its VEV identity
13+// names the tab that started it; floating panes are not --self targets.
14+//
15+// A claim takes the claiming tab's geometry (resize before the reader starts)
16+// and cwd: a spare started in another directory is discarded and the claim
17+// launches a fresh shell instead.
18+//
19+// The spare is guarded by session.floatingLaunchMu, which is never held across
20+// PTY operations or architecture locks.
21+type floatingSpare struct {
22+ pane *pane
23+ cwd string
24+ command string
25+}
26+
27+// ensureFloatingSpare starts the session's spare when none exists or is
28+// starting. tb supplies the initial geometry and cwd; a claim from another tab
29+// resizes it or, when the cwd differs, replaces it.
30+func (d *Daemon) ensureFloatingSpare(sess *session, tb *tab) {
31+ if d == nil || sess == nil || tb == nil || d.ptys == nil {
32+ return
33+ }
34+ cfg := d.currentFloatingConfig()
35+ sess.floatingLaunchMu.Lock()
36+ if sess.floatingLaunchStopping || sess.floatingSpare != nil || sess.floatingSpareStarting {
37+ sess.floatingLaunchMu.Unlock()
38+ return
39+ }
40+ sess.floatingSpareStarting = true
41+ sess.floatingLaunchMu.Unlock()
42+
43+ spec, err := d.newFloatingLaunchSpec(sess, tb, cfg, false)
44+ // The spare belongs to the session, so closing the tab that started it
45+ // must not cancel it.
46+ spec.parentCtx = sess.ctx
47+ var launch *floatingLaunch
48+ ok := err == nil && spec.parentCtx != nil
49+ if ok {
50+ launch, ok = sess.registerFloatingLaunch()
51+ }
52+ if !ok {
53+ sess.floatingLaunchMu.Lock()
54+ sess.floatingSpareStarting = false
55+ sess.floatingLaunchMu.Unlock()
56+ return
57+ }
58+ d.sessWg.Go(func() {
59+ defer sess.finishFloatingLaunch(launch)
60+ p := d.openFloatingPane(sess, spec)
61+ sess.floatingLaunchMu.Lock()
62+ sess.floatingSpareStarting = false
63+ if p != nil && !sess.floatingLaunchStopping {
64+ sess.floatingSpare = &floatingSpare{pane: p, cwd: spec.cwd, command: cfg.Command}
65+ p = nil
66+ }
67+ sess.floatingLaunchMu.Unlock()
68+ if p != nil {
69+ closeFloatingPane(p)
70+ }
71+ })
72+}
73+
74+// takeFloatingSpare removes the session spare. It returns the pane when the
75+// spare was started for cwd and command; a mismatched spare is closed because
76+// it can never serve this directory or config. had reports whether a spare
77+// existed, so the caller knows to start its replacement.
78+func (sess *session) takeFloatingSpare(cwd, command string) (p *pane, had bool) {
79+ sess.floatingLaunchMu.Lock()
80+ spare := sess.floatingSpare
81+ sess.floatingSpare = nil
82+ sess.floatingLaunchMu.Unlock()
83+ if spare == nil {
84+ return nil, false
85+ }
86+ if spare.cwd != cwd || spare.command != command {
87+ closeFloatingPane(spare.pane)
88+ return nil, true
89+ }
90+ return spare.pane, true
91+}
92+
93+// closeFloatingSpare releases the spare on session teardown. The caller has
94+// already set floatingLaunchStopping, so no launch can publish a new one.
95+func (sess *session) closeFloatingSpare() {
96+ sess.floatingLaunchMu.Lock()
97+ spare := sess.floatingSpare
98+ sess.floatingSpare = nil
99+ sess.floatingLaunchMu.Unlock()
100+ if spare != nil {
101+ closeFloatingPane(spare.pane)
102+ }
103+}
104+
105+// openFloatingPane opens one floating PTY for spec and returns its initialized,
106+// unpublished pane, or nil when the launch failed or was cancelled.
107+func (d *Daemon) openFloatingPane(sess *session, spec floatingLaunchSpec) *pane {
108+ if err := spec.parentCtx.Err(); err != nil {
109+ return nil
110+ }
111+ lifetime := d.newPaneProcessLifetime(spec.parentCtx, sess.ctx)
112+ pty, err := d.ptys.Open(lifetime.ctx, spec.command, spec.args, spec.env, spec.cwd, spec.ptyGeometry)
113+ if err != nil {
114+ lifetime.abort()
115+ if pty != nil {
116+ _ = pty.Close()
117+ }
118+ d.log.Debug("floating spare spawn failed", "err", err, "session", spec.sessionName)
119+ return nil
120+ }
121+ p := newPaneWithStableIDAndTitle(layout.PaneID("floating"), spec.paneStableID, pty, spec.size, spec.fallback, d.currentHistoryConfig())
122+ p.geometry = spec.ptyGeometry
123+ setScreenGeometry(p.screen, spec.ptyGeometry)
124+ p.rect = spec.geometry.ptyRect()
125+ p.popupGeometry = spec.geometry
126+ if !lifetime.publish(p) {
127+ _ = pty.Close()
128+ return nil
129+ }
130+ return p
131+}
132+
133+// adoptFloatingSpare fits a claimed spare to the claiming tab's launch spec
134+// and installs it in the current slot generation. It runs in the launch worker
135+// without any architecture lock, because PTY.Resize is external.
136+func (d *Daemon) adoptFloatingSpare(sess *session, tb *tab, p *pane, spec floatingLaunchSpec, generation uint64) {
137+ if p.geometry != spec.ptyGeometry {
138+ if err := p.pty.Resize(spec.ptyGeometry); err != nil {
139+ closeFloatingPane(p)
140+ d.failFloatingLaunch(sess, tb, generation, true, spec.sessionName, err)
141+ return
142+ }
143+ }
144+ p.mu.Lock()
145+ p.geometry = spec.ptyGeometry
146+ setScreenGeometry(p.screen, spec.ptyGeometry)
147+ p.rect = spec.geometry.ptyRect()
148+ p.popupGeometry = spec.geometry
149+ p.mu.Unlock()
150+ p.onExit = func() { d.reapInstalledFloating(p) }
151+ d.installFloating(sess, tb, p, generation)
152+}
@@ -0,0 +1,152 @@
1+package daemon
2+
3+import (
4+ "github.com/bnema/vev/internal/usecase/layout"
5+)
6+
7+// floatingSpare is one pre-started floating shell owned by a session rather
8+// than by a tab. The first tab that opens its floating pane claims it, and the
9+// session then starts a new spare in the background, so a session keeps at
10+// most one idle floating shell instead of one hidden shell per tab. A spare is
11+// never persisted or restored. Its reader starts only once a tab installs it;
12+// until then the kernel PTY buffer holds its first prompt. Its VEV identity
13+// names the tab that started it; floating panes are not --self targets.
14+//
15+// A claim takes the claiming tab's geometry (resize before the reader starts)
16+// and cwd: a spare started in another directory is discarded and the claim
17+// launches a fresh shell instead.
18+//
19+// The spare is guarded by session.floatingLaunchMu, which is never held across
20+// PTY operations or architecture locks.
21+type floatingSpare struct {
22+ pane *pane
23+ cwd string
24+ command string
25+}
26+
27+// ensureFloatingSpare starts the session's spare when none exists or is
28+// starting. tb supplies the initial geometry and cwd; a claim from another tab
29+// resizes it or, when the cwd differs, replaces it.
30+func (d *Daemon) ensureFloatingSpare(sess *session, tb *tab) {
31+ if d == nil || sess == nil || tb == nil || d.ptys == nil {
32+ return
33+ }
34+ cfg := d.currentFloatingConfig()
35+ sess.floatingLaunchMu.Lock()
36+ if sess.floatingLaunchStopping || sess.floatingSpare != nil || sess.floatingSpareStarting {
37+ sess.floatingLaunchMu.Unlock()
38+ return
39+ }
40+ sess.floatingSpareStarting = true
41+ sess.floatingLaunchMu.Unlock()
42+
43+ spec, err := d.newFloatingLaunchSpec(sess, tb, cfg, false)
44+ // The spare belongs to the session, so closing the tab that started it
45+ // must not cancel it.
46+ spec.parentCtx = sess.ctx
47+ var launch *floatingLaunch
48+ ok := err == nil && spec.parentCtx != nil
49+ if ok {
50+ launch, ok = sess.registerFloatingLaunch()
51+ }
52+ if !ok {
53+ sess.floatingLaunchMu.Lock()
54+ sess.floatingSpareStarting = false
55+ sess.floatingLaunchMu.Unlock()
56+ return
57+ }
58+ d.sessWg.Go(func() {
59+ defer sess.finishFloatingLaunch(launch)
60+ p := d.openFloatingPane(sess, spec)
61+ sess.floatingLaunchMu.Lock()
62+ sess.floatingSpareStarting = false
63+ if p != nil && !sess.floatingLaunchStopping {
64+ sess.floatingSpare = &floatingSpare{pane: p, cwd: spec.cwd, command: cfg.Command}
65+ p = nil
66+ }
67+ sess.floatingLaunchMu.Unlock()
68+ if p != nil {
69+ closeFloatingPane(p)
70+ }
71+ })
72+}
73+
74+// takeFloatingSpare removes the session spare. It returns the pane when the
75+// spare was started for cwd and command; a mismatched spare is closed because
76+// it can never serve this directory or config. had reports whether a spare
77+// existed, so the caller knows to start its replacement.
78+func (sess *session) takeFloatingSpare(cwd, command string) (p *pane, had bool) {
79+ sess.floatingLaunchMu.Lock()
80+ spare := sess.floatingSpare
81+ sess.floatingSpare = nil
82+ sess.floatingLaunchMu.Unlock()
83+ if spare == nil {
84+ return nil, false
85+ }
86+ if spare.cwd != cwd || spare.command != command {
87+ closeFloatingPane(spare.pane)
88+ return nil, true
89+ }
90+ return spare.pane, true
91+}
92+
93+// closeFloatingSpare releases the spare on session teardown. The caller has
94+// already set floatingLaunchStopping, so no launch can publish a new one.
95+func (sess *session) closeFloatingSpare() {
96+ sess.floatingLaunchMu.Lock()
97+ spare := sess.floatingSpare
98+ sess.floatingSpare = nil
99+ sess.floatingLaunchMu.Unlock()
100+ if spare != nil {
101+ closeFloatingPane(spare.pane)
102+ }
103+}
104+
105+// openFloatingPane opens one floating PTY for spec and returns its initialized,
106+// unpublished pane, or nil when the launch failed or was cancelled.
107+func (d *Daemon) openFloatingPane(sess *session, spec floatingLaunchSpec) *pane {
108+ if err := spec.parentCtx.Err(); err != nil {
109+ return nil
110+ }
111+ lifetime := d.newPaneProcessLifetime(spec.parentCtx, sess.ctx)
112+ pty, err := d.ptys.Open(lifetime.ctx, spec.command, spec.args, spec.env, spec.cwd, spec.ptyGeometry)
113+ if err != nil {
114+ lifetime.abort()
115+ if pty != nil {
116+ _ = pty.Close()
117+ }
118+ d.log.Debug("floating spare spawn failed", "err", err, "session", spec.sessionName)
119+ return nil
120+ }
121+ p := newPaneWithStableIDAndTitle(layout.PaneID("floating"), spec.paneStableID, pty, spec.size, spec.fallback, d.currentHistoryConfig())
122+ p.geometry = spec.ptyGeometry
123+ setScreenGeometry(p.screen, spec.ptyGeometry)
124+ p.rect = spec.geometry.ptyRect()
125+ p.popupGeometry = spec.geometry
126+ if !lifetime.publish(p) {
127+ _ = pty.Close()
128+ return nil
129+ }
130+ return p
131+}
132+
133+// adoptFloatingSpare fits a claimed spare to the claiming tab's launch spec
134+// and installs it in the current slot generation. It runs in the launch worker
135+// without any architecture lock, because PTY.Resize is external.
136+func (d *Daemon) adoptFloatingSpare(sess *session, tb *tab, p *pane, spec floatingLaunchSpec, generation uint64) {
137+ if p.geometry != spec.ptyGeometry {
138+ if err := p.pty.Resize(spec.ptyGeometry); err != nil {
139+ closeFloatingPane(p)
140+ d.failFloatingLaunch(sess, tb, generation, true, spec.sessionName, err)
141+ return
142+ }
143+ }
144+ p.mu.Lock()
145+ p.geometry = spec.ptyGeometry
146+ setScreenGeometry(p.screen, spec.ptyGeometry)
147+ p.rect = spec.geometry.ptyRect()
148+ p.popupGeometry = spec.geometry
149+ p.mu.Unlock()
150+ p.onExit = func() { d.reapInstalledFloating(p) }
151+ d.installFloating(sess, tb, p, generation)
152+}
A internal/usecase/daemon/floating_spare_test.go +185 -0
@@ -0,0 +1,185 @@
1+package daemon
2+
3+import (
4+ "context"
5+ "io"
6+ "sync"
7+ "testing"
8+ "time"
9+
10+ "github.com/stretchr/testify/require"
11+
12+ "github.com/bnema/vev/internal/domain"
13+ "github.com/bnema/vev/internal/ports"
14+)
15+
16+// spareOpen records one PTY Open issued for a floating shell.
17+type spareOpen struct {
18+ cwd string
19+ geometry domain.Geometry
20+ pty *sparePTY
21+}
22+
23+type sparePTY struct {
24+ ctx context.Context
25+ mu sync.Mutex
26+ resizes []domain.Geometry
27+ closed chan struct{}
28+ once sync.Once
29+}
30+
31+func (p *sparePTY) Read([]byte) (int, error) {
32+ select {
33+ case <-p.ctx.Done():
34+ return 0, p.ctx.Err()
35+ case <-p.closed:
36+ return 0, io.EOF
37+ }
38+}
39+func (*sparePTY) Write(b []byte) (int, error) { return len(b), nil }
40+func (p *sparePTY) Resize(g domain.Geometry) error {
41+ p.mu.Lock()
42+ p.resizes = append(p.resizes, g)
43+ p.mu.Unlock()
44+ return nil
45+}
46+func (*sparePTY) Pid() int { return 1 }
47+func (*sparePTY) ForegroundPgid() (int, error) { return 1, nil }
48+func (p *sparePTY) Close() error { p.once.Do(func() { close(p.closed) }); return nil }
49+func (p *sparePTY) isClosed() bool {
50+ select {
51+ case <-p.closed:
52+ return true
53+ default:
54+ return false
55+ }
56+}
57+
58+type spareFactory struct {
59+ mu sync.Mutex
60+ opens []spareOpen
61+}
62+
63+func (f *spareFactory) Open(ctx context.Context, _ string, _ []string, _ []string, cwd string, g domain.Geometry) (ports.PTY, error) {
64+ p := &sparePTY{ctx: ctx, closed: make(chan struct{})}
65+ f.mu.Lock()
66+ f.opens = append(f.opens, spareOpen{cwd: cwd, geometry: g, pty: p})
67+ f.mu.Unlock()
68+ return p, nil
69+}
70+
71+func (f *spareFactory) snapshot() []spareOpen {
72+ f.mu.Lock()
73+ defer f.mu.Unlock()
74+ return append([]spareOpen(nil), f.opens...)
75+}
76+
77+// liveShells counts opened floating PTYs that are still running.
78+func (f *spareFactory) liveShells() int {
79+ n := 0
80+ for _, open := range f.snapshot() {
81+ if !open.pty.isClosed() {
82+ n++
83+ }
84+ }
85+ return n
86+}
87+
88+func waitSpareReady(t *testing.T, sess *session) {
89+ t.Helper()
90+ require.Eventually(t, func() bool {
91+ sess.floatingLaunchMu.Lock()
92+ defer sess.floatingLaunchMu.Unlock()
93+ return sess.floatingSpare != nil && !sess.floatingSpareStarting
94+ }, time.Second, time.Millisecond)
95+}
96+
97+func waitFloatingVisible(t *testing.T, tb *tab) *pane {
98+ t.Helper()
99+ var p *pane
100+ require.Eventually(t, func() bool {
101+ tb.mu.Lock()
102+ defer tb.mu.Unlock()
103+ p = tb.floating.pane
104+ return tb.floating.state == floatingVisible && p != nil
105+ }, time.Second, time.Millisecond)
106+ return p
107+}
108+
109+func TestFloatingSpareIsSharedPerSession(t *testing.T) {
110+ cfg := domain.FloatingConfig{Width: 80, Height: 80}
111+ tests := []struct {
112+ name string
113+ claimCwd string
114+ wantReused bool
115+ }{
116+ {name: "same cwd claims the spare", claimCwd: "/work", wantReused: true},
117+ {name: "other cwd launches a fresh shell", claimCwd: "/other", wantReused: false},
118+ }
119+ for _, tt := range tests {
120+ t.Run(tt.name, func(t *testing.T) {
121+ factory := &spareFactory{}
122+ d := newTestDaemon(t, factory, stubClock{})
123+ d.ApplyConfig(domain.Config{Floating: cfg})
124+ cwds := map[int]string{1: "/work", 2: tt.claimCwd}
125+ d.procCwd = func(pid int) (string, error) { return cwds[pid], nil }
126+ d.dirOrHome = func(cwd string) string { return cwd }
127+
128+ first := newTab(&pidPTY{pid: 1}, domain.Size{Cols: 120, Rows: 40})
129+ second := newTab(&pidPTY{pid: 2}, domain.Size{Cols: 60, Rows: 20})
130+ for _, tb := range []*tab{first, second} {
131+ tb.ctx, tb.cancel = context.WithCancel(t.Context())
132+ }
133+ sess := &session{sessionCore: sessionCore{name: "work"}, tabs: []*tab{first, second}, ctx: t.Context()}
134+
135+ // Activating both tabs starts exactly one session spare.
136+ d.ensureFloatingWarm(sess, first)
137+ d.ensureFloatingWarm(sess, second)
138+ waitSpareReady(t, sess)
139+ require.Len(t, factory.snapshot(), 1)
140+ require.Equal(t, "/work", factory.snapshot()[0].cwd)
141+
142+ // The second (smaller) tab opens its floating pane and claims the
143+ // spare that the first tab started.
144+ second.mu.Lock()
145+ generation := second.beginFloatingWarmLocked(true)
146+ second.mu.Unlock()
147+ d.launchFloating(sess, second, cfg, generation, true)
148+ claimed := waitFloatingVisible(t, second)
149+ waitSpareReady(t, sess)
150+
151+ want := calculateContentFloatingGeometry(domain.Size{Cols: 60, Rows: 20}, cfg)
152+ claimed.mu.Lock()
153+ require.Equal(t, want.ptyRect(), claimed.rect)
154+ require.Equal(t, rectSize(want.ptyRect()), claimed.geometry.Size)
155+ require.Equal(t, rectSize(want.ptyRect()).Cols, claimed.screen.Columns())
156+ claimed.mu.Unlock()
157+
158+ opens := factory.snapshot()
159+ if tt.wantReused {
160+ require.Same(t, opens[0].pty, claimed.pty, "the claim must reuse the pre-started shell")
161+ require.Equal(t, []domain.Geometry{claimed.geometry}, opens[0].pty.resizes, "the claim resizes the spare to the claiming tab")
162+ require.Len(t, opens, 2, "one claimed shell plus one new spare")
163+ } else {
164+ require.True(t, opens[0].pty.isClosed(), "a spare from another cwd is discarded")
165+ require.Equal(t, tt.claimCwd, opens[1].cwd)
166+ require.Same(t, opens[1].pty, claimed.pty)
167+ require.Len(t, opens, 3, "a fresh shell plus one new spare")
168+ }
169+ // Panes visible as floating plus exactly one idle spare.
170+ require.Equal(t, 2, factory.liveShells())
171+
172+ sess.stopInMemoryLifecycle()
173+ d.teardownFloating(second, nil)
174+ d.sessWg.Wait()
175+ require.Zero(t, factory.liveShells(), "teardown closes the spare")
176+ })
177+ }
178+}
179+
180+type pidPTY struct {
181+ transactionalResizePTY
182+ pid int
183+}
184+
185+func (p *pidPTY) Pid() int { return p.pid }
@@ -0,0 +1,185 @@
1+package daemon
2+
3+import (
4+ "context"
5+ "io"
6+ "sync"
7+ "testing"
8+ "time"
9+
10+ "github.com/stretchr/testify/require"
11+
12+ "github.com/bnema/vev/internal/domain"
13+ "github.com/bnema/vev/internal/ports"
14+)
15+
16+// spareOpen records one PTY Open issued for a floating shell.
17+type spareOpen struct {
18+ cwd string
19+ geometry domain.Geometry
20+ pty *sparePTY
21+}
22+
23+type sparePTY struct {
24+ ctx context.Context
25+ mu sync.Mutex
26+ resizes []domain.Geometry
27+ closed chan struct{}
28+ once sync.Once
29+}
30+
31+func (p *sparePTY) Read([]byte) (int, error) {
32+ select {
33+ case <-p.ctx.Done():
34+ return 0, p.ctx.Err()
35+ case <-p.closed:
36+ return 0, io.EOF
37+ }
38+}
39+func (*sparePTY) Write(b []byte) (int, error) { return len(b), nil }
40+func (p *sparePTY) Resize(g domain.Geometry) error {
41+ p.mu.Lock()
42+ p.resizes = append(p.resizes, g)
43+ p.mu.Unlock()
44+ return nil
45+}
46+func (*sparePTY) Pid() int { return 1 }
47+func (*sparePTY) ForegroundPgid() (int, error) { return 1, nil }
48+func (p *sparePTY) Close() error { p.once.Do(func() { close(p.closed) }); return nil }
49+func (p *sparePTY) isClosed() bool {
50+ select {
51+ case <-p.closed:
52+ return true
53+ default:
54+ return false
55+ }
56+}
57+
58+type spareFactory struct {
59+ mu sync.Mutex
60+ opens []spareOpen
61+}
62+
63+func (f *spareFactory) Open(ctx context.Context, _ string, _ []string, _ []string, cwd string, g domain.Geometry) (ports.PTY, error) {
64+ p := &sparePTY{ctx: ctx, closed: make(chan struct{})}
65+ f.mu.Lock()
66+ f.opens = append(f.opens, spareOpen{cwd: cwd, geometry: g, pty: p})
67+ f.mu.Unlock()
68+ return p, nil
69+}
70+
71+func (f *spareFactory) snapshot() []spareOpen {
72+ f.mu.Lock()
73+ defer f.mu.Unlock()
74+ return append([]spareOpen(nil), f.opens...)
75+}
76+
77+// liveShells counts opened floating PTYs that are still running.
78+func (f *spareFactory) liveShells() int {
79+ n := 0
80+ for _, open := range f.snapshot() {
81+ if !open.pty.isClosed() {
82+ n++
83+ }
84+ }
85+ return n
86+}
87+
88+func waitSpareReady(t *testing.T, sess *session) {
89+ t.Helper()
90+ require.Eventually(t, func() bool {
91+ sess.floatingLaunchMu.Lock()
92+ defer sess.floatingLaunchMu.Unlock()
93+ return sess.floatingSpare != nil && !sess.floatingSpareStarting
94+ }, time.Second, time.Millisecond)
95+}
96+
97+func waitFloatingVisible(t *testing.T, tb *tab) *pane {
98+ t.Helper()
99+ var p *pane
100+ require.Eventually(t, func() bool {
101+ tb.mu.Lock()
102+ defer tb.mu.Unlock()
103+ p = tb.floating.pane
104+ return tb.floating.state == floatingVisible && p != nil
105+ }, time.Second, time.Millisecond)
106+ return p
107+}
108+
109+func TestFloatingSpareIsSharedPerSession(t *testing.T) {
110+ cfg := domain.FloatingConfig{Width: 80, Height: 80}
111+ tests := []struct {
112+ name string
113+ claimCwd string
114+ wantReused bool
115+ }{
116+ {name: "same cwd claims the spare", claimCwd: "/work", wantReused: true},
117+ {name: "other cwd launches a fresh shell", claimCwd: "/other", wantReused: false},
118+ }
119+ for _, tt := range tests {
120+ t.Run(tt.name, func(t *testing.T) {
121+ factory := &spareFactory{}
122+ d := newTestDaemon(t, factory, stubClock{})
123+ d.ApplyConfig(domain.Config{Floating: cfg})
124+ cwds := map[int]string{1: "/work", 2: tt.claimCwd}
125+ d.procCwd = func(pid int) (string, error) { return cwds[pid], nil }
126+ d.dirOrHome = func(cwd string) string { return cwd }
127+
128+ first := newTab(&pidPTY{pid: 1}, domain.Size{Cols: 120, Rows: 40})
129+ second := newTab(&pidPTY{pid: 2}, domain.Size{Cols: 60, Rows: 20})
130+ for _, tb := range []*tab{first, second} {
131+ tb.ctx, tb.cancel = context.WithCancel(t.Context())
132+ }
133+ sess := &session{sessionCore: sessionCore{name: "work"}, tabs: []*tab{first, second}, ctx: t.Context()}
134+
135+ // Activating both tabs starts exactly one session spare.
136+ d.ensureFloatingWarm(sess, first)
137+ d.ensureFloatingWarm(sess, second)
138+ waitSpareReady(t, sess)
139+ require.Len(t, factory.snapshot(), 1)
140+ require.Equal(t, "/work", factory.snapshot()[0].cwd)
141+
142+ // The second (smaller) tab opens its floating pane and claims the
143+ // spare that the first tab started.
144+ second.mu.Lock()
145+ generation := second.beginFloatingWarmLocked(true)
146+ second.mu.Unlock()
147+ d.launchFloating(sess, second, cfg, generation, true)
148+ claimed := waitFloatingVisible(t, second)
149+ waitSpareReady(t, sess)
150+
151+ want := calculateContentFloatingGeometry(domain.Size{Cols: 60, Rows: 20}, cfg)
152+ claimed.mu.Lock()
153+ require.Equal(t, want.ptyRect(), claimed.rect)
154+ require.Equal(t, rectSize(want.ptyRect()), claimed.geometry.Size)
155+ require.Equal(t, rectSize(want.ptyRect()).Cols, claimed.screen.Columns())
156+ claimed.mu.Unlock()
157+
158+ opens := factory.snapshot()
159+ if tt.wantReused {
160+ require.Same(t, opens[0].pty, claimed.pty, "the claim must reuse the pre-started shell")
161+ require.Equal(t, []domain.Geometry{claimed.geometry}, opens[0].pty.resizes, "the claim resizes the spare to the claiming tab")
162+ require.Len(t, opens, 2, "one claimed shell plus one new spare")
163+ } else {
164+ require.True(t, opens[0].pty.isClosed(), "a spare from another cwd is discarded")
165+ require.Equal(t, tt.claimCwd, opens[1].cwd)
166+ require.Same(t, opens[1].pty, claimed.pty)
167+ require.Len(t, opens, 3, "a fresh shell plus one new spare")
168+ }
169+ // Panes visible as floating plus exactly one idle spare.
170+ require.Equal(t, 2, factory.liveShells())
171+
172+ sess.stopInMemoryLifecycle()
173+ d.teardownFloating(second, nil)
174+ d.sessWg.Wait()
175+ require.Zero(t, factory.liveShells(), "teardown closes the spare")
176+ })
177+ }
178+}
179+
180+type pidPTY struct {
181+ transactionalResizePTY
182+ pid int
183+}
184+
185+func (p *pidPTY) Pid() int { return p.pid }
M internal/usecase/daemon/floating_test.go +2 -2
@@ -176,7 +176,7 @@ func TestFloatingLifecycleCapturesLaunchBeforeOpenAndDoesNotHoldTabLock(t *testi
176176 tb.ctx, tb.cancel = context.WithCancel(t.Context())
177177 sess := &session{sessionCore: sessionCore{name: "work"}, cwd: cwd, env: []string{"ORDINARY=preserved", "DUP=first", "DUP=second", "PAIR=a=b", "SHELL=/bin/first", "SHELL=/bin/custom-shell", "TERM=old", "COLORTERM=old", "TERM_PROGRAM=old", "VEV=old"}, tabs: []*tab{tb}, ctx: t.Context()}
178178 d.ApplyConfig(domain.Config{Theme: domain.ThemeDark, Floating: domain.FloatingConfig{Command: "btop --utf", Width: 50, Height: 50}})
179- d.ensureFloatingWarm(sess, tb)
179+ d.startFloating(sess, tb, false)
180180 // Open has started while this goroutine owns tab.mu: an external factory
181181 // call under that lock would deadlock this channel-controlled test.
182182 tb.mu.Lock()
@@ -348,7 +348,7 @@ func TestFloatingLifecycleStaleSuccessAndOldExitCannotReplaceCurrentSlot(t *test
348348 d := newTestDaemon(t, factory, stubClock{})
349349 tb := newFloatingTestTab(t)
350350 sess := &session{sessionCore: sessionCore{name: "work"}, tabs: []*tab{tb}, ctx: t.Context()}
351- d.ensureFloatingWarm(sess, tb)
351+ d.startFloating(sess, tb, false)
352352 <-opened
353353 d.teardownFloating(tb, nil) // invalidate before the delayed Open returns
354354 allowOpen()
@@ -176,7 +176,7 @@ func TestFloatingLifecycleCapturesLaunchBeforeOpenAndDoesNotHoldTabLock(t *testi
176 tb.ctx, tb.cancel = context.WithCancel(t.Context())176 tb.ctx, tb.cancel = context.WithCancel(t.Context())
177 sess := &session{sessionCore: sessionCore{name: "work"}, cwd: cwd, env: []string{"ORDINARY=preserved", "DUP=first", "DUP=second", "PAIR=a=b", "SHELL=/bin/first", "SHELL=/bin/custom-shell", "TERM=old", "COLORTERM=old", "TERM_PROGRAM=old", "VEV=old"}, tabs: []*tab{tb}, ctx: t.Context()}177 sess := &session{sessionCore: sessionCore{name: "work"}, cwd: cwd, env: []string{"ORDINARY=preserved", "DUP=first", "DUP=second", "PAIR=a=b", "SHELL=/bin/first", "SHELL=/bin/custom-shell", "TERM=old", "COLORTERM=old", "TERM_PROGRAM=old", "VEV=old"}, tabs: []*tab{tb}, ctx: t.Context()}
178 d.ApplyConfig(domain.Config{Theme: domain.ThemeDark, Floating: domain.FloatingConfig{Command: "btop --utf", Width: 50, Height: 50}})178 d.ApplyConfig(domain.Config{Theme: domain.ThemeDark, Floating: domain.FloatingConfig{Command: "btop --utf", Width: 50, Height: 50}})
179- d.ensureFloatingWarm(sess, tb)179+ d.startFloating(sess, tb, false)
180 // Open has started while this goroutine owns tab.mu: an external factory180 // Open has started while this goroutine owns tab.mu: an external factory
181 // call under that lock would deadlock this channel-controlled test.181 // call under that lock would deadlock this channel-controlled test.
182 tb.mu.Lock()182 tb.mu.Lock()
@@ -348,7 +348,7 @@ func TestFloatingLifecycleStaleSuccessAndOldExitCannotReplaceCurrentSlot(t *test
348 d := newTestDaemon(t, factory, stubClock{})348 d := newTestDaemon(t, factory, stubClock{})
349 tb := newFloatingTestTab(t)349 tb := newFloatingTestTab(t)
350 sess := &session{sessionCore: sessionCore{name: "work"}, tabs: []*tab{tb}, ctx: t.Context()}350 sess := &session{sessionCore: sessionCore{name: "work"}, tabs: []*tab{tb}, ctx: t.Context()}
351- d.ensureFloatingWarm(sess, tb)351+ d.startFloating(sess, tb, false)
352 <-opened352 <-opened
353 d.teardownFloating(tb, nil) // invalidate before the delayed Open returns353 d.teardownFloating(tb, nil) // invalidate before the delayed Open returns
354 allowOpen()354 allowOpen()
M internal/usecase/daemon/input_test.go +6 -4
@@ -348,11 +348,13 @@ func TestAltFToggleRetainedFloatingPaneRepaintsImmediately(t *testing.T) {
348348 }
349349 }
350350
351-func requireFloatingInitialized(t *testing.T, tb *tab) {
351+// requireFloatingInitialized asserts that tab activation keeps the session's
352+// shared floating spare starting or ready. Tabs no longer prewarm their own.
353+func requireFloatingInitialized(t *testing.T, sess *session) {
352354 t.Helper()
353- tb.mu.Lock()
354- defer tb.mu.Unlock()
355- require.NotEqual(t, floatingUninitialized, tb.floating.state)
355+ sess.floatingLaunchMu.Lock()
356+ defer sess.floatingLaunchMu.Unlock()
357+ require.True(t, sess.floatingSpareStarting || sess.floatingSpare != nil)
356358 }
357359
358360 func installTestFloating(tb *tab, p *pane, visible bool) {
@@ -348,11 +348,13 @@ func TestAltFToggleRetainedFloatingPaneRepaintsImmediately(t *testing.T) {
348 }348 }
349 }349 }
350 350
351-func requireFloatingInitialized(t *testing.T, tb *tab) {351+// requireFloatingInitialized asserts that tab activation keeps the session's
352+// shared floating spare starting or ready. Tabs no longer prewarm their own.
353+func requireFloatingInitialized(t *testing.T, sess *session) {
352 t.Helper()354 t.Helper()
353- tb.mu.Lock()355+ sess.floatingLaunchMu.Lock()
354- defer tb.mu.Unlock()356+ defer sess.floatingLaunchMu.Unlock()
355- require.NotEqual(t, floatingUninitialized, tb.floating.state)357+ require.True(t, sess.floatingSpareStarting || sess.floatingSpare != nil)
356 }358 }
357 359
358 func installTestFloating(tb *tab, p *pane, visible bool) {360 func installTestFloating(tb *tab, p *pane, visible bool) {
M internal/usecase/daemon/palette_test.go +1 -1
@@ -212,7 +212,7 @@ func TestPaletteOpenTypeEnterRunAndEscClose(t *testing.T) {
212212 d.handleInput(sess, ac, []byte("NXT\r"))
213213 require.False(t, ac.overlays.paletteActive())
214214 require.Equal(t, 1, testAttachmentTabIndex(sess))
215- requireFloatingInitialized(t, testAttachmentTab(sess))
215+ requireFloatingInitialized(t, sess)
216216 awaitFrame(t, sends, "Output")
217217
218218 d.handleInput(sess, ac, []byte("\x1b "))
@@ -212,7 +212,7 @@ func TestPaletteOpenTypeEnterRunAndEscClose(t *testing.T) {
212 d.handleInput(sess, ac, []byte("NXT\r"))212 d.handleInput(sess, ac, []byte("NXT\r"))
213 require.False(t, ac.overlays.paletteActive())213 require.False(t, ac.overlays.paletteActive())
214 require.Equal(t, 1, testAttachmentTabIndex(sess))214 require.Equal(t, 1, testAttachmentTabIndex(sess))
215- requireFloatingInitialized(t, testAttachmentTab(sess))215+ requireFloatingInitialized(t, sess)
216 awaitFrame(t, sends, "Output")216 awaitFrame(t, sends, "Output")
217 217
218 d.handleInput(sess, ac, []byte("\x1b "))218 d.handleInput(sess, ac, []byte("\x1b "))
M internal/usecase/daemon/performance_benchmark_test.go +1 -1
@@ -1445,7 +1445,7 @@ func (s *countingSnapshotRepository) Publish(_ context.Context, publication port
14451445 s.headBytes += snapshotHeadBytes
14461446 for _, object := range publication.Objects {
14471447 s.suppliedObjectBytes += uint64(len(object.Data))
1448- kind, _, err := snapcodec.PreflightObject(object.Data)
1448+ kind, err := snapcodec.PreflightObject(object.Data)
14491449 if err == nil && kind == snapcodec.HistoryChunk {
14501450 s.suppliedHistoryBytes += uint64(len(object.Data))
14511451 }
@@ -1445,7 +1445,7 @@ func (s *countingSnapshotRepository) Publish(_ context.Context, publication port
1445 s.headBytes += snapshotHeadBytes1445 s.headBytes += snapshotHeadBytes
1446 for _, object := range publication.Objects {1446 for _, object := range publication.Objects {
1447 s.suppliedObjectBytes += uint64(len(object.Data))1447 s.suppliedObjectBytes += uint64(len(object.Data))
1448- kind, _, err := snapcodec.PreflightObject(object.Data)1448+ kind, err := snapcodec.PreflightObject(object.Data)
1449 if err == nil && kind == snapcodec.HistoryChunk {1449 if err == nil && kind == snapcodec.HistoryChunk {
1450 s.suppliedHistoryBytes += uint64(len(object.Data))1450 s.suppliedHistoryBytes += uint64(len(object.Data))
1451 }1451 }
M internal/usecase/daemon/session.go +5 -0
@@ -122,6 +122,10 @@ type session struct {
122122 floatingLaunchMu sync.Mutex
123123 floatingLaunchStopping bool
124124 floatingLaunches map[*floatingLaunch]struct{}
125+ // floatingSpare is the session's single pre-started floating shell; see
126+ // floating_spare.go. Guarded by floatingLaunchMu.
127+ floatingSpare *floatingSpare
128+ floatingSpareStarting bool
125129 }
126130
127131 func (s *session) nameSnapshot() string {
@@ -1542,6 +1546,7 @@ func (s *session) stopInMemoryLifecycle() {
15421546 }
15431547 s.lifecycleStopOnce.Do(func() {
15441548 s.stopFloatingLaunches()
1549+ s.closeFloatingSpare()
15451550 if s.cancel != nil {
15461551 s.cancel()
15471552 }
@@ -122,6 +122,10 @@ type session struct {
122 floatingLaunchMu sync.Mutex122 floatingLaunchMu sync.Mutex
123 floatingLaunchStopping bool123 floatingLaunchStopping bool
124 floatingLaunches map[*floatingLaunch]struct{}124 floatingLaunches map[*floatingLaunch]struct{}
125+ // floatingSpare is the session's single pre-started floating shell; see
126+ // floating_spare.go. Guarded by floatingLaunchMu.
127+ floatingSpare *floatingSpare
128+ floatingSpareStarting bool
125 }129 }
126 130
127 func (s *session) nameSnapshot() string {131 func (s *session) nameSnapshot() string {
@@ -1542,6 +1546,7 @@ func (s *session) stopInMemoryLifecycle() {
1542 }1546 }
1543 s.lifecycleStopOnce.Do(func() {1547 s.lifecycleStopOnce.Do(func() {
1544 s.stopFloatingLaunches()1548 s.stopFloatingLaunches()
1549+ s.closeFloatingSpare()
1545 if s.cancel != nil {1550 if s.cancel != nil {
1546 s.cancel()1551 s.cancel()
1547 }1552 }
M internal/usecase/daemon/snapshot_incremental_test.go +1 -1
@@ -199,7 +199,7 @@ func TestIncrementalPublicationReusesSealedChunkObject(t *testing.T) {
199199 require.Len(t, second.Objects, 2, "a generation must supply its tail and transcript once without resupplying persisted sealed chunks")
200200 kinds := make(map[snapcodec.ObjectKind]int)
201201 for _, object := range second.Objects {
202- kind, _, err := snapcodec.PreflightObject(object.Data)
202+ kind, err := snapcodec.PreflightObject(object.Data)
203203 require.NoError(t, err)
204204 kinds[kind]++
205205 }
@@ -199,7 +199,7 @@ func TestIncrementalPublicationReusesSealedChunkObject(t *testing.T) {
199 require.Len(t, second.Objects, 2, "a generation must supply its tail and transcript once without resupplying persisted sealed chunks")199 require.Len(t, second.Objects, 2, "a generation must supply its tail and transcript once without resupplying persisted sealed chunks")
200 kinds := make(map[snapcodec.ObjectKind]int)200 kinds := make(map[snapcodec.ObjectKind]int)
201 for _, object := range second.Objects {201 for _, object := range second.Objects {
202- kind, _, err := snapcodec.PreflightObject(object.Data)202+ kind, err := snapcodec.PreflightObject(object.Data)
203 require.NoError(t, err)203 require.NoError(t, err)
204 kinds[kind]++204 kinds[kind]++
205 }205 }
M internal/usecase/daemon/snapshot_restore_incremental.go +1 -4
@@ -432,10 +432,7 @@ func generationObject(generation ports.SnapshotGeneration, ref snapcodec.ObjectR
432432 if uint32(len(data)) != ref.Size {
433433 return nil, fmt.Errorf("snapshot: object size mismatch")
434434 }
435- // The payload aliases the loaded object. A generation's objects are private
436- // to this restore and are only decoded, never mutated, so copying here would
437- // double the transient footprint of every restored history chunk.
438- gotKind, payload, err := snapcodec.PreflightObject(data)
435+ gotKind, payload, err := snapcodec.UnmarshalObject(data)
439436 if err != nil || gotKind != kind {
440437 return nil, fmt.Errorf("snapshot: invalid object")
441438 }
@@ -432,10 +432,7 @@ func generationObject(generation ports.SnapshotGeneration, ref snapcodec.ObjectR
432 if uint32(len(data)) != ref.Size {432 if uint32(len(data)) != ref.Size {
433 return nil, fmt.Errorf("snapshot: object size mismatch")433 return nil, fmt.Errorf("snapshot: object size mismatch")
434 }434 }
435- // The payload aliases the loaded object. A generation's objects are private435+ gotKind, payload, err := snapcodec.UnmarshalObject(data)
436- // to this restore and are only decoded, never mutated, so copying here would
437- // double the transient footprint of every restored history chunk.
438- gotKind, payload, err := snapcodec.PreflightObject(data)
439 if err != nil || gotKind != kind {436 if err != nil || gotKind != kind {
440 return nil, fmt.Errorf("snapshot: invalid object")437 return nil, fmt.Errorf("snapshot: invalid object")
441 }438 }
M internal/usecase/snapshot/manifest.go +37 -23
@@ -16,7 +16,8 @@ const (
1616 manifestMagic = "VEVM"
1717 headMagic = "VEVH"
1818 objectMagic = "VEVO"
19- ManifestVersion = uint16(4) // Reject dense VT checkpoints before loading their objects.
19+ ManifestVersion = uint16(5) // Objects are zlib-compressed; reject older checkpoints before loading them.
20+ objectVersion = uint16(2) // kind, raw payload length, zlib payload
2021 manifestHeaderSize = 16
2122 objectEnvelopeBodyPrefixSize = 1 + 4 // object kind and payload length
2223 minObjectEnvelopeSize = manifestHeaderSize + objectEnvelopeBodyPrefixSize + 1
@@ -237,8 +238,9 @@ func decodeManifest(body []byte) (Manifest, error) {
237238 return m, nil
238239 }
239240
240-// MarshalObject frames payload as VEVO and returns the bytes together with the
241-// SHA-256 address of those complete bytes.
241+// MarshalObject frames payload as a zlib-compressed VEVO object and returns the
242+// bytes together with the SHA-256 address of those complete bytes. Encoding is
243+// deterministic, so equal payloads still share one content address.
242244 func MarshalObject(kind ObjectKind, payload []byte) (ports.SnapshotObject, error) {
243245 if !validObjectKind(kind) || len(payload) == 0 || len(payload) > maxObjectPayloadSize {
244246 return ports.SnapshotObject{}, fmt.Errorf("%w: object", ErrInvalidData)
@@ -246,47 +248,56 @@ func MarshalObject(kind ObjectKind, payload []byte) (ports.SnapshotObject, error
246248 var w payloadWriter
247249 w.putUint8(uint8(kind))
248250 w.putUint32(uint32(len(payload)))
249- w.b = append(w.b, payload...)
250- data, err := marshalManifestEnvelope(objectMagic, w.b)
251+ body, err := compressObjectPayload(w.b, payload)
252+ if err != nil {
253+ return ports.SnapshotObject{}, err
254+ }
255+ data, err := marshalManifestEnvelope(objectMagic, body)
251256 if err != nil {
252257 return ports.SnapshotObject{}, err
253258 }
254259 return ports.SnapshotObject{Digest: sha256.Sum256(data), Data: data}, nil
255260 }
256261
262+// UnmarshalObject verifies VEVO framing and returns the inflated payload. It
263+// requires exactly the declared payload length and a clean zlib end of stream.
257264 func UnmarshalObject(encoded []byte) (ObjectKind, []byte, error) {
258- kind, payload, err := PreflightObject(encoded)
265+ kind, rawLen, compressed, err := preflightObject(encoded)
266+ if err != nil {
267+ return 0, nil, err
268+ }
269+ payload, err := inflateObjectPayload(compressed, rawLen)
259270 if err != nil {
260271 return 0, nil, err
261272 }
262- return kind, append([]byte(nil), payload...), nil
273+ return kind, payload, nil
263274 }
264275
265-// PreflightObject verifies VEVO framing and declared payload size without
266-// copying; the returned payload aliases encoded and is only valid while it is.
267-func PreflightObject(encoded []byte) (ObjectKind, []byte, error) {
276+// PreflightObject verifies VEVO framing (magic, version, length, CRC, kind, and
277+// declared payload length) without inflating it. Repository adapters use it to
278+// accept or reject stored objects cheaply; UnmarshalObject decodes the payload.
279+func PreflightObject(encoded []byte) (ObjectKind, error) {
280+ kind, _, _, err := preflightObject(encoded)
281+ return kind, err
282+}
283+
284+func preflightObject(encoded []byte) (ObjectKind, uint32, []byte, error) {
268285 body, err := unmarshalManifestEnvelope(encoded, objectMagic)
269286 if err != nil {
270- return 0, nil, err
287+ return 0, 0, nil, err
271288 }
272- if len(body) < 5 {
273- return 0, nil, ErrShortPayload
289+ if len(body) < objectEnvelopeBodyPrefixSize+1 {
290+ return 0, 0, nil, ErrShortPayload
274291 }
275292 kind := ObjectKind(body[0])
276293 if !validObjectKind(kind) {
277- return 0, nil, fmt.Errorf("%w: object kind", ErrInvalidData)
294+ return 0, 0, nil, fmt.Errorf("%w: object kind", ErrInvalidData)
278295 }
279296 n := binary.BigEndian.Uint32(body[1:5])
280297 if n == 0 || n > maxObjectPayloadSize {
281- return 0, nil, fmt.Errorf("%w: object length", ErrInvalidData)
282- }
283- if uint64(n) > uint64(len(body)-5) {
284- return 0, nil, ErrShortPayload
285- }
286- if uint64(n) != uint64(len(body)-5) {
287- return 0, nil, ErrTrailingBytes
298+ return 0, 0, nil, fmt.Errorf("%w: object length", ErrInvalidData)
288299 }
289- return kind, body[5:], nil
300+ return kind, n, body[objectEnvelopeBodyPrefixSize:], nil
290301 }
291302
292303 func marshalManifestEnvelope(magic string, body []byte) ([]byte, error) {
@@ -303,8 +314,11 @@ func marshalManifestEnvelope(magic string, body []byte) ([]byte, error) {
303314 }
304315
305316 func envelopeVersion(magic string) uint16 {
306- if magic == manifestMagic {
317+ switch magic {
318+ case manifestMagic:
307319 return ManifestVersion
320+ case objectMagic:
321+ return objectVersion
308322 }
309323 return 1
310324 }
@@ -16,7 +16,8 @@ const (
16 manifestMagic = "VEVM"16 manifestMagic = "VEVM"
17 headMagic = "VEVH"17 headMagic = "VEVH"
18 objectMagic = "VEVO"18 objectMagic = "VEVO"
19- ManifestVersion = uint16(4) // Reject dense VT checkpoints before loading their objects.19+ ManifestVersion = uint16(5) // Objects are zlib-compressed; reject older checkpoints before loading them.
20+ objectVersion = uint16(2) // kind, raw payload length, zlib payload
20 manifestHeaderSize = 1621 manifestHeaderSize = 16
21 objectEnvelopeBodyPrefixSize = 1 + 4 // object kind and payload length22 objectEnvelopeBodyPrefixSize = 1 + 4 // object kind and payload length
22 minObjectEnvelopeSize = manifestHeaderSize + objectEnvelopeBodyPrefixSize + 123 minObjectEnvelopeSize = manifestHeaderSize + objectEnvelopeBodyPrefixSize + 1
@@ -237,8 +238,9 @@ func decodeManifest(body []byte) (Manifest, error) {
237 return m, nil238 return m, nil
238 }239 }
239 240
240-// MarshalObject frames payload as VEVO and returns the bytes together with the241+// MarshalObject frames payload as a zlib-compressed VEVO object and returns the
241-// SHA-256 address of those complete bytes.242+// bytes together with the SHA-256 address of those complete bytes. Encoding is
243+// deterministic, so equal payloads still share one content address.
242 func MarshalObject(kind ObjectKind, payload []byte) (ports.SnapshotObject, error) {244 func MarshalObject(kind ObjectKind, payload []byte) (ports.SnapshotObject, error) {
243 if !validObjectKind(kind) || len(payload) == 0 || len(payload) > maxObjectPayloadSize {245 if !validObjectKind(kind) || len(payload) == 0 || len(payload) > maxObjectPayloadSize {
244 return ports.SnapshotObject{}, fmt.Errorf("%w: object", ErrInvalidData)246 return ports.SnapshotObject{}, fmt.Errorf("%w: object", ErrInvalidData)
@@ -246,47 +248,56 @@ func MarshalObject(kind ObjectKind, payload []byte) (ports.SnapshotObject, error
246 var w payloadWriter248 var w payloadWriter
247 w.putUint8(uint8(kind))249 w.putUint8(uint8(kind))
248 w.putUint32(uint32(len(payload)))250 w.putUint32(uint32(len(payload)))
249- w.b = append(w.b, payload...)251+ body, err := compressObjectPayload(w.b, payload)
250- data, err := marshalManifestEnvelope(objectMagic, w.b)252+ if err != nil {
253+ return ports.SnapshotObject{}, err
254+ }
255+ data, err := marshalManifestEnvelope(objectMagic, body)
251 if err != nil {256 if err != nil {
252 return ports.SnapshotObject{}, err257 return ports.SnapshotObject{}, err
253 }258 }
254 return ports.SnapshotObject{Digest: sha256.Sum256(data), Data: data}, nil259 return ports.SnapshotObject{Digest: sha256.Sum256(data), Data: data}, nil
255 }260 }
256 261
262+// UnmarshalObject verifies VEVO framing and returns the inflated payload. It
263+// requires exactly the declared payload length and a clean zlib end of stream.
257 func UnmarshalObject(encoded []byte) (ObjectKind, []byte, error) {264 func UnmarshalObject(encoded []byte) (ObjectKind, []byte, error) {
258- kind, payload, err := PreflightObject(encoded)265+ kind, rawLen, compressed, err := preflightObject(encoded)
266+ if err != nil {
267+ return 0, nil, err
268+ }
269+ payload, err := inflateObjectPayload(compressed, rawLen)
259 if err != nil {270 if err != nil {
260 return 0, nil, err271 return 0, nil, err
261 }272 }
262- return kind, append([]byte(nil), payload...), nil273+ return kind, payload, nil
263 }274 }
264 275
265-// PreflightObject verifies VEVO framing and declared payload size without276+// PreflightObject verifies VEVO framing (magic, version, length, CRC, kind, and
266-// copying; the returned payload aliases encoded and is only valid while it is.277+// declared payload length) without inflating it. Repository adapters use it to
267-func PreflightObject(encoded []byte) (ObjectKind, []byte, error) {278+// accept or reject stored objects cheaply; UnmarshalObject decodes the payload.
279+func PreflightObject(encoded []byte) (ObjectKind, error) {
280+ kind, _, _, err := preflightObject(encoded)
281+ return kind, err
282+}
283+
284+func preflightObject(encoded []byte) (ObjectKind, uint32, []byte, error) {
268 body, err := unmarshalManifestEnvelope(encoded, objectMagic)285 body, err := unmarshalManifestEnvelope(encoded, objectMagic)
269 if err != nil {286 if err != nil {
270- return 0, nil, err287+ return 0, 0, nil, err
271 }288 }
272- if len(body) < 5 {289+ if len(body) < objectEnvelopeBodyPrefixSize+1 {
273- return 0, nil, ErrShortPayload290+ return 0, 0, nil, ErrShortPayload
274 }291 }
275 kind := ObjectKind(body[0])292 kind := ObjectKind(body[0])
276 if !validObjectKind(kind) {293 if !validObjectKind(kind) {
277- return 0, nil, fmt.Errorf("%w: object kind", ErrInvalidData)294+ return 0, 0, nil, fmt.Errorf("%w: object kind", ErrInvalidData)
278 }295 }
279 n := binary.BigEndian.Uint32(body[1:5])296 n := binary.BigEndian.Uint32(body[1:5])
280 if n == 0 || n > maxObjectPayloadSize {297 if n == 0 || n > maxObjectPayloadSize {
281- return 0, nil, fmt.Errorf("%w: object length", ErrInvalidData)298+ return 0, 0, nil, fmt.Errorf("%w: object length", ErrInvalidData)
282- }
283- if uint64(n) > uint64(len(body)-5) {
284- return 0, nil, ErrShortPayload
285- }
286- if uint64(n) != uint64(len(body)-5) {
287- return 0, nil, ErrTrailingBytes
288 }299 }
289- return kind, body[5:], nil300+ return kind, n, body[objectEnvelopeBodyPrefixSize:], nil
290 }301 }
291 302
292 func marshalManifestEnvelope(magic string, body []byte) ([]byte, error) {303 func marshalManifestEnvelope(magic string, body []byte) ([]byte, error) {
@@ -303,8 +314,11 @@ func marshalManifestEnvelope(magic string, body []byte) ([]byte, error) {
303 }314 }
304 315
305 func envelopeVersion(magic string) uint16 {316 func envelopeVersion(magic string) uint16 {
306- if magic == manifestMagic {317+ switch magic {
318+ case manifestMagic:
307 return ManifestVersion319 return ManifestVersion
320+ case objectMagic:
321+ return objectVersion
308 }322 }
309 return 1323 return 1
310 }324 }
M internal/usecase/snapshot/manifest_test.go +7 -5
@@ -119,9 +119,9 @@ func TestObjectCodecReturnsContentAddressedObjectAndPayload(t *testing.T) {
119119 if err != nil || kind != RecoveryTranscript || !bytes.Equal(payload, []byte("canonical-vt")) {
120120 t.Fatalf("round trip = %v %q %v", kind, payload, err)
121121 }
122- kind, payload, err = PreflightObject(object.Data)
123- if err != nil || kind != RecoveryTranscript || !bytes.Equal(payload, []byte("canonical-vt")) {
124- t.Fatalf("preflight = %v %q %v", kind, payload, err)
122+ kind, err = PreflightObject(object.Data)
123+ if err != nil || kind != RecoveryTranscript {
124+ t.Fatalf("preflight = %v %v", kind, err)
125125 }
126126 for n := range len(object.Data) {
127127 if _, _, err := UnmarshalObject(object.Data[:n]); err == nil {
@@ -178,8 +178,10 @@ func TestObjectRefEnvelopeSizeBoundaries(t *testing.T) {
178178 if err != nil {
179179 t.Fatalf("MarshalObject() error = %v", err)
180180 }
181- if len(object.Data) != minimum {
182- t.Fatalf("MarshalObject() size = %d, want %d", len(object.Data), minimum)
181+ // The smallest payload is still a complete zlib stream, so real objects sit
182+ // above the declared lower bound the manifest accepts.
183+ if len(object.Data) < minimum {
184+ t.Fatalf("MarshalObject() size = %d, want >= %d", len(object.Data), minimum)
183185 }
184186
185187 for _, tc := range []struct {
@@ -119,9 +119,9 @@ func TestObjectCodecReturnsContentAddressedObjectAndPayload(t *testing.T) {
119 if err != nil || kind != RecoveryTranscript || !bytes.Equal(payload, []byte("canonical-vt")) {119 if err != nil || kind != RecoveryTranscript || !bytes.Equal(payload, []byte("canonical-vt")) {
120 t.Fatalf("round trip = %v %q %v", kind, payload, err)120 t.Fatalf("round trip = %v %q %v", kind, payload, err)
121 }121 }
122- kind, payload, err = PreflightObject(object.Data)122+ kind, err = PreflightObject(object.Data)
123- if err != nil || kind != RecoveryTranscript || !bytes.Equal(payload, []byte("canonical-vt")) {123+ if err != nil || kind != RecoveryTranscript {
124- t.Fatalf("preflight = %v %q %v", kind, payload, err)124+ t.Fatalf("preflight = %v %v", kind, err)
125 }125 }
126 for n := range len(object.Data) {126 for n := range len(object.Data) {
127 if _, _, err := UnmarshalObject(object.Data[:n]); err == nil {127 if _, _, err := UnmarshalObject(object.Data[:n]); err == nil {
@@ -178,8 +178,10 @@ func TestObjectRefEnvelopeSizeBoundaries(t *testing.T) {
178 if err != nil {178 if err != nil {
179 t.Fatalf("MarshalObject() error = %v", err)179 t.Fatalf("MarshalObject() error = %v", err)
180 }180 }
181- if len(object.Data) != minimum {181+ // The smallest payload is still a complete zlib stream, so real objects sit
182- t.Fatalf("MarshalObject() size = %d, want %d", len(object.Data), minimum)182+ // above the declared lower bound the manifest accepts.
183+ if len(object.Data) < minimum {
184+ t.Fatalf("MarshalObject() size = %d, want >= %d", len(object.Data), minimum)
183 }185 }
184 186
185 for _, tc := range []struct {187 for _, tc := range []struct {
A internal/usecase/snapshot/object_compress.go +81 -0
@@ -0,0 +1,81 @@
1+package snapshot
2+
3+import (
4+ "bytes"
5+ "compress/zlib"
6+ "errors"
7+ "fmt"
8+ "io"
9+ "sync"
10+)
11+
12+// Object payloads are stored zlib-compressed. History cells are highly
13+// repetitive, so a sealed 10k-row history shrinks several times on disk.
14+// BestSpeed keeps snapshot encoding cheap; Go's deflate output is
15+// deterministic for one level, so content addressing stays stable.
16+
17+var objectCompressorPool = sync.Pool{New: func() any {
18+ w, err := zlib.NewWriterLevel(io.Discard, zlib.BestSpeed)
19+ if err != nil {
20+ panic(err) // BestSpeed is a valid level
21+ }
22+ return w
23+}}
24+
25+var objectDecompressorPool sync.Pool
26+
27+// compressObjectPayload appends the zlib stream of payload to prefix.
28+func compressObjectPayload(prefix, payload []byte) ([]byte, error) {
29+ out := bytes.NewBuffer(prefix)
30+ out.Grow(len(payload)/4 + 64)
31+ w := objectCompressorPool.Get().(*zlib.Writer)
32+ w.Reset(out)
33+ defer func() {
34+ w.Reset(io.Discard)
35+ objectCompressorPool.Put(w)
36+ }()
37+ if _, err := w.Write(payload); err != nil {
38+ return nil, err
39+ }
40+ if err := w.Close(); err != nil {
41+ return nil, err
42+ }
43+ return out.Bytes(), nil
44+}
45+
46+// inflateObjectPayload decodes exactly size bytes and requires a clean end of
47+// stream (which also verifies the zlib checksum) with no trailing data.
48+func inflateObjectPayload(compressed []byte, size uint32) ([]byte, error) {
49+ invalid := func(err error) error { return fmt.Errorf("%w: object payload: %v", ErrInvalidData, err) }
50+ source := bytes.NewReader(compressed)
51+ r, err := objectDecompressor(source)
52+ if err != nil {
53+ return nil, invalid(err)
54+ }
55+ defer objectDecompressorPool.Put(r)
56+ payload := make([]byte, size)
57+ if _, err := io.ReadFull(r, payload); err != nil {
58+ return nil, invalid(err)
59+ }
60+ var extra [1]byte
61+ if n, err := r.Read(extra[:]); n != 0 || err != io.EOF {
62+ if err == nil || err == io.EOF {
63+ err = errors.New("payload longer than declared")
64+ }
65+ return nil, invalid(err)
66+ }
67+ if source.Len() != 0 {
68+ return nil, ErrTrailingBytes
69+ }
70+ return payload, nil
71+}
72+
73+func objectDecompressor(source io.Reader) (io.ReadCloser, error) {
74+ if pooled, ok := objectDecompressorPool.Get().(io.ReadCloser); ok {
75+ if err := pooled.(zlib.Resetter).Reset(source, nil); err != nil {
76+ return nil, err
77+ }
78+ return pooled, nil
79+ }
80+ return zlib.NewReader(source)
81+}
@@ -0,0 +1,81 @@
1+package snapshot
2+
3+import (
4+ "bytes"
5+ "compress/zlib"
6+ "errors"
7+ "fmt"
8+ "io"
9+ "sync"
10+)
11+
12+// Object payloads are stored zlib-compressed. History cells are highly
13+// repetitive, so a sealed 10k-row history shrinks several times on disk.
14+// BestSpeed keeps snapshot encoding cheap; Go's deflate output is
15+// deterministic for one level, so content addressing stays stable.
16+
17+var objectCompressorPool = sync.Pool{New: func() any {
18+ w, err := zlib.NewWriterLevel(io.Discard, zlib.BestSpeed)
19+ if err != nil {
20+ panic(err) // BestSpeed is a valid level
21+ }
22+ return w
23+}}
24+
25+var objectDecompressorPool sync.Pool
26+
27+// compressObjectPayload appends the zlib stream of payload to prefix.
28+func compressObjectPayload(prefix, payload []byte) ([]byte, error) {
29+ out := bytes.NewBuffer(prefix)
30+ out.Grow(len(payload)/4 + 64)
31+ w := objectCompressorPool.Get().(*zlib.Writer)
32+ w.Reset(out)
33+ defer func() {
34+ w.Reset(io.Discard)
35+ objectCompressorPool.Put(w)
36+ }()
37+ if _, err := w.Write(payload); err != nil {
38+ return nil, err
39+ }
40+ if err := w.Close(); err != nil {
41+ return nil, err
42+ }
43+ return out.Bytes(), nil
44+}
45+
46+// inflateObjectPayload decodes exactly size bytes and requires a clean end of
47+// stream (which also verifies the zlib checksum) with no trailing data.
48+func inflateObjectPayload(compressed []byte, size uint32) ([]byte, error) {
49+ invalid := func(err error) error { return fmt.Errorf("%w: object payload: %v", ErrInvalidData, err) }
50+ source := bytes.NewReader(compressed)
51+ r, err := objectDecompressor(source)
52+ if err != nil {
53+ return nil, invalid(err)
54+ }
55+ defer objectDecompressorPool.Put(r)
56+ payload := make([]byte, size)
57+ if _, err := io.ReadFull(r, payload); err != nil {
58+ return nil, invalid(err)
59+ }
60+ var extra [1]byte
61+ if n, err := r.Read(extra[:]); n != 0 || err != io.EOF {
62+ if err == nil || err == io.EOF {
63+ err = errors.New("payload longer than declared")
64+ }
65+ return nil, invalid(err)
66+ }
67+ if source.Len() != 0 {
68+ return nil, ErrTrailingBytes
69+ }
70+ return payload, nil
71+}
72+
73+func objectDecompressor(source io.Reader) (io.ReadCloser, error) {
74+ if pooled, ok := objectDecompressorPool.Get().(io.ReadCloser); ok {
75+ if err := pooled.(zlib.Resetter).Reset(source, nil); err != nil {
76+ return nil, err
77+ }
78+ return pooled, nil
79+ }
80+ return zlib.NewReader(source)
81+}
A internal/usecase/snapshot/object_compress_test.go +63 -0
@@ -0,0 +1,63 @@
1+package snapshot
2+
3+import (
4+ "bytes"
5+ "encoding/binary"
6+ "errors"
7+ "hash/crc32"
8+ "testing"
9+)
10+
11+// reframeObject rewrites the VEVO body and fixes length and CRC so a test
12+// reaches the payload checks instead of failing on framing.
13+func reframeObject(t *testing.T, encoded []byte, mutate func(body []byte) []byte) []byte {
14+ t.Helper()
15+ body := mutate(append([]byte(nil), encoded[manifestHeaderSize:]...))
16+ out := append([]byte(nil), encoded[:manifestHeaderSize]...)
17+ binary.BigEndian.PutUint32(out[8:12], uint32(len(body)))
18+ binary.BigEndian.PutUint32(out[12:16], crc32.ChecksumIEEE(body))
19+ return append(out, body...)
20+}
21+
22+func TestObjectCompressionRejectsMalformedPayloads(t *testing.T) {
23+ payload := bytes.Repeat([]byte("history row "), 512)
24+ object, err := MarshalObject(HistoryChunk, payload)
25+ if err != nil {
26+ t.Fatal(err)
27+ }
28+ if len(object.Data) >= len(payload)/4 {
29+ t.Fatalf("object = %d bytes, want well under %d raw bytes", len(object.Data), len(payload))
30+ }
31+ again, err := MarshalObject(HistoryChunk, payload)
32+ if err != nil || !bytes.Equal(again.Data, object.Data) || again.Digest != object.Digest {
33+ t.Fatal("equal payloads must share one deterministic content address")
34+ }
35+
36+ for _, tc := range []struct {
37+ name string
38+ mutate func(body []byte) []byte
39+ want error
40+ }{
41+ {name: "declared shorter", mutate: func(b []byte) []byte {
42+ binary.BigEndian.PutUint32(b[1:5], uint32(len(payload)-1))
43+ return b
44+ }, want: ErrInvalidData},
45+ {name: "declared longer", mutate: func(b []byte) []byte {
46+ binary.BigEndian.PutUint32(b[1:5], uint32(len(payload)+1))
47+ return b
48+ }, want: ErrInvalidData},
49+ {name: "truncated stream", mutate: func(b []byte) []byte { return b[:len(b)-1] }, want: ErrInvalidData},
50+ {name: "corrupt stream", mutate: func(b []byte) []byte { b[len(b)-6] ^= 0xff; return b }, want: ErrInvalidData},
51+ {name: "trailing garbage", mutate: func(b []byte) []byte { return append(b, 0) }, want: ErrTrailingBytes},
52+ } {
53+ t.Run(tc.name, func(t *testing.T) {
54+ bad := reframeObject(t, object.Data, tc.mutate)
55+ if _, err := PreflightObject(bad); err != nil {
56+ t.Fatalf("preflight must accept valid framing, got %v", err)
57+ }
58+ if _, _, err := UnmarshalObject(bad); !errors.Is(err, tc.want) {
59+ t.Fatalf("UnmarshalObject() error = %v, want %v", err, tc.want)
60+ }
61+ })
62+ }
63+}
@@ -0,0 +1,63 @@
1+package snapshot
2+
3+import (
4+ "bytes"
5+ "encoding/binary"
6+ "errors"
7+ "hash/crc32"
8+ "testing"
9+)
10+
11+// reframeObject rewrites the VEVO body and fixes length and CRC so a test
12+// reaches the payload checks instead of failing on framing.
13+func reframeObject(t *testing.T, encoded []byte, mutate func(body []byte) []byte) []byte {
14+ t.Helper()
15+ body := mutate(append([]byte(nil), encoded[manifestHeaderSize:]...))
16+ out := append([]byte(nil), encoded[:manifestHeaderSize]...)
17+ binary.BigEndian.PutUint32(out[8:12], uint32(len(body)))
18+ binary.BigEndian.PutUint32(out[12:16], crc32.ChecksumIEEE(body))
19+ return append(out, body...)
20+}
21+
22+func TestObjectCompressionRejectsMalformedPayloads(t *testing.T) {
23+ payload := bytes.Repeat([]byte("history row "), 512)
24+ object, err := MarshalObject(HistoryChunk, payload)
25+ if err != nil {
26+ t.Fatal(err)
27+ }
28+ if len(object.Data) >= len(payload)/4 {
29+ t.Fatalf("object = %d bytes, want well under %d raw bytes", len(object.Data), len(payload))
30+ }
31+ again, err := MarshalObject(HistoryChunk, payload)
32+ if err != nil || !bytes.Equal(again.Data, object.Data) || again.Digest != object.Digest {
33+ t.Fatal("equal payloads must share one deterministic content address")
34+ }
35+
36+ for _, tc := range []struct {
37+ name string
38+ mutate func(body []byte) []byte
39+ want error
40+ }{
41+ {name: "declared shorter", mutate: func(b []byte) []byte {
42+ binary.BigEndian.PutUint32(b[1:5], uint32(len(payload)-1))
43+ return b
44+ }, want: ErrInvalidData},
45+ {name: "declared longer", mutate: func(b []byte) []byte {
46+ binary.BigEndian.PutUint32(b[1:5], uint32(len(payload)+1))
47+ return b
48+ }, want: ErrInvalidData},
49+ {name: "truncated stream", mutate: func(b []byte) []byte { return b[:len(b)-1] }, want: ErrInvalidData},
50+ {name: "corrupt stream", mutate: func(b []byte) []byte { b[len(b)-6] ^= 0xff; return b }, want: ErrInvalidData},
51+ {name: "trailing garbage", mutate: func(b []byte) []byte { return append(b, 0) }, want: ErrTrailingBytes},
52+ } {
53+ t.Run(tc.name, func(t *testing.T) {
54+ bad := reframeObject(t, object.Data, tc.mutate)
55+ if _, err := PreflightObject(bad); err != nil {
56+ t.Fatalf("preflight must accept valid framing, got %v", err)
57+ }
58+ if _, _, err := UnmarshalObject(bad); !errors.Is(err, tc.want) {
59+ t.Fatalf("UnmarshalObject() error = %v, want %v", err, tc.want)
60+ }
61+ })
62+ }
63+}
M internal/usecase/snapshot/weights_test.go +4 -4
@@ -85,11 +85,11 @@ func TestManifestWeightRoundTripAndOldCompatibility(t *testing.T) {
8585 func TestSnapshotEnvelopeCompatibility(t *testing.T) {
8686 const (
8787 headGolden = "56455648000100000000002898ebcac200000000000000070100000000000000000000000000000000000000000000000000000000000000"
88- objectGolden = "5645564f00010000000000111cb8f09f030000000c63616e6f6e6963616c2d7674"
89- manifestGolden = "5645564d0004000000000133d118cc030000000000000001010000000000000000000000000000000000056e616d6564000000000000000000000001000374616200500018000000000000000000036f6e65010000020000036f6e6500000374776f000200036f6e6500036f6e6500042f6f6e65000101010000000000000000000000000000000000000000000000000000000000000000000016020200000000000000000000000000000000000000000000000000000000000000000000160303000000000000000000000000000000000000000000000000000000000000000000001600000374776f000374776f00042f74776f0000020400000000000000000000000000000000000000000000000000000000000000000000160305000000000000000000000000000000000000000000000000000000000000000000001600"
88+ objectGolden = "5645564f000200000000001ef35457a7030000000c7801000c00f3ff63616e6f6e6963616c2d767403001f2b04c0"
89+ manifestGolden = "5645564d0005000000000133d118cc030000000000000001010000000000000000000000000000000000056e616d6564000000000000000000000001000374616200500018000000000000000000036f6e65010000020000036f6e6500000374776f000200036f6e6500036f6e6500042f6f6e65000101010000000000000000000000000000000000000000000000000000000000000000000016020200000000000000000000000000000000000000000000000000000000000000000000160303000000000000000000000000000000000000000000000000000000000000000000001600000374776f000374776f00042f74776f0000020400000000000000000000000000000000000000000000000000000000000000000000160305000000000000000000000000000000000000000000000000000000000000000000001600"
9090 )
91- if ManifestVersion != 4 {
92- t.Fatalf("ManifestVersion = %d, want 4", ManifestVersion)
91+ if ManifestVersion != 5 {
92+ t.Fatalf("ManifestVersion = %d, want 5", ManifestVersion)
9393 }
9494
9595 head, err := MarshalHead(Head{Generation: 7, ManifestDigest: SnapshotDigest{1}})
@@ -85,11 +85,11 @@ func TestManifestWeightRoundTripAndOldCompatibility(t *testing.T) {
85 func TestSnapshotEnvelopeCompatibility(t *testing.T) {85 func TestSnapshotEnvelopeCompatibility(t *testing.T) {
86 const (86 const (
87 headGolden = "56455648000100000000002898ebcac200000000000000070100000000000000000000000000000000000000000000000000000000000000"87 headGolden = "56455648000100000000002898ebcac200000000000000070100000000000000000000000000000000000000000000000000000000000000"
88- objectGolden = "5645564f00010000000000111cb8f09f030000000c63616e6f6e6963616c2d7674"88+ objectGolden = "5645564f000200000000001ef35457a7030000000c7801000c00f3ff63616e6f6e6963616c2d767403001f2b04c0"
89- manifestGolden = "5645564d0004000000000133d118cc030000000000000001010000000000000000000000000000000000056e616d6564000000000000000000000001000374616200500018000000000000000000036f6e65010000020000036f6e6500000374776f000200036f6e6500036f6e6500042f6f6e65000101010000000000000000000000000000000000000000000000000000000000000000000016020200000000000000000000000000000000000000000000000000000000000000000000160303000000000000000000000000000000000000000000000000000000000000000000001600000374776f000374776f00042f74776f0000020400000000000000000000000000000000000000000000000000000000000000000000160305000000000000000000000000000000000000000000000000000000000000000000001600"89+ manifestGolden = "5645564d0005000000000133d118cc030000000000000001010000000000000000000000000000000000056e616d6564000000000000000000000001000374616200500018000000000000000000036f6e65010000020000036f6e6500000374776f000200036f6e6500036f6e6500042f6f6e65000101010000000000000000000000000000000000000000000000000000000000000000000016020200000000000000000000000000000000000000000000000000000000000000000000160303000000000000000000000000000000000000000000000000000000000000000000001600000374776f000374776f00042f74776f0000020400000000000000000000000000000000000000000000000000000000000000000000160305000000000000000000000000000000000000000000000000000000000000000000001600"
90 )90 )
91- if ManifestVersion != 4 {91+ if ManifestVersion != 5 {
92- t.Fatalf("ManifestVersion = %d, want 4", ManifestVersion)92+ t.Fatalf("ManifestVersion = %d, want 5", ManifestVersion)
93 }93 }
94 94
95 head, err := MarshalHead(Head{Generation: 7, ManifestDigest: SnapshotDigest{1}})95 head, err := MarshalHead(Head{Generation: 7, ManifestDigest: SnapshotDigest{1}})
M internal/usecase/visualsearch/model.go +14 -3
@@ -2,6 +2,7 @@ package visualsearch
22
33 import (
44 "strconv"
5+ "strings"
56
67 renderer "github.com/bnema/vev-vt"
78 "github.com/bnema/vev/internal/domain"
@@ -13,8 +14,10 @@ type Model struct {
1314 snapshot scopy.Snapshot
1415 input ui.TextInput
1516 matches []scopy.SearchMatch
16- selected int
17- scroll int
17+ // matchedQuery is the trimmed query matches was computed for.
18+ matchedQuery string
19+ selected int
20+ scroll int
1821 }
1922
2023 func New(snapshot scopy.Snapshot) *Model {
@@ -106,7 +109,15 @@ func (m *Model) Matches() []scopy.SearchMatch {
106109 }
107110
108111 func (m *Model) refresh() {
109- m.matches = scopy.FindMatches(scopy.NewDocument(m.snapshot, ""), m.input.Value())
112+ doc := scopy.NewDocument(m.snapshot, "")
113+ query := strings.TrimSpace(m.input.Value())
114+ // Typing extends the query, so only rows that already matched can match.
115+ if m.matchedQuery != "" && strings.HasPrefix(query, m.matchedQuery) {
116+ m.matches = scopy.RefineMatches(doc, m.matches, query)
117+ } else {
118+ m.matches = scopy.FindMatches(doc, query)
119+ }
120+ m.matchedQuery = query
110121 m.clamp()
111122 m.ensureVisible(defaultVisibleRows)
112123 }
@@ -2,6 +2,7 @@ package visualsearch
2 2
3 import (3 import (
4 "strconv"4 "strconv"
5+ "strings"
5 6
6 renderer "github.com/bnema/vev-vt"7 renderer "github.com/bnema/vev-vt"
7 "github.com/bnema/vev/internal/domain"8 "github.com/bnema/vev/internal/domain"
@@ -13,8 +14,10 @@ type Model struct {
13 snapshot scopy.Snapshot14 snapshot scopy.Snapshot
14 input ui.TextInput15 input ui.TextInput
15 matches []scopy.SearchMatch16 matches []scopy.SearchMatch
16- selected int17+ // matchedQuery is the trimmed query matches was computed for.
17- scroll int18+ matchedQuery string
19+ selected int
20+ scroll int
18 }21 }
19 22
20 func New(snapshot scopy.Snapshot) *Model {23 func New(snapshot scopy.Snapshot) *Model {
@@ -106,7 +109,15 @@ func (m *Model) Matches() []scopy.SearchMatch {
106 }109 }
107 110
108 func (m *Model) refresh() {111 func (m *Model) refresh() {
109- m.matches = scopy.FindMatches(scopy.NewDocument(m.snapshot, ""), m.input.Value())112+ doc := scopy.NewDocument(m.snapshot, "")
113+ query := strings.TrimSpace(m.input.Value())
114+ // Typing extends the query, so only rows that already matched can match.
115+ if m.matchedQuery != "" && strings.HasPrefix(query, m.matchedQuery) {
116+ m.matches = scopy.RefineMatches(doc, m.matches, query)
117+ } else {
118+ m.matches = scopy.FindMatches(doc, query)
119+ }
120+ m.matchedQuery = query
110 m.clamp()121 m.clamp()
111 m.ensureVisible(defaultVisibleRows)122 m.ensureVisible(defaultVisibleRows)
112 }123 }
M internal/usecase/visualsearch/model_test.go +37 -0
@@ -47,6 +47,43 @@ func TestVisualSearchModelFiltersAndSelectsLineMatches(t *testing.T) {
4747 require.Equal(t, matches[1], selected)
4848 }
4949
50+// Typing refines previous matches incrementally; every intermediate result
51+// must equal a full search for the same query.
52+func TestVisualSearchIncrementalMatchesEqualFullSearch(t *testing.T) {
53+ snapshot := testSnapshot("Snapshot ssh", "snap SNAPshot snapsh", "界snap 界snapshot", "none", " snaps ")
54+ doc := scopy.NewDocument(snapshot, "")
55+ steps := []struct {
56+ name string
57+ edit func(*Model)
58+ }{
59+ {name: "type", edit: func(m *Model) {
60+ for _, r := range "snapsh" {
61+ m.Insert(r)
62+ }
63+ }},
64+ {name: "backspace then retype", edit: func(m *Model) { m.Backspace(); m.Backspace(); m.Insert('S') }},
65+ {name: "leading space", edit: func(m *Model) {
66+ for range 10 {
67+ m.Backspace()
68+ }
69+ for _, r := range " snap" {
70+ m.Insert(r)
71+ }
72+ }},
73+ }
74+ m := New(snapshot)
75+ for _, step := range steps {
76+ t.Run(step.name, func(t *testing.T) {
77+ step.edit(m)
78+ require.Equal(t, scopy.FindMatches(doc, m.Query()), m.Matches(), "query %q", m.Query())
79+ require.NotEmpty(t, m.Matches(), "fixture must keep the query matching")
80+ })
81+ }
82+ clone := m.Clone()
83+ clone.Insert('s')
84+ require.Equal(t, scopy.FindMatches(doc, clone.Query()), clone.Matches())
85+}
86+
5087 func TestVisualSearchNewSnapshotOwnsSourceRows(t *testing.T) {
5188 rows := [][]renderer.Cell{{{Rune: 'a'}, {Rune: 'l'}, {Rune: 'p'}, {Rune: 'h'}, {Rune: 'a'}}}
5289 m := New(scopy.NewSnapshotFromRows(rows, 40, 1))
@@ -47,6 +47,43 @@ func TestVisualSearchModelFiltersAndSelectsLineMatches(t *testing.T) {
47 require.Equal(t, matches[1], selected)47 require.Equal(t, matches[1], selected)
48 }48 }
49 49
50+// Typing refines previous matches incrementally; every intermediate result
51+// must equal a full search for the same query.
52+func TestVisualSearchIncrementalMatchesEqualFullSearch(t *testing.T) {
53+ snapshot := testSnapshot("Snapshot ssh", "snap SNAPshot snapsh", "界snap 界snapshot", "none", " snaps ")
54+ doc := scopy.NewDocument(snapshot, "")
55+ steps := []struct {
56+ name string
57+ edit func(*Model)
58+ }{
59+ {name: "type", edit: func(m *Model) {
60+ for _, r := range "snapsh" {
61+ m.Insert(r)
62+ }
63+ }},
64+ {name: "backspace then retype", edit: func(m *Model) { m.Backspace(); m.Backspace(); m.Insert('S') }},
65+ {name: "leading space", edit: func(m *Model) {
66+ for range 10 {
67+ m.Backspace()
68+ }
69+ for _, r := range " snap" {
70+ m.Insert(r)
71+ }
72+ }},
73+ }
74+ m := New(snapshot)
75+ for _, step := range steps {
76+ t.Run(step.name, func(t *testing.T) {
77+ step.edit(m)
78+ require.Equal(t, scopy.FindMatches(doc, m.Query()), m.Matches(), "query %q", m.Query())
79+ require.NotEmpty(t, m.Matches(), "fixture must keep the query matching")
80+ })
81+ }
82+ clone := m.Clone()
83+ clone.Insert('s')
84+ require.Equal(t, scopy.FindMatches(doc, clone.Query()), clone.Matches())
85+}
86+
50 func TestVisualSearchNewSnapshotOwnsSourceRows(t *testing.T) {87 func TestVisualSearchNewSnapshotOwnsSourceRows(t *testing.T) {
51 rows := [][]renderer.Cell{{{Rune: 'a'}, {Rune: 'l'}, {Rune: 'p'}, {Rune: 'h'}, {Rune: 'a'}}}88 rows := [][]renderer.Cell{{{Rune: 'a'}, {Rune: 'l'}, {Rune: 'p'}, {Rune: 'h'}, {Rune: 'a'}}}
52 m := New(scopy.NewSnapshotFromRows(rows, 40, 1))89 m := New(scopy.NewSnapshotFromRows(rows, 40, 1))

Keyboard shortcuts

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

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

Branches 12

Tags 11

↑↓ moveenter openesc close