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 Unverified
B5690EEEBB952194The key is unknown
39 files · +1182 -149
M docs/configuration.md +2 -1
| @@ -25,7 +25,7 @@ theme.accent = auto | |||
| 25 | 25 | # bottom-left, bottom, or bottom-right. | |
| 26 | 26 | 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 | 29 | floating.command = | |
| 30 | 30 | floating.width = 80% | |
| 31 | 31 | floating.height = 80% | |
| @@ -287,6 +287,7 @@ Below 80 columns, popups (palette, picker, floating terminal, prompts) become fu | |||
| 287 | 287 | - `floating.command` runs through your shell. Changes apply on the next launch. | |
| 288 | 288 | - Below 80 columns, the terminal takes the full width and keeps `floating.height`. | |
| 289 | 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 | 292 | ## Bar anchors | |
| 292 | 293 | ||
| @@ -25,7 +25,7 @@ theme.accent = auto | |||||
| 25 | # bottom-left, bottom, or bottom-right. | 25 | # bottom-left, bottom, or bottom-right. | ||
| 26 | palette.anchor = center | 26 | 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 anchors | 292 | ## 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 | |||
| 192 | 192 | 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 | 215 | ## Mouse scroll rendering | |
| 196 | 216 | ||
| 197 | 217 | 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$' -benchmem | 192 | 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 rendering | 215 | ## Mouse scroll rendering | ||
| 196 | 216 | ||||
| 197 | Wheel input moves the immutable copy viewport, rather than walking the keyboard | 217 | 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 | |||
| 3 | 3 | go 1.27 | |
| 4 | 4 | ||
| 5 | 5 | require ( | |
| 6 | - | github.com/bnema/vev-vt v0.6.0 | |
| 6 | + | github.com/bnema/vev-vt v0.7.0 | |
| 7 | 7 | github.com/coder/websocket v1.8.15 | |
| 8 | 8 | github.com/quic-go/quic-go v0.62.0 | |
| 9 | 9 | github.com/stretchr/testify v1.12.1 | |
| @@ -3,7 +3,7 @@ module github.com/bnema/vev | |||||
| 3 | go 1.27 | 3 | go 1.27 | ||
| 4 | 4 | ||||
| 5 | require ( | 5 | require ( | ||
| 6 | - | github.com/bnema/vev-vt v0.6.0 | 6 | + | github.com/bnema/vev-vt v0.7.0 |
| 7 | github.com/coder/websocket v1.8.15 | 7 | github.com/coder/websocket v1.8.15 | ||
| 8 | github.com/quic-go/quic-go v0.62.0 | 8 | github.com/quic-go/quic-go v0.62.0 | ||
| 9 | github.com/stretchr/testify v1.12.1 | 9 | 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 | |||
| 107 | 107 | return out | |
| 108 | 108 | } | |
| 109 | 109 | ||
| 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)) | |
| 114 | 115 | var total uint64 | |
| 115 | 116 | for _, part := range parts { | |
| 116 | 117 | payload, err := brokerwire.EncodeServer(part, maxEnvelopeBytes, maxChunkBytes) | |
| 117 | 118 | if err != nil { | |
| 118 | - | return 0, err | |
| 119 | + | return nil, 0, err | |
| 119 | 120 | } | |
| 121 | + | payloads = append(payloads, payload) | |
| 120 | 122 | total += uint64(len(payload)) | |
| 121 | 123 | } | |
| 122 | - | return total, nil | |
| 124 | + | return payloads, total, nil | |
| 123 | 125 | } | |
| @@ -107,17 +107,19 @@ func cloneCatalogSession(session catalogue.RemoteCatalogSession) catalogue.Remot | |||||
| 107 | return out | 107 | return out | ||
| 108 | } | 108 | } | ||
| 109 | 109 | ||||
| 110 | - | // snapshotTransferBytes sums the encoded size of one prepared transfer so the | 110 | + | // encodeSnapshotTransfer encodes every part of one prepared transfer once and |
| 111 | - | // publisher can refuse a publication above the staged ceiling before the peer | 111 | + | // 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 uint64 | 115 | 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, err | 119 | + | 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, nil | 124 | + | 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 | |||
| 111 | 111 | if err != nil { | |
| 112 | 112 | 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 | 115 | if err != nil { | |
| 116 | 116 | return err | |
| 117 | 117 | } | |
| 118 | 118 | if total > uint64(brokerwire.MaxSnapshotStagedBytes) { | |
| 119 | 119 | return errors.New("brokeripc: snapshot publication exceeds the staged ceiling") | |
| 120 | 120 | } | |
| 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 { | |
| 123 | 123 | return err | |
| 124 | 124 | } | |
| 125 | 125 | } | |
| @@ -111,15 +111,15 @@ func (s *serverSession) publishSnapshot(generation brokerwire.SubscriptionGenera | |||||
| 111 | if err != nil { | 111 | if err != nil { | ||
| 112 | return err | 112 | 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 err | 116 | 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 err | 123 | 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 { | |||
| 815 | 815 | if err != nil { | |
| 816 | 816 | 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 | 823 | if err := s.transport.Send(wire.Envelope{Payload: payload}); err != nil { | |
| 819 | 824 | return transportFailure(err) | |
| 820 | 825 | } | |
| @@ -815,6 +815,11 @@ func (s *serverSession) send(message brokerwire.ServerMessage) error { | |||||
| 815 | if err != nil { | 815 | if err != nil { | ||
| 816 | return err | 816 | 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 | |||
| 283 | 283 | return ports.SnapshotObject{}, fmt.Errorf("conflicting object") | |
| 284 | 284 | } | |
| 285 | 285 | } | |
| 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) { | |
| 288 | 288 | return ports.SnapshotObject{}, fmt.Errorf("invalid object envelope") | |
| 289 | 289 | } | |
| 290 | 290 | 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 | 331 | 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) | |
| 334 | 334 | } | |
| 335 | 335 | ||
| 336 | 336 | 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...)}, nil | 290 | 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 { | |||
| 35 | 35 | mirror bool | |
| 36 | 36 | geometry domain.Geometry | |
| 37 | 37 | 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 | |
| 39 | 42 | revision uint64 | |
| 40 | 43 | available bool | |
| 41 | 44 | captureTooLarge bool | |
| @@ -257,21 +260,44 @@ func (t *Terminal) completeContextLocked(context ports.UIContext) ports.UIContex | |||
| 257 | 260 | return context | |
| 258 | 261 | } | |
| 259 | 262 | ||
| 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 { | |
| 263 | 266 | if !t.available { | |
| 264 | - | return ports.UISnapshot{}, ports.ErrUIUnavailable | |
| 267 | + | return ports.ErrUIUnavailable | |
| 265 | 268 | } | |
| 266 | 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 { | |
| 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) | |
| 271 | 286 | } | |
| 272 | 287 | 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 | 301 | func (t *Terminal) Changes() <-chan struct{} { return t.changes } | |
| 276 | 302 | ||
| 277 | 303 | func (t *Terminal) ObserveTerminalWrite(data []byte) { | |
| @@ -349,9 +375,12 @@ func (t *Terminal) publishLocked() { | |||
| 349 | 375 | if !t.available || t.captureTooLarge || t.screen == nil { | |
| 350 | 376 | 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 | 383 | t.revision++ | |
| 354 | - | t.latest = convertSnapshot(screen, t.geometry, t.context, t.revision) | |
| 355 | 384 | t.dirty = false | |
| 356 | 385 | t.signalChange() | |
| 357 | 386 | } | |
| @@ -35,7 +35,10 @@ type Terminal struct { | |||||
| 35 | mirror bool | 35 | mirror bool | ||
| 36 | geometry domain.Geometry | 36 | geometry domain.Geometry | ||
| 37 | context ports.UIContext | 37 | 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 | |||
| 39 | revision uint64 | 42 | revision uint64 | ||
| 40 | available bool | 43 | available bool | ||
| 41 | captureTooLarge bool | 44 | captureTooLarge bool | ||
| @@ -257,21 +260,44 @@ func (t *Terminal) completeContextLocked(context ports.UIContext) ports.UIContex | |||||
| 257 | return context | 260 | 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.ErrUIUnavailable | 267 | + | 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.ErrUIUnavailable | 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) | |||
| 271 | } | 286 | } | ||
| 272 | return cloneSnapshot(t.latest), nil | 287 | 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 | return | 376 | 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 = false | 384 | 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 { | |||
| 625 | 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 | 681 | // Changes provides a mock function for the type MockUIState | |
| 629 | 682 | func (_mock *MockUIState) Changes() <-chan struct{} { | |
| 630 | 683 | 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 MockUIState | 681 | // 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 { | |||
| 229 | 229 | ||
| 230 | 230 | // UIState exposes the latest immutable publication and a coalesced change | |
| 231 | 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 | 235 | type UIState interface { | |
| 233 | 236 | Snapshot() (UISnapshot, error) | |
| 237 | + | Boundary() (UIActionResult, error) | |
| 234 | 238 | Changes() <-chan struct{} | |
| 235 | 239 | } | |
| @@ -229,7 +229,11 @@ type UIOutputTransaction interface { | |||||
| 229 | 229 | ||||
| 230 | // UIState exposes the latest immutable publication and a coalesced change | 230 | // 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 | |||
| 951 | 951 | if _, inflight := r.inflight[endpoint]; inflight { | |
| 952 | 952 | 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 | 956 | continue | |
| 956 | 957 | } | |
| 957 | 958 | delete(r.pending, endpoint) | |
| @@ -959,8 +960,10 @@ func (r *Registry) dispatch(ctx context.Context, now time.Time, results chan<- p | |||
| 959 | 960 | attempt := r.attempts | |
| 960 | 961 | probeCtx, cancel := context.WithCancel(ctx) | |
| 961 | 962 | 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 | + | } | |
| 964 | 967 | host.LastAttempt = now | |
| 965 | 968 | r.hosts[endpoint] = host | |
| 966 | 969 | registration := host.Registration | |
| @@ -982,9 +985,10 @@ func (r *Registry) dispatch(ctx context.Context, now time.Time, results chan<- p | |||
| 982 | 985 | } | |
| 983 | 986 | }(endpoint, registration, attempt, probeCtx) | |
| 984 | 987 | } | |
| 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. | |
| 988 | 992 | if r.dispatchLocalLocked(ctx, now, results) { | |
| 989 | 993 | started = true | |
| 990 | 994 | } | |
| @@ -994,6 +998,15 @@ func (r *Registry) dispatch(ctx context.Context, now time.Time, results chan<- p | |||
| 994 | 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 | 1010 | // observeBounded runs one observation under its attempt deadline. retire is the | |
| 998 | 1011 | // attempt's retirement context: a replaced, removed, or settled attempt returns | |
| 999 | 1012 | // at once and its completion is fenced by the caller. The probe itself runs on | |
| @@ -1420,6 +1433,10 @@ type snapshotWriter struct { | |||
| 1420 | 1433 | mu sync.Mutex | |
| 1421 | 1434 | pending *ports.BrokerSnapshot | |
| 1422 | 1435 | 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 | 1440 | started bool | |
| 1424 | 1441 | closed bool | |
| 1425 | 1442 | wake chan struct{} | |
| @@ -1496,10 +1513,20 @@ func (w *snapshotWriter) flush() { | |||
| 1496 | 1513 | if snapshot.Revision <= written { | |
| 1497 | 1514 | 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 | 1526 | w.log.Error("broker: durable snapshot write failed", "revision", snapshot.Revision, "err", err) | |
| 1501 | 1527 | continue | |
| 1502 | 1528 | } | |
| 1529 | + | w.last = &key | |
| 1503 | 1530 | w.mu.Lock() | |
| 1504 | 1531 | if snapshot.Revision > w.written { | |
| 1505 | 1532 | 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 | 1554 | // close flushes the newest staged snapshot, stops the writer, and waits for | |
| 1512 | 1555 | // the drain to finish. It never returns before the in-flight write completes. | |
| 1513 | 1556 | 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 | continue | 952 | 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 | continue | 956 | 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.attempts | 960 | 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 = true | 963 | + | if visibleCheck(host, explicit) { |
| 963 | - | host.Checking = true | 964 | + | started = true |
| 965 | + | host.Checking = true | |||
| 966 | + | } | |||
| 964 | host.LastAttempt = now | 967 | host.LastAttempt = now | ||
| 965 | r.hosts[endpoint] = host | 968 | r.hosts[endpoint] = host | ||
| 966 | registration := host.Registration | 969 | 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; an | 988 | + | // Only an observation that visibly started changes the projection; an |
| 986 | - | // idle dispatch must not bump the revision or wake subscribers. The | 989 | + | // 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 = true | 993 | 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 the | 1010 | // observeBounded runs one observation under its attempt deadline. retire is the | ||
| 998 | // attempt's retirement context: a replaced, removed, or settled attempt returns | 1011 | // 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 on | 1012 | // 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.Mutex | 1433 | mu sync.Mutex | ||
| 1421 | pending *ports.BrokerSnapshot | 1434 | pending *ports.BrokerSnapshot | ||
| 1422 | written ports.BrokerRevision | 1435 | 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 bool | 1440 | started bool | ||
| 1424 | closed bool | 1441 | 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 | continue | 1514 | 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 | continue | 1527 | 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.Revision | 1532 | 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 for | 1554 | // 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 { | |||
| 81 | 81 | // dispatchLocalLocked admits at most one local probe attempt when its schedule | |
| 82 | 82 | // says it is due, or immediately when localPending marks explicit demand for a | |
| 83 | 83 | // 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. | |
| 86 | 86 | func (r *Registry) dispatchLocalLocked(ctx context.Context, now time.Time, results chan<- probeResult) bool { | |
| 87 | 87 | if r.local == nil || r.localAttempt != nil { | |
| 88 | 88 | 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 | 92 | return false | |
| 92 | 93 | } | |
| 93 | 94 | r.localPending = false | |
| @@ -95,7 +96,8 @@ func (r *Registry) dispatchLocalLocked(ctx context.Context, now time.Time, resul | |||
| 95 | 96 | attempt := r.attempts | |
| 96 | 97 | probeCtx, cancel := context.WithCancel(ctx) | |
| 97 | 98 | r.localAttempt = &probeAttempt{token: attempt, cancel: cancel} | |
| 98 | - | r.localHost.Checking = true | |
| 99 | + | visible := visibleCheck(r.localHost, explicit) | |
| 100 | + | r.localHost.Checking = visible | |
| 99 | 101 | r.localHost.LastAttempt = now | |
| 100 | 102 | deadline := r.clock.NewTimer(r.probeTimeout) | |
| 101 | 103 | go func(attempt uint64, probeCtx context.Context) { | |
| @@ -109,7 +111,7 @@ func (r *Registry) dispatchLocalLocked(ctx context.Context, now time.Time, resul | |||
| 109 | 111 | // queueing a result the run loop would only discard. | |
| 110 | 112 | } | |
| 111 | 113 | }(attempt, probeCtx) | |
| 112 | - | return true | |
| 114 | + | return visible | |
| 113 | 115 | } | |
| 114 | 116 | ||
| 115 | 117 | // 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 schedule | 81 | // dispatchLocalLocked admits at most one local probe attempt when its schedule | ||
| 82 | // says it is due, or immediately when localPending marks explicit demand for a | 82 | // 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 an | 83 | // 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 | 84 | + | // 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 false | 88 | 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 false | 92 | return false | ||
| 92 | } | 93 | } | ||
| 93 | r.localPending = false | 94 | r.localPending = false | ||
| @@ -95,7 +96,8 @@ func (r *Registry) dispatchLocalLocked(ctx context.Context, now time.Time, resul | |||||
| 95 | attempt := r.attempts | 96 | 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 = true | 99 | + | visible := visibleCheck(r.localHost, explicit) |
| 100 | + | r.localHost.Checking = visible | |||
| 99 | r.localHost.LastAttempt = now | 101 | 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 true | 114 | + | return visible |
| 113 | } | 115 | } | ||
| 114 | 116 | ||||
| 115 | // applyLocal adopts one completed local probe. Callers must hold r.mu, exactly | 117 | // 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) { | |||
| 2345 | 2345 | require.Zero(t, settled.Daemons[0].ConsecutiveFailures) | |
| 2346 | 2346 | ||
| 2347 | 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 | 2350 | stored := waitStored(t, store, func(snapshot ports.BrokerSnapshot) bool { | |
| 2349 | - | return snapshot.Revision >= settled.Revision | |
| 2351 | + | 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 | 2355 | require.False(t, stored.Daemons[0].Checking) | |
| 2353 | 2356 | writes := store.writeCount() | |
| 2354 | 2357 | 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.Revision | 2351 | + | 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) { | |||
| 187 | 187 | 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 | 195 | func (t *attachTestTerminal) Changes() <-chan struct{} { | |
| 191 | 196 | t.mu.Lock() | |
| 192 | 197 | defer t.mu.Unlock() | |
| @@ -187,6 +187,11 @@ func (t *attachTestTerminal) Snapshot() (ports.UISnapshot, error) { | |||||
| 187 | return ports.UISnapshot{Revision: t.revision, Context: ctx}, nil | 187 | 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 | |||
| 148 | 148 | // published remembers only a committed output boundary, not receipt receive | |
| 149 | 149 | // time or an unrelated status-only revision. | |
| 150 | 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 | 153 | if err != nil { | |
| 153 | 154 | return | |
| 154 | 155 | } | |
| 155 | 156 | u.mu.Lock() | |
| 156 | 157 | defer u.mu.Unlock() | |
| 157 | - | if generation != u.generation || snapshot.Context.Generation != generation { | |
| 158 | + | if generation != u.generation || boundary.Context.Generation != generation { | |
| 158 | 159 | return | |
| 159 | 160 | } | |
| 160 | - | u.boundary = ports.UIActionResult{Revision: snapshot.Revision, Context: snapshot.Context} | |
| 161 | + | u.boundary = boundary | |
| 161 | 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 | 164 | u.handoff.boundary = u.boundary | |
| 164 | 165 | if u.dispatched[u.handoff.actionID] { | |
| 165 | 166 | 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 receive | 148 | // 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 | return | 154 | 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 | return | 159 | 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.boundary | 164 | 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 { | |||
| 209 | 209 | return -1 | |
| 210 | 210 | } | |
| 211 | 211 | ||
| 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 { | |
| 214 | 218 | 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) { | |
| 217 | 221 | stopped = true | |
| 218 | 222 | return false | |
| 219 | 223 | } | |
| 220 | - | i++ | |
| 221 | 224 | return true | |
| 222 | 225 | }) | |
| 223 | - | if err != nil { | |
| 226 | + | if err != nil || stopped { | |
| 224 | 227 | return err | |
| 225 | 228 | } | |
| 226 | - | if stopped { | |
| 227 | - | return nil | |
| 228 | - | } | |
| 229 | + | offset := s.history.Len() | |
| 230 | + | var runes []rune | |
| 231 | + | var columns []int | |
| 229 | 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 | 235 | return nil | |
| 232 | 236 | } | |
| 233 | 237 | } | |
| @@ -494,14 +498,60 @@ func FindMatches(doc *Document, query string) []SearchMatch { | |||
| 494 | 498 | if doc == nil || query == "" { | |
| 495 | 499 | 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 | 547 | needle := lowerRunes(query) | |
| 502 | 548 | 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 | + | } | |
| 505 | 555 | text := "" | |
| 506 | 556 | for start := 0; start+len(needle) <= len(hay); { | |
| 507 | 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 | 559 | continue | |
| 510 | 560 | } | |
| 511 | 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 | 565 | 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] | |
| 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 | 571 | start += len(needle) | |
| 521 | 572 | } | |
| 522 | 573 | return true | |
| @@ -527,22 +578,6 @@ func findMatches(doc *Document, query string, rangeRows func(func(int, []rendere | |||
| 527 | 578 | } | |
| 528 | 579 | 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 | 581 | func lowerRunes(s string) []rune { | |
| 547 | 582 | rs := []rune(s) | |
| 548 | 583 | for i, r := range rs { | |
| @@ -209,25 +209,29 @@ func (s Snapshot) FindRowID(id vt.RowID) int { | |||||
| 209 | return -1 | 209 | 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 := 0 | 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 { | |||
| 214 | stopped := false | 218 | 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 = true | 221 | stopped = true | ||
| 218 | return false | 222 | return false | ||
| 219 | } | 223 | } | ||
| 220 | - | i++ | |||
| 221 | return true | 224 | return true | ||
| 222 | }) | 225 | }) | ||
| 223 | - | if err != nil { | 226 | + | if err != nil || stopped { |
| 224 | return err | 227 | return err | ||
| 225 | } | 228 | } | ||
| 226 | - | if stopped { | 229 | + | offset := s.history.Len() |
| 227 | - | return nil | 230 | + | 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 nil | 235 | 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 nil | 499 | 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 | continue | 559 | 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 true | 573 | return true | ||
| @@ -527,22 +578,6 @@ func findMatches(doc *Document, query string, rangeRows func(func(int, []rendere | |||||
| 527 | } | 578 | } | ||
| 528 | return matches | 579 | 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 ( | |||
| 4 | 4 | "errors" | |
| 5 | 5 | "testing" | |
| 6 | 6 | ||
| 7 | - | renderer "github.com/bnema/vev-vt/ansi" | |
| 8 | 7 | "github.com/stretchr/testify/require" | |
| 9 | 8 | ) | |
| 10 | 9 | ||
| @@ -19,9 +18,9 @@ func TestFindMatchesDiscardsPartialResultsOnHistoryError(t *testing.T) { | |||
| 19 | 18 | t.Run(tc.name, func(t *testing.T) { | |
| 20 | 19 | m := modeFor([]string{"match"}, 1) | |
| 21 | 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 | 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 | 25 | return errors.New("corrupt cold history") | |
| 27 | 26 | }) | |
| @@ -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) { | |||
| 443 | 443 | d.handleInput(sess, ac, []byte("\x1ba")) | |
| 444 | 444 | ||
| 445 | 445 | require.Equal(t, 2, testAttachmentTabIndex(sess)) | |
| 446 | - | requireFloatingInitialized(t, testAttachmentTab(sess)) | |
| 446 | + | requireFloatingInitialized(t, sess) | |
| 447 | 447 | _ = mustOutputData(t, sends) | |
| 448 | 448 | } | |
| 449 | 449 | ||
| @@ -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, | |||
| 150 | 150 | 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 | 155 | 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) | |
| 159 | 157 | } | |
| 160 | 158 | ||
| 161 | 159 | // activateTab performs the work associated with making a tab the destination. | |
| @@ -325,6 +323,9 @@ type floatingLaunchSpec struct { | |||
| 325 | 323 | fallback string | |
| 326 | 324 | parentCtx context.Context | |
| 327 | 325 | 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 | 331 | // 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 | 384 | launch.args = []string{"-lc", cfg.Command} | |
| 384 | 385 | } | |
| 385 | 386 | 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, | |
| 398 | 400 | }, nil | |
| 399 | 401 | } | |
| 400 | 402 | ||
| 401 | 403 | // openAndInstallFloating runs entirely in the launch worker. No PTY operation | |
| 402 | 404 | // occurs under tb.mu; the pane is fully initialized before publication. | |
| 403 | 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 | 419 | // Open is context-aware and is intentionally called without any daemon, | |
| 405 | 420 | // session, tab, or launch-ownership lock. Cancellation makes teardown | |
| 406 | 421 | // 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, true | 150 | 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 string | 323 | fallback string | ||
| 326 | parentCtx context.Context | 324 | parentCtx context.Context | ||
| 327 | userOpen bool | 325 | 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 through | 331 | // 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 | }, nil | 400 | }, nil | ||
| 399 | } | 401 | } | ||
| 400 | 402 | ||||
| 401 | // openAndInstallFloating runs entirely in the launch worker. No PTY operation | 403 | // 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 teardown | 420 | // 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 | |||
| 176 | 176 | tb.ctx, tb.cancel = context.WithCancel(t.Context()) | |
| 177 | 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 | 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 | 180 | // Open has started while this goroutine owns tab.mu: an external factory | |
| 181 | 181 | // call under that lock would deadlock this channel-controlled test. | |
| 182 | 182 | tb.mu.Lock() | |
| @@ -348,7 +348,7 @@ func TestFloatingLifecycleStaleSuccessAndOldExitCannotReplaceCurrentSlot(t *test | |||
| 348 | 348 | d := newTestDaemon(t, factory, stubClock{}) | |
| 349 | 349 | tb := newFloatingTestTab(t) | |
| 350 | 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 | 352 | <-opened | |
| 353 | 353 | d.teardownFloating(tb, nil) // invalidate before the delayed Open returns | |
| 354 | 354 | 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 factory | 180 | // 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 | <-opened | 352 | <-opened | ||
| 353 | d.teardownFloating(tb, nil) // invalidate before the delayed Open returns | 353 | 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) { | |||
| 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 | 354 | 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) | |
| 356 | 358 | } | |
| 357 | 359 | ||
| 358 | 360 | 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) { | |||
| 212 | 212 | d.handleInput(sess, ac, []byte("NXT\r")) | |
| 213 | 213 | require.False(t, ac.overlays.paletteActive()) | |
| 214 | 214 | require.Equal(t, 1, testAttachmentTabIndex(sess)) | |
| 215 | - | requireFloatingInitialized(t, testAttachmentTab(sess)) | |
| 215 | + | requireFloatingInitialized(t, sess) | |
| 216 | 216 | awaitFrame(t, sends, "Output") | |
| 217 | 217 | ||
| 218 | 218 | 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 | |||
| 1445 | 1445 | s.headBytes += snapshotHeadBytes | |
| 1446 | 1446 | for _, object := range publication.Objects { | |
| 1447 | 1447 | s.suppliedObjectBytes += uint64(len(object.Data)) | |
| 1448 | - | kind, _, err := snapcodec.PreflightObject(object.Data) | |
| 1448 | + | kind, err := snapcodec.PreflightObject(object.Data) | |
| 1449 | 1449 | if err == nil && kind == snapcodec.HistoryChunk { | |
| 1450 | 1450 | s.suppliedHistoryBytes += uint64(len(object.Data)) | |
| 1451 | 1451 | } | |
| @@ -1445,7 +1445,7 @@ func (s *countingSnapshotRepository) Publish(_ context.Context, publication port | |||||
| 1445 | s.headBytes += snapshotHeadBytes | 1445 | 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 { | |||
| 122 | 122 | floatingLaunchMu sync.Mutex | |
| 123 | 123 | floatingLaunchStopping bool | |
| 124 | 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 | 131 | func (s *session) nameSnapshot() string { | |
| @@ -1542,6 +1546,7 @@ func (s *session) stopInMemoryLifecycle() { | |||
| 1542 | 1546 | } | |
| 1543 | 1547 | s.lifecycleStopOnce.Do(func() { | |
| 1544 | 1548 | s.stopFloatingLaunches() | |
| 1549 | + | s.closeFloatingSpare() | |
| 1545 | 1550 | if s.cancel != nil { | |
| 1546 | 1551 | s.cancel() | |
| 1547 | 1552 | } | |
| @@ -122,6 +122,10 @@ type session struct { | |||||
| 122 | floatingLaunchMu sync.Mutex | 122 | floatingLaunchMu sync.Mutex | ||
| 123 | floatingLaunchStopping bool | 123 | 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) { | |||
| 199 | 199 | require.Len(t, second.Objects, 2, "a generation must supply its tail and transcript once without resupplying persisted sealed chunks") | |
| 200 | 200 | kinds := make(map[snapcodec.ObjectKind]int) | |
| 201 | 201 | for _, object := range second.Objects { | |
| 202 | - | kind, _, err := snapcodec.PreflightObject(object.Data) | |
| 202 | + | kind, err := snapcodec.PreflightObject(object.Data) | |
| 203 | 203 | require.NoError(t, err) | |
| 204 | 204 | kinds[kind]++ | |
| 205 | 205 | } | |
| @@ -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 | |||
| 432 | 432 | if uint32(len(data)) != ref.Size { | |
| 433 | 433 | return nil, fmt.Errorf("snapshot: object size mismatch") | |
| 434 | 434 | } | |
| 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) | |
| 439 | 436 | if err != nil || gotKind != kind { | |
| 440 | 437 | return nil, fmt.Errorf("snapshot: invalid object") | |
| 441 | 438 | } | |
| @@ -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 private | 435 | + | 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 ( | |||
| 16 | 16 | manifestMagic = "VEVM" | |
| 17 | 17 | headMagic = "VEVH" | |
| 18 | 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 | 21 | manifestHeaderSize = 16 | |
| 21 | 22 | objectEnvelopeBodyPrefixSize = 1 + 4 // object kind and payload length | |
| 22 | 23 | minObjectEnvelopeSize = manifestHeaderSize + objectEnvelopeBodyPrefixSize + 1 | |
| @@ -237,8 +238,9 @@ func decodeManifest(body []byte) (Manifest, error) { | |||
| 237 | 238 | return m, nil | |
| 238 | 239 | } | |
| 239 | 240 | ||
| 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. | |
| 242 | 244 | func MarshalObject(kind ObjectKind, payload []byte) (ports.SnapshotObject, error) { | |
| 243 | 245 | if !validObjectKind(kind) || len(payload) == 0 || len(payload) > maxObjectPayloadSize { | |
| 244 | 246 | return ports.SnapshotObject{}, fmt.Errorf("%w: object", ErrInvalidData) | |
| @@ -246,47 +248,56 @@ func MarshalObject(kind ObjectKind, payload []byte) (ports.SnapshotObject, error | |||
| 246 | 248 | var w payloadWriter | |
| 247 | 249 | w.putUint8(uint8(kind)) | |
| 248 | 250 | 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) | |
| 251 | 256 | if err != nil { | |
| 252 | 257 | return ports.SnapshotObject{}, err | |
| 253 | 258 | } | |
| 254 | 259 | 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 | 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 | 270 | if err != nil { | |
| 260 | 271 | return 0, nil, err | |
| 261 | 272 | } | |
| 262 | - | return kind, append([]byte(nil), payload...), nil | |
| 273 | + | return kind, payload, nil | |
| 263 | 274 | } | |
| 264 | 275 | ||
| 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) { | |
| 268 | 285 | body, err := unmarshalManifestEnvelope(encoded, objectMagic) | |
| 269 | 286 | if err != nil { | |
| 270 | - | return 0, nil, err | |
| 287 | + | return 0, 0, nil, err | |
| 271 | 288 | } | |
| 272 | - | if len(body) < 5 { | |
| 273 | - | return 0, nil, ErrShortPayload | |
| 289 | + | if len(body) < objectEnvelopeBodyPrefixSize+1 { | |
| 290 | + | return 0, 0, nil, ErrShortPayload | |
| 274 | 291 | } | |
| 275 | 292 | kind := ObjectKind(body[0]) | |
| 276 | 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 | 296 | n := binary.BigEndian.Uint32(body[1:5]) | |
| 280 | 297 | 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) | |
| 288 | 299 | } | |
| 289 | - | return kind, body[5:], nil | |
| 300 | + | return kind, n, body[objectEnvelopeBodyPrefixSize:], nil | |
| 290 | 301 | } | |
| 291 | 302 | ||
| 292 | 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 | 316 | func envelopeVersion(magic string) uint16 { | |
| 306 | - | if magic == manifestMagic { | |
| 317 | + | switch magic { | |
| 318 | + | case manifestMagic: | |
| 307 | 319 | return ManifestVersion | |
| 320 | + | case objectMagic: | |
| 321 | + | return objectVersion | |
| 308 | 322 | } | |
| 309 | 323 | return 1 | |
| 310 | 324 | } | |
| @@ -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 = 16 | 21 | manifestHeaderSize = 16 | ||
| 21 | objectEnvelopeBodyPrefixSize = 1 + 4 // object kind and payload length | 22 | objectEnvelopeBodyPrefixSize = 1 + 4 // object kind and payload length | ||
| 22 | minObjectEnvelopeSize = manifestHeaderSize + objectEnvelopeBodyPrefixSize + 1 | 23 | minObjectEnvelopeSize = manifestHeaderSize + objectEnvelopeBodyPrefixSize + 1 | ||
| @@ -237,8 +238,9 @@ func decodeManifest(body []byte) (Manifest, error) { | |||||
| 237 | return m, nil | 238 | return m, nil | ||
| 238 | } | 239 | } | ||
| 239 | 240 | ||||
| 240 | - | // MarshalObject frames payload as VEVO and returns the bytes together with the | 241 | + | // 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 payloadWriter | 248 | 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{}, err | 257 | return ports.SnapshotObject{}, err | ||
| 253 | } | 258 | } | ||
| 254 | return ports.SnapshotObject{Digest: sha256.Sum256(data), Data: data}, nil | 259 | 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, err | 271 | return 0, nil, err | ||
| 261 | } | 272 | } | ||
| 262 | - | return kind, append([]byte(nil), payload...), nil | 273 | + | return kind, payload, nil |
| 263 | } | 274 | } | ||
| 264 | 275 | ||||
| 265 | - | // PreflightObject verifies VEVO framing and declared payload size without | 276 | + | // 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, err | 287 | + | return 0, 0, nil, err |
| 271 | } | 288 | } | ||
| 272 | - | if len(body) < 5 { | 289 | + | if len(body) < objectEnvelopeBodyPrefixSize+1 { |
| 273 | - | return 0, nil, ErrShortPayload | 290 | + | 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:], nil | 300 | + | 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 ManifestVersion | 319 | return ManifestVersion | ||
| 320 | + | case objectMagic: | |||
| 321 | + | return objectVersion | |||
| 308 | } | 322 | } | ||
| 309 | return 1 | 323 | return 1 | ||
| 310 | } | 324 | } | ||
M internal/usecase/snapshot/manifest_test.go +7 -5
| @@ -119,9 +119,9 @@ func TestObjectCodecReturnsContentAddressedObjectAndPayload(t *testing.T) { | |||
| 119 | 119 | if err != nil || kind != RecoveryTranscript || !bytes.Equal(payload, []byte("canonical-vt")) { | |
| 120 | 120 | t.Fatalf("round trip = %v %q %v", kind, payload, err) | |
| 121 | 121 | } | |
| 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) | |
| 125 | 125 | } | |
| 126 | 126 | for n := range len(object.Data) { | |
| 127 | 127 | if _, _, err := UnmarshalObject(object.Data[:n]); err == nil { | |
| @@ -178,8 +178,10 @@ func TestObjectRefEnvelopeSizeBoundaries(t *testing.T) { | |||
| 178 | 178 | if err != nil { | |
| 179 | 179 | t.Fatalf("MarshalObject() error = %v", err) | |
| 180 | 180 | } | |
| 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) | |
| 183 | 185 | } | |
| 184 | 186 | ||
| 185 | 187 | 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) { | |||
| 85 | 85 | func TestSnapshotEnvelopeCompatibility(t *testing.T) { | |
| 86 | 86 | const ( | |
| 87 | 87 | headGolden = "56455648000100000000002898ebcac200000000000000070100000000000000000000000000000000000000000000000000000000000000" | |
| 88 | - | objectGolden = "5645564f00010000000000111cb8f09f030000000c63616e6f6e6963616c2d7674" | |
| 89 | - | manifestGolden = "5645564d0004000000000133d118cc030000000000000001010000000000000000000000000000000000056e616d6564000000000000000000000001000374616200500018000000000000000000036f6e65010000020000036f6e6500000374776f000200036f6e6500036f6e6500042f6f6e65000101010000000000000000000000000000000000000000000000000000000000000000000016020200000000000000000000000000000000000000000000000000000000000000000000160303000000000000000000000000000000000000000000000000000000000000000000001600000374776f000374776f00042f74776f0000020400000000000000000000000000000000000000000000000000000000000000000000160305000000000000000000000000000000000000000000000000000000000000000000001600" | |
| 88 | + | objectGolden = "5645564f000200000000001ef35457a7030000000c7801000c00f3ff63616e6f6e6963616c2d767403001f2b04c0" | |
| 89 | + | manifestGolden = "5645564d0005000000000133d118cc030000000000000001010000000000000000000000000000000000056e616d6564000000000000000000000001000374616200500018000000000000000000036f6e65010000020000036f6e6500000374776f000200036f6e6500036f6e6500042f6f6e65000101010000000000000000000000000000000000000000000000000000000000000000000016020200000000000000000000000000000000000000000000000000000000000000000000160303000000000000000000000000000000000000000000000000000000000000000000001600000374776f000374776f00042f74776f0000020400000000000000000000000000000000000000000000000000000000000000000000160305000000000000000000000000000000000000000000000000000000000000000000001600" | |
| 90 | 90 | ) | |
| 91 | - | if ManifestVersion != 4 { | |
| 92 | - | t.Fatalf("ManifestVersion = %d, want 4", ManifestVersion) | |
| 91 | + | if ManifestVersion != 5 { | |
| 92 | + | t.Fatalf("ManifestVersion = %d, want 5", ManifestVersion) | |
| 93 | 93 | } | |
| 94 | 94 | ||
| 95 | 95 | 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 | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "strconv" | |
| 5 | + | "strings" | |
| 5 | 6 | ||
| 6 | 7 | renderer "github.com/bnema/vev-vt" | |
| 7 | 8 | "github.com/bnema/vev/internal/domain" | |
| @@ -13,8 +14,10 @@ type Model struct { | |||
| 13 | 14 | snapshot scopy.Snapshot | |
| 14 | 15 | input ui.TextInput | |
| 15 | 16 | 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 | |
| 18 | 21 | } | |
| 19 | 22 | ||
| 20 | 23 | func New(snapshot scopy.Snapshot) *Model { | |
| @@ -106,7 +109,15 @@ func (m *Model) Matches() []scopy.SearchMatch { | |||
| 106 | 109 | } | |
| 107 | 110 | ||
| 108 | 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 | 121 | m.clamp() | |
| 111 | 122 | m.ensureVisible(defaultVisibleRows) | |
| 112 | 123 | } | |
| @@ -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.Snapshot | 14 | snapshot scopy.Snapshot | ||
| 14 | input ui.TextInput | 15 | input ui.TextInput | ||
| 15 | matches []scopy.SearchMatch | 16 | matches []scopy.SearchMatch | ||
| 16 | - | selected int | 17 | + | // matchedQuery is the trimmed query matches was computed for. |
| 17 | - | scroll int | 18 | + | 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) { | |||
| 47 | 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 | 87 | func TestVisualSearchNewSnapshotOwnsSourceRows(t *testing.T) { | |
| 51 | 88 | rows := [][]renderer.Cell{{{Rune: 'a'}, {Rune: 'l'}, {Rune: 'p'}, {Rune: 'h'}, {Rune: 'a'}}} | |
| 52 | 89 | 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)) | ||