detached 65bd500

Merge pull request 'refactor: architecture cleanup' (#10) from refactor/architecture-cleanup into main

Reviewed-on: https://git.bnema.dev/bnema/vev/pulls/10
commit
65bd5004ff39a12622998879b6a876a2ebeac5f7
author
bnema · · 2026-10-08 05:39 UTC
merge of
4c16dd8 + 4efbe32 · diff against the first parent
signature
unsigned
154 files · +1840 -1812
M AGENTS.md +3 -2
@@ -31,14 +31,15 @@ go test ./internal/adapters/ipc ./internal/usecase/daemon -run '^$' -bench=. -be
3131 The exhaustive production matrix and separate test-import policy are enforced by `boundary_test.go`; package ownership is documented in `docs/architecture.md`.
3232
3333 - `pkg/` is reusable and never imports `internal/`.
34-- Production use cases import only `internal/ports`, semantic `internal/protocol` packages, `internal/domain`, and approved sibling use cases.
34+- Production use cases import only `internal/ports`, semantic `internal/protocol` packages, `internal/domain`, `internal/snapshotcodec`, and approved sibling use cases.
3535 - Production use cases never import `internal/protocol/wire`, concrete adapters, `internal/app`, `internal/persist`, or `internal/platform`.
3636 - `internal/ports` owns application seams, not codecs, raw frames, environment policy, or worker implementations.
3737
3838 Layer map:
3939
4040 - `main.go` → `internal/app`: CLI parsing, composition, daemon startup, and hidden subcommands.
41-- `internal/domain`: pure shared values and terminal capability policy.
41+- `internal/domain`: pure shared values, the pane layout tree, and terminal capability policy.
42+- `internal/snapshotcodec`: durable snapshot format, shared by the daemon and the snapshot adapter.
4243 - `internal/protocol`: typed session messages; `protocol/catalogue` owns remote JSON schema; `protocol/wire` owns binary encoding and raw carriage contracts.
4344 - `internal/ports`: typed application connections and infrastructure-facing use-case seams.
4445 - `internal/adapters/sessionwire`: typed message ↔ raw frame adaptation.
M boundary_test.go +18 -7
@@ -30,6 +30,7 @@ const (
3030 layerWire packageLayer = "wire"
3131 layerDomain packageLayer = "domain"
3232 layerPersist packageLayer = "persist"
33+ layerSnapshot packageLayer = "snapshot-codec"
3334 layerPlatform packageLayer = "platform"
3435 layerLogging packageLayer = "logging"
3536 layerPkg packageLayer = "pkg"
@@ -40,15 +41,16 @@ var productionDependencies = map[packageLayer]map[packageLayer]bool{
4041 layerRoot: {layerApp: true},
4142 layerCommand: {layerPkg: true},
4243 layerScript: {layerAdapter: true, layerDomain: true, layerPorts: true, layerProtocol: true, layerCatalogue: true, layerWire: true, layerPkg: true},
43- layerApp: {layerAdapter: true, layerDomain: true, layerPorts: true, layerProtocol: true, layerCatalogue: true, layerWire: true, layerUsecase: true, layerPersist: true, layerPlatform: true, layerLogging: true, layerPkg: true},
44- layerAdapter: {layerAdapter: true, layerDomain: true, layerPorts: true, layerProtocol: true, layerCatalogue: true, layerWire: true, layerPlatform: true, layerPkg: true},
45- layerUsecase: {layerUsecase: true, layerDomain: true, layerPorts: true, layerProtocol: true, layerCatalogue: true},
44+ layerApp: {layerAdapter: true, layerDomain: true, layerPorts: true, layerProtocol: true, layerCatalogue: true, layerWire: true, layerUsecase: true, layerPersist: true, layerSnapshot: true, layerPlatform: true, layerLogging: true, layerPkg: true},
45+ layerAdapter: {layerAdapter: true, layerDomain: true, layerPorts: true, layerProtocol: true, layerCatalogue: true, layerWire: true, layerSnapshot: true, layerPlatform: true, layerPkg: true},
46+ layerUsecase: {layerUsecase: true, layerDomain: true, layerPorts: true, layerProtocol: true, layerCatalogue: true, layerSnapshot: true},
4647 layerPorts: {layerDomain: true, layerProtocol: true, layerCatalogue: true},
4748 layerProtocol: {layerDomain: true},
4849 layerCatalogue: {layerDomain: true, layerProtocol: true},
4950 layerWire: {layerDomain: true, layerProtocol: true},
5051 layerDomain: {layerDomain: true},
5152 layerPersist: {layerDomain: true, layerPorts: true, layerProtocol: true, layerPkg: true},
53+ layerSnapshot: {layerDomain: true, layerPorts: true},
5254 layerPlatform: {},
5355 layerLogging: {layerPkg: true},
5456 layerPkg: {layerPkg: true},
@@ -91,7 +93,12 @@ func TestImportBoundaryNegativeFixtures(t *testing.T) {
9193 {"usecase rejects adapter", modulePath + "/internal/usecase/client", modulePath + "/internal/adapters/ipc", false, false},
9294 {"domain rejects ports", modulePath + "/internal/domain", modulePath + "/internal/ports", false, false},
9395 {"pkg rejects internal", modulePath + "/pkg/rawterm", modulePath + "/internal/domain", false, false},
94- {"snapshot adapter exception", modulePath + "/internal/adapters/snapshot", modulePath + "/internal/usecase/snapshot", false, true},
96+ {"snapshot adapter accepts codec", modulePath + "/internal/adapters/snapshot", modulePath + "/internal/snapshotcodec", false, true},
97+ {"daemon accepts snapshot codec", modulePath + "/internal/usecase/daemon", modulePath + "/internal/snapshotcodec", false, true},
98+ {"other adapter rejects snapshot codec", modulePath + "/internal/adapters/ipc", modulePath + "/internal/snapshotcodec", false, false},
99+ {"snapshot codec rejects usecase", modulePath + "/internal/snapshotcodec", modulePath + "/internal/usecase/daemon", false, false},
100+ {"snapshot codec rejects adapter", modulePath + "/internal/snapshotcodec", modulePath + "/internal/adapters/snapshot", false, false},
101+ {"snapshot adapter rejects usecase", modulePath + "/internal/adapters/snapshot", modulePath + "/internal/usecase/daemon", false, false},
95102 {"other adapter rejects usecase", modulePath + "/internal/adapters/ipc", modulePath + "/internal/usecase/client", false, false},
96103 {"usecase test may use wire fixture", modulePath + "/internal/usecase/client", modulePath + "/internal/protocol/wire", true, true},
97104 {"pkg test still rejects internal", modulePath + "/pkg/rawterm", modulePath + "/internal/domain", true, false},
@@ -201,9 +208,6 @@ func dependencyAllowed(source, target string, testFile bool) (bool, error) {
201208 if sourceLayer == layerTestSupport {
202209 return testSupportDependencyAllowed(source, targetLayer), nil
203210 }
204- if source == modulePath+"/internal/adapters/snapshot" && target == modulePath+"/internal/usecase/snapshot" {
205- return true, nil
206- }
207211 if packageImportDenied(source, target) {
208212 return false, nil
209213 }
@@ -222,6 +226,11 @@ func packageImportDenied(source, target string) bool {
222226 under := func(path, root string) bool {
223227 return path == root || strings.HasPrefix(path, root+"/")
224228 }
229+ // The durable snapshot format is shared by the daemon and its one
230+ // repository adapter; no other adapter may depend on it.
231+ if target == modulePath+"/internal/snapshotcodec" && strings.HasPrefix(source, modulePath+"/internal/adapters/") {
232+ return source != modulePath+"/internal/adapters/snapshot"
233+ }
225234 switch {
226235 case under(source, brokerPkg):
227236 return !under(target, brokerPkg) && strings.HasPrefix(target, usecasePrefix)
@@ -275,6 +284,8 @@ func classifyPackage(path string) (packageLayer, error) {
275284 return layerUsecase, nil
276285 case path == modulePath+"/internal/persist":
277286 return layerPersist, nil
287+ case path == modulePath+"/internal/snapshotcodec":
288+ return layerSnapshot, nil
278289 case path == modulePath+"/internal/platform":
279290 return layerPlatform, nil
280291 case path == modulePath+"/internal/logging":
M docs/architecture.md +3 -2
@@ -14,7 +14,7 @@ Every result keeps its request ID for strict correlation.
1414
1515 ## Package ownership
1616
17-- `internal/domain`: pure shared values and invariants. `domain/terminalcap` owns terminal capability values and environment detection policy.
17+- `internal/domain`: pure shared values and invariants. `domain/terminalcap` owns terminal capability values and environment detection policy. `domain/layout` owns the pure pane split tree and its geometry.
1818 - `internal/protocol`: typed, transport-neutral client/daemon messages, protocol version, semantic validation, and handshake policy.
1919 - `internal/protocol/catalogue`: independently versioned remote discovery JSON schema and bounded validation.
2020 - `internal/protocol/wire`: Protobuf wire contract. Sources of truth are
@@ -25,6 +25,7 @@ Every result keeps its request ID for strict correlation.
2525 envelope, no type byte).
2626 - `internal/ports`: application-facing interfaces and the values required by those interfaces. It contains no codecs, raw frames, environment policy, or worker implementations.
2727 - `internal/usecase/colorprofile`: the single `terminalcap.ColorCapabilities` → vev-vt color profile mapping shared by the client picker and daemon output renderer.
28+- `internal/usecase/fuzzy`: the exact/prefix/subsequence matcher and tie-break order shared by the session picker and the command palette; each caller maps match kinds to its own ranks.
2829 - `internal/usecase`: client, daemon, broker, and supporting application behavior. Production use cases may consume semantic protocol packages but never `protocol/wire` or concrete adapters. The broker may use its own subpackages but cannot import sibling use cases. Client, daemon, and remote-registry use cases cannot own or import each other; they collaborate only through ports and application composition.
2930 - `internal/adapters/sessionwire`: translates between typed session
3031 connections and raw wire transports: directional `oneof` envelope
@@ -59,7 +60,7 @@ main → app → usecase → ports
5960 carriage adapters
6061 ```
6162
62-`internal/app` may import every layer to compose the process. Adapters depend inward on ports, protocol, and domain. The existing `adapters/snapshot → usecase/snapshot` dependency is an explicit exception until the snapshot codec has its own owner. Production dependency rules and the separate test-import policy are executable in `boundary_test.go`.
63+`internal/app` may import every layer to compose the process. Adapters depend inward on ports, protocol, and domain. `internal/snapshotcodec` owns the durable snapshot format (session codec, manifests, objects, preflight); it depends only on domain and ports, and both the daemon use case and the snapshot adapter consume it. Production dependency rules and the separate test-import policy are executable in `boundary_test.go`.
6364
6465 ## UI observation and control
6566
M internal/adapters/sessionwire/client.go +3 -116
@@ -1,9 +1,7 @@
11 package sessionwire
22
33 import (
4- "context"
54 "errors"
6- "sync"
75 "time"
86
97 "google.golang.org/protobuf/proto"
@@ -14,27 +12,7 @@ import (
1412 )
1513
1614 type clientConnection struct {
17- raw wire.Transport
18- ceilings protoCeilings
19- // ceilingsMu guards ceilings. The lazy preamble publishes the negotiated
20- // values from whichever goroutine first uses the connection, while another
21- // goroutine may already be asking for capabilities or starting a send, so
22- // the once-guarded write is not by itself a happens-before edge for them.
23- ceilingsMu sync.Mutex
24-
25- preambleOnce sync.Once
26- preambleErr error
27- deadline time.Time
28-
29- // preambleDone closes exactly once when the handshake has run, whether it
30- // succeeded or failed. It backs the handshake completion hook that a logical
31- // mux connection exposes; the absolute deadline is never restarted.
32- preambleDone chan struct{}
33-
34- hooksMu sync.Mutex
35- handshakeDone bool
36- hooksRun bool
37- hooks []func()
15+ handshake
3816 }
3917
4018 var _ ports.ClientConnection = (*clientConnection)(nil)
@@ -46,102 +24,11 @@ func NewClientConnection(raw wire.Transport) ports.ClientConnection {
4624 if raw == nil {
4725 return nil
4826 }
49- return &clientConnection{raw: raw, ceilings: defaultProtoCeilings(), deadline: time.Now().Add(protocol.HandshakeTimeout), preambleDone: make(chan struct{})}
50-}
51-
52-// HandshakeDeadline returns the absolute local deadline of the session
53-// handshake this connection started with. It is the one accepted deadline the
54-// preamble, Hello/Welcome, and first committed publication share; the value is
55-// fixed for the connection's lifetime and is never restarted.
56-func (c *clientConnection) HandshakeDeadline() time.Time { return c.deadline }
57-
58-// HandshakeDone returns a channel that is closed exactly once when the
59-// handshake has run, whether it succeeded or failed.
60-func (c *clientConnection) HandshakeDone() <-chan struct{} { return c.preambleDone }
61-
62-// OnHandshakeComplete registers fn to run exactly once when the handshake has
63-// run. Registering after completion runs fn immediately; a nil fn is ignored.
64-func (c *clientConnection) OnHandshakeComplete(fn func()) {
65- if fn == nil {
66- return
67- }
68- c.hooksMu.Lock()
69- if c.handshakeDone {
70- c.hooksMu.Unlock()
71- fn()
72- return
73- }
74- c.hooks = append(c.hooks, fn)
75- c.hooksMu.Unlock()
76-}
77-
78-// finishPreamble publishes handshake completion from inside the preamble's
79-// sync.Once: it closes the done channel and marks the handshake complete so a
80-// later registration runs immediately. It deliberately runs no registered hook,
81-// because a hook that calls back into the connection would re-enter sync.Once
82-// and deadlock; runHandshakeHooks runs the hooks after the Once body returns.
83-// It tolerates a zero-value connection that never allocated the done channel
84-// (test literals and seam-only construction), so closing is nil-safe.
85-func (c *clientConnection) finishPreamble() {
86- if c.preambleDone != nil {
87- close(c.preambleDone)
88- }
89- c.hooksMu.Lock()
90- c.handshakeDone = true
91- c.hooksMu.Unlock()
92-}
93-
94-// runHandshakeHooks runs the completion hooks registered before the handshake
95-// finished exactly once, after the preamble's sync.Once body has returned.
96-// Running them outside the Once is what lets a hook call back into the
97-// connection - for example to send a message - without re-entering sync.Once
98-// and deadlocking. Every ensurePreamble caller invokes it; only the first
99-// drains the hooks, and a registrant that arrives after completion observes the
100-// completed flag and runs itself.
101-func (c *clientConnection) runHandshakeHooks() {
102- c.hooksMu.Lock()
103- if c.hooksRun {
104- c.hooksMu.Unlock()
105- return
106- }
107- c.hooksRun = true
108- hooks := c.hooks
109- c.hooks = nil
110- c.hooksMu.Unlock()
111- for _, fn := range hooks {
112- fn()
113- }
27+ return &clientConnection{handshake: newHandshake(raw, time.Now().Add(protocol.HandshakeTimeout))}
11428 }
11529
11630 func (c *clientConnection) ensurePreamble() error {
117- c.preambleOnce.Do(func() {
118- ctx, cancel := context.WithDeadline(context.Background(), c.deadline)
119- defer cancel()
120- next, err := runProtoClientPreamble(ctx, c.raw, c.limits())
121- c.publishLimits(next)
122- c.preambleErr = err
123- if err != nil {
124- _ = c.raw.Close()
125- }
126- c.finishPreamble()
127- })
128- c.runHandshakeHooks()
129- return c.preambleErr
130-}
131-
132-// limits returns the negotiated ceilings under the lock the preamble publishes
133-// them through.
134-func (c *clientConnection) limits() protoCeilings {
135- c.ceilingsMu.Lock()
136- defer c.ceilingsMu.Unlock()
137- return c.ceilings
138-}
139-
140-// publishLimits records the ceilings one preamble negotiation produced.
141-func (c *clientConnection) publishLimits(next protoCeilings) {
142- c.ceilingsMu.Lock()
143- c.ceilings = next
144- c.ceilingsMu.Unlock()
31+ return c.handshake.ensurePreamble(runProtoClientPreamble)
14532 }
14633
14734 func (c *clientConnection) SendClient(message protocol.ClientMessage) error {
M internal/adapters/sessionwire/command_result_test.go +2 -2
@@ -227,11 +227,11 @@ func TestCommandResultIsBoundedControlEnvelope(t *testing.T) {
227227 require.ErrorIs(t, checkCategoryCeiling(oversizeRaw, categoryControl, wire.AbsoluteEnvelopeLimit), wire.ErrScanLength,
228228 "a CommandResult above the control ceiling must fail even under an absolute negotiated ceiling")
229229
230- send := &serverConnection{raw: &scriptedTransport{}, ceilings: defaultProtoCeilings()}
230+ send := &serverConnection{handshake: handshake{raw: &scriptedTransport{}, ceilings: defaultProtoCeilings()}}
231231 require.NoError(t, send.SendServer(message))
232232 require.ErrorIs(t, send.SendServer(oversize), wire.ErrScanLength)
233233
234- recv := &clientConnection{raw: &scriptedTransport{recv: []wire.Envelope{{Payload: oversizeRaw}}}, ceilings: defaultProtoCeilings()}
234+ recv := &clientConnection{handshake: handshake{raw: &scriptedTransport{recv: []wire.Envelope{{Payload: oversizeRaw}}}, ceilings: defaultProtoCeilings()}}
235235 recv.preambleOnce.Do(func() {})
236236 _, err = recv.ReceiveServer()
237237 require.Error(t, err)
A internal/adapters/sessionwire/handshake.go +142 -0
@@ -0,0 +1,142 @@
1+package sessionwire
2+
3+import (
4+ "context"
5+ "sync"
6+ "time"
7+
8+ "github.com/bnema/vev/internal/protocol/wire"
9+)
10+
11+// preambleRunner runs one directional preamble and returns the ceilings it
12+// negotiated.
13+type preambleRunner func(context.Context, wire.Transport, protoCeilings) (protoCeilings, error)
14+
15+// handshake is the lazy, once-only preamble lifecycle shared by client and
16+// server connections: the fixed absolute deadline, the negotiated ceilings,
17+// the completion signal, and the completion hooks.
18+type handshake struct {
19+ raw wire.Transport
20+ ceilings protoCeilings
21+ // ceilingsMu guards ceilings. The lazy preamble publishes the negotiated
22+ // values from whichever goroutine first uses the connection, while another
23+ // goroutine may already be asking for capabilities or starting a send, so
24+ // the once-guarded write is not by itself a happens-before edge for them.
25+ ceilingsMu sync.Mutex
26+
27+ preambleOnce sync.Once
28+ preambleErr error
29+ deadline time.Time
30+
31+ // preambleDone closes exactly once when the handshake has run, whether it
32+ // succeeded or failed. It backs the handshake completion seam a logical mux
33+ // connection exposes; the absolute deadline is never restarted.
34+ preambleDone chan struct{}
35+
36+ hooksMu sync.Mutex
37+ handshakeDone bool
38+ hooksRun bool
39+ hooks []func()
40+}
41+
42+func newHandshake(raw wire.Transport, deadline time.Time) handshake {
43+ return handshake{raw: raw, ceilings: defaultProtoCeilings(), deadline: deadline, preambleDone: make(chan struct{})}
44+}
45+
46+// HandshakeDeadline returns the absolute local deadline of the session
47+// handshake this connection started with. It is the one accepted deadline the
48+// preamble, Hello/Welcome, and first committed publication share; the value is
49+// fixed for the connection's lifetime and is never restarted.
50+func (h *handshake) HandshakeDeadline() time.Time { return h.deadline }
51+
52+// HandshakeDone returns a channel that is closed exactly once when the
53+// handshake has run, whether it succeeded or failed.
54+func (h *handshake) HandshakeDone() <-chan struct{} { return h.preambleDone }
55+
56+// OnHandshakeComplete registers fn to run exactly once when the handshake has
57+// run. Registering after completion runs fn immediately; a nil fn is ignored.
58+func (h *handshake) OnHandshakeComplete(fn func()) {
59+ if fn == nil {
60+ return
61+ }
62+ h.hooksMu.Lock()
63+ if h.handshakeDone {
64+ h.hooksMu.Unlock()
65+ fn()
66+ return
67+ }
68+ h.hooks = append(h.hooks, fn)
69+ h.hooksMu.Unlock()
70+}
71+
72+// ensurePreamble runs the preamble once, bounded by the handshake deadline.
73+// A failed preamble closes the raw transport.
74+func (h *handshake) ensurePreamble(run preambleRunner) error {
75+ h.preambleOnce.Do(func() {
76+ ctx, cancel := context.WithDeadline(context.Background(), h.deadline)
77+ defer cancel()
78+ next, err := run(ctx, h.raw, h.limits())
79+ h.publishLimits(next)
80+ h.preambleErr = err
81+ if err != nil {
82+ _ = h.raw.Close()
83+ }
84+ h.finishPreamble()
85+ })
86+ h.runHandshakeHooks()
87+ return h.preambleErr
88+}
89+
90+// finishPreamble publishes handshake completion from inside the preamble's
91+// sync.Once: it closes the done channel and marks the handshake complete so a
92+// later registration runs immediately. It deliberately runs no registered hook,
93+// because a hook that calls back into the connection would re-enter sync.Once
94+// and deadlock; runHandshakeHooks runs the hooks after the Once body returns.
95+// It tolerates a zero-value handshake that never allocated the done channel
96+// (test literals and seam-only construction), so closing is nil-safe.
97+func (h *handshake) finishPreamble() {
98+ if h.preambleDone != nil {
99+ close(h.preambleDone)
100+ }
101+ h.hooksMu.Lock()
102+ h.handshakeDone = true
103+ h.hooksMu.Unlock()
104+}
105+
106+// runHandshakeHooks runs the completion hooks registered before the handshake
107+// finished exactly once, after the preamble's sync.Once body has returned.
108+// Running them outside the Once is what lets a hook call back into the
109+// connection - for example to send or receive a message - without re-entering
110+// sync.Once and deadlocking. Every ensurePreamble caller invokes it; only the
111+// first drains the hooks, and a registrant that arrives after completion
112+// observes the completed flag and runs itself.
113+func (h *handshake) runHandshakeHooks() {
114+ h.hooksMu.Lock()
115+ if h.hooksRun {
116+ h.hooksMu.Unlock()
117+ return
118+ }
119+ h.hooksRun = true
120+ hooks := h.hooks
121+ h.hooks = nil
122+ h.hooksMu.Unlock()
123+ for _, fn := range hooks {
124+ fn()
125+ }
126+}
127+
128+// limits returns the negotiated ceilings under the lock the preamble publishes
129+// them through. Send paths may read them without the lock once ensurePreamble
130+// has returned, because that call orders the publish before the read.
131+func (h *handshake) limits() protoCeilings {
132+ h.ceilingsMu.Lock()
133+ defer h.ceilingsMu.Unlock()
134+ return h.ceilings
135+}
136+
137+// publishLimits records the ceilings one preamble negotiation produced.
138+func (h *handshake) publishLimits(next protoCeilings) {
139+ h.ceilingsMu.Lock()
140+ h.ceilings = next
141+ h.ceilingsMu.Unlock()
142+}
M internal/adapters/sessionwire/kill_result_test.go +2 -2
@@ -239,11 +239,11 @@ func TestKillResultIsBoundedControlEnvelope(t *testing.T) {
239239 require.ErrorIs(t, checkCategoryCeiling(oversize, categoryControl, wire.AbsoluteEnvelopeLimit), wire.ErrScanLength,
240240 "a KillResult above the control ceiling must fail even under an absolute negotiated ceiling")
241241
242- send := &serverConnection{raw: &scriptedTransport{}, ceilings: defaultProtoCeilings()}
242+ send := &serverConnection{handshake: handshake{raw: &scriptedTransport{}, ceilings: defaultProtoCeilings()}}
243243 require.NoError(t, send.SendServer(message))
244244 require.ErrorIs(t, send.SendServer(protocol.KillResult{RequestID: 1, Outcome: protocol.KillSucceeded, Text: string(make([]byte, wire.ControlEnvelopeLimit))}), wire.ErrScanLength)
245245
246- recv := &clientConnection{raw: &scriptedTransport{recv: []wire.Envelope{{Payload: oversize}}}, ceilings: defaultProtoCeilings()}
246+ recv := &clientConnection{handshake: handshake{raw: &scriptedTransport{recv: []wire.Envelope{{Payload: oversize}}}, ceilings: defaultProtoCeilings()}}
247247 recv.preambleOnce.Do(func() {})
248248 _, err = recv.ReceiveServer()
249249 require.Error(t, err)
M internal/adapters/sessionwire/proto_limits_category_test.go +5 -5
@@ -133,7 +133,7 @@ func TestClientSendCategoryEnvelopeCeilings(t *testing.T) {
133133 for _, tt := range tests {
134134 t.Run(tt.name, func(t *testing.T) {
135135 raw := &scriptedTransport{}
136- conn := &clientConnection{raw: raw, ceilings: tt.ceilings}
136+ conn := &clientConnection{handshake: handshake{raw: raw, ceilings: tt.ceilings}}
137137 conn.preambleOnce.Do(func() {})
138138 err := conn.SendClient(tt.message)
139139 if tt.wantErr != nil {
@@ -194,7 +194,7 @@ func TestServerSendCategoryEnvelopeCeilings(t *testing.T) {
194194 for _, tt := range tests {
195195 t.Run(tt.name, func(t *testing.T) {
196196 raw := &scriptedTransport{}
197- conn := &serverConnection{raw: raw, ceilings: tt.ceilings}
197+ conn := &serverConnection{handshake: handshake{raw: raw, ceilings: tt.ceilings}}
198198 err := conn.SendServer(tt.message)
199199 if tt.wantErr != nil {
200200 require.ErrorIs(t, err, tt.wantErr)
@@ -212,7 +212,7 @@ func TestServerSendCategoryEnvelopeCeilings(t *testing.T) {
212212 func TestServerSendModesShareControlCategoryCap(t *testing.T) {
213213 controlOver := protocol.Sessions{Sessions: []protocol.SessionInfo{{Name: strings.Repeat("x", overControlCap)}}}
214214 raw := &asyncScriptedTransport{scriptedTransport: &scriptedTransport{}}
215- conn := &serverConnection{raw: raw, ceilings: defaultProtoCeilings()}
215+ conn := &serverConnection{handshake: handshake{raw: raw, ceilings: defaultProtoCeilings()}}
216216
217217 require.ErrorIs(t, conn.SendServerAsync(controlOver), wire.ErrScanLength)
218218 require.ErrorIs(t, conn.SendServerSynchronous(controlOver), wire.ErrScanLength)
@@ -331,11 +331,11 @@ func TestReceiveCategoryEnvelopeCeilings(t *testing.T) {
331331 raw := &scriptedTransport{recv: []wire.Envelope{{Payload: tt.payload}}}
332332 var err error
333333 if tt.receiver == "server" {
334- conn := &serverConnection{raw: raw, ceilings: tt.ceilings}
334+ conn := &serverConnection{handshake: handshake{raw: raw, ceilings: tt.ceilings}}
335335 conn.preambleOnce.Do(func() {})
336336 _, err = conn.ReceiveClient()
337337 } else {
338- conn := &clientConnection{raw: raw, ceilings: tt.ceilings}
338+ conn := &clientConnection{handshake: handshake{raw: raw, ceilings: tt.ceilings}}
339339 conn.preambleOnce.Do(func() {})
340340 _, err = conn.ReceiveServer()
341341 }
M internal/adapters/sessionwire/proto_limits_test.go +10 -10
@@ -42,7 +42,7 @@ func TestServerSendEnforcesNegotiatedCeilings(t *testing.T) {
4242
4343 t.Run("normal send", func(t *testing.T) {
4444 raw := &scriptedTransport{}
45- conn := &serverConnection{raw: raw, ceilings: envelopeBound}
45+ conn := &serverConnection{handshake: handshake{raw: raw, ceilings: envelopeBound}}
4646 require.ErrorIs(t, conn.SendServer(protocol.ErrorMsg{Code: protocol.ErrInternal, Text: "oversized"}), wire.ErrScanLength)
4747 require.Zero(t, raw.sentLen())
4848 conn.ceilings = outputBound
@@ -51,7 +51,7 @@ func TestServerSendEnforcesNegotiatedCeilings(t *testing.T) {
5151 })
5252 t.Run("async and owned synchronous send", func(t *testing.T) {
5353 raw := &asyncScriptedTransport{scriptedTransport: &scriptedTransport{}}
54- conn := &serverConnection{raw: raw, ceilings: envelopeBound}
54+ conn := &serverConnection{handshake: handshake{raw: raw, ceilings: envelopeBound}}
5555 require.ErrorIs(t, conn.SendServerAsync(protocol.ErrorMsg{Code: protocol.ErrInternal, Text: "oversized"}), wire.ErrScanLength)
5656 require.ErrorIs(t, conn.SendServerSynchronous(protocol.ErrorMsg{Code: protocol.ErrInternal, Text: "oversized"}), wire.ErrScanLength)
5757 require.Empty(t, raw.async)
@@ -64,7 +64,7 @@ func TestServerSendEnforcesNegotiatedCeilings(t *testing.T) {
6464 })
6565 t.Run("within ceilings", func(t *testing.T) {
6666 raw := &scriptedTransport{}
67- conn := &serverConnection{raw: raw, ceilings: defaultProtoCeilings()}
67+ conn := &serverConnection{handshake: handshake{raw: raw, ceilings: defaultProtoCeilings()}}
6868 require.NoError(t, conn.SendServer(protocol.Pong{}))
6969 require.NoError(t, conn.SendOutput(testOutput([]byte("12345"))))
7070 require.Equal(t, 2, raw.sentLen())
@@ -81,7 +81,7 @@ func TestTypedReceiveEnforcesNegotiatedCeilings(t *testing.T) {
8181
8282 t.Run("server receive envelope ceiling", func(t *testing.T) {
8383 raw := &scriptedTransport{recv: []wire.Envelope{{Payload: largeInput}}}
84- conn := &serverConnection{raw: raw, ceilings: protoCeilings{maxReceiveEnvelopeBytes: 16, outputDataLimit: uint64(protocol.MaxOutputDataLen)}}
84+ conn := &serverConnection{handshake: handshake{raw: raw, ceilings: protoCeilings{maxReceiveEnvelopeBytes: 16, outputDataLimit: uint64(protocol.MaxOutputDataLen)}}}
8585 conn.preambleOnce.Do(func() {})
8686 _, err := conn.ReceiveClient()
8787 var failure *protocol.DecodeFailure
@@ -90,7 +90,7 @@ func TestTypedReceiveEnforcesNegotiatedCeilings(t *testing.T) {
9090 })
9191 t.Run("client receive envelope ceiling", func(t *testing.T) {
9292 raw := &scriptedTransport{recv: []wire.Envelope{{Payload: largeOutput}}}
93- conn := &clientConnection{raw: raw, ceilings: protoCeilings{maxReceiveEnvelopeBytes: 16, outputDataLimit: uint64(protocol.MaxOutputDataLen)}}
93+ conn := &clientConnection{handshake: handshake{raw: raw, ceilings: protoCeilings{maxReceiveEnvelopeBytes: 16, outputDataLimit: uint64(protocol.MaxOutputDataLen)}}}
9494 conn.preambleOnce.Do(func() {})
9595 _, err := conn.ReceiveServer()
9696 var failure *protocol.DecodeFailure
@@ -99,7 +99,7 @@ func TestTypedReceiveEnforcesNegotiatedCeilings(t *testing.T) {
9999 })
100100 t.Run("client receive output data ceiling", func(t *testing.T) {
101101 raw := &scriptedTransport{recv: []wire.Envelope{{Payload: smallOutput}}}
102- conn := &clientConnection{raw: raw, ceilings: protoCeilings{maxReceiveEnvelopeBytes: wire.AbsoluteEnvelopeLimit, outputDataLimit: 4}}
102+ conn := &clientConnection{handshake: handshake{raw: raw, ceilings: protoCeilings{maxReceiveEnvelopeBytes: wire.AbsoluteEnvelopeLimit, outputDataLimit: 4}}}
103103 conn.preambleOnce.Do(func() {})
104104 _, err := conn.ReceiveServer()
105105 var failure *protocol.DecodeFailure
@@ -108,14 +108,14 @@ func TestTypedReceiveEnforcesNegotiatedCeilings(t *testing.T) {
108108 })
109109 t.Run("within ceilings decodes", func(t *testing.T) {
110110 serverRaw := &scriptedTransport{recv: []wire.Envelope{{Payload: largeInput}}}
111- server := &serverConnection{raw: serverRaw, ceilings: defaultProtoCeilings()}
111+ server := &serverConnection{handshake: handshake{raw: serverRaw, ceilings: defaultProtoCeilings()}}
112112 server.preambleOnce.Do(func() {})
113113 got, err := server.ReceiveClient()
114114 require.NoError(t, err)
115115 require.Equal(t, []byte(bytes.Repeat([]byte("x"), 64)), got.(protocol.Input).Data)
116116
117117 clientRaw := &scriptedTransport{recv: []wire.Envelope{{Payload: smallOutput}}}
118- client := &clientConnection{raw: clientRaw, ceilings: defaultProtoCeilings()}
118+ client := &clientConnection{handshake: handshake{raw: clientRaw, ceilings: defaultProtoCeilings()}}
119119 client.preambleOnce.Do(func() {})
120120 decoded, err := client.ReceiveServer()
121121 require.NoError(t, err)
@@ -127,12 +127,12 @@ func TestTypedReceiveEnforcesNegotiatedCeilings(t *testing.T) {
127127 // path and advertised capabilities reflect the negotiated ceilings.
128128 func TestClientSendAndCapabilitiesUseNegotiatedCeilings(t *testing.T) {
129129 raw := &scriptedTransport{}
130- conn := &clientConnection{raw: raw, ceilings: protoCeilings{maxReceiveEnvelopeBytes: 8, outputDataLimit: 1234}}
130+ conn := &clientConnection{handshake: handshake{raw: raw, ceilings: protoCeilings{maxReceiveEnvelopeBytes: 8, outputDataLimit: 1234}}}
131131 conn.preambleOnce.Do(func() {})
132132 require.ErrorIs(t, conn.SendClient(protocol.Input{InputSeq: 1, Data: []byte("hello")}), wire.ErrScanLength)
133133 require.Zero(t, raw.sentLen())
134134 require.Equal(t, 1234, conn.Capabilities().OutputDataLimit)
135135
136- server := &serverConnection{raw: &scriptedTransport{}, ceilings: protoCeilings{maxReceiveEnvelopeBytes: 1, outputDataLimit: 4321}}
136+ server := &serverConnection{handshake: handshake{raw: &scriptedTransport{}, ceilings: protoCeilings{maxReceiveEnvelopeBytes: 1, outputDataLimit: 4321}}}
137137 require.Equal(t, 4321, server.Capabilities().OutputDataLimit)
138138 }
M internal/adapters/sessionwire/server.go +7 -124
@@ -2,9 +2,7 @@
22 package sessionwire
33
44 import (
5- "context"
65 "errors"
7- "sync"
86 "time"
97
108 "google.golang.org/protobuf/encoding/protowire"
@@ -22,35 +20,15 @@ var (
2220 )
2321
2422 type serverConnection struct {
25- raw wire.Transport
26- ceilings protoCeilings
27- // ceilingsMu guards ceilings. The lazy preamble publishes the negotiated
28- // values from whichever goroutine first uses the connection, while another
29- // goroutine may already be asking for capabilities, so the once-guarded write
30- // is not by itself a happens-before edge for that reader.
31- ceilingsMu sync.Mutex
32-
33- preambleOnce sync.Once
34- preambleErr error
35- deadline time.Time
23+ handshake
3624
3725 // admission is the provisioned admission metadata the accepting side
38- // stamped, or the zero value with hasAdmission=false for a connection built
39- // by a legacy constructor that carried no admission. It is never mutated
40- // after construction and is exposed only through SessionAdmission, which
41- // returns a deep copy.
26+ // stamped, or the zero value with hasAdmission=false for a connection whose
27+ // accepting side provisioned none (a direct local daemon socket). It is
28+ // never mutated after construction and is exposed only through
29+ // SessionAdmission, which returns a deep copy.
4230 admission ports.SessionAdmission
4331 hasAdmission bool
44-
45- // preambleDone closes exactly once when the handshake has run, whether it
46- // succeeded or failed. It backs the handshake completion seam a mux listener
47- // exposes to the daemon; the absolute deadline is never restarted.
48- preambleDone chan struct{}
49-
50- hooksMu sync.Mutex
51- handshakeDone bool
52- hooksRun bool
53- hooks []func()
5432 }
5533
5634 var (
@@ -114,12 +92,9 @@ func newServerConnection(raw wire.Transport, deadline time.Time, admission ports
11492 deadline = time.Now().Add(protocol.HandshakeTimeout)
11593 }
11694 return &serverConnection{
117- raw: raw,
118- ceilings: defaultProtoCeilings(),
119- deadline: deadline,
95+ handshake: newHandshake(raw, deadline),
12096 admission: admission.Clone(),
12197 hasAdmission: hasAdmission,
122- preambleDone: make(chan struct{}),
12398 }
12499 }
125100
@@ -135,100 +110,8 @@ func (c *serverConnection) SessionAdmission() (ports.SessionAdmission, bool) {
135110 return c.admission.Clone(), true
136111 }
137112
138-// HandshakeDeadline returns the absolute local deadline of the session
139-// handshake this connection accepted. It is fixed for the connection's
140-// lifetime and is never restarted: the preamble, Hello/Welcome, and the first
141-// committed publication all share it.
142-func (c *serverConnection) HandshakeDeadline() time.Time { return c.deadline }
143-
144-// HandshakeDone returns a channel that is closed exactly once when the
145-// handshake has run, whether it succeeded or failed.
146-func (c *serverConnection) HandshakeDone() <-chan struct{} { return c.preambleDone }
147-
148-// OnHandshakeComplete registers fn to run exactly once when the handshake has
149-// run. Registering after completion runs fn immediately; a nil fn is ignored.
150-func (c *serverConnection) OnHandshakeComplete(fn func()) {
151- if fn == nil {
152- return
153- }
154- c.hooksMu.Lock()
155- if c.handshakeDone {
156- c.hooksMu.Unlock()
157- fn()
158- return
159- }
160- c.hooks = append(c.hooks, fn)
161- c.hooksMu.Unlock()
162-}
163-
164-// finishPreamble publishes handshake completion from inside the preamble's
165-// sync.Once: it closes the done channel and marks the handshake complete so a
166-// later registration runs immediately. It deliberately runs no registered hook,
167-// because a hook that calls back into the connection would re-enter sync.Once
168-// and deadlock; runHandshakeHooks runs the hooks after the Once body returns.
169-// It tolerates a zero-value connection that never allocated the done channel
170-// (test literals and seam-only construction), so closing is nil-safe.
171-func (c *serverConnection) finishPreamble() {
172- if c.preambleDone != nil {
173- close(c.preambleDone)
174- }
175- c.hooksMu.Lock()
176- c.handshakeDone = true
177- c.hooksMu.Unlock()
178-}
179-
180-// runHandshakeHooks runs the completion hooks registered before the handshake
181-// finished exactly once, after the preamble's sync.Once body has returned.
182-// Running them outside the Once is what lets a hook call back into the
183-// connection - for example to receive a message - without re-entering
184-// sync.Once and deadlocking. Every ensurePreamble caller invokes it; only the
185-// first drains the hooks, and a registrant that arrives after completion
186-// observes the completed flag and runs itself.
187-func (c *serverConnection) runHandshakeHooks() {
188- c.hooksMu.Lock()
189- if c.hooksRun {
190- c.hooksMu.Unlock()
191- return
192- }
193- c.hooksRun = true
194- hooks := c.hooks
195- c.hooks = nil
196- c.hooksMu.Unlock()
197- for _, fn := range hooks {
198- fn()
199- }
200-}
201-
202113 func (c *serverConnection) ensurePreamble() error {
203- c.preambleOnce.Do(func() {
204- ctx, cancel := context.WithDeadline(context.Background(), c.deadline)
205- defer cancel()
206- next, err := runProtoServerPreamble(ctx, c.raw, c.limits())
207- c.publishLimits(next)
208- c.preambleErr = err
209- if err != nil {
210- _ = c.raw.Close()
211- }
212- c.finishPreamble()
213- })
214- c.runHandshakeHooks()
215- return c.preambleErr
216-}
217-
218-// limits returns the negotiated ceilings under the lock the preamble publishes
219-// them through. Send paths may read them without the lock once ensurePreamble
220-// has returned, because that call orders the publish before the read.
221-func (c *serverConnection) limits() protoCeilings {
222- c.ceilingsMu.Lock()
223- defer c.ceilingsMu.Unlock()
224- return c.ceilings
225-}
226-
227-// publishLimits records the ceilings one preamble negotiation produced.
228-func (c *serverConnection) publishLimits(next protoCeilings) {
229- c.ceilingsMu.Lock()
230- c.ceilings = next
231- c.ceilingsMu.Unlock()
114+ return c.handshake.ensurePreamble(runProtoServerPreamble)
232115 }
233116
234117 func (c *serverConnection) ReceiveClient() (protocol.ClientMessage, error) {
M internal/adapters/snapshot/direct_load_test.go +1 -1
@@ -11,8 +11,8 @@ import (
1111
1212 "github.com/bnema/vev/internal/domain"
1313 "github.com/bnema/vev/internal/ports"
14+ codec "github.com/bnema/vev/internal/snapshotcodec"
1415 recoveryusecase "github.com/bnema/vev/internal/usecase/recovery"
15- codec "github.com/bnema/vev/internal/usecase/snapshot"
1616 "github.com/stretchr/testify/require"
1717 )
1818
M internal/adapters/snapshot/load.go +1 -1
@@ -10,7 +10,7 @@ import (
1010
1111 "github.com/bnema/vev/internal/domain"
1212 "github.com/bnema/vev/internal/ports"
13- codec "github.com/bnema/vev/internal/usecase/snapshot"
13+ codec "github.com/bnema/vev/internal/snapshotcodec"
1414 )
1515
1616 var ErrInvalidHEAD = errors.New("invalid snapshot HEAD")
M internal/adapters/snapshot/maintenance_gc.go +1 -1
@@ -10,7 +10,7 @@ import (
1010
1111 "github.com/bnema/vev/internal/domain"
1212 "github.com/bnema/vev/internal/ports"
13- codec "github.com/bnema/vev/internal/usecase/snapshot"
13+ codec "github.com/bnema/vev/internal/snapshotcodec"
1414 )
1515
1616 // SnapshotIncarnations lists the canonical incarnation directories.
M internal/adapters/snapshot/path_security_test.go +1 -1
@@ -10,7 +10,7 @@ import (
1010 "testing"
1111
1212 "github.com/bnema/vev/internal/domain"
13- codec "github.com/bnema/vev/internal/usecase/snapshot"
13+ codec "github.com/bnema/vev/internal/snapshotcodec"
1414 )
1515
1616 func TestRepositoryRejectsSymlinkedRoot(t *testing.T) {
M internal/adapters/snapshot/publish.go +1 -1
@@ -10,7 +10,7 @@ import (
1010
1111 "github.com/bnema/vev/internal/domain"
1212 "github.com/bnema/vev/internal/ports"
13- codec "github.com/bnema/vev/internal/usecase/snapshot"
13+ codec "github.com/bnema/vev/internal/snapshotcodec"
1414 )
1515
1616 func (r *Repository) Publish(ctx context.Context, publication ports.SnapshotPublication) error {
M internal/adapters/snapshot/publish_efficiency_test.go +1 -1
@@ -10,7 +10,7 @@ import (
1010
1111 "github.com/bnema/vev/internal/domain"
1212 "github.com/bnema/vev/internal/ports"
13- codec "github.com/bnema/vev/internal/usecase/snapshot"
13+ codec "github.com/bnema/vev/internal/snapshotcodec"
1414 )
1515
1616 func TestRepositoryPublishRevalidatesUnchangedTenThousandObjectHistory(t *testing.T) {
M internal/adapters/snapshot/repository_hardening_test.go +1 -1
@@ -8,7 +8,7 @@ import (
88 "syscall"
99 "testing"
1010
11- codec "github.com/bnema/vev/internal/usecase/snapshot"
11+ codec "github.com/bnema/vev/internal/snapshotcodec"
1212 )
1313
1414 func TestReadBoundedRejectsUnsafeFiles(t *testing.T) {
M internal/adapters/snapshot/repository_test.go +1 -1
@@ -8,7 +8,7 @@ import (
88 "github.com/bnema/vev/internal/domain"
99
1010 "github.com/bnema/vev/internal/ports"
11- codec "github.com/bnema/vev/internal/usecase/snapshot"
11+ codec "github.com/bnema/vev/internal/snapshotcodec"
1212 )
1313
1414 func TestRepositoryPublishesAndLoadsCompleteGeneration(t *testing.T) {
M internal/app/broker_offline_mux.go +3 -3
@@ -65,9 +65,9 @@ import (
6565
6666 const (
6767 // brokerMuxStdioCommand is the hidden remote SSH stdio mux bridge.
68- brokerMuxStdioCommand = "_broker-mux-stdio"
68+ brokerMuxStdioCommand = ports.BrokerMuxStdioCommand
6969 // brokerMuxQUICBootstrapCommand is the hidden remote QUIC mux bootstrap.
70- brokerMuxQUICBootstrapCommand = "_broker-mux-quic-bootstrap"
70+ brokerMuxQUICBootstrapCommand = ports.BrokerMuxQUICBootstrapCommand
7171 // brokerMuxQUICProxyCommand is the hidden detached remote QUIC mux proxy.
7272 brokerMuxQUICProxyCommand = "_broker-mux-quic-proxy"
7373 )
@@ -105,7 +105,7 @@ const brokerDaemonStartArg = "--daemon-start"
105105
106106 // brokerScopeArg is the scope flag carried by every durable remote route argv
107107 // (see ports.BrokerRouteForTransport). Stored routes keep it verbatim.
108-const brokerScopeArg = "--production"
108+const brokerScopeArg = ports.BrokerRouteScopeArg
109109
110110 // brokerMuxOptions is the parsed hidden mux helper invocation.
111111 type brokerMuxOptions struct {
R internal/usecase/layout/consume_expel.go → internal/domain/layout/consume_expel.go +0 -0 renamed, 100% similar

renamed, 100% similar

R internal/usecase/layout/consume_expel_test.go → internal/domain/layout/consume_expel_test.go +0 -0 renamed, 100% similar

renamed, 100% similar

R internal/usecase/layout/cross_focus_test.go → internal/domain/layout/cross_focus_test.go +0 -0 renamed, 100% similar

renamed, 100% similar

R internal/usecase/layout/dividers.go → internal/domain/layout/dividers.go +0 -0 renamed, 100% similar

renamed, 100% similar

R internal/usecase/layout/dividers_test.go → internal/domain/layout/dividers_test.go +0 -0 renamed, 100% similar

renamed, 100% similar

R internal/usecase/layout/geometry.go → internal/domain/layout/geometry.go +0 -0 renamed, 100% similar

renamed, 100% similar

R internal/usecase/layout/layout_test.go → internal/domain/layout/layout_test.go +0 -0 renamed, 100% similar

renamed, 100% similar

R internal/usecase/layout/ops.go → internal/domain/layout/ops.go +0 -0 renamed, 100% similar

renamed, 100% similar

R internal/usecase/layout/resize_test.go → internal/domain/layout/resize_test.go +0 -0 renamed, 100% similar

renamed, 100% similar

R internal/usecase/layout/tree.go → internal/domain/layout/tree.go +0 -0 renamed, 100% similar

renamed, 100% similar

R internal/usecase/layout/weights_test.go → internal/domain/layout/weights_test.go +0 -0 renamed, 100% similar

renamed, 100% similar

M internal/ports/broker_store.go +13 -4
@@ -90,14 +90,23 @@ func (r BrokerRouteSpec) Clone() BrokerRouteSpec {
9090 return r
9191 }
9292
93-// BrokerRouteForTransport maps the closed legacy policy transport vocabulary
94-// to canonical remote routes.
93+// Remote helper argv vocabulary. Durable SSH routes store this argv verbatim,
94+// so these spellings are part of the persisted route contract; the CLI parses
95+// the same constants.
96+const (
97+ BrokerMuxStdioCommand = "_broker-mux-stdio"
98+ BrokerMuxQUICBootstrapCommand = "_broker-mux-quic-bootstrap"
99+ BrokerRouteScopeArg = "--production"
100+)
101+
102+// BrokerRouteForTransport maps the closed policy transport vocabulary to the
103+// canonical remote route.
95104 func BrokerRouteForTransport(transport, endpoint string) (BrokerRouteSpec, error) {
96105 switch transport {
97106 case "ssh-stdio", "stdio":
98- return BrokerRouteSpec{Kind: BrokerRouteSSHStdio, Target: endpoint, Argv: []string{"vev", "_broker-mux-stdio", "--production"}}, nil
107+ return BrokerRouteSpec{Kind: BrokerRouteSSHStdio, Target: endpoint, Argv: []string{"vev", BrokerMuxStdioCommand, BrokerRouteScopeArg}}, nil
99108 case "ssh-quic", "quic":
100- return BrokerRouteSpec{Kind: BrokerRouteSSHQUIC, Target: endpoint, Argv: []string{"vev", "_broker-mux-quic-bootstrap", "--production"}}, nil
109+ return BrokerRouteSpec{Kind: BrokerRouteSSHQUIC, Target: endpoint, Argv: []string{"vev", BrokerMuxQUICBootstrapCommand, BrokerRouteScopeArg}}, nil
101110 default:
102111 return BrokerRouteSpec{}, fmt.Errorf("ports: unsupported broker transport %q", transport)
103112 }
M internal/ports/session_admission.go +3 -2
@@ -165,8 +165,9 @@ func (a SessionAdmission) Clone() SessionAdmission {
165165 // SessionAdmissionProvider is the optional boundary-safe port a typed accepted
166166 // server connection implements when the accepting side stamped admission
167167 // metadata onto it. A daemon-consuming use case asserts it structurally; a
168-// connection built by a legacy constructor that carries no provisioned
169-// admission reports ok=false rather than a zero admission.
168+// connection accepted without provisioned admission (the direct local daemon
169+// socket used by broker-less list and stop commands) reports ok=false rather
170+// than a zero admission, and the daemon applies standard Hello validation.
170171 //
171172 // The returned value is a defensive copy: mutating it never alters the
172173 // connection's admitted authority, and concurrent readers each receive their
R internal/usecase/snapshot/codec.go → internal/snapshotcodec/codec.go +2 -2 renamed, 99% similar
@@ -1,4 +1,4 @@
1-package snapshot
1+package snapshotcodec
22
33 import (
44 "encoding/binary"
@@ -7,7 +7,7 @@ import (
77 "hash/crc32"
88 "math"
99
10- "github.com/bnema/vev/internal/usecase/layout"
10+ "github.com/bnema/vev/internal/domain/layout"
1111 )
1212
1313 var (
R internal/usecase/snapshot/codec_encoder_test.go → internal/snapshotcodec/codec_encoder_test.go +2 -2 renamed, 97% similar
@@ -1,4 +1,4 @@
1-package snapshot
1+package snapshotcodec
22
33 import (
44 "encoding/binary"
@@ -6,7 +6,7 @@ import (
66 "hash/crc32"
77 "math"
88
9- "github.com/bnema/vev/internal/usecase/layout"
9+ "github.com/bnema/vev/internal/domain/layout"
1010 )
1111
1212 // marshalTest is a test-only v4 encoder. Production retains only the decoder
R internal/usecase/snapshot/codec_fuzz_test.go → internal/snapshotcodec/codec_fuzz_test.go +1 -1 renamed, 97% similar
@@ -1,4 +1,4 @@
1-package snapshot
1+package snapshotcodec
22
33 import (
44 "encoding/hex"
R internal/usecase/snapshot/codec_test.go → internal/snapshotcodec/codec_test.go +2 -2 renamed, 99% similar
@@ -1,4 +1,4 @@
1-package snapshot
1+package snapshotcodec
22
33 import (
44 "bytes"
@@ -11,7 +11,7 @@ import (
1111
1212 vt "github.com/bnema/vev-vt"
1313 renderer "github.com/bnema/vev-vt/ansi"
14- "github.com/bnema/vev/internal/usecase/layout"
14+ "github.com/bnema/vev/internal/domain/layout"
1515 )
1616
1717 func TestPayloadReaderGetBytes(t *testing.T) {
R internal/usecase/snapshot/head_test.go → internal/snapshotcodec/head_test.go +1 -1 renamed, 98% similar
@@ -1,4 +1,4 @@
1-package snapshot
1+package snapshotcodec
22
33 import (
44 "errors"
R internal/usecase/snapshot/manifest.go → internal/snapshotcodec/manifest.go +2 -2 renamed, 99% similar
@@ -1,4 +1,4 @@
1-package snapshot
1+package snapshotcodec
22
33 import (
44 "crypto/sha256"
@@ -8,8 +8,8 @@ import (
88 "math"
99
1010 "github.com/bnema/vev/internal/domain"
11+ "github.com/bnema/vev/internal/domain/layout"
1112 "github.com/bnema/vev/internal/ports"
12- "github.com/bnema/vev/internal/usecase/layout"
1313 )
1414
1515 const (
R internal/usecase/snapshot/manifest_preflight.go → internal/snapshotcodec/manifest_preflight.go +2 -2 renamed, 99% similar
@@ -1,10 +1,10 @@
1-package snapshot
1+package snapshotcodec
22
33 import (
44 "fmt"
55
66 "github.com/bnema/vev/internal/domain"
7- "github.com/bnema/vev/internal/usecase/layout"
7+ "github.com/bnema/vev/internal/domain/layout"
88 )
99
1010 // preflightManifest rejects hostile declarations before UnmarshalManifest
R internal/usecase/snapshot/manifest_preflight_test.go → internal/snapshotcodec/manifest_preflight_test.go +2 -2 renamed, 99% similar
@@ -1,4 +1,4 @@
1-package snapshot
1+package snapshotcodec
22
33 import (
44 "encoding/binary"
@@ -7,7 +7,7 @@ import (
77 "testing"
88
99 "github.com/bnema/vev/internal/domain"
10- "github.com/bnema/vev/internal/usecase/layout"
10+ "github.com/bnema/vev/internal/domain/layout"
1111 )
1212
1313 func TestManifestGrammarRoundTrip(t *testing.T) {
R internal/usecase/snapshot/manifest_test.go → internal/snapshotcodec/manifest_test.go +2 -2 renamed, 99% similar
@@ -1,4 +1,4 @@
1-package snapshot
1+package snapshotcodec
22
33 import (
44 "bytes"
@@ -9,8 +9,8 @@ import (
99 "testing"
1010
1111 "github.com/bnema/vev/internal/domain"
12+ "github.com/bnema/vev/internal/domain/layout"
1213 "github.com/bnema/vev/internal/ports"
13- "github.com/bnema/vev/internal/usecase/layout"
1414 )
1515
1616 func TestManifestIncarnation(t *testing.T) {
R internal/usecase/snapshot/object_compress.go → internal/snapshotcodec/object_compress.go +1 -1 renamed, 99% similar
@@ -1,4 +1,4 @@
1-package snapshot
1+package snapshotcodec
22
33 import (
44 "bytes"
R internal/usecase/snapshot/object_compress_test.go → internal/snapshotcodec/object_compress_test.go +1 -1 renamed, 99% similar
@@ -1,4 +1,4 @@
1-package snapshot
1+package snapshotcodec
22
33 import (
44 "bytes"
R internal/usecase/snapshot/preflight.go → internal/snapshotcodec/preflight.go +2 -2 renamed, 99% similar
@@ -1,11 +1,11 @@
1-package snapshot
1+package snapshotcodec
22
33 import (
44 "fmt"
55 "math"
66
77 vt "github.com/bnema/vev-vt"
8- "github.com/bnema/vev/internal/usecase/layout"
8+ "github.com/bnema/vev/internal/domain/layout"
99 )
1010
1111 func preflightSession(body []byte) error {
R internal/usecase/snapshot/preflight_acceptance_test.go → internal/snapshotcodec/preflight_acceptance_test.go +2 -2 renamed, 93% similar
@@ -1,11 +1,11 @@
1-package snapshot
1+package snapshotcodec
22
33 import (
44 "errors"
55 "testing"
66
77 renderer "github.com/bnema/vev-vt"
8- "github.com/bnema/vev/internal/usecase/layout"
8+ "github.com/bnema/vev/internal/domain/layout"
99 )
1010
1111 func TestPreflightRejectsDanglingTreeAndFocusReferencesBeforeDecode(t *testing.T) {
R internal/usecase/snapshot/snapshot.go → internal/snapshotcodec/snapshot.go +6 -2 renamed, 76% similar
@@ -1,6 +1,10 @@
1-package snapshot
1+// Package snapshotcodec owns the durable snapshot format: the session codec,
2+// checkpoint manifests, content-addressed objects, and their bounded preflight.
3+// It depends only on domain values and ports, so both the daemon use case and
4+// the snapshot repository adapter can consume it.
5+package snapshotcodec
26
3-import "github.com/bnema/vev/internal/usecase/layout"
7+import "github.com/bnema/vev/internal/domain/layout"
48
59 // Session is the durable transfer representation for a named daemon session.
610 type Session struct {
R internal/usecase/snapshot/vevs_v4_golden_test.go → internal/snapshotcodec/vevs_v4_golden_test.go +2 -2 renamed, 98% similar
@@ -1,4 +1,4 @@
1-package snapshot
1+package snapshotcodec
22
33 import (
44 "bytes"
@@ -6,7 +6,7 @@ import (
66 "testing"
77
88 vt "github.com/bnema/vev-vt"
9- "github.com/bnema/vev/internal/usecase/layout"
9+ "github.com/bnema/vev/internal/domain/layout"
1010 )
1111
1212 const compactVEVSGolden = "564556530005000000000104dbb0765f0007726573746f7265000000000000007b0000000100037461620050001800000000000000020001700000017000010001700006737461626c6500042f746d70000000010000003f5654433100000001000000000000002a0000000200000001000000010000000000000000000000290000000100000000410000000000000000200000000000000000105654433100000000000000000000002a000000645654433100000001000000000000002b000000020000000100000002000000000900000000000000000000ffffffffffffffff010203000000000000000000000000000000000000000000002a00000002000000754c000000010000000000000000010100"
R internal/usecase/snapshot/weights_test.go → internal/snapshotcodec/weights_test.go +2 -2 renamed, 99% similar
@@ -1,4 +1,4 @@
1-package snapshot
1+package snapshotcodec
22
33 import (
44 "bytes"
@@ -7,7 +7,7 @@ import (
77 "math"
88 "testing"
99
10- "github.com/bnema/vev/internal/usecase/layout"
10+ "github.com/bnema/vev/internal/domain/layout"
1111 )
1212
1313 func TestManifestWeightRoundTripAndOldCompatibility(t *testing.T) {
M internal/usecase/broker/doc.go +7 -0
@@ -2,4 +2,11 @@
22 // reachability and catalogue snapshots, pooled physical transports, and
33 // multiplexed logical streams. It does not own picker UI, sessions, PTYs,
44 // terminal geometry, or attachment semantics.
5+//
6+// Registry state lives in registry.go and is guarded by one mutex. Its
7+// behaviour is split by responsibility: durable membership mutation
8+// (registry_membership.go), probe scheduling and result application
9+// (registry_probe.go), local daemon observation (registry_local.go),
10+// snapshot publication and subscriptions (registry_publish.go), and the
11+// asynchronous observation writer (registry_snapshot_writer.go).
512 package broker
M internal/usecase/broker/registry.go +0 -1163
@@ -3,14 +3,10 @@ package broker
33 import (
44 "context"
55 "crypto/rand"
6- "encoding/binary"
76 "errors"
87 "fmt"
9- "hash/fnv"
108 "log/slog"
119 "reflect"
12- "slices"
13- "sort"
1410 "sync"
1511 "sync/atomic"
1612 "time"
@@ -424,1162 +420,3 @@ func (r *Registry) restore(snapshot ports.BrokerSnapshot) error {
424420 }
425421 return nil
426422 }
427-
428-// Snapshot returns a defensive immutable copy without I/O.
429-func (r *Registry) Snapshot() ports.BrokerSnapshot { return r.current.Load().Clone() }
430-
431-// Subscribe returns a capacity-one notification stream. Slow consumers never
432-// block publication and always re-read the newest snapshot.
433-func (r *Registry) Subscribe() ports.BrokerSubscription {
434- s := &subscription{changed: make(chan struct{}, 1)}
435- s.close = func() {
436- r.mu.Lock()
437- delete(r.subs, s)
438- r.mu.Unlock()
439- }
440- r.mu.Lock()
441- r.subs[s] = struct{}{}
442- r.mu.Unlock()
443- s.offer()
444- return s
445-}
446-
447-// AddHost adds or pins endpoint under policy. Duplicate same-policy additions
448-// CAS-verify authority and preserve identity; a conflicting policy is refused.
449-func (r *Registry) LookupHost(ctx context.Context, endpoint string) (ports.BrokerHostRecord, bool, error) {
450- if err := ctx.Err(); err != nil {
451- return ports.BrokerHostRecord{}, false, err
452- }
453- r.mu.Lock()
454- defer r.mu.Unlock()
455- for _, record := range r.authority.Hosts {
456- if record.Registration.Endpoint == endpoint {
457- record.Route = record.Route.Clone()
458- return record, true, nil
459- }
460- }
461- return ports.BrokerHostRecord{}, false, nil
462-}
463-
464-func (r *Registry) AddHost(ctx context.Context, endpoint string, policy ports.BrokerPolicy) (domain.RemoteRegistration, error) {
465- if err := domain.ValidateRemoteHostTarget(endpoint); err != nil {
466- return domain.RemoteRegistration{}, err
467- }
468- if err := policy.Validate(); err != nil {
469- return domain.RemoteRegistration{}, err
470- }
471- r.mu.Lock()
472- defer r.mu.Unlock()
473- if err := r.mutableLocked(ctx); err != nil {
474- return domain.RemoteRegistration{}, err
475- }
476- records := append([]ports.BrokerHostRecord(nil), r.authority.Hosts...)
477- for i := range records {
478- if records[i].Registration.Endpoint != endpoint {
479- continue
480- }
481- if records[i].Policy != policy {
482- return domain.RemoteRegistration{}, ports.ErrBrokerHostConflict
483- }
484- records[i].Pinned = true
485- if err := r.commitMembershipLocked(records); err != nil {
486- return domain.RemoteRegistration{}, err
487- }
488- return records[i].Registration, nil
489- }
490- id, err := r.newIncarnation()
491- if err != nil {
492- return domain.RemoteRegistration{}, err
493- }
494- reg, err := domain.NewRemoteRegistration(endpoint, id)
495- if err != nil {
496- return domain.RemoteRegistration{}, err
497- }
498- if reg.IsZero() {
499- return domain.RemoteRegistration{}, errors.New("broker: incarnation generator returned zero")
500- }
501- route, err := ports.BrokerRouteForTransport(policy.Transport, endpoint)
502- if err != nil {
503- return domain.RemoteRegistration{}, err
504- }
505- records = append(records, ports.BrokerHostRecord{Registration: reg, Pinned: true, Policy: policy, Route: route})
506- if err := r.commitMembershipLocked(records); err != nil {
507- return domain.RemoteRegistration{}, err
508- }
509- return reg, nil
510-}
511-
512-// RemoveHost removes only the exact expected registration.
513-// BindAuthenticatedIdentity durably binds first-contact identity under the
514-// exact registration and policy fence. The same binding is idempotent; a
515-// different identity is refused and never overwrites authority.
516-func (r *Registry) BindAuthenticatedIdentity(ctx context.Context, request ports.BrokerIdentityBindingRequest) (ports.BrokerDaemonIdentity, error) {
517- if err := request.Validate(); err != nil {
518- return "", err
519- }
520- if request.Fence.Local {
521- return request.Identity, nil
522- }
523- if err := ctx.Err(); err != nil {
524- return "", err
525- }
526- r.mu.Lock()
527- defer r.mu.Unlock()
528- records := append([]ports.BrokerHostRecord(nil), r.authority.Hosts...)
529- for index := range records {
530- record := &records[index]
531- if record.Identity == request.Identity && record.Policy != request.Policy {
532- return "", ports.BrokerError{Code: ports.BrokerErrorConflictingPolicy, Cause: errors.New("broker: authenticated daemon identity is already bound under an incompatible policy")}
533- }
534- }
535- for index := range records {
536- record := &records[index]
537- if !record.Registration.Equal(request.Fence.Registration) || record.Policy != request.Policy {
538- continue
539- }
540- if record.Identity != "" {
541- if record.Identity != request.Identity {
542- return "", ports.BrokerError{Code: ports.BrokerErrorConflictingPolicy, Cause: errors.New("broker: authenticated daemon identity conflicts with durable binding")}
543- }
544- return record.Identity, nil
545- }
546- record.Identity = request.Identity
547- if err := r.commitMembershipLocked(records); err != nil {
548- return "", err
549- }
550- return request.Identity, nil
551- }
552- return "", ports.BrokerError{Code: ports.BrokerErrorUnavailable, Cause: errors.New("broker: stale identity binding fence")}
553-}
554-
555-func (r *Registry) RemoveHost(ctx context.Context, expected domain.RemoteRegistration) (bool, error) {
556- if err := expected.Validate(); err != nil {
557- return false, err
558- }
559- r.mu.Lock()
560- defer r.mu.Unlock()
561- if err := r.mutableLocked(ctx); err != nil {
562- return false, err
563- }
564- records := append([]ports.BrokerHostRecord(nil), r.authority.Hosts...)
565- for i := range records {
566- if records[i].Registration.Endpoint != expected.Endpoint {
567- continue
568- }
569- if !records[i].Registration.Equal(expected) {
570- return false, ports.ErrBrokerHostConflict
571- }
572- records = append(records[:i], records[i+1:]...)
573- if err := r.commitMembershipLocked(records); err != nil {
574- return false, err
575- }
576- return true, nil
577- }
578- // A no-op still verifies the loaded revision.
579- return false, r.commitMembershipLocked(records)
580-}
581-
582-// UpdateHostPolicy updates exact authority and advances its generation.
583-func (r *Registry) UpdateHostPolicy(ctx context.Context, expected domain.RemoteRegistration, policy ports.BrokerPolicy) (domain.RemoteRegistration, error) {
584- if err := expected.Validate(); err != nil {
585- return domain.RemoteRegistration{}, err
586- }
587- if err := policy.Validate(); err != nil {
588- return domain.RemoteRegistration{}, err
589- }
590- r.mu.Lock()
591- defer r.mu.Unlock()
592- if err := r.mutableLocked(ctx); err != nil {
593- return domain.RemoteRegistration{}, err
594- }
595- records := append([]ports.BrokerHostRecord(nil), r.authority.Hosts...)
596- for i := range records {
597- if records[i].Registration.Endpoint != expected.Endpoint {
598- continue
599- }
600- if !records[i].Registration.Equal(expected) {
601- return domain.RemoteRegistration{}, ports.ErrBrokerHostConflict
602- }
603- if records[i].Policy == policy {
604- if err := r.commitMembershipLocked(records); err != nil {
605- return domain.RemoteRegistration{}, err
606- }
607- return expected, nil
608- }
609- if expected.Generation == ^domain.RemoteGeneration(0) {
610- return domain.RemoteRegistration{}, ports.ErrBrokerRevisionExhausted
611- }
612- route, err := ports.BrokerRouteForTransport(policy.Transport, expected.Endpoint)
613- if err != nil {
614- return domain.RemoteRegistration{}, err
615- }
616- records[i].Registration.Generation++
617- records[i].Route = route
618- records[i].Policy = policy
619- if err := r.commitMembershipLocked(records); err != nil {
620- return domain.RemoteRegistration{}, err
621- }
622- return records[i].Registration, nil
623- }
624- return domain.RemoteRegistration{}, ports.ErrBrokerHostConflict
625-}
626-
627-func (r *Registry) mutableLocked(ctx context.Context) error {
628- if r.membershipMode != MembershipMutable {
629- return ports.ErrBrokerMembershipImmutable
630- }
631- if err := ctx.Err(); err != nil {
632- return err
633- }
634- if r.running.Load() == runStopped {
635- return ports.ErrBrokerRegistryClosed
636- }
637- if r.revisionExhaustedLocked() {
638- return ports.ErrBrokerRevisionExhausted
639- }
640- return nil
641-}
642-
643-// commitMembershipLocked is the sole mutable-membership durable commit path.
644-// It installs projection only after CAS success.
645-func (r *Registry) commitMembershipLocked(records []ports.BrokerHostRecord) error {
646- if err := ports.ValidateBrokerHostRecords(records); err != nil {
647- return err
648- }
649- if len(records) > 0 && !r.observationDisabled && nilDependency(r.probe) {
650- return errors.New("broker: remote hosts require a host probe")
651- }
652- next := make(map[string]ports.BrokerDaemonObservation, len(records))
653- order := make([]string, 0, len(records))
654- for rank, record := range records {
655- observation := stampHostAuthority(ports.BrokerDaemonObservation{}, record, rank)
656- // A membership the registry cannot publish is refused here, before the
657- // store CAS, rather than persisted and left to freeze every later
658- // publication: an endpoint whose derived display hint is empty after the
659- // display rule drops its runes makes every snapshot carrying it invalid.
660- if err := observation.Validate(); err != nil {
661- return fmt.Errorf("broker: host record %q is not publishable: %w", record.Registration.Endpoint, err)
662- }
663- next[record.Registration.Endpoint] = observation
664- order = append(order, record.Registration.Endpoint)
665- }
666- if err := r.hostStore.ReplaceHosts(r.authority.Revision, records); err != nil {
667- return fmt.Errorf("broker: replace hosts: %w", err)
668- }
669- r.authority = ports.BrokerHosts{Revision: r.authority.Revision + 1, Hosts: ports.CloneBrokerHostRecords(records)}
670- r.setHostsLocked(next, order)
671- return nil
672-}
673-
674-// ReplaceHosts durably replaces membership and explicit policy using the loaded
675-// authority revision. It delegates to commitMembershipLocked, the same
676-// validated commit path the runtime mutations use, so membership whose
677-// projection could never be published is refused before the store CAS instead
678-// of being persisted and silently freezing every later publication. Success
679-// means membership is committed, not that advisory observations have flushed.
680-// Store errors (including stale authority) are returned without changing the
681-// projection, probe attempts, or local token. A settled run reports
682-// ports.ErrBrokerRegistryClosed and an exhausted revision series reports
683-// ports.ErrBrokerRevisionExhausted; both refuse the mutation before the store
684-// CAS, so a refusal never changes durable authority either. Conflicts are not
685-// retried or refreshed implicitly: reconcile by reopening the owner. Callers
686-// must supply fresh incarnations when removing and re-adding. Even identical
687-// records perform CAS so stale authority cannot report success. The synchronous
688-// write holds mu; Snapshot remains lock-free, but scheduling waits for
689-// membership durability. Observation writes remain asynchronous.
690-func (r *Registry) ReplaceHosts(records []ports.BrokerHostRecord) error {
691- var err error
692- records, err = ports.UpgradeBrokerHostRoutes(records)
693- if err != nil {
694- return err
695- }
696- if err := ports.ValidateBrokerHostRecords(records); err != nil {
697- return err
698- }
699- r.mu.Lock()
700- defer r.mu.Unlock()
701- if r.running.Load() == runStopped {
702- return fmt.Errorf("broker: membership replacement: %w", ports.ErrBrokerRegistryClosed)
703- }
704- if r.revisionExhaustedLocked() {
705- return fmt.Errorf("broker: membership replacement: %w", ports.ErrBrokerRevisionExhausted)
706- }
707- return r.commitMembershipLocked(records)
708-}
709-
710-// setHostsLocked applies only a projection; the public path commits authority
711-// first. The caller holds mu through both steps, fencing probe completions.
712-// order is the registration order the projection was built from; it becomes
713-// the publication order of remote daemons.
714-func (r *Registry) setHostsLocked(next map[string]ports.BrokerDaemonObservation, order []string) {
715- if registrationsUnchanged(r.hosts, next, r.order, order) {
716- return
717- }
718- for endpoint, current := range r.hosts {
719- candidate, present := next[endpoint]
720- if present && current.Registration.Equal(candidate.Registration) && current.Policy == candidate.Policy {
721- // Unchanged authority: keep the live observation as is. Rank is the
722- // one authority field that follows membership order rather than the
723- // record, so it is re-stamped from the new projection.
724- kept := current
725- kept.Rank = candidate.Rank
726- next[endpoint] = kept
727- continue
728- }
729- // A retired registration owns no observation: cancelling its attempt
730- // releases a ctx-honoring probe immediately, and dropping its record
731- // means the retired completion can never clear or answer the
732- // replacement, and the replacement is never blocked behind it. The
733- // cancellation happens under the registry lock, so no replacement
734- // attempt can start before the retired context is cancelled.
735- r.cancelInflightLocked(endpoint)
736- delete(r.pending, endpoint)
737- if present {
738- // The endpoint stays live under a new registration, so its
739- // retirement is never published as a tombstone alongside a live
740- // host; the attempt token fences the stale completion.
741- delete(r.tombstones, endpoint)
742- continue
743- }
744- r.retireLocked(current.Registration)
745- }
746- for endpoint := range next {
747- // Re-adding an endpoint supersedes its tombstone: a live host and a
748- // tombstone for the same endpoint are mutually exclusive.
749- delete(r.tombstones, endpoint)
750- }
751- r.hosts = next
752- r.order = order
753- r.publishLocked(true)
754- r.hint()
755-}
756-
757-// cancelInflightLocked cancels the in-flight attempt for one endpoint and
758-// drops its record. Callers must hold r.mu: the retired attempt's context is
759-// cancelled before the record disappears, so no replacement attempt can start
760-// while the retired probe's context is still live.
761-func (r *Registry) cancelInflightLocked(endpoint string) {
762- if attempt := r.inflight[endpoint]; attempt != nil {
763- attempt.cancel()
764- delete(r.inflight, endpoint)
765- }
766-}
767-
768-// cancelAllInflightLocked cancels every in-flight attempt and drops the
769-// records. Callers must hold r.mu.
770-func (r *Registry) cancelAllInflightLocked() {
771- for endpoint, attempt := range r.inflight {
772- attempt.cancel()
773- delete(r.inflight, endpoint)
774- }
775-}
776-
777-// RequestProbe is non-blocking and coalesces concurrent demand per endpoint.
778-// A disabled registry never observes, so the demand is dropped without
779-// scheduling anything. An empty endpoint requests a local re-probe instead of a
780-// configured remote: the broker's own machine daemon is re-observed ahead of
781-// its scheduled freshness window, honored by dispatchLocalLocked exactly like
782-// pending honors remote demand. A registry with no local observation producer
783-// drops it, just like an unknown remote endpoint is dropped.
784-func (r *Registry) RequestProbe(endpoint string) {
785- if r.observationDisabled {
786- return
787- }
788- r.mu.Lock()
789- if endpoint == "" {
790- if r.local != nil {
791- r.localPending = true
792- }
793- } else if _, ok := r.hosts[endpoint]; ok {
794- r.pending[endpoint] = true
795- }
796- r.mu.Unlock()
797- r.hint()
798-}
799-
800-// SetDemand records one live client subscription opening (active=true) or
801-// closing (active=false). internal/usecase/broker/service.go calls it from
802-// Service.Subscribe and serviceSubscription.Close, so the demand count tracks
803-// exactly the connections a client is actively watching. The first
804-// subscription requests one immediate observation per host; while demand
805-// stays positive, the demand cadence schedules later observations. With no
806-// subscription, only explicit RequestProbe calls dispatch. A disabled registry
807-// still counts subscriptions but never observes.
808-func (r *Registry) SetDemand(active bool) {
809- r.mu.Lock()
810- if active {
811- if r.demand == 0 {
812- r.localPending = r.local != nil
813- for endpoint := range r.hosts {
814- r.pending[endpoint] = true
815- }
816- }
817- r.demand++
818- } else if r.demand > 0 {
819- r.demand--
820- }
821- r.mu.Unlock()
822- r.hint()
823-}
824-
825-// freshForLocked returns the demand cadence while subscribed. An absent
826-// daemon uses the longer freshFor interval instead. Callers must hold r.mu.
827-func (r *Registry) freshForLocked(local bool) time.Duration {
828- if r.demand <= 0 {
829- return r.freshFor
830- }
831- if local {
832- return r.demandFreshForLocal
833- }
834- return r.demandFreshForRemote
835-}
836-
837-func (r *Registry) hint() {
838- select {
839- case r.wake <- struct{}{}:
840- default:
841- }
842-}
843-
844-// Run schedules observations until cancellation. Run owns the registry
845-// lifecycle and may execute exactly once: a second call is refused so a
846-// concurrent or restarted caller can never duplicate scheduling. On return it
847-// clears in-flight observation state, publishes the settled projection, and
848-// flushes durable persistence before any further write is refused.
849-//
850-// An observation-disabled registry runs the same lifecycle and drain but never
851-// schedules observation: it arms no timer, starts no probe, and drops every
852-// reconcile. It still owns and drains its durable writer, so a shutdown never
853-// returns before the newest staged publication has reached the store.
854-func (r *Registry) Run(ctx context.Context) {
855- if !r.running.CompareAndSwap(runIdle, runActive) {
856- r.log.Warn("broker: registry Run refused; single-run already started or finished")
857- return
858- }
859- defer r.settle()
860- if r.observationDisabled {
861- <-ctx.Done()
862- return
863- }
864- timer := r.clock.NewTimer(0)
865- defer timer.Stop()
866- results := make(chan probeResult, ports.BrokerMaxHosts)
867- for {
868- now := r.clock.Now()
869- r.dispatch(ctx, now, results)
870- timer.Reset(r.nextDelay(now))
871- select {
872- case <-ctx.Done():
873- return
874- case <-r.wake:
875- case <-timer.C():
876- case result := <-results:
877- if ctx.Err() != nil {
878- // Shutdown wins: a cancellation is never recorded as an
879- // observation failure.
880- return
881- }
882- r.apply(result)
883- }
884- }
885-}
886-
887-// settle is Run's cleanup: nothing stays in flight, the published projection
888-// carries no transient Checking flag, durable persistence is flushed, and
889-// every later Run call is refused.
890-func (r *Registry) settle() {
891- r.mu.Lock()
892- r.cancelAllInflightLocked()
893- if r.localAttempt != nil {
894- r.localAttempt.cancel()
895- r.localAttempt = nil
896- }
897- cleared := false
898- if r.local != nil && r.localHost.Checking {
899- r.localHost.Checking = false
900- cleared = true
901- }
902- for endpoint, host := range r.hosts {
903- if !host.Checking {
904- continue
905- }
906- host.Checking = false
907- r.hosts[endpoint] = host
908- cleared = true
909- }
910- if cleared {
911- r.publishLocked(true)
912- }
913- r.running.Store(runStopped)
914- r.mu.Unlock()
915- r.store.close()
916-}
917-
918-// probeAttempt is one admitted observation: the registry-wide token that
919-// fences its completion, and the cancel for the context derived from the run
920-// context. Cancelling the derived context retires the attempt: a probe that
921-// honors cancellation returns promptly, so a replaced or removed endpoint
922-// never accumulates overlapping probes, and settle releases every attempt
923-// before the run loop exits.
924-type probeAttempt struct {
925- token uint64
926- cancel context.CancelFunc
927-}
928-
929-// probeResult is one completed observation bound to the exact attempt token
930-// that started it.
931-type probeResult struct {
932- endpoint string
933- registration domain.RemoteRegistration
934- attempt uint64
935- snapshot ports.BrokerDaemonObservation
936- err error
937- at time.Time
938- // local marks the result of one local probe attempt, which carries no
939- // endpoint or registration and is applied to the configured local entry.
940- local bool
941-}
942-
943-func (r *Registry) dispatch(ctx context.Context, now time.Time, results chan<- probeResult) {
944- r.mu.Lock()
945- if r.demand == 0 && len(r.pending) == 0 && !r.localPending {
946- r.mu.Unlock()
947- return
948- }
949- started := false
950- for endpoint, host := range r.hosts {
951- if _, inflight := r.inflight[endpoint]; inflight {
952- continue
953- }
954- explicit := r.pending[endpoint]
955- if !explicit && (r.demand == 0 || !host.NextDue.IsZero() && now.Before(host.NextDue)) {
956- continue
957- }
958- delete(r.pending, endpoint)
959- r.attempts++
960- attempt := r.attempts
961- probeCtx, cancel := context.WithCancel(ctx)
962- r.inflight[endpoint] = &probeAttempt{token: attempt, cancel: cancel}
963- if visibleCheck(host, explicit) {
964- started = true
965- host.Checking = true
966- }
967- host.LastAttempt = now
968- r.hosts[endpoint] = host
969- registration := host.Registration
970- // The deadline is armed here, under r.mu and at dispatch time, so the
971- // attempt's bound starts with the attempt rather than whenever its
972- // goroutine is scheduled.
973- deadline := r.clock.NewTimer(r.probeTimeout)
974- go func(endpoint string, registration domain.RemoteRegistration, attempt uint64, probeCtx context.Context) {
975- snapshot, err := observeBounded(probeCtx, deadline, func(ctx context.Context) (ports.BrokerDaemonObservation, error) {
976- return r.probe.Probe(ctx, registration)
977- })
978- result := probeResult{endpoint: endpoint, registration: registration, attempt: attempt, snapshot: snapshot, err: err, at: r.clock.Now()}
979- select {
980- case results <- result:
981- case <-probeCtx.Done():
982- // The attempt was retired (replaced, removed, or shutdown), so
983- // its completion is already fenced: dropping it keeps churn from
984- // queueing results the run loop would only discard.
985- }
986- }(endpoint, registration, attempt, probeCtx)
987- }
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.
992- if r.dispatchLocalLocked(ctx, now, results) {
993- started = true
994- }
995- if started {
996- r.publishLocked(false)
997- }
998- r.mu.Unlock()
999-}
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-
1010-// observeBounded runs one observation under its attempt deadline. retire is the
1011-// attempt's retirement context: a replaced, removed, or settled attempt returns
1012-// at once and its completion is fenced by the caller. The probe itself runs on
1013-// a context derived from retire that the deadline also cancels, so a
1014-// ctx-honoring probe unwinds its dial, handshake, or exchange when either
1015-// fires. The attempt never waits for the probe past its deadline: a probe that
1016-// ignores cancellation is abandoned (its late result is discarded) and the
1017-// attempt still reports errProbeTimeout, so the endpoint's single in-flight
1018-// slot is always released.
1019-func observeBounded(retire context.Context, deadline ports.Timer, observe func(context.Context) (ports.BrokerDaemonObservation, error)) (ports.BrokerDaemonObservation, error) {
1020- defer deadline.Stop()
1021- probeCtx, cancel := context.WithCancelCause(retire)
1022- defer cancel(nil)
1023- type outcome struct {
1024- snapshot ports.BrokerDaemonObservation
1025- err error
1026- }
1027- done := make(chan outcome, 1)
1028- go func() {
1029- snapshot, err := observe(probeCtx)
1030- done <- outcome{snapshot: snapshot, err: err}
1031- }()
1032- select {
1033- case result := <-done:
1034- return result.snapshot, result.err
1035- case <-deadline.C():
1036- cancel(errProbeTimeout)
1037- return ports.BrokerDaemonObservation{}, errProbeTimeout
1038- case <-retire.Done():
1039- return ports.BrokerDaemonObservation{}, retire.Err()
1040- }
1041-}
1042-
1043-func (r *Registry) apply(result probeResult) {
1044- r.mu.Lock()
1045- defer r.mu.Unlock()
1046- if result.local {
1047- r.applyLocal(result)
1048- return
1049- }
1050- current, ok := r.hosts[result.endpoint]
1051- attempt := r.inflight[result.endpoint]
1052- if !ok || !current.Registration.Equal(result.registration) || attempt == nil || attempt.token != result.attempt {
1053- // A stale completion touches neither current authority nor the
1054- // in-flight slot, which may already belong to a newer registration or
1055- // attempt.
1056- return
1057- }
1058- delete(r.inflight, result.endpoint)
1059- // The probe has returned, so releasing its derived context here only
1060- // frees the attempt's resources; the record is gone before any new
1061- // attempt can start.
1062- attempt.cancel()
1063- current.Checking = false
1064- current.LastAttempt = result.at
1065-
1066- kind, availability, failed := observationOutcome(result)
1067- if !failed && availability == domain.RemoteAvailabilityNoDaemon {
1068- current.Availability = availability
1069- current.LastSuccess = result.at
1070- // An absent daemon is stable: demand does not require a fresh SSH
1071- // handshake every two seconds just to confirm it is still absent.
1072- current.NextDue = result.at.Add(r.jitter(r.freshFor, result.endpoint, result.attempt))
1073- current.ConsecutiveFailures = 0
1074- current.LastFailure = domain.RemoteFailure{}
1075- current.Identity = ""
1076- current.Incarnation = ports.BrokerDaemonIncarnation{}
1077- current.ProtocolVersion = 0
1078- current.Capabilities = 0
1079- current.InventoryKnown = false
1080- current.Sessions = nil
1081- r.hosts[result.endpoint] = current
1082- r.publishLocked(true)
1083- return
1084- }
1085- if !failed {
1086- // Configured authority comes only from the registry's own projection:
1087- // a probe result supplies observed state, never endpoint, registration,
1088- // policy, display origin, or rank.
1089- observed := stampObservationAuthority(result.snapshot.Clone(), current)
1090- observed.Checking = false
1091- observed.LastAttempt = result.at
1092- observed.LastSuccess = result.at
1093- observed.NextDue = result.at.Add(r.jitter(r.freshForLocked(false), result.endpoint, result.attempt))
1094- observed.ConsecutiveFailures = 0
1095- observed.LastFailure = domain.RemoteFailure{}
1096- observed.FailureEpisode = current.FailureEpisode
1097- // The published observation must be fully valid once authority is
1098- // stamped: a probe that invents partial identity (identity without a
1099- // protocol version) or an inventory without a known catalogue is an
1100- // invalid response, never a published observation. The durable rule is
1101- // re-checked too: a projection the offline store would reject is never
1102- // published as if it had persisted. The store applies the same rule.
1103- if err := observed.Validate(); err != nil {
1104- r.log.Debug("broker: observation is invalid", "endpoint", result.endpoint, "err", err)
1105- kind, availability, failed = domain.RemoteFailureInvalidResponse, domain.RemoteAvailabilityInvalidResponse, true
1106- } else if err := ports.ValidateDurableHostProjection(observed); err != nil {
1107- r.log.Debug("broker: observation is not persistable", "endpoint", result.endpoint, "err", err)
1108- kind, availability, failed = domain.RemoteFailureInvalidResponse, domain.RemoteAvailabilityInvalidResponse, true
1109- } else {
1110- r.hosts[result.endpoint] = observed
1111- r.publishLocked(true)
1112- return
1113- }
1114- }
1115- // Every non-reachable outcome is a typed failure on the capped retry
1116- // cadence: the last success, last-known inventory, and failure episode
1117- // counter stay authoritative.
1118- current.Availability = availability
1119- if current.ConsecutiveFailures == 0 {
1120- current.FailureEpisode++
1121- }
1122- current.ConsecutiveFailures++
1123- current.LastFailure = domain.RemoteFailure{Kind: kind, Err: result.err}
1124- delay := r.retryDelay(current.ConsecutiveFailures)
1125- if r.demand > 0 {
1126- delay = demandRetryDelay(r.demandFreshForRemote, current.ConsecutiveFailures)
1127- }
1128- current.NextDue = result.at.Add(r.jitter(delay, result.endpoint, result.attempt))
1129- r.log.Debug("broker_probe_failed", "endpoint", result.endpoint, "kind", kind, "failures", current.ConsecutiveFailures, "err", result.err, "cause", errors.Unwrap(result.err))
1130- r.hosts[result.endpoint] = current
1131- r.publishLocked(true)
1132-}
1133-
1134-// observationOutcome classifies a completed observation. It reports
1135-// failed=false only for a confirmed reachable result; every other outcome
1136-// carries the typed failure kind and the availability to publish. The caller
1137-// additionally checks the adopted projection against the durable rules
1138-// (ports.ValidateDurableHostProjection), which cover the session bound and
1139-// catalogue validity for the exact incarnation and success time.
1140-func observationOutcome(result probeResult) (domain.RemoteFailureKind, domain.RemoteAvailability, bool) {
1141- if result.err != nil {
1142- kind := domain.RemoteFailureTransport
1143- var typed domain.RemoteFailure
1144- var brokerErr ports.BrokerError
1145- switch {
1146- case errors.As(result.err, &typed) && typed.Kind != domain.RemoteFailureNone:
1147- // The dialer classified the failure (for example SSH authentication).
1148- kind = typed.Kind
1149- case errors.As(result.err, &brokerErr) && (brokerErr.Code == ports.BrokerErrorConflictingPolicy || brokerErr.Code == ports.BrokerErrorIncompatible):
1150- // SSH worked; the daemon's identity or version was refused.
1151- kind = domain.RemoteFailureIncompatible
1152- case errors.Is(result.err, context.DeadlineExceeded):
1153- kind = domain.RemoteFailureTimeout
1154- }
1155- return kind, availabilityFor(kind), true
1156- }
1157- switch result.snapshot.Availability {
1158- case domain.RemoteAvailabilityReachable:
1159- return domain.RemoteFailureNone, domain.RemoteAvailabilityReachable, false
1160- case domain.RemoteAvailabilityNoDaemon:
1161- return domain.RemoteFailureNone, domain.RemoteAvailabilityNoDaemon, false
1162- case domain.RemoteAvailabilityIncompatible:
1163- return domain.RemoteFailureIncompatible, domain.RemoteAvailabilityIncompatible, true
1164- case domain.RemoteAvailabilityAuthFailed:
1165- return domain.RemoteFailureAuthentication, domain.RemoteAvailabilityAuthFailed, true
1166- case domain.RemoteAvailabilityInvalidResponse:
1167- return domain.RemoteFailureInvalidResponse, domain.RemoteAvailabilityInvalidResponse, true
1168- default:
1169- // Unknown or Unreachable without an error still means reachability was
1170- // not confirmed, so it retries on the typed backoff cadence.
1171- return domain.RemoteFailureTransport, domain.RemoteAvailabilityUnreachable, true
1172- }
1173-}
1174-
1175-// availabilityFor maps a typed failure kind to the published availability.
1176-// The typed kind is preserved separately for notice policy.
1177-func availabilityFor(kind domain.RemoteFailureKind) domain.RemoteAvailability {
1178- switch kind {
1179- case domain.RemoteFailureAuthentication:
1180- return domain.RemoteAvailabilityAuthFailed
1181- case domain.RemoteFailureIncompatible:
1182- return domain.RemoteAvailabilityIncompatible
1183- case domain.RemoteFailureInvalidResponse:
1184- return domain.RemoteAvailabilityInvalidResponse
1185- default:
1186- return domain.RemoteAvailabilityUnreachable
1187- }
1188-}
1189-
1190-// demandRetryDelay is the watched-host failure backoff: the demand cadence
1191-// doubled per consecutive failure, capped at demandRetryMax.
1192-func demandRetryDelay(base time.Duration, failures uint) time.Duration {
1193- delay := base
1194- for i := uint(1); i < failures && delay < demandRetryMax; i++ {
1195- delay *= 2
1196- }
1197- return min(delay, max(base, demandRetryMax))
1198-}
1199-
1200-// retryDelay is the capped exponential retry cadence for a failure streak.
1201-func (r *Registry) retryDelay(failures uint) time.Duration {
1202- delay := r.retryBase
1203- for i := uint(1); i < failures; i++ {
1204- if delay >= r.retryMax {
1205- return r.retryMax
1206- }
1207- delay *= 2
1208- }
1209- if delay > r.retryMax {
1210- return r.retryMax
1211- }
1212- return delay
1213-}
1214-
1215-// jittered spreads a base interval deterministically across endpoint and
1216-// attempt: identical inputs always yield the identical duration, and the
1217-// result stays within 90%-110% of the base. No wall clock or global
1218-// randomness participates, so retry and healthy-refresh schedules are
1219-// reproducible instead of racing a shared PRNG.
1220-func jittered(base time.Duration, endpoint string, attempt uint64) time.Duration {
1221- if base <= 0 {
1222- return base
1223- }
1224- h := fnv.New32a()
1225- h.Write([]byte(endpoint))
1226- var counter [8]byte
1227- binary.BigEndian.PutUint64(counter[:], attempt)
1228- h.Write(counter[:])
1229- spread := int64(h.Sum32() % 21)
1230- return base*90/100 + time.Duration(spread)*base/100
1231-}
1232-
1233-func (r *Registry) nextDelay(now time.Time) time.Duration {
1234- r.mu.Lock()
1235- defer r.mu.Unlock()
1236- if r.demand == 0 {
1237- if r.localPending || len(r.pending) > 0 {
1238- return 0
1239- }
1240- return time.Hour
1241- }
1242- var earliest time.Time
1243- if r.local != nil && r.localAttempt == nil {
1244- if r.localHost.NextDue.IsZero() {
1245- return 0
1246- }
1247- if earliest.IsZero() || r.localHost.NextDue.Before(earliest) {
1248- earliest = r.localHost.NextDue
1249- }
1250- }
1251- for endpoint, host := range r.hosts {
1252- // An observation already in flight is completed by its result, never
1253- // by the schedule; waiting on it here would spin the run loop.
1254- if _, inflight := r.inflight[endpoint]; inflight {
1255- continue
1256- }
1257- if r.pending[endpoint] || host.NextDue.IsZero() {
1258- return 0
1259- }
1260- if earliest.IsZero() || host.NextDue.Before(earliest) {
1261- earliest = host.NextDue
1262- }
1263- }
1264- if earliest.IsZero() {
1265- return time.Hour
1266- }
1267- if delay := earliest.Sub(now); delay > 0 {
1268- return delay
1269- }
1270- return 0
1271-}
1272-
1273-// registrationsUnchanged reports exact membership equality: the same endpoints
1274-// in the same registration order, bound to the same registration identities and
1275-// policy. Publication order and presentation rank are functions of durable
1276-// membership order, so a reordering of identical records is a membership change
1277-// that must republish; otherwise the same durable state yields different
1278-// publications depending on process history. Projection fields such as
1279-// availability, checking, or inventory never participate.
1280-func registrationsUnchanged(current, next map[string]ports.BrokerDaemonObservation, currentOrder, nextOrder []string) bool {
1281- if len(current) != len(next) {
1282- return false
1283- }
1284- if !slices.Equal(currentOrder, nextOrder) {
1285- return false
1286- }
1287- for endpoint, candidate := range next {
1288- existing, ok := current[endpoint]
1289- if !ok || !existing.Registration.Equal(candidate.Registration) || existing.Policy != candidate.Policy {
1290- return false
1291- }
1292- }
1293- return true
1294-}
1295-
1296-// retireLocked records the tombstone for one removed registration at the
1297-// revision of the publication that carries it. Mutation paths refuse an
1298-// exhausted series before retiring, so the retirement revision can never wrap
1299-// to zero or fall below the last published one.
1300-func (r *Registry) retireLocked(registration domain.RemoteRegistration) {
1301- retired := r.revision + 1
1302- r.tombstones[registration.Endpoint] = ports.BrokerHostTombstone{
1303- Endpoint: registration.Endpoint,
1304- Registration: registration,
1305- RetiredRevision: retired,
1306- }
1307- r.pruneTombstonesLocked()
1308-}
1309-
1310-// revisionExhaustedLocked reports whether this epoch has no revision left to
1311-// publish, latching the exhausted state on first detection. Callers must hold
1312-// r.mu.
1313-func (r *Registry) revisionExhaustedLocked() bool {
1314- if r.revisionExhausted {
1315- return true
1316- }
1317- if r.revision != ^ports.BrokerRevision(0) {
1318- return false
1319- }
1320- r.revisionExhausted = true
1321- r.log.Error("broker: revision series exhausted; refusing further publication", "epoch", r.epoch)
1322- return true
1323-}
1324-
1325-// pruneTombstonesLocked keeps only the newest BrokerMaxTombstones retirements
1326-// so an endpoint that churns can never grow the published set without bound.
1327-func (r *Registry) pruneTombstonesLocked() {
1328- for len(r.tombstones) > ports.BrokerMaxTombstones {
1329- oldest := ""
1330- var oldestRevision ports.BrokerRevision
1331- for endpoint, tombstone := range r.tombstones {
1332- if oldest == "" || tombstone.RetiredRevision < oldestRevision ||
1333- (tombstone.RetiredRevision == oldestRevision && endpoint < oldest) {
1334- oldest, oldestRevision = endpoint, tombstone.RetiredRevision
1335- }
1336- }
1337- delete(r.tombstones, oldest)
1338- }
1339-}
1340-
1341-// tombstonesLocked renders the bounded tombstone publication, newest first.
1342-// Live endpoints are never published as tombstones.
1343-func (r *Registry) tombstonesLocked() []ports.BrokerHostTombstone {
1344- if len(r.tombstones) == 0 {
1345- return nil
1346- }
1347- out := make([]ports.BrokerHostTombstone, 0, len(r.tombstones))
1348- for endpoint, tombstone := range r.tombstones {
1349- if _, live := r.hosts[endpoint]; live {
1350- continue
1351- }
1352- out = append(out, tombstone)
1353- }
1354- sort.Slice(out, func(i, j int) bool {
1355- if out[i].RetiredRevision != out[j].RetiredRevision {
1356- return out[i].RetiredRevision > out[j].RetiredRevision
1357- }
1358- return out[i].Endpoint < out[j].Endpoint
1359- })
1360- if len(out) > ports.BrokerMaxTombstones {
1361- out = out[:ports.BrokerMaxTombstones]
1362- }
1363- return out
1364-}
1365-
1366-func (r *Registry) publishLocked(persist bool) {
1367- // Fail closed on an exhausted series: a wrapped revision would be zero, and
1368- // a wrapped series would publish revisions below the newest one, either of
1369- // which silently freezes durable persistence. The last valid snapshot stays
1370- // published, nothing is stored, and no subscriber is woken for a publication
1371- // that did not happen.
1372- if r.revisionExhaustedLocked() {
1373- return
1374- }
1375- // Remote daemons are published in registration order. The broker's own
1376- // machine daemon, when a producer is configured, is the
1377- // prepended entry at index zero; without a producer the registry produces no
1378- // local observation and never invents one.
1379- daemons := make([]ports.BrokerDaemonObservation, 0, len(r.hosts)+1)
1380- if r.local != nil {
1381- daemons = append(daemons, r.localHost.Clone())
1382- }
1383- for _, endpoint := range r.order {
1384- host, ok := r.hosts[endpoint]
1385- if !ok {
1386- continue
1387- }
1388- daemons = append(daemons, host.Clone())
1389- }
1390- snapshot := ports.BrokerSnapshot{Epoch: r.epoch, Revision: r.revision + 1, Daemons: daemons, Removed: r.tombstonesLocked()}
1391- if err := snapshot.Validate(); err != nil {
1392- // Fail closed: a publication the wire refuses aborts every client
1393- // connection on subscribe, so an invalid projection is never published.
1394- // The last valid snapshot stays current, nothing is persisted, and the
1395- // revision series does not advance past a publication that never
1396- // happened.
1397- r.log.Error("broker: refused invalid snapshot publication", "revision", r.revision+1, "err", err)
1398- return
1399- }
1400- r.revision = snapshot.Revision
1401- r.current.Store(&snapshot)
1402- if persist {
1403- // enqueue only stages an immutable publication: rendering the durable
1404- // copy and every store call happen on the writer goroutine, never
1405- // under the registry lock and never on the probe schedule.
1406- r.store.enqueue(snapshot)
1407- }
1408- for sub := range r.subs {
1409- sub.offer()
1410- }
1411-}
1412-
1413-// durable returns the persistable copy of a publication, rendered by the
1414-// writer goroutine with the shared rule (ports.BrokerSnapshot.DurableProjection).
1415-// The registry may publish the process-local daemon, which is never durable,
1416-// so it is dropped first; the store would refuse it otherwise. Restored
1417-// observations regain policy from membership (see stampHostAuthority).
1418-func durable(snapshot ports.BrokerSnapshot) ports.BrokerSnapshot {
1419- return snapshot.WithoutLocal().DurableProjection()
1420-}
1421-
1422-// snapshotWriter serializes durable snapshot writes off the registry lock.
1423-// Enqueue never blocks on store I/O and always coalesces to the newest
1424-// revision; Store calls run on one goroutine, so the durable store never sees
1425-// an out-of-order write. A failed write is logged and retried by the next
1426-// publication, so a store fault never stalls publication. Close flushes the
1427-// newest pending snapshot, stops the writer, and waits, so no write outlives
1428-// shutdown.
1429-type snapshotWriter struct {
1430- store ports.BrokerSnapshotStore
1431- log *slog.Logger
1432-
1433- mu sync.Mutex
1434- pending *ports.BrokerSnapshot
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
1440- started bool
1441- closed bool
1442- wake chan struct{}
1443- stop chan struct{}
1444- done chan struct{}
1445-}
1446-
1447-func newSnapshotWriter(store ports.BrokerSnapshotStore, log *slog.Logger) *snapshotWriter {
1448- return &snapshotWriter{
1449- store: store, log: log,
1450- wake: make(chan struct{}, 1), stop: make(chan struct{}), done: make(chan struct{}),
1451- }
1452-}
1453-
1454-// enqueue stages a snapshot for durable persistence. It coalesces to the
1455-// newest revision, starts the writer on first use, and never blocks on the
1456-// store or copies session payloads. Enqueue after Close is refused: nothing is
1457-// written after shutdown.
1458-func (w *snapshotWriter) enqueue(snapshot ports.BrokerSnapshot) {
1459- w.mu.Lock()
1460- if w.closed {
1461- w.mu.Unlock()
1462- w.log.Debug("broker: durable snapshot write refused after shutdown", "revision", snapshot.Revision)
1463- return
1464- }
1465- if w.pending != nil {
1466- if snapshot.Revision <= w.pending.Revision {
1467- w.mu.Unlock()
1468- return
1469- }
1470- } else if snapshot.Revision <= w.written {
1471- w.mu.Unlock()
1472- return
1473- }
1474- // The publication is immutable after publication, so staging it is a value
1475- // copy; the defensive durable copy is rendered on the writer goroutine.
1476- w.pending = &snapshot
1477- if !w.started {
1478- w.started = true
1479- go w.run()
1480- }
1481- w.mu.Unlock()
1482- select {
1483- case w.wake <- struct{}{}:
1484- default:
1485- }
1486-}
1487-
1488-func (w *snapshotWriter) run() {
1489- defer close(w.done)
1490- for {
1491- select {
1492- case <-w.stop:
1493- w.flush()
1494- return
1495- case <-w.wake:
1496- w.flush()
1497- }
1498- }
1499-}
1500-
1501-// flush writes the newest staged snapshot, skipping revisions the store
1502-// already holds.
1503-func (w *snapshotWriter) flush() {
1504- for {
1505- w.mu.Lock()
1506- snapshot := w.pending
1507- written := w.written
1508- w.pending = nil
1509- w.mu.Unlock()
1510- if snapshot == nil {
1511- return
1512- }
1513- if snapshot.Revision <= written {
1514- continue
1515- }
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
1526- w.log.Error("broker: durable snapshot write failed", "revision", snapshot.Revision, "err", err)
1527- continue
1528- }
1529- w.last = &key
1530- w.mu.Lock()
1531- if snapshot.Revision > w.written {
1532- w.written = snapshot.Revision
1533- }
1534- w.mu.Unlock()
1535- }
1536-}
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-
1554-// close flushes the newest staged snapshot, stops the writer, and waits for
1555-// the drain to finish. It never returns before the in-flight write completes.
1556-func (w *snapshotWriter) close() {
1557- w.mu.Lock()
1558- if w.closed {
1559- w.mu.Unlock()
1560- return
1561- }
1562- w.closed = true
1563- started := w.started
1564- w.mu.Unlock()
1565- if !started {
1566- return
1567- }
1568- close(w.stop)
1569- <-w.done
1570-}
1571-
1572-type subscription struct {
1573- changed chan struct{}
1574- once sync.Once
1575- close func()
1576-}
1577-
1578-func (s *subscription) Changed() <-chan struct{} { return s.changed }
1579-func (s *subscription) Close() { s.once.Do(s.close) }
1580-func (s *subscription) offer() {
1581- select {
1582- case s.changed <- struct{}{}:
1583- default:
1584- }
1585-}
A internal/usecase/broker/registry_membership.go +400 -0
@@ -0,0 +1,400 @@
1+package broker
2+
3+import (
4+ "context"
5+ "errors"
6+ "fmt"
7+ "slices"
8+ "sort"
9+
10+ "github.com/bnema/vev/internal/domain"
11+ "github.com/bnema/vev/internal/ports"
12+)
13+
14+// AddHost adds or pins endpoint under policy. Duplicate same-policy additions
15+// CAS-verify authority and preserve identity; a conflicting policy is refused.
16+func (r *Registry) LookupHost(ctx context.Context, endpoint string) (ports.BrokerHostRecord, bool, error) {
17+ if err := ctx.Err(); err != nil {
18+ return ports.BrokerHostRecord{}, false, err
19+ }
20+ r.mu.Lock()
21+ defer r.mu.Unlock()
22+ for _, record := range r.authority.Hosts {
23+ if record.Registration.Endpoint == endpoint {
24+ record.Route = record.Route.Clone()
25+ return record, true, nil
26+ }
27+ }
28+ return ports.BrokerHostRecord{}, false, nil
29+}
30+
31+func (r *Registry) AddHost(ctx context.Context, endpoint string, policy ports.BrokerPolicy) (domain.RemoteRegistration, error) {
32+ if err := domain.ValidateRemoteHostTarget(endpoint); err != nil {
33+ return domain.RemoteRegistration{}, err
34+ }
35+ if err := policy.Validate(); err != nil {
36+ return domain.RemoteRegistration{}, err
37+ }
38+ r.mu.Lock()
39+ defer r.mu.Unlock()
40+ if err := r.mutableLocked(ctx); err != nil {
41+ return domain.RemoteRegistration{}, err
42+ }
43+ records := append([]ports.BrokerHostRecord(nil), r.authority.Hosts...)
44+ for i := range records {
45+ if records[i].Registration.Endpoint != endpoint {
46+ continue
47+ }
48+ if records[i].Policy != policy {
49+ return domain.RemoteRegistration{}, ports.ErrBrokerHostConflict
50+ }
51+ records[i].Pinned = true
52+ if err := r.commitMembershipLocked(records); err != nil {
53+ return domain.RemoteRegistration{}, err
54+ }
55+ return records[i].Registration, nil
56+ }
57+ id, err := r.newIncarnation()
58+ if err != nil {
59+ return domain.RemoteRegistration{}, err
60+ }
61+ reg, err := domain.NewRemoteRegistration(endpoint, id)
62+ if err != nil {
63+ return domain.RemoteRegistration{}, err
64+ }
65+ if reg.IsZero() {
66+ return domain.RemoteRegistration{}, errors.New("broker: incarnation generator returned zero")
67+ }
68+ route, err := ports.BrokerRouteForTransport(policy.Transport, endpoint)
69+ if err != nil {
70+ return domain.RemoteRegistration{}, err
71+ }
72+ records = append(records, ports.BrokerHostRecord{Registration: reg, Pinned: true, Policy: policy, Route: route})
73+ if err := r.commitMembershipLocked(records); err != nil {
74+ return domain.RemoteRegistration{}, err
75+ }
76+ return reg, nil
77+}
78+
79+// RemoveHost removes only the exact expected registration.
80+// BindAuthenticatedIdentity durably binds first-contact identity under the
81+// exact registration and policy fence. The same binding is idempotent; a
82+// different identity is refused and never overwrites authority.
83+func (r *Registry) BindAuthenticatedIdentity(ctx context.Context, request ports.BrokerIdentityBindingRequest) (ports.BrokerDaemonIdentity, error) {
84+ if err := request.Validate(); err != nil {
85+ return "", err
86+ }
87+ if request.Fence.Local {
88+ return request.Identity, nil
89+ }
90+ if err := ctx.Err(); err != nil {
91+ return "", err
92+ }
93+ r.mu.Lock()
94+ defer r.mu.Unlock()
95+ records := append([]ports.BrokerHostRecord(nil), r.authority.Hosts...)
96+ for index := range records {
97+ record := &records[index]
98+ if record.Identity == request.Identity && record.Policy != request.Policy {
99+ return "", ports.BrokerError{Code: ports.BrokerErrorConflictingPolicy, Cause: errors.New("broker: authenticated daemon identity is already bound under an incompatible policy")}
100+ }
101+ }
102+ for index := range records {
103+ record := &records[index]
104+ if !record.Registration.Equal(request.Fence.Registration) || record.Policy != request.Policy {
105+ continue
106+ }
107+ if record.Identity != "" {
108+ if record.Identity != request.Identity {
109+ return "", ports.BrokerError{Code: ports.BrokerErrorConflictingPolicy, Cause: errors.New("broker: authenticated daemon identity conflicts with durable binding")}
110+ }
111+ return record.Identity, nil
112+ }
113+ record.Identity = request.Identity
114+ if err := r.commitMembershipLocked(records); err != nil {
115+ return "", err
116+ }
117+ return request.Identity, nil
118+ }
119+ return "", ports.BrokerError{Code: ports.BrokerErrorUnavailable, Cause: errors.New("broker: stale identity binding fence")}
120+}
121+
122+func (r *Registry) RemoveHost(ctx context.Context, expected domain.RemoteRegistration) (bool, error) {
123+ if err := expected.Validate(); err != nil {
124+ return false, err
125+ }
126+ r.mu.Lock()
127+ defer r.mu.Unlock()
128+ if err := r.mutableLocked(ctx); err != nil {
129+ return false, err
130+ }
131+ records := append([]ports.BrokerHostRecord(nil), r.authority.Hosts...)
132+ for i := range records {
133+ if records[i].Registration.Endpoint != expected.Endpoint {
134+ continue
135+ }
136+ if !records[i].Registration.Equal(expected) {
137+ return false, ports.ErrBrokerHostConflict
138+ }
139+ records = append(records[:i], records[i+1:]...)
140+ if err := r.commitMembershipLocked(records); err != nil {
141+ return false, err
142+ }
143+ return true, nil
144+ }
145+ // A no-op still verifies the loaded revision.
146+ return false, r.commitMembershipLocked(records)
147+}
148+
149+// UpdateHostPolicy updates exact authority and advances its generation.
150+func (r *Registry) UpdateHostPolicy(ctx context.Context, expected domain.RemoteRegistration, policy ports.BrokerPolicy) (domain.RemoteRegistration, error) {
151+ if err := expected.Validate(); err != nil {
152+ return domain.RemoteRegistration{}, err
153+ }
154+ if err := policy.Validate(); err != nil {
155+ return domain.RemoteRegistration{}, err
156+ }
157+ r.mu.Lock()
158+ defer r.mu.Unlock()
159+ if err := r.mutableLocked(ctx); err != nil {
160+ return domain.RemoteRegistration{}, err
161+ }
162+ records := append([]ports.BrokerHostRecord(nil), r.authority.Hosts...)
163+ for i := range records {
164+ if records[i].Registration.Endpoint != expected.Endpoint {
165+ continue
166+ }
167+ if !records[i].Registration.Equal(expected) {
168+ return domain.RemoteRegistration{}, ports.ErrBrokerHostConflict
169+ }
170+ if records[i].Policy == policy {
171+ if err := r.commitMembershipLocked(records); err != nil {
172+ return domain.RemoteRegistration{}, err
173+ }
174+ return expected, nil
175+ }
176+ if expected.Generation == ^domain.RemoteGeneration(0) {
177+ return domain.RemoteRegistration{}, ports.ErrBrokerRevisionExhausted
178+ }
179+ route, err := ports.BrokerRouteForTransport(policy.Transport, expected.Endpoint)
180+ if err != nil {
181+ return domain.RemoteRegistration{}, err
182+ }
183+ records[i].Registration.Generation++
184+ records[i].Route = route
185+ records[i].Policy = policy
186+ if err := r.commitMembershipLocked(records); err != nil {
187+ return domain.RemoteRegistration{}, err
188+ }
189+ return records[i].Registration, nil
190+ }
191+ return domain.RemoteRegistration{}, ports.ErrBrokerHostConflict
192+}
193+
194+func (r *Registry) mutableLocked(ctx context.Context) error {
195+ if r.membershipMode != MembershipMutable {
196+ return ports.ErrBrokerMembershipImmutable
197+ }
198+ if err := ctx.Err(); err != nil {
199+ return err
200+ }
201+ if r.running.Load() == runStopped {
202+ return ports.ErrBrokerRegistryClosed
203+ }
204+ if r.revisionExhaustedLocked() {
205+ return ports.ErrBrokerRevisionExhausted
206+ }
207+ return nil
208+}
209+
210+// commitMembershipLocked is the sole mutable-membership durable commit path.
211+// It installs projection only after CAS success.
212+func (r *Registry) commitMembershipLocked(records []ports.BrokerHostRecord) error {
213+ if err := ports.ValidateBrokerHostRecords(records); err != nil {
214+ return err
215+ }
216+ if len(records) > 0 && !r.observationDisabled && nilDependency(r.probe) {
217+ return errors.New("broker: remote hosts require a host probe")
218+ }
219+ next := make(map[string]ports.BrokerDaemonObservation, len(records))
220+ order := make([]string, 0, len(records))
221+ for rank, record := range records {
222+ observation := stampHostAuthority(ports.BrokerDaemonObservation{}, record, rank)
223+ // A membership the registry cannot publish is refused here, before the
224+ // store CAS, rather than persisted and left to freeze every later
225+ // publication: an endpoint whose derived display hint is empty after the
226+ // display rule drops its runes makes every snapshot carrying it invalid.
227+ if err := observation.Validate(); err != nil {
228+ return fmt.Errorf("broker: host record %q is not publishable: %w", record.Registration.Endpoint, err)
229+ }
230+ next[record.Registration.Endpoint] = observation
231+ order = append(order, record.Registration.Endpoint)
232+ }
233+ if err := r.hostStore.ReplaceHosts(r.authority.Revision, records); err != nil {
234+ return fmt.Errorf("broker: replace hosts: %w", err)
235+ }
236+ r.authority = ports.BrokerHosts{Revision: r.authority.Revision + 1, Hosts: ports.CloneBrokerHostRecords(records)}
237+ r.setHostsLocked(next, order)
238+ return nil
239+}
240+
241+// ReplaceHosts durably replaces membership and explicit policy using the loaded
242+// authority revision. It delegates to commitMembershipLocked, the same
243+// validated commit path the runtime mutations use, so membership whose
244+// projection could never be published is refused before the store CAS instead
245+// of being persisted and silently freezing every later publication. Success
246+// means membership is committed, not that advisory observations have flushed.
247+// Store errors (including stale authority) are returned without changing the
248+// projection, probe attempts, or local token. A settled run reports
249+// ports.ErrBrokerRegistryClosed and an exhausted revision series reports
250+// ports.ErrBrokerRevisionExhausted; both refuse the mutation before the store
251+// CAS, so a refusal never changes durable authority either. Conflicts are not
252+// retried or refreshed implicitly: reconcile by reopening the owner. Callers
253+// must supply fresh incarnations when removing and re-adding. Even identical
254+// records perform CAS so stale authority cannot report success. The synchronous
255+// write holds mu; Snapshot remains lock-free, but scheduling waits for
256+// membership durability. Observation writes remain asynchronous.
257+func (r *Registry) ReplaceHosts(records []ports.BrokerHostRecord) error {
258+ var err error
259+ records, err = ports.UpgradeBrokerHostRoutes(records)
260+ if err != nil {
261+ return err
262+ }
263+ if err := ports.ValidateBrokerHostRecords(records); err != nil {
264+ return err
265+ }
266+ r.mu.Lock()
267+ defer r.mu.Unlock()
268+ if r.running.Load() == runStopped {
269+ return fmt.Errorf("broker: membership replacement: %w", ports.ErrBrokerRegistryClosed)
270+ }
271+ if r.revisionExhaustedLocked() {
272+ return fmt.Errorf("broker: membership replacement: %w", ports.ErrBrokerRevisionExhausted)
273+ }
274+ return r.commitMembershipLocked(records)
275+}
276+
277+// setHostsLocked applies only a projection; the public path commits authority
278+// first. The caller holds mu through both steps, fencing probe completions.
279+// order is the registration order the projection was built from; it becomes
280+// the publication order of remote daemons.
281+func (r *Registry) setHostsLocked(next map[string]ports.BrokerDaemonObservation, order []string) {
282+ if registrationsUnchanged(r.hosts, next, r.order, order) {
283+ return
284+ }
285+ for endpoint, current := range r.hosts {
286+ candidate, present := next[endpoint]
287+ if present && current.Registration.Equal(candidate.Registration) && current.Policy == candidate.Policy {
288+ // Unchanged authority: keep the live observation as is. Rank is the
289+ // one authority field that follows membership order rather than the
290+ // record, so it is re-stamped from the new projection.
291+ kept := current
292+ kept.Rank = candidate.Rank
293+ next[endpoint] = kept
294+ continue
295+ }
296+ // A retired registration owns no observation: cancelling its attempt
297+ // releases a ctx-honoring probe immediately, and dropping its record
298+ // means the retired completion can never clear or answer the
299+ // replacement, and the replacement is never blocked behind it. The
300+ // cancellation happens under the registry lock, so no replacement
301+ // attempt can start before the retired context is cancelled.
302+ r.cancelInflightLocked(endpoint)
303+ delete(r.pending, endpoint)
304+ if present {
305+ // The endpoint stays live under a new registration, so its
306+ // retirement is never published as a tombstone alongside a live
307+ // host; the attempt token fences the stale completion.
308+ delete(r.tombstones, endpoint)
309+ continue
310+ }
311+ r.retireLocked(current.Registration)
312+ }
313+ for endpoint := range next {
314+ // Re-adding an endpoint supersedes its tombstone: a live host and a
315+ // tombstone for the same endpoint are mutually exclusive.
316+ delete(r.tombstones, endpoint)
317+ }
318+ r.hosts = next
319+ r.order = order
320+ r.publishLocked(true)
321+ r.hint()
322+}
323+
324+// registrationsUnchanged reports exact membership equality: the same endpoints
325+// in the same registration order, bound to the same registration identities and
326+// policy. Publication order and presentation rank are functions of durable
327+// membership order, so a reordering of identical records is a membership change
328+// that must republish; otherwise the same durable state yields different
329+// publications depending on process history. Projection fields such as
330+// availability, checking, or inventory never participate.
331+func registrationsUnchanged(current, next map[string]ports.BrokerDaemonObservation, currentOrder, nextOrder []string) bool {
332+ if len(current) != len(next) {
333+ return false
334+ }
335+ if !slices.Equal(currentOrder, nextOrder) {
336+ return false
337+ }
338+ for endpoint, candidate := range next {
339+ existing, ok := current[endpoint]
340+ if !ok || !existing.Registration.Equal(candidate.Registration) || existing.Policy != candidate.Policy {
341+ return false
342+ }
343+ }
344+ return true
345+}
346+
347+// retireLocked records the tombstone for one removed registration at the
348+// revision of the publication that carries it. Mutation paths refuse an
349+// exhausted series before retiring, so the retirement revision can never wrap
350+// to zero or fall below the last published one.
351+func (r *Registry) retireLocked(registration domain.RemoteRegistration) {
352+ retired := r.revision + 1
353+ r.tombstones[registration.Endpoint] = ports.BrokerHostTombstone{
354+ Endpoint: registration.Endpoint,
355+ Registration: registration,
356+ RetiredRevision: retired,
357+ }
358+ r.pruneTombstonesLocked()
359+}
360+
361+// pruneTombstonesLocked keeps only the newest BrokerMaxTombstones retirements
362+// so an endpoint that churns can never grow the published set without bound.
363+func (r *Registry) pruneTombstonesLocked() {
364+ for len(r.tombstones) > ports.BrokerMaxTombstones {
365+ oldest := ""
366+ var oldestRevision ports.BrokerRevision
367+ for endpoint, tombstone := range r.tombstones {
368+ if oldest == "" || tombstone.RetiredRevision < oldestRevision ||
369+ (tombstone.RetiredRevision == oldestRevision && endpoint < oldest) {
370+ oldest, oldestRevision = endpoint, tombstone.RetiredRevision
371+ }
372+ }
373+ delete(r.tombstones, oldest)
374+ }
375+}
376+
377+// tombstonesLocked renders the bounded tombstone publication, newest first.
378+// Live endpoints are never published as tombstones.
379+func (r *Registry) tombstonesLocked() []ports.BrokerHostTombstone {
380+ if len(r.tombstones) == 0 {
381+ return nil
382+ }
383+ out := make([]ports.BrokerHostTombstone, 0, len(r.tombstones))
384+ for endpoint, tombstone := range r.tombstones {
385+ if _, live := r.hosts[endpoint]; live {
386+ continue
387+ }
388+ out = append(out, tombstone)
389+ }
390+ sort.Slice(out, func(i, j int) bool {
391+ if out[i].RetiredRevision != out[j].RetiredRevision {
392+ return out[i].RetiredRevision > out[j].RetiredRevision
393+ }
394+ return out[i].Endpoint < out[j].Endpoint
395+ })
396+ if len(out) > ports.BrokerMaxTombstones {
397+ out = out[:ports.BrokerMaxTombstones]
398+ }
399+ return out
400+}
A internal/usecase/broker/registry_probe.go +528 -0
@@ -0,0 +1,528 @@
1+package broker
2+
3+import (
4+ "context"
5+ "encoding/binary"
6+ "errors"
7+ "hash/fnv"
8+ "time"
9+
10+ "github.com/bnema/vev/internal/domain"
11+ "github.com/bnema/vev/internal/ports"
12+)
13+
14+// cancelInflightLocked cancels the in-flight attempt for one endpoint and
15+// drops its record. Callers must hold r.mu: the retired attempt's context is
16+// cancelled before the record disappears, so no replacement attempt can start
17+// while the retired probe's context is still live.
18+func (r *Registry) cancelInflightLocked(endpoint string) {
19+ if attempt := r.inflight[endpoint]; attempt != nil {
20+ attempt.cancel()
21+ delete(r.inflight, endpoint)
22+ }
23+}
24+
25+// cancelAllInflightLocked cancels every in-flight attempt and drops the
26+// records. Callers must hold r.mu.
27+func (r *Registry) cancelAllInflightLocked() {
28+ for endpoint, attempt := range r.inflight {
29+ attempt.cancel()
30+ delete(r.inflight, endpoint)
31+ }
32+}
33+
34+// RequestProbe is non-blocking and coalesces concurrent demand per endpoint.
35+// A disabled registry never observes, so the demand is dropped without
36+// scheduling anything. An empty endpoint requests a local re-probe instead of a
37+// configured remote: the broker's own machine daemon is re-observed ahead of
38+// its scheduled freshness window, honored by dispatchLocalLocked exactly like
39+// pending honors remote demand. A registry with no local observation producer
40+// drops it, just like an unknown remote endpoint is dropped.
41+func (r *Registry) RequestProbe(endpoint string) {
42+ if r.observationDisabled {
43+ return
44+ }
45+ r.mu.Lock()
46+ if endpoint == "" {
47+ if r.local != nil {
48+ r.localPending = true
49+ }
50+ } else if _, ok := r.hosts[endpoint]; ok {
51+ r.pending[endpoint] = true
52+ }
53+ r.mu.Unlock()
54+ r.hint()
55+}
56+
57+// SetDemand records one live client subscription opening (active=true) or
58+// closing (active=false). internal/usecase/broker/service.go calls it from
59+// Service.Subscribe and serviceSubscription.Close, so the demand count tracks
60+// exactly the connections a client is actively watching. The first
61+// subscription requests one immediate observation per host; while demand
62+// stays positive, the demand cadence schedules later observations. With no
63+// subscription, only explicit RequestProbe calls dispatch. A disabled registry
64+// still counts subscriptions but never observes.
65+func (r *Registry) SetDemand(active bool) {
66+ r.mu.Lock()
67+ if active {
68+ if r.demand == 0 {
69+ r.localPending = r.local != nil
70+ for endpoint := range r.hosts {
71+ r.pending[endpoint] = true
72+ }
73+ }
74+ r.demand++
75+ } else if r.demand > 0 {
76+ r.demand--
77+ }
78+ r.mu.Unlock()
79+ r.hint()
80+}
81+
82+// freshForLocked returns the demand cadence while subscribed. An absent
83+// daemon uses the longer freshFor interval instead. Callers must hold r.mu.
84+func (r *Registry) freshForLocked(local bool) time.Duration {
85+ if r.demand <= 0 {
86+ return r.freshFor
87+ }
88+ if local {
89+ return r.demandFreshForLocal
90+ }
91+ return r.demandFreshForRemote
92+}
93+
94+func (r *Registry) hint() {
95+ select {
96+ case r.wake <- struct{}{}:
97+ default:
98+ }
99+}
100+
101+// Run schedules observations until cancellation. Run owns the registry
102+// lifecycle and may execute exactly once: a second call is refused so a
103+// concurrent or restarted caller can never duplicate scheduling. On return it
104+// clears in-flight observation state, publishes the settled projection, and
105+// flushes durable persistence before any further write is refused.
106+//
107+// An observation-disabled registry runs the same lifecycle and drain but never
108+// schedules observation: it arms no timer, starts no probe, and drops every
109+// reconcile. It still owns and drains its durable writer, so a shutdown never
110+// returns before the newest staged publication has reached the store.
111+func (r *Registry) Run(ctx context.Context) {
112+ if !r.running.CompareAndSwap(runIdle, runActive) {
113+ r.log.Warn("broker: registry Run refused; single-run already started or finished")
114+ return
115+ }
116+ defer r.settle()
117+ if r.observationDisabled {
118+ <-ctx.Done()
119+ return
120+ }
121+ timer := r.clock.NewTimer(0)
122+ defer timer.Stop()
123+ results := make(chan probeResult, ports.BrokerMaxHosts)
124+ for {
125+ now := r.clock.Now()
126+ r.dispatch(ctx, now, results)
127+ timer.Reset(r.nextDelay(now))
128+ select {
129+ case <-ctx.Done():
130+ return
131+ case <-r.wake:
132+ case <-timer.C():
133+ case result := <-results:
134+ if ctx.Err() != nil {
135+ // Shutdown wins: a cancellation is never recorded as an
136+ // observation failure.
137+ return
138+ }
139+ r.apply(result)
140+ }
141+ }
142+}
143+
144+// settle is Run's cleanup: nothing stays in flight, the published projection
145+// carries no transient Checking flag, durable persistence is flushed, and
146+// every later Run call is refused.
147+func (r *Registry) settle() {
148+ r.mu.Lock()
149+ r.cancelAllInflightLocked()
150+ if r.localAttempt != nil {
151+ r.localAttempt.cancel()
152+ r.localAttempt = nil
153+ }
154+ cleared := false
155+ if r.local != nil && r.localHost.Checking {
156+ r.localHost.Checking = false
157+ cleared = true
158+ }
159+ for endpoint, host := range r.hosts {
160+ if !host.Checking {
161+ continue
162+ }
163+ host.Checking = false
164+ r.hosts[endpoint] = host
165+ cleared = true
166+ }
167+ if cleared {
168+ r.publishLocked(true)
169+ }
170+ r.running.Store(runStopped)
171+ r.mu.Unlock()
172+ r.store.close()
173+}
174+
175+// probeAttempt is one admitted observation: the registry-wide token that
176+// fences its completion, and the cancel for the context derived from the run
177+// context. Cancelling the derived context retires the attempt: a probe that
178+// honors cancellation returns promptly, so a replaced or removed endpoint
179+// never accumulates overlapping probes, and settle releases every attempt
180+// before the run loop exits.
181+type probeAttempt struct {
182+ token uint64
183+ cancel context.CancelFunc
184+}
185+
186+// probeResult is one completed observation bound to the exact attempt token
187+// that started it.
188+type probeResult struct {
189+ endpoint string
190+ registration domain.RemoteRegistration
191+ attempt uint64
192+ snapshot ports.BrokerDaemonObservation
193+ err error
194+ at time.Time
195+ // local marks the result of one local probe attempt, which carries no
196+ // endpoint or registration and is applied to the configured local entry.
197+ local bool
198+}
199+
200+func (r *Registry) dispatch(ctx context.Context, now time.Time, results chan<- probeResult) {
201+ r.mu.Lock()
202+ if r.demand == 0 && len(r.pending) == 0 && !r.localPending {
203+ r.mu.Unlock()
204+ return
205+ }
206+ started := false
207+ for endpoint, host := range r.hosts {
208+ if _, inflight := r.inflight[endpoint]; inflight {
209+ continue
210+ }
211+ explicit := r.pending[endpoint]
212+ if !explicit && (r.demand == 0 || !host.NextDue.IsZero() && now.Before(host.NextDue)) {
213+ continue
214+ }
215+ delete(r.pending, endpoint)
216+ r.attempts++
217+ attempt := r.attempts
218+ probeCtx, cancel := context.WithCancel(ctx)
219+ r.inflight[endpoint] = &probeAttempt{token: attempt, cancel: cancel}
220+ if visibleCheck(host, explicit) {
221+ started = true
222+ host.Checking = true
223+ }
224+ host.LastAttempt = now
225+ r.hosts[endpoint] = host
226+ registration := host.Registration
227+ // The deadline is armed here, under r.mu and at dispatch time, so the
228+ // attempt's bound starts with the attempt rather than whenever its
229+ // goroutine is scheduled.
230+ deadline := r.clock.NewTimer(r.probeTimeout)
231+ go func(endpoint string, registration domain.RemoteRegistration, attempt uint64, probeCtx context.Context) {
232+ snapshot, err := observeBounded(probeCtx, deadline, func(ctx context.Context) (ports.BrokerDaemonObservation, error) {
233+ return r.probe.Probe(ctx, registration)
234+ })
235+ result := probeResult{endpoint: endpoint, registration: registration, attempt: attempt, snapshot: snapshot, err: err, at: r.clock.Now()}
236+ select {
237+ case results <- result:
238+ case <-probeCtx.Done():
239+ // The attempt was retired (replaced, removed, or shutdown), so
240+ // its completion is already fenced: dropping it keeps churn from
241+ // queueing results the run loop would only discard.
242+ }
243+ }(endpoint, registration, attempt, probeCtx)
244+ }
245+ // Only an observation that visibly started changes the projection; an
246+ // idle dispatch or a routine refresh must not bump the revision or wake
247+ // subscribers. The in-flight projection is never persisted: Checking is
248+ // transient.
249+ if r.dispatchLocalLocked(ctx, now, results) {
250+ started = true
251+ }
252+ if started {
253+ r.publishLocked(false)
254+ }
255+ r.mu.Unlock()
256+}
257+
258+// visibleCheck reports whether starting an observation publishes the transient
259+// Checking state. A routine scheduled refresh of a reachable daemon stays
260+// silent until its result lands, so an idle watched broker publishes once per
261+// observation instead of twice; an explicit request or a daemon that is not
262+// confirmed reachable still shows that it is being checked.
263+func visibleCheck(host ports.BrokerDaemonObservation, explicit bool) bool {
264+ return explicit || host.Availability != domain.RemoteAvailabilityReachable
265+}
266+
267+// observeBounded runs one observation under its attempt deadline. retire is the
268+// attempt's retirement context: a replaced, removed, or settled attempt returns
269+// at once and its completion is fenced by the caller. The probe itself runs on
270+// a context derived from retire that the deadline also cancels, so a
271+// ctx-honoring probe unwinds its dial, handshake, or exchange when either
272+// fires. The attempt never waits for the probe past its deadline: a probe that
273+// ignores cancellation is abandoned (its late result is discarded) and the
274+// attempt still reports errProbeTimeout, so the endpoint's single in-flight
275+// slot is always released.
276+func observeBounded(retire context.Context, deadline ports.Timer, observe func(context.Context) (ports.BrokerDaemonObservation, error)) (ports.BrokerDaemonObservation, error) {
277+ defer deadline.Stop()
278+ probeCtx, cancel := context.WithCancelCause(retire)
279+ defer cancel(nil)
280+ type outcome struct {
281+ snapshot ports.BrokerDaemonObservation
282+ err error
283+ }
284+ done := make(chan outcome, 1)
285+ go func() {
286+ snapshot, err := observe(probeCtx)
287+ done <- outcome{snapshot: snapshot, err: err}
288+ }()
289+ select {
290+ case result := <-done:
291+ return result.snapshot, result.err
292+ case <-deadline.C():
293+ cancel(errProbeTimeout)
294+ return ports.BrokerDaemonObservation{}, errProbeTimeout
295+ case <-retire.Done():
296+ return ports.BrokerDaemonObservation{}, retire.Err()
297+ }
298+}
299+
300+func (r *Registry) apply(result probeResult) {
301+ r.mu.Lock()
302+ defer r.mu.Unlock()
303+ if result.local {
304+ r.applyLocal(result)
305+ return
306+ }
307+ current, ok := r.hosts[result.endpoint]
308+ attempt := r.inflight[result.endpoint]
309+ if !ok || !current.Registration.Equal(result.registration) || attempt == nil || attempt.token != result.attempt {
310+ // A stale completion touches neither current authority nor the
311+ // in-flight slot, which may already belong to a newer registration or
312+ // attempt.
313+ return
314+ }
315+ delete(r.inflight, result.endpoint)
316+ // The probe has returned, so releasing its derived context here only
317+ // frees the attempt's resources; the record is gone before any new
318+ // attempt can start.
319+ attempt.cancel()
320+ current.Checking = false
321+ current.LastAttempt = result.at
322+
323+ kind, availability, failed := observationOutcome(result)
324+ if !failed && availability == domain.RemoteAvailabilityNoDaemon {
325+ current.Availability = availability
326+ current.LastSuccess = result.at
327+ // An absent daemon is stable: demand does not require a fresh SSH
328+ // handshake every two seconds just to confirm it is still absent.
329+ current.NextDue = result.at.Add(r.jitter(r.freshFor, result.endpoint, result.attempt))
330+ current.ConsecutiveFailures = 0
331+ current.LastFailure = domain.RemoteFailure{}
332+ current.Identity = ""
333+ current.Incarnation = ports.BrokerDaemonIncarnation{}
334+ current.ProtocolVersion = 0
335+ current.Capabilities = 0
336+ current.InventoryKnown = false
337+ current.Sessions = nil
338+ r.hosts[result.endpoint] = current
339+ r.publishLocked(true)
340+ return
341+ }
342+ if !failed {
343+ // Configured authority comes only from the registry's own projection:
344+ // a probe result supplies observed state, never endpoint, registration,
345+ // policy, display origin, or rank.
346+ observed := stampObservationAuthority(result.snapshot.Clone(), current)
347+ observed.Checking = false
348+ observed.LastAttempt = result.at
349+ observed.LastSuccess = result.at
350+ observed.NextDue = result.at.Add(r.jitter(r.freshForLocked(false), result.endpoint, result.attempt))
351+ observed.ConsecutiveFailures = 0
352+ observed.LastFailure = domain.RemoteFailure{}
353+ observed.FailureEpisode = current.FailureEpisode
354+ // The published observation must be fully valid once authority is
355+ // stamped: a probe that invents partial identity (identity without a
356+ // protocol version) or an inventory without a known catalogue is an
357+ // invalid response, never a published observation. The durable rule is
358+ // re-checked too: a projection the offline store would reject is never
359+ // published as if it had persisted. The store applies the same rule.
360+ if err := observed.Validate(); err != nil {
361+ r.log.Debug("broker: observation is invalid", "endpoint", result.endpoint, "err", err)
362+ kind, availability, failed = domain.RemoteFailureInvalidResponse, domain.RemoteAvailabilityInvalidResponse, true
363+ } else if err := ports.ValidateDurableHostProjection(observed); err != nil {
364+ r.log.Debug("broker: observation is not persistable", "endpoint", result.endpoint, "err", err)
365+ kind, availability, failed = domain.RemoteFailureInvalidResponse, domain.RemoteAvailabilityInvalidResponse, true
366+ } else {
367+ r.hosts[result.endpoint] = observed
368+ r.publishLocked(true)
369+ return
370+ }
371+ }
372+ // Every non-reachable outcome is a typed failure on the capped retry
373+ // cadence: the last success, last-known inventory, and failure episode
374+ // counter stay authoritative.
375+ current.Availability = availability
376+ if current.ConsecutiveFailures == 0 {
377+ current.FailureEpisode++
378+ }
379+ current.ConsecutiveFailures++
380+ current.LastFailure = domain.RemoteFailure{Kind: kind, Err: result.err}
381+ delay := r.retryDelay(current.ConsecutiveFailures)
382+ if r.demand > 0 {
383+ delay = demandRetryDelay(r.demandFreshForRemote, current.ConsecutiveFailures)
384+ }
385+ current.NextDue = result.at.Add(r.jitter(delay, result.endpoint, result.attempt))
386+ r.log.Debug("broker_probe_failed", "endpoint", result.endpoint, "kind", kind, "failures", current.ConsecutiveFailures, "err", result.err, "cause", errors.Unwrap(result.err))
387+ r.hosts[result.endpoint] = current
388+ r.publishLocked(true)
389+}
390+
391+// observationOutcome classifies a completed observation. It reports
392+// failed=false only for a confirmed reachable result; every other outcome
393+// carries the typed failure kind and the availability to publish. The caller
394+// additionally checks the adopted projection against the durable rules
395+// (ports.ValidateDurableHostProjection), which cover the session bound and
396+// catalogue validity for the exact incarnation and success time.
397+func observationOutcome(result probeResult) (domain.RemoteFailureKind, domain.RemoteAvailability, bool) {
398+ if result.err != nil {
399+ kind := domain.RemoteFailureTransport
400+ var typed domain.RemoteFailure
401+ var brokerErr ports.BrokerError
402+ switch {
403+ case errors.As(result.err, &typed) && typed.Kind != domain.RemoteFailureNone:
404+ // The dialer classified the failure (for example SSH authentication).
405+ kind = typed.Kind
406+ case errors.As(result.err, &brokerErr) && (brokerErr.Code == ports.BrokerErrorConflictingPolicy || brokerErr.Code == ports.BrokerErrorIncompatible):
407+ // SSH worked; the daemon's identity or version was refused.
408+ kind = domain.RemoteFailureIncompatible
409+ case errors.Is(result.err, context.DeadlineExceeded):
410+ kind = domain.RemoteFailureTimeout
411+ }
412+ return kind, availabilityFor(kind), true
413+ }
414+ switch result.snapshot.Availability {
415+ case domain.RemoteAvailabilityReachable:
416+ return domain.RemoteFailureNone, domain.RemoteAvailabilityReachable, false
417+ case domain.RemoteAvailabilityNoDaemon:
418+ return domain.RemoteFailureNone, domain.RemoteAvailabilityNoDaemon, false
419+ case domain.RemoteAvailabilityIncompatible:
420+ return domain.RemoteFailureIncompatible, domain.RemoteAvailabilityIncompatible, true
421+ case domain.RemoteAvailabilityAuthFailed:
422+ return domain.RemoteFailureAuthentication, domain.RemoteAvailabilityAuthFailed, true
423+ case domain.RemoteAvailabilityInvalidResponse:
424+ return domain.RemoteFailureInvalidResponse, domain.RemoteAvailabilityInvalidResponse, true
425+ default:
426+ // Unknown or Unreachable without an error still means reachability was
427+ // not confirmed, so it retries on the typed backoff cadence.
428+ return domain.RemoteFailureTransport, domain.RemoteAvailabilityUnreachable, true
429+ }
430+}
431+
432+// availabilityFor maps a typed failure kind to the published availability.
433+// The typed kind is preserved separately for notice policy.
434+func availabilityFor(kind domain.RemoteFailureKind) domain.RemoteAvailability {
435+ switch kind {
436+ case domain.RemoteFailureAuthentication:
437+ return domain.RemoteAvailabilityAuthFailed
438+ case domain.RemoteFailureIncompatible:
439+ return domain.RemoteAvailabilityIncompatible
440+ case domain.RemoteFailureInvalidResponse:
441+ return domain.RemoteAvailabilityInvalidResponse
442+ default:
443+ return domain.RemoteAvailabilityUnreachable
444+ }
445+}
446+
447+// demandRetryDelay is the watched-host failure backoff: the demand cadence
448+// doubled per consecutive failure, capped at demandRetryMax.
449+func demandRetryDelay(base time.Duration, failures uint) time.Duration {
450+ delay := base
451+ for i := uint(1); i < failures && delay < demandRetryMax; i++ {
452+ delay *= 2
453+ }
454+ return min(delay, max(base, demandRetryMax))
455+}
456+
457+// retryDelay is the capped exponential retry cadence for a failure streak.
458+func (r *Registry) retryDelay(failures uint) time.Duration {
459+ delay := r.retryBase
460+ for i := uint(1); i < failures; i++ {
461+ if delay >= r.retryMax {
462+ return r.retryMax
463+ }
464+ delay *= 2
465+ }
466+ if delay > r.retryMax {
467+ return r.retryMax
468+ }
469+ return delay
470+}
471+
472+// jittered spreads a base interval deterministically across endpoint and
473+// attempt: identical inputs always yield the identical duration, and the
474+// result stays within 90%-110% of the base. No wall clock or global
475+// randomness participates, so retry and healthy-refresh schedules are
476+// reproducible instead of racing a shared PRNG.
477+func jittered(base time.Duration, endpoint string, attempt uint64) time.Duration {
478+ if base <= 0 {
479+ return base
480+ }
481+ h := fnv.New32a()
482+ h.Write([]byte(endpoint))
483+ var counter [8]byte
484+ binary.BigEndian.PutUint64(counter[:], attempt)
485+ h.Write(counter[:])
486+ spread := int64(h.Sum32() % 21)
487+ return base*90/100 + time.Duration(spread)*base/100
488+}
489+
490+func (r *Registry) nextDelay(now time.Time) time.Duration {
491+ r.mu.Lock()
492+ defer r.mu.Unlock()
493+ if r.demand == 0 {
494+ if r.localPending || len(r.pending) > 0 {
495+ return 0
496+ }
497+ return time.Hour
498+ }
499+ var earliest time.Time
500+ if r.local != nil && r.localAttempt == nil {
501+ if r.localHost.NextDue.IsZero() {
502+ return 0
503+ }
504+ if earliest.IsZero() || r.localHost.NextDue.Before(earliest) {
505+ earliest = r.localHost.NextDue
506+ }
507+ }
508+ for endpoint, host := range r.hosts {
509+ // An observation already in flight is completed by its result, never
510+ // by the schedule; waiting on it here would spin the run loop.
511+ if _, inflight := r.inflight[endpoint]; inflight {
512+ continue
513+ }
514+ if r.pending[endpoint] || host.NextDue.IsZero() {
515+ return 0
516+ }
517+ if earliest.IsZero() || host.NextDue.Before(earliest) {
518+ earliest = host.NextDue
519+ }
520+ }
521+ if earliest.IsZero() {
522+ return time.Hour
523+ }
524+ if delay := earliest.Sub(now); delay > 0 {
525+ return delay
526+ }
527+ return 0
528+}
A internal/usecase/broker/registry_publish.go +114 -0
@@ -0,0 +1,114 @@
1+package broker
2+
3+import (
4+ "sync"
5+
6+ "github.com/bnema/vev/internal/ports"
7+)
8+
9+// Snapshot returns a defensive immutable copy without I/O.
10+func (r *Registry) Snapshot() ports.BrokerSnapshot { return r.current.Load().Clone() }
11+
12+// Subscribe returns a capacity-one notification stream. Slow consumers never
13+// block publication and always re-read the newest snapshot.
14+func (r *Registry) Subscribe() ports.BrokerSubscription {
15+ s := &subscription{changed: make(chan struct{}, 1)}
16+ s.close = func() {
17+ r.mu.Lock()
18+ delete(r.subs, s)
19+ r.mu.Unlock()
20+ }
21+ r.mu.Lock()
22+ r.subs[s] = struct{}{}
23+ r.mu.Unlock()
24+ s.offer()
25+ return s
26+}
27+
28+// revisionExhaustedLocked reports whether this epoch has no revision left to
29+// publish, latching the exhausted state on first detection. Callers must hold
30+// r.mu.
31+func (r *Registry) revisionExhaustedLocked() bool {
32+ if r.revisionExhausted {
33+ return true
34+ }
35+ if r.revision != ^ports.BrokerRevision(0) {
36+ return false
37+ }
38+ r.revisionExhausted = true
39+ r.log.Error("broker: revision series exhausted; refusing further publication", "epoch", r.epoch)
40+ return true
41+}
42+
43+func (r *Registry) publishLocked(persist bool) {
44+ // Fail closed on an exhausted series: a wrapped revision would be zero, and
45+ // a wrapped series would publish revisions below the newest one, either of
46+ // which silently freezes durable persistence. The last valid snapshot stays
47+ // published, nothing is stored, and no subscriber is woken for a publication
48+ // that did not happen.
49+ if r.revisionExhaustedLocked() {
50+ return
51+ }
52+ // Remote daemons are published in registration order. The broker's own
53+ // machine daemon, when a producer is configured, is the
54+ // prepended entry at index zero; without a producer the registry produces no
55+ // local observation and never invents one.
56+ daemons := make([]ports.BrokerDaemonObservation, 0, len(r.hosts)+1)
57+ if r.local != nil {
58+ daemons = append(daemons, r.localHost.Clone())
59+ }
60+ for _, endpoint := range r.order {
61+ host, ok := r.hosts[endpoint]
62+ if !ok {
63+ continue
64+ }
65+ daemons = append(daemons, host.Clone())
66+ }
67+ snapshot := ports.BrokerSnapshot{Epoch: r.epoch, Revision: r.revision + 1, Daemons: daemons, Removed: r.tombstonesLocked()}
68+ if err := snapshot.Validate(); err != nil {
69+ // Fail closed: a publication the wire refuses aborts every client
70+ // connection on subscribe, so an invalid projection is never published.
71+ // The last valid snapshot stays current, nothing is persisted, and the
72+ // revision series does not advance past a publication that never
73+ // happened.
74+ r.log.Error("broker: refused invalid snapshot publication", "revision", r.revision+1, "err", err)
75+ return
76+ }
77+ r.revision = snapshot.Revision
78+ r.current.Store(&snapshot)
79+ if persist {
80+ // enqueue only stages an immutable publication: rendering the durable
81+ // copy and every store call happen on the writer goroutine, never
82+ // under the registry lock and never on the probe schedule.
83+ r.store.enqueue(snapshot)
84+ }
85+ for sub := range r.subs {
86+ sub.offer()
87+ }
88+}
89+
90+// durable returns the persistable copy of a publication, rendered by the
91+// writer goroutine with the shared rule (ports.BrokerSnapshot.DurableProjection).
92+// The registry may publish the process-local daemon, which is never durable,
93+// so it is dropped first; the store would refuse it otherwise. Restored
94+// observations regain policy from membership (see stampHostAuthority).
95+func durable(snapshot ports.BrokerSnapshot) ports.BrokerSnapshot {
96+ return snapshot.WithoutLocal().DurableProjection()
97+}
98+
99+type subscription struct {
100+ changed chan struct{}
101+ once sync.Once
102+ close func()
103+}
104+
105+func (s *subscription) Changed() <-chan struct{} { return s.changed }
106+
107+func (s *subscription) Close() { s.once.Do(s.close) }
108+
109+func (s *subscription) offer() {
110+ select {
111+ case s.changed <- struct{}{}:
112+ default:
113+ }
114+}
A internal/usecase/broker/registry_snapshot_writer.go +160 -0
@@ -0,0 +1,160 @@
1+package broker
2+
3+import (
4+ "log/slog"
5+ "reflect"
6+ "sync"
7+ "time"
8+
9+ "github.com/bnema/vev/internal/ports"
10+)
11+
12+// snapshotWriter serializes durable snapshot writes off the registry lock.
13+// Enqueue never blocks on store I/O and always coalesces to the newest
14+// revision; Store calls run on one goroutine, so the durable store never sees
15+// an out-of-order write. A failed write is logged and retried by the next
16+// publication, so a store fault never stalls publication. Close flushes the
17+// newest pending snapshot, stops the writer, and waits, so no write outlives
18+// shutdown.
19+type snapshotWriter struct {
20+ store ports.BrokerSnapshotStore
21+ log *slog.Logger
22+
23+ mu sync.Mutex
24+ pending *ports.BrokerSnapshot
25+ written ports.BrokerRevision
26+ // last is the durable content of the newest successful write with its
27+ // revision and observation timestamps cleared. Only the writer goroutine
28+ // touches it.
29+ last *ports.BrokerSnapshot
30+ started bool
31+ closed bool
32+ wake chan struct{}
33+ stop chan struct{}
34+ done chan struct{}
35+}
36+
37+func newSnapshotWriter(store ports.BrokerSnapshotStore, log *slog.Logger) *snapshotWriter {
38+ return &snapshotWriter{
39+ store: store, log: log,
40+ wake: make(chan struct{}, 1), stop: make(chan struct{}), done: make(chan struct{}),
41+ }
42+}
43+
44+// enqueue stages a snapshot for durable persistence. It coalesces to the
45+// newest revision, starts the writer on first use, and never blocks on the
46+// store or copies session payloads. Enqueue after Close is refused: nothing is
47+// written after shutdown.
48+func (w *snapshotWriter) enqueue(snapshot ports.BrokerSnapshot) {
49+ w.mu.Lock()
50+ if w.closed {
51+ w.mu.Unlock()
52+ w.log.Debug("broker: durable snapshot write refused after shutdown", "revision", snapshot.Revision)
53+ return
54+ }
55+ if w.pending != nil {
56+ if snapshot.Revision <= w.pending.Revision {
57+ w.mu.Unlock()
58+ return
59+ }
60+ } else if snapshot.Revision <= w.written {
61+ w.mu.Unlock()
62+ return
63+ }
64+ // The publication is immutable after publication, so staging it is a value
65+ // copy; the defensive durable copy is rendered on the writer goroutine.
66+ w.pending = &snapshot
67+ if !w.started {
68+ w.started = true
69+ go w.run()
70+ }
71+ w.mu.Unlock()
72+ select {
73+ case w.wake <- struct{}{}:
74+ default:
75+ }
76+}
77+
78+func (w *snapshotWriter) run() {
79+ defer close(w.done)
80+ for {
81+ select {
82+ case <-w.stop:
83+ w.flush()
84+ return
85+ case <-w.wake:
86+ w.flush()
87+ }
88+ }
89+}
90+
91+// flush writes the newest staged snapshot, skipping revisions the store
92+// already holds.
93+func (w *snapshotWriter) flush() {
94+ for {
95+ w.mu.Lock()
96+ snapshot := w.pending
97+ written := w.written
98+ w.pending = nil
99+ w.mu.Unlock()
100+ if snapshot == nil {
101+ return
102+ }
103+ if snapshot.Revision <= written {
104+ continue
105+ }
106+ next := durable(*snapshot)
107+ key := durableContent(next)
108+ if w.last != nil && reflect.DeepEqual(*w.last, key) {
109+ // Only observation timestamps moved: the stored copy already
110+ // carries the same durable state, so skip the rewrite and fsync.
111+ // A restart re-observes every host before trusting freshness.
112+ continue
113+ }
114+ if err := w.store.Store(next); err != nil {
115+ w.last = nil
116+ w.log.Error("broker: durable snapshot write failed", "revision", snapshot.Revision, "err", err)
117+ continue
118+ }
119+ w.last = &key
120+ w.mu.Lock()
121+ if snapshot.Revision > w.written {
122+ w.written = snapshot.Revision
123+ }
124+ w.mu.Unlock()
125+ }
126+}
127+
128+// durableContent returns the durable projection without the fields that change
129+// on every routine observation: the revision and the attempt, success, and
130+// due timestamps.
131+func durableContent(snapshot ports.BrokerSnapshot) ports.BrokerSnapshot {
132+ out := snapshot
133+ out.Revision = 0
134+ out.Daemons = make([]ports.BrokerDaemonObservation, len(snapshot.Daemons))
135+ for i, daemon := range snapshot.Daemons {
136+ daemon.LastAttempt = time.Time{}
137+ daemon.LastSuccess = time.Time{}
138+ daemon.NextDue = time.Time{}
139+ out.Daemons[i] = daemon
140+ }
141+ return out
142+}
143+
144+// close flushes the newest staged snapshot, stops the writer, and waits for
145+// the drain to finish. It never returns before the in-flight write completes.
146+func (w *snapshotWriter) close() {
147+ w.mu.Lock()
148+ if w.closed {
149+ w.mu.Unlock()
150+ return
151+ }
152+ w.closed = true
153+ started := w.started
154+ w.mu.Unlock()
155+ if !started {
156+ return
157+ }
158+ close(w.stop)
159+ <-w.done
160+}
M internal/usecase/daemon/attachment_effect_gate_test.go +10 -10
@@ -198,11 +198,11 @@ func TestKillSessionAcquisitionTimeoutDoesNotPublishPartialInvalidation(t *testi
198198 first.replaceTransport(firstTransport)
199199
200200 blockedTransport := &closeTrackingTransport{}
201- blocked := &attachedClient{tr: blockedTransport, output: newOutputStateStream(), size: first.size}
201+ blocked := &attachedClient{tr: blockedTransport, output: newOutputStateStream(), geometry: domain.Geometry{Size: first.geometry.Size}}
202202 blocked.initOverlays()
203203 blocked.setSession(sess)
204204 laterTransport := &closeTrackingTransport{}
205- later := &attachedClient{tr: laterTransport, output: newOutputStateStream(), size: first.size}
205+ later := &attachedClient{tr: laterTransport, output: newOutputStateStream(), geometry: domain.Geometry{Size: first.geometry.Size}}
206206 later.initOverlays()
207207 later.setSession(sess)
208208 sess.mu.Lock()
@@ -320,7 +320,7 @@ func TestKillSessionInterruptsOnlyExactParticipantBlockedSends(t *testing.T) {
320320 snatched.installTestAttachmentCapability(sess.captureAttachmentCapability(snatched, snatchedTransport))
321321
322322 activeTransport := newTeardownBlockingTransport()
323- active := &attachedClient{tr: activeTransport, output: newOutputStateStream(), size: snatched.size}
323+ active := &attachedClient{tr: activeTransport, output: newOutputStateStream(), geometry: domain.Geometry{Size: snatched.geometry.Size}}
324324 active.initOverlays()
325325 transition, err := d.transitionAttachment(attachmentTransitionRequest{
326326 target: sess, next: active, expectedTransport: active.transportSnapshot(), ready: true,
@@ -329,7 +329,7 @@ func TestKillSessionInterruptsOnlyExactParticipantBlockedSends(t *testing.T) {
329329 defer d.deferAttachmentTransitionCleanups(transition)
330330
331331 unrelatedTransport := &closeTrackingTransport{}
332- unrelatedClient := &attachedClient{tr: unrelatedTransport, output: newOutputStateStream(), size: active.size}
332+ unrelatedClient := &attachedClient{tr: unrelatedTransport, output: newOutputStateStream(), geometry: domain.Geometry{Size: active.geometry.Size}}
333333 unrelatedClient.initOverlays()
334334 unrelated := &session{sessionCore: sessionCore{id: "unrelated", name: "unrelated", attachments: map[*attachedClient]struct{}{unrelatedClient: {}}}, ctx: sess.ctx, cancel: func() {}}
335335 unrelatedClient.setSession(unrelated)
@@ -505,7 +505,7 @@ func TestAttachmentEffectGateReplacementInterruptsBlockedOldRenderBeforePublicat
505505 <-oldTransport.sendEntered
506506
507507 newTransport := &closeTrackingTransport{}
508- next := &attachedClient{tr: newTransport, output: newOutputStateStream(), size: old.size}
508+ next := &attachedClient{tr: newTransport, output: newOutputStateStream(), geometry: domain.Geometry{Size: old.geometry.Size}}
509509 next.initOverlays()
510510 transitionDone := make(chan attachmentTransitionResult, 1)
511511 transitionErr := make(chan error, 1)
@@ -554,7 +554,7 @@ func TestAttachmentEffectGateAdmittedEffectCompletesBeforeConflictingTransition(
554554 ticket, ok := old.beginAttachmentEffect(token)
555555 require.True(t, ok)
556556
557- next := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: old.size}
557+ next := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), geometry: domain.Geometry{Size: old.geometry.Size}}
558558 next.initOverlays()
559559 done := make(chan error, 1)
560560 go func() {
@@ -668,7 +668,7 @@ func TestAttachmentEffectGateAdmittedActiveEffectsFinishBeforeReplacement(t *tes
668668 }()
669669 <-admitted
670670
671- next := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: old.size}
671+ next := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), geometry: domain.Geometry{Size: old.geometry.Size}}
672672 next.initOverlays()
673673 transitionDone := make(chan error, 1)
674674 go func() {
@@ -817,7 +817,7 @@ func TestPickerDeleteDoesNotDeleteSourceAfterInitiatorReplacement(t *testing.T)
817817 }()
818818 <-admissionEnded
819819
820- next := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: old.size}
820+ next := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), geometry: domain.Geometry{Size: old.geometry.Size}}
821821 next.initOverlays()
822822 result, err := d.transitionAttachment(attachmentTransitionRequest{
823823 target: sess, next: next, expectedTransport: next.transportSnapshot(), ready: true,
@@ -873,7 +873,7 @@ func TestAttachmentEffectGateAdmittedFirstPaintFinishesBeforeReplacement(t *test
873873 go func() { paintDone <- d.firstPaintForTransition(token) }()
874874 <-admitted
875875
876- next := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: old.size}
876+ next := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), geometry: domain.Geometry{Size: old.geometry.Size}}
877877 next.initOverlays()
878878 transitionDone := make(chan error, 1)
879879 go func() {
@@ -897,7 +897,7 @@ func TestAttachmentEffectGateReversedConcurrentTransitionsDoNotDeadlock(t *testi
897897 first.captureAttachmentCapability(a, aTransport)
898898
899899 bTransport := newDatagramTestTransport()
900- b := &attachedClient{tr: bTransport, output: newOutputStateStream(), size: a.size}
900+ b := &attachedClient{tr: bTransport, output: newOutputStateStream(), geometry: domain.Geometry{Size: a.geometry.Size}}
901901 b.initOverlays()
902902 second := &session{sessionCore: sessionCore{id: "second", name: "second", attachments: map[*attachedClient]struct{}{b: {}}}}
903903 b.setSession(second)
M internal/usecase/daemon/attachment_output_test.go +3 -3
@@ -9,10 +9,10 @@ import (
99 renderer "github.com/bnema/vev-vt/ansi"
1010 "github.com/bnema/vev-vt/graphics"
1111 "github.com/bnema/vev/internal/domain"
12+ "github.com/bnema/vev/internal/domain/layout"
1213 "github.com/bnema/vev/internal/domain/terminalcap"
1314 "github.com/bnema/vev/internal/protocol"
1415 "github.com/bnema/vev/internal/protocol/wire"
15- "github.com/bnema/vev/internal/usecase/layout"
1616 "github.com/stretchr/testify/require"
1717 )
1818
@@ -122,7 +122,7 @@ func TestAttachmentOutputEpochsAreAttachmentLocalAndPreparedFramesAreFenced(t *t
122122 fillOutputStateRows(frame, []string{"abc"})
123123 newAttachment := func() *attachmentOutput {
124124 stream := newOutputStateStream()
125- ac := &attachedClient{output: stream, size: domain.Size{Cols: 3, Rows: 1}}
125+ ac := &attachedClient{output: stream, geometry: domain.Geometry{Size: domain.Size{Cols: 3, Rows: 1}}}
126126 stream.attachment = ac
127127 return stream
128128 }
@@ -195,7 +195,7 @@ func TestPreparedOutputDropsReplacedConnectionAndView(t *testing.T) {
195195 } {
196196 t.Run(tt.name, func(t *testing.T) {
197197 stream := newOutputStateStream()
198- ac := &attachedClient{output: stream, size: domain.Size{Cols: 3, Rows: 1}}
198+ ac := &attachedClient{output: stream, geometry: domain.Geometry{Size: domain.Size{Cols: 3, Rows: 1}}}
199199 stream.attachment = ac
200200 frame := renderer.NewFrame(3, 1)
201201 fillOutputStateRows(frame, []string{"abc"})
M internal/usecase/daemon/attachment_registry_test.go +2 -2
@@ -77,7 +77,7 @@ func TestAttachmentRegistryRepairsRemovedStableTarget(t *testing.T) {
7777 func TestTabForAttachmentRepairRebasesOutputBeforePublishingRevision(t *testing.T) {
7878 sess := registryTestSession()
7979 ac := registryTestAttachment(1)
80- ac.size = domain.Size{Cols: 80, Rows: 23}
80+ ac.geometry.Size = domain.Size{Cols: 80, Rows: 23}
8181 ac.output = newOutputStateStream()
8282 ac.output.attachment = ac
8383 require.True(t, sess.registerAttachment(ac))
@@ -105,7 +105,7 @@ func TestTabForAttachmentRepairRebasesOutputBeforePublishingRevision(t *testing.
105105 func TestPrepareRemovedTabViewRebasesOutputBeforeSideEffect(t *testing.T) {
106106 sess := registryTestSession()
107107 ac := registryTestAttachment(1)
108- ac.size = domain.Size{Cols: 80, Rows: 23}
108+ ac.geometry.Size = domain.Size{Cols: 80, Rows: 23}
109109 ac.output = newOutputStateStream()
110110 ac.output.attachment = ac
111111 require.True(t, sess.registerAttachment(ac))
M internal/usecase/daemon/attachment_render_test.go +7 -7
@@ -118,7 +118,7 @@ func TestAttachmentResizeKeepsPeerWindowAndExpandsSharedContent(t *testing.T) {
118118 require.True(t, sess.repairAttachmentView(second))
119119
120120 tb := sess.tabs[0]
121- firstSize := first.size
121+ firstSize := first.geometry.Size
122122 secondRevision := second.viewSnapshot().revision
123123 secondEpoch := second.output.currentEpoch()
124124
@@ -136,8 +136,8 @@ func TestAttachmentResizeKeepsPeerWindowAndExpandsSharedContent(t *testing.T) {
136136 tb.mu.Lock()
137137 require.Equal(t, domain.Size{Cols: 120, Rows: 48}, tb.size)
138138 tb.mu.Unlock()
139- require.Equal(t, firstSize, first.size)
140- require.Equal(t, domain.Size{Cols: 120, Rows: 50}, second.size)
139+ require.Equal(t, firstSize, first.geometry.Size)
140+ require.Equal(t, domain.Size{Cols: 120, Rows: 50}, second.geometry.Size)
141141 require.Equal(t, secondRevision+1, second.viewSnapshot().revision)
142142 require.Greater(t, second.output.currentEpoch(), secondEpoch)
143143 }
@@ -292,8 +292,8 @@ func TestAttachmentResizeUsesLatestClaimedSessionGeometry(t *testing.T) {
292292 tb.mu.Lock()
293293 require.Equal(t, domain.Size{Cols: 120, Rows: 48}, tb.size)
294294 tb.mu.Unlock()
295- require.Equal(t, domain.Size{Cols: 80, Rows: 24}, first.size)
296- require.Equal(t, domain.Size{Cols: 120, Rows: 50}, second.size)
295+ require.Equal(t, domain.Size{Cols: 80, Rows: 24}, first.geometry.Size)
296+ require.Equal(t, domain.Size{Cols: 120, Rows: 50}, second.geometry.Size)
297297
298298 // A same-size resize is still a claim, so the smaller peer can take
299299 // authority without changing its local window state first.
@@ -308,8 +308,8 @@ func TestAttachmentResizeUsesLatestClaimedSessionGeometry(t *testing.T) {
308308 tb.mu.Lock()
309309 require.Equal(t, domain.Size{Cols: 70, Rows: 18}, tb.size)
310310 tb.mu.Unlock()
311- require.Equal(t, domain.Size{Cols: 70, Rows: 20}, first.size)
312- require.Equal(t, domain.Size{Cols: 90, Rows: 30}, second.size)
311+ require.Equal(t, domain.Size{Cols: 70, Rows: 20}, first.geometry.Size)
312+ require.Equal(t, domain.Size{Cols: 90, Rows: 30}, second.geometry.Size)
313313
314314 // Detaching the latest claimant falls back to the most recent remaining
315315 // attachment claim rather than reverting to the historical maximum.
M internal/usecase/daemon/attention.go +1 -4
@@ -19,10 +19,7 @@ func (d *Daemon) noteAttention(sess *session, tb *tab) {
1919 return
2020 }
2121
22- now := d.clock.Now()
23- if now.IsZero() {
24- now = time.Now()
25- }
22+ now := d.daemonNow()
2623
2724 sess.mu.Lock()
2825 if !tb.attention {
M internal/usecase/daemon/attention_test.go +2 -2
@@ -12,11 +12,11 @@ import (
1212
1313 renderer "github.com/bnema/vev-vt"
1414 "github.com/bnema/vev/internal/domain"
15+ "github.com/bnema/vev/internal/domain/layout"
1516 "github.com/bnema/vev/internal/ports"
1617 "github.com/bnema/vev/internal/protocol"
1718 "github.com/bnema/vev/internal/protocol/wire"
1819 "github.com/bnema/vev/internal/usecase/keys"
19- "github.com/bnema/vev/internal/usecase/layout"
2020 "github.com/bnema/vev/internal/usecase/ui"
2121 )
2222
@@ -172,7 +172,7 @@ func TestNoteAttentionDoesNotBlockOnWedgedOtherClient(t *testing.T) {
172172 return nil
173173 }).Maybe()
174174 trW.EXPECT().Close().Return(nil).Maybe()
175- acW := &attachedClient{tr: trW, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}}
175+ acW := &attachedClient{tr: trW, output: newOutputStateStream(), geometry: domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}}}
176176 acW.initOverlays()
177177 sctxW, cancelW := context.WithCancel(d.serveCtx)
178178 t.Cleanup(cancelW)
M internal/usecase/daemon/bar_refresh_test.go +15 -15
@@ -11,9 +11,9 @@ import (
1111 "time"
1212
1313 "github.com/bnema/vev/internal/domain"
14+ "github.com/bnema/vev/internal/domain/layout"
1415 "github.com/bnema/vev/internal/ports"
1516 portsmocks "github.com/bnema/vev/internal/ports/mocks"
16- "github.com/bnema/vev/internal/usecase/layout"
1717 "github.com/stretchr/testify/mock"
1818 "github.com/stretchr/testify/require"
1919 )
@@ -64,7 +64,7 @@ func TestBarScriptRefreshIntervalClampingTickAndLastGoodOnFailure(t *testing.T)
6464 r := &fakeBarRunner{outs: []string{"top1", "bot1", "top2", "bot2"}}
6565 d := newBarRefreshTestDaemon(r, 100*time.Millisecond)
6666 sess := newBarRefreshTestSession()
67- sess.registerAttachment(&attachedClient{size: domain.Size{Cols: 120, Rows: 24}})
67+ sess.registerAttachment(&attachedClient{geometry: domain.Geometry{Size: domain.Size{Cols: 120, Rows: 24}}})
6868
6969 require.True(t, d.refreshBarScriptsIfDue(sess, time.Unix(0, 0), false))
7070 waitBarRefreshIdle(t, d)
@@ -91,7 +91,7 @@ func TestBarScriptContextUsesFirstOrderedPaneAndClientCols(t *testing.T) {
9191 sess := newBarRefreshTestSession()
9292 sess.name = "work"
9393 sess.cwd = "/repo"
94- sess.registerAttachment(&attachedClient{size: domain.Size{Cols: 132, Rows: 40}})
94+ sess.registerAttachment(&attachedClient{geometry: domain.Geometry{Size: domain.Size{Cols: 132, Rows: 40}}})
9595 sess.tabs[0].size = domain.Size{Cols: 132, Rows: 38}
9696 active := sess.tabs[0].focusedPane()
9797 active.stableID = "pane-default"
@@ -130,7 +130,7 @@ func TestBarScriptRefreshSkipsRunnerWhenCommandsDisabled(t *testing.T) {
130130 d.barScripts.cfg.topRight = ""
131131 d.barScripts.cfg.bottomRight = ""
132132 sess := newBarRefreshTestSession()
133- sess.registerAttachment(&attachedClient{size: domain.Size{Cols: 80, Rows: 24}})
133+ sess.registerAttachment(&attachedClient{geometry: domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}}})
134134 d.barScripts.outputs[sess.id] = barScriptOutputs{topRight: "old", bottomRight: "old"}
135135
136136 require.False(t, d.refreshBarScriptsIfDue(sess, time.Unix(0, 0), true))
@@ -145,7 +145,7 @@ func TestDefaultBarConfigDoesNotInvokeRunner(t *testing.T) {
145145 d := newBarRefreshTestDaemon(r, time.Second)
146146 d.barScripts.cfg = barConfigFromDomain(domain.Defaults().Bar)
147147 sess := newBarRefreshTestSession()
148- sess.registerAttachment(&attachedClient{size: domain.Size{Cols: 80, Rows: 24}})
148+ sess.registerAttachment(&attachedClient{geometry: domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}}})
149149
150150 require.False(t, d.refreshBarScriptsIfDue(sess, time.Unix(0, 0), true))
151151 require.Empty(t, r.calls)
@@ -155,7 +155,7 @@ func TestBarScriptForcedRefreshRespectsMinimumInterval(t *testing.T) {
155155 r := &fakeBarRunner{outs: []string{"top1", "bottom1", "top2", "bottom2"}}
156156 d := newBarRefreshTestDaemon(r, 5*time.Second)
157157 sess := newBarRefreshTestSession()
158- sess.registerAttachment(&attachedClient{size: domain.Size{Cols: 80, Rows: 24}})
158+ sess.registerAttachment(&attachedClient{geometry: domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}}})
159159
160160 require.True(t, d.refreshBarScriptsIfDue(sess, time.Unix(10, 0), true))
161161 waitBarRefreshIdle(t, d)
@@ -172,7 +172,7 @@ func TestBarScriptContextChangeDebouncesUntilMinimumInterval(t *testing.T) {
172172 r := &fakeBarRunner{outs: []string{"top1", "bottom1", "top2", "bottom2"}}
173173 d := newBarRefreshTestDaemon(r, 5*time.Second)
174174 sess := newBarRefreshTestSession()
175- sess.registerAttachment(&attachedClient{size: domain.Size{Cols: 80, Rows: 24}})
175+ sess.registerAttachment(&attachedClient{geometry: domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}}})
176176
177177 require.True(t, d.refreshBarScriptsIfDue(sess, time.Now(), false))
178178 waitBarRefreshIdle(t, d)
@@ -190,7 +190,7 @@ func TestBarScriptRefreshForcesWhenContextChanges(t *testing.T) {
190190 r := &fakeBarRunner{outs: []string{"top1", "bottom1", "top2", "bottom2"}}
191191 d := newBarRefreshTestDaemon(r, 5*time.Second)
192192 sess := newBarRefreshTestSession()
193- sess.registerAttachment(&attachedClient{size: domain.Size{Cols: 80, Rows: 24}})
193+ sess.registerAttachment(&attachedClient{geometry: domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}}})
194194
195195 require.True(t, d.refreshBarScriptsIfDue(sess, time.Unix(0, 0), false))
196196 waitBarRefreshIdle(t, d)
@@ -218,7 +218,7 @@ func TestRefreshBarScriptsAllSessionsForcesRun(t *testing.T) {
218218 r := &fakeBarRunner{outs: []string{"top1", "bottom1"}}
219219 d := newBarRefreshTestDaemon(r, 60*time.Second)
220220 sess := newBarRefreshTestSession()
221- sess.registerAttachment(&attachedClient{size: domain.Size{Cols: 80, Rows: 24}})
221+ sess.registerAttachment(&attachedClient{geometry: domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}}})
222222 d.sessions = map[domain.SessionID]*session{sess.id: sess}
223223
224224 // Verifies refreshBarScriptsAllSessions iterates every live session and
@@ -251,7 +251,7 @@ func TestBarScriptFailureLogsAndRetainsLastGood(t *testing.T) {
251251 d := newBarRefreshTestDaemon(r, time.Second)
252252 d.log = slog.New(slog.NewTextHandler(&logs, nil))
253253 sess := newBarRefreshTestSession()
254- sess.registerAttachment(&attachedClient{size: domain.Size{Cols: 80, Rows: 24}})
254+ sess.registerAttachment(&attachedClient{geometry: domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}}})
255255
256256 require.True(t, d.refreshBarScriptsIfDue(sess, time.Unix(10, 0), true))
257257 waitBarRefreshIdle(t, d)
@@ -286,7 +286,7 @@ func TestBarScriptRunConsumesPendingAfterRunningClears(t *testing.T) {
286286 r := newBlockingBarRunner([]string{"top1", "bottom1", "top2", "bottom2"})
287287 d := newBarRefreshTestDaemon(r, time.Second)
288288 sess := newBarRefreshTestSession()
289- sess.registerAttachment(&attachedClient{size: domain.Size{Cols: 80, Rows: 24}})
289+ sess.registerAttachment(&attachedClient{geometry: domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}}})
290290
291291 require.True(t, d.refreshBarScriptsIfDue(sess, time.Unix(10, 0), true))
292292 select {
@@ -336,7 +336,7 @@ func TestBarScriptFailureLogsOnceUntilSignatureChanges(t *testing.T) {
336336 d.log = logger
337337 d.baseEnv = []string{"PATH=/usr/bin"}
338338 sess := newBarRefreshTestSession()
339- sess.registerAttachment(&attachedClient{size: domain.Size{Cols: 80, Rows: 24}})
339+ sess.registerAttachment(&attachedClient{geometry: domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}}})
340340
341341 readBuf := func() string {
342342 mu.Lock()
@@ -419,11 +419,11 @@ func TestBarScriptRefreshIsPerSession(t *testing.T) {
419419 a := newBarRefreshTestSession()
420420 a.id = "a"
421421 a.name = "a"
422- a.registerAttachment(&attachedClient{size: domain.Size{Cols: 80, Rows: 24}})
422+ a.registerAttachment(&attachedClient{geometry: domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}}})
423423 b := newBarRefreshTestSession()
424424 b.id = "b"
425425 b.name = "b"
426- b.registerAttachment(&attachedClient{size: domain.Size{Cols: 80, Rows: 24}})
426+ b.registerAttachment(&attachedClient{geometry: domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}}})
427427
428428 require.True(t, d.refreshBarScriptsIfDue(a, time.Unix(0, 0), false))
429429 waitBarRefreshIdle(t, d)
@@ -644,7 +644,7 @@ func TestBarScriptsUseSessionEnvNotDaemonBaseEnv(t *testing.T) {
644644 d.baseEnv = tc.baseEnv
645645 sess := newBarRefreshTestSession()
646646 sess.env = tc.sessEnv
647- sess.registerAttachment(&attachedClient{size: domain.Size{Cols: 80, Rows: 24}})
647+ sess.registerAttachment(&attachedClient{geometry: domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}}})
648648
649649 require.True(t, d.refreshBarScriptsIfDue(sess, time.Unix(0, 0), true))
650650 waitBarRefreshIdle(t, d)
M internal/usecase/daemon/client.go +2 -6
@@ -94,7 +94,6 @@ type attachedClient struct {
9494 // sizeMu lets shared-geometry snapshots read attachment windows without
9595 // waiting behind a blocked transport send.
9696 sizeMu sync.RWMutex
97- size domain.Size // retained for legacy fixtures; live paths publish geometry
9897 geometry domain.Geometry // controlling-terminal geometry for this Attachment
9998 keys *keys.Router
10099 // view is attachment-local navigation state. It is never inferred from a
@@ -181,7 +180,7 @@ func (ac *attachedClient) sizeSnapshot() domain.Size {
181180 }
182181 ac.sizeMu.RLock()
183182 defer ac.sizeMu.RUnlock()
184- return ac.size
183+ return ac.geometry.Size
185184 }
186185
187186 func (ac *attachedClient) setSize(size domain.Size) {
@@ -189,7 +188,6 @@ func (ac *attachedClient) setSize(size domain.Size) {
189188 return
190189 }
191190 ac.sizeMu.Lock()
192- ac.size = size
193191 ac.geometry.Size = size
194192 ac.geometry = ac.geometry.NormalizePixels()
195193 ac.sizeMu.Unlock()
@@ -204,7 +202,7 @@ func (ac *attachedClient) geometrySnapshot() domain.Geometry {
204202 if ac.geometry.Valid() {
205203 return ac.geometry
206204 }
207- return domain.Geometry{Size: ac.size}
205+ return domain.Geometry{Size: ac.geometry.Size}
208206 }
209207
210208 func (ac *attachedClient) setGeometry(geometry domain.Geometry) {
@@ -213,7 +211,6 @@ func (ac *attachedClient) setGeometry(geometry domain.Geometry) {
213211 }
214212 geometry = geometry.NormalizePixels()
215213 ac.sizeMu.Lock()
216- ac.size = geometry.Size
217214 ac.geometry = geometry
218215 ac.sizeMu.Unlock()
219216 }
@@ -772,7 +769,6 @@ func (d *Daemon) prepareAttachedClientLocked(sess *session, tr ports.ServerConne
772769 ac := &attachedClient{
773770 tr: tr,
774771 output: output,
775- size: geometry.Size,
776772 geometry: geometry,
777773 view: attachmentView{windowRows: geometry.Rows, windowSet: true},
778774 clientID: opts.clientID,
M internal/usecase/daemon/coderabbit_regression_test.go +1 -1
@@ -15,9 +15,9 @@ import (
1515 "github.com/bnema/vev/internal/ports"
1616 portsmocks "github.com/bnema/vev/internal/ports/mocks"
1717 "github.com/bnema/vev/internal/protocol"
18+ snapcodec "github.com/bnema/vev/internal/snapshotcodec"
1819 "github.com/bnema/vev/internal/usecase/picker"
1920 recoveryusecase "github.com/bnema/vev/internal/usecase/recovery"
20- snapcodec "github.com/bnema/vev/internal/usecase/snapshot"
2121 )
2222
2323 func TestRestoreAmbiguousBadVersionLoadFailurePreservesCheckpoint(t *testing.T) {
M internal/usecase/daemon/control.go +1 -1
@@ -11,11 +11,11 @@ import (
1111 "strings"
1212
1313 "github.com/bnema/vev/internal/domain"
14+ "github.com/bnema/vev/internal/domain/layout"
1415 "github.com/bnema/vev/internal/ports"
1516 "github.com/bnema/vev/internal/protocol"
1617 "github.com/bnema/vev/internal/protocol/catalogue"
1718 "github.com/bnema/vev/internal/usecase/command"
18- "github.com/bnema/vev/internal/usecase/layout"
1919 )
2020
2121 // handleCommand serves one typed one-shot control request.
M internal/usecase/daemon/control_test.go +1 -1
@@ -14,13 +14,13 @@ import (
1414 "time"
1515
1616 "github.com/bnema/vev/internal/domain"
17+ "github.com/bnema/vev/internal/domain/layout"
1718 appports "github.com/bnema/vev/internal/ports"
1819 portsmocks "github.com/bnema/vev/internal/ports/mocks"
1920 "github.com/bnema/vev/internal/protocol"
2021 "github.com/bnema/vev/internal/protocol/catalogue"
2122 "github.com/bnema/vev/internal/protocol/wire"
2223 "github.com/bnema/vev/internal/usecase/command"
23- "github.com/bnema/vev/internal/usecase/layout"
2424 "github.com/stretchr/testify/mock"
2525 "github.com/stretchr/testify/require"
2626 )
M internal/usecase/daemon/copy_scroll_ownership_test.go +2 -1
@@ -4,6 +4,7 @@ import (
44 "testing"
55
66 renderer "github.com/bnema/vev-vt"
7+ "github.com/bnema/vev/internal/domain"
78 "github.com/bnema/vev/internal/protocol"
89 scopy "github.com/bnema/vev/internal/usecase/copy"
910 "github.com/stretchr/testify/require"
@@ -94,7 +95,7 @@ func TestCopyExitRefreshesAfterOtherAttachmentConsumesDamage(t *testing.T) {
9495 copyBefore := captureTestFrame(ac.render.cache.frame)
9596
9697 tr, peerSends := newCapturingTransport(t)
97- peer := &attachedClient{tr: tr, output: newOutputStateStream(), size: ac.size}
98+ peer := &attachedClient{tr: tr, output: newOutputStateStream(), geometry: domain.Geometry{Size: ac.geometry.Size}}
9899 peer.output.attachment = peer
99100 peer.initOverlays()
100101 peer.setSession(sess)
M internal/usecase/daemon/copymode_lifecycle_test.go +1 -1
@@ -8,9 +8,9 @@ import (
88 "github.com/stretchr/testify/require"
99
1010 "github.com/bnema/vev/internal/domain"
11+ "github.com/bnema/vev/internal/domain/layout"
1112 portsmocks "github.com/bnema/vev/internal/ports/mocks"
1213 scopy "github.com/bnema/vev/internal/usecase/copy"
13- "github.com/bnema/vev/internal/usecase/layout"
1414 )
1515
1616 func TestCopyModeLifecycleResizeExitsBeforeGeometryChange(t *testing.T) {
M internal/usecase/daemon/copymode_test.go +1 -1
@@ -17,12 +17,12 @@ import (
1717 vt "github.com/bnema/vev-vt"
1818 renderer "github.com/bnema/vev-vt/ansi"
1919 "github.com/bnema/vev/internal/domain"
20+ "github.com/bnema/vev/internal/domain/layout"
2021 "github.com/bnema/vev/internal/ports"
2122 portsmocks "github.com/bnema/vev/internal/ports/mocks"
2223 "github.com/bnema/vev/internal/protocol"
2324 "github.com/bnema/vev/internal/protocol/wire"
2425 scopy "github.com/bnema/vev/internal/usecase/copy"
25- "github.com/bnema/vev/internal/usecase/layout"
2626 "github.com/bnema/vev/internal/usecase/ui"
2727 )
2828
M internal/usecase/daemon/daemon_test.go +4 -3
@@ -26,10 +26,11 @@ import (
2626
2727 // stubClock returns timers whose channel never fires, so a scheduler under it
2828 // blocks in its debounce loop until the session context is cancelled. Used by
29-// every test that is not specifically exercising the debounce.
29+// every test that is not specifically exercising the debounce. Now reports a
30+// fixed non-zero instant so recorded timestamps are deterministic and set.
3031 type stubClock struct{}
3132
32-func (stubClock) Now() time.Time { return time.Time{} }
33+func (stubClock) Now() time.Time { return time.Unix(1_700_000_000, 0) }
3334 func (stubClock) NewTimer(time.Duration) ports.Timer { return stubTimer{} }
3435
3536 type stubTimer struct{}
@@ -416,7 +417,7 @@ func newManualSessionWithPTYsClockCleanup(t testing.TB, clock ports.Clock, regis
416417 t.Helper()
417418 d := newTestDaemonWithCleanup(t, nil, clock, registerCleanup)
418419 tr, sends := newCapturingTransport(t)
419- ac := &attachedClient{tr: tr, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}}
420+ ac := &attachedClient{tr: tr, output: newOutputStateStream(), geometry: domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}}}
420421 ac.output.attachment = ac
421422 ac.initOverlays()
422423 sctx, cancel := context.WithCancel(d.serveCtx)
M internal/usecase/daemon/durable_recovery_test.go +1 -1
@@ -21,8 +21,8 @@ import (
2121 "github.com/bnema/vev/internal/ports"
2222 portsmocks "github.com/bnema/vev/internal/ports/mocks"
2323 "github.com/bnema/vev/internal/protocol"
24+ snapcodec "github.com/bnema/vev/internal/snapshotcodec"
2425 recoveryusecase "github.com/bnema/vev/internal/usecase/recovery"
25- snapcodec "github.com/bnema/vev/internal/usecase/snapshot"
2626 )
2727
2828 type durableRecoveryCatalogue struct {
M internal/usecase/daemon/final_acceptance_test.go +10 -10
@@ -5,8 +5,8 @@ import (
55
66 vt "github.com/bnema/vev-vt"
77 "github.com/bnema/vev/internal/domain"
8+ "github.com/bnema/vev/internal/domain/layout"
89 "github.com/bnema/vev/internal/protocol"
9- "github.com/bnema/vev/internal/usecase/layout"
1010 "github.com/stretchr/testify/require"
1111 )
1212
@@ -19,9 +19,9 @@ func TestAcceptancePaletteNewTabUsesSessionGeometryAfterAttachmentResize(t *test
1919
2020 secondTransport, _ := newCapturingTransport(t)
2121 second := &attachedClient{
22- tr: secondTransport,
23- output: newOutputStateStream(),
24- size: domain.Size{Cols: 80, Rows: 24},
22+ tr: secondTransport,
23+ output: newOutputStateStream(),
24+ geometry: domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}},
2525 }
2626 second.initOverlays()
2727 second.clientID[0] = 2
@@ -85,7 +85,7 @@ func TestAcceptancePaletteNewTabUsesSessionGeometryAfterAttachmentResize(t *test
8585 first.sendMu.Unlock()
8686 require.Equal(t, firstEpoch, gotFirstEpoch, "new tab render crossed attachment boundary")
8787 second.sendMu.Lock()
88- gotSecondSize := second.size
88+ gotSecondSize := second.geometry.Size
8989 second.sendMu.Unlock()
9090 require.Equal(t, domain.Size{Cols: 120, Rows: 40}, gotSecondSize)
9191 }
@@ -99,9 +99,9 @@ func TestAcceptanceAttachmentStateIsolationAcrossResetResizeAndDetach(t *testing
9999
100100 secondTransport, _ := newCapturingTransport(t)
101101 second := &attachedClient{
102- tr: secondTransport,
103- output: newOutputStateStream(),
104- size: domain.Size{Cols: 80, Rows: 24},
102+ tr: secondTransport,
103+ output: newOutputStateStream(),
104+ geometry: domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}},
105105 }
106106 second.initOverlays()
107107 second.clientID[0] = 2
@@ -156,8 +156,8 @@ func TestAcceptanceAttachmentStateIsolationAcrossResetResizeAndDetach(t *testing
156156 require.True(t, admitted)
157157 require.True(t, d.resizeAttachmentForLease(secondEffect, domain.Size{Cols: 120, Rows: 40}))
158158 secondEffect.End()
159- require.Equal(t, domain.Size{Cols: 120, Rows: 40}, second.size)
160- require.Equal(t, domain.Size{Cols: 80, Rows: 24}, first.size, "peer resize crossed attachment boundary")
159+ require.Equal(t, domain.Size{Cols: 120, Rows: 40}, second.geometry.Size)
160+ require.Equal(t, domain.Size{Cols: 80, Rows: 24}, first.geometry.Size, "peer resize crossed attachment boundary")
161161 sess.mu.Lock()
162162 tb := sess.tabs[0]
163163 sess.mu.Unlock()
M internal/usecase/daemon/floating.go +1 -1
@@ -6,7 +6,7 @@ import (
66 "strings"
77
88 "github.com/bnema/vev/internal/domain"
9- "github.com/bnema/vev/internal/usecase/layout"
9+ "github.com/bnema/vev/internal/domain/layout"
1010 )
1111
1212 // floatingState describes the lifecycle of a tab's independent floating pane.
M internal/usecase/daemon/floating_open_test.go +1 -1
@@ -226,7 +226,7 @@ func TestFloatingOpenErrorReleasesReturnedPTYBeforePublishingFailure(t *testing.
226226 d := newTestDaemon(t, factory, stubClock{})
227227 tb := newFloatingTestTab(t)
228228 tr, _ := newCapturingTransport(t)
229- ac := &attachedClient{tr: tr, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}}
229+ ac := &attachedClient{tr: tr, output: newOutputStateStream(), geometry: domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}}}
230230 ac.initOverlays()
231231 sessCtx, cancelSession := context.WithCancel(t.Context())
232232 sess := &session{sessionCore: sessionCore{id: "floating-open-error", name: "work", attachments: map[*attachedClient]struct{}{ac: {}}}, tabs: []*tab{tb}, ctx: sessCtx, cancel: cancelSession}
M internal/usecase/daemon/floating_render_test.go +1 -1
@@ -11,7 +11,7 @@ import (
1111
1212 renderer "github.com/bnema/vev-vt/ansi"
1313 "github.com/bnema/vev/internal/domain"
14- "github.com/bnema/vev/internal/usecase/layout"
14+ "github.com/bnema/vev/internal/domain/layout"
1515 themeui "github.com/bnema/vev/internal/usecase/theme"
1616 "github.com/bnema/vev/internal/usecase/ui"
1717 )
M internal/usecase/daemon/floating_spare.go +1 -1
@@ -1,7 +1,7 @@
11 package daemon
22
33 import (
4- "github.com/bnema/vev/internal/usecase/layout"
4+ "github.com/bnema/vev/internal/domain/layout"
55 )
66
77 // floatingSpare is one pre-started floating shell owned by a session rather
M internal/usecase/daemon/floating_test.go +2 -2
@@ -15,12 +15,12 @@ import (
1515 "github.com/stretchr/testify/require"
1616
1717 "github.com/bnema/vev/internal/domain"
18+ "github.com/bnema/vev/internal/domain/layout"
1819 "github.com/bnema/vev/internal/ports"
1920 portsmocks "github.com/bnema/vev/internal/ports/mocks"
2021 "github.com/bnema/vev/internal/protocol"
2122 "github.com/bnema/vev/internal/protocol/wire"
2223 scopy "github.com/bnema/vev/internal/usecase/copy"
23- "github.com/bnema/vev/internal/usecase/layout"
2424 themeui "github.com/bnema/vev/internal/usecase/theme"
2525 )
2626
@@ -768,7 +768,7 @@ func TestFloatingAsyncSpawnFailureToastsOnlyForUserOpen(t *testing.T) {
768768 d := newTestDaemon(t, factory, stubClock{})
769769 tb := newFloatingTestTab(t)
770770 tr, _ := newCapturingTransport(t)
771- ac := &attachedClient{tr: tr, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}}
771+ ac := &attachedClient{tr: tr, output: newOutputStateStream(), geometry: domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}}}
772772 ac.initOverlays()
773773 sess := &session{sessionCore: sessionCore{id: "floating-spawn", name: "work", attachments: map[*attachedClient]struct{}{ac: {}}}, tabs: []*tab{tb}, ctx: t.Context()}
774774 ac.setSession(sess)
M internal/usecase/daemon/graphics_output.go +1 -1
@@ -15,8 +15,8 @@ import (
1515 renderer "github.com/bnema/vev-vt"
1616 "github.com/bnema/vev-vt/graphics"
1717 "github.com/bnema/vev/internal/domain"
18+ "github.com/bnema/vev/internal/domain/layout"
1819 "github.com/bnema/vev/internal/protocol"
19- "github.com/bnema/vev/internal/usecase/layout"
2020 themeui "github.com/bnema/vev/internal/usecase/theme"
2121 )
2222
M internal/usecase/daemon/graphics_output_test.go +1 -1
@@ -13,10 +13,10 @@ import (
1313 vt "github.com/bnema/vev-vt"
1414 "github.com/bnema/vev-vt/graphics"
1515 "github.com/bnema/vev/internal/domain"
16+ "github.com/bnema/vev/internal/domain/layout"
1617 "github.com/bnema/vev/internal/domain/terminalcap"
1718 "github.com/bnema/vev/internal/protocol"
1819 "github.com/bnema/vev/internal/protocol/wire"
19- "github.com/bnema/vev/internal/usecase/layout"
2020 "github.com/stretchr/testify/require"
2121 )
2222
M internal/usecase/daemon/input.go +1 -1
@@ -7,10 +7,10 @@ import (
77 "strconv"
88
99 "github.com/bnema/vev/internal/domain"
10+ "github.com/bnema/vev/internal/domain/layout"
1011 "github.com/bnema/vev/internal/usecase/command"
1112 "github.com/bnema/vev/internal/usecase/keys"
1213 "github.com/bnema/vev/internal/usecase/keys/kittykey"
13- "github.com/bnema/vev/internal/usecase/layout"
1414 "github.com/bnema/vev/internal/usecase/mouse"
1515 )
1616
M internal/usecase/daemon/input_test.go +2 -2
@@ -15,12 +15,12 @@ import (
1515
1616 vt "github.com/bnema/vev-vt"
1717 "github.com/bnema/vev/internal/domain"
18+ "github.com/bnema/vev/internal/domain/layout"
1819 portsmocks "github.com/bnema/vev/internal/ports/mocks"
1920 "github.com/bnema/vev/internal/protocol"
2021 "github.com/bnema/vev/internal/protocol/wire"
2122 scopy "github.com/bnema/vev/internal/usecase/copy"
2223 "github.com/bnema/vev/internal/usecase/keys"
23- "github.com/bnema/vev/internal/usecase/layout"
2424 "github.com/bnema/vev/internal/usecase/mouse"
2525 "github.com/bnema/vev/internal/usecase/picker"
2626 "github.com/bnema/vev/internal/usecase/ui"
@@ -191,7 +191,7 @@ func TestSwitchTabFirstFrameDoesNotReuseSamePaneIDCapture(t *testing.T) {
191191 d.paint(sess, ac, true, nil)
192192 first := awaitFrame(t, sends, "Output")
193193 firstOutput := unmarshalTestOutput(t, first.Payload)
194- terminal := vt.NewScreen(ac.size.Cols, ac.size.Rows)
194+ terminal := vt.NewScreen(ac.geometry.Size.Cols, ac.geometry.Size.Rows)
195195 terminal.Write(firstOutput.Data)
196196
197197 // A clean pane relies on the attachment capture cache for its retained
M internal/usecase/daemon/layout_mutation.go +1 -1
@@ -4,7 +4,7 @@ import (
44 "slices"
55
66 "github.com/bnema/vev/internal/domain"
7- "github.com/bnema/vev/internal/usecase/layout"
7+ "github.com/bnema/vev/internal/domain/layout"
88 )
99
1010 // mutateTargetLayout preserves the publication semantics used by resize and
M internal/usecase/daemon/mouse_panes.go +1 -1
@@ -6,7 +6,7 @@ import (
66 "strings"
77
88 "github.com/bnema/vev/internal/domain"
9- "github.com/bnema/vev/internal/usecase/layout"
9+ "github.com/bnema/vev/internal/domain/layout"
1010 "github.com/bnema/vev/internal/usecase/mouse"
1111 )
1212
M internal/usecase/daemon/move_acceptance_test.go +1 -1
@@ -7,7 +7,7 @@ import (
77 "github.com/stretchr/testify/require"
88
99 "github.com/bnema/vev/internal/domain"
10- "github.com/bnema/vev/internal/usecase/layout"
10+ "github.com/bnema/vev/internal/domain/layout"
1111 )
1212
1313 func catalogueUpdateNames(updates []domain.CatalogueMetadataUpdate) []string {
M internal/usecase/daemon/move_cleanup_test.go +1 -1
@@ -62,7 +62,7 @@ func TestMovePaneCleanupUsesCapturedSourceAttachment(t *testing.T) {
6262
6363 replacementTransport, _ := newCapturingTransport(t)
6464 replacement := &attachedClient{
65- tr: replacementTransport, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24},
65+ tr: replacementTransport, output: newOutputStateStream(), geometry: domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}},
6666 }
6767 replacement.initOverlays()
6868 replacement.setSession(source)
M internal/usecase/daemon/move_gate_race_test.go +1 -1
@@ -186,7 +186,7 @@ func TestPaletteFinalCloseEndsCurrentEffectBeforeDrainingPeers(t *testing.T) {
186186 d, sess, current, _ := newManualSessionWithPTYs(t, newQuietPTY())
187187 d.attachCoordinator(sess, nil, current, true)
188188 otherTransport := &closeTrackingTransport{}
189- other := &attachedClient{tr: otherTransport, output: newOutputStateStream(), size: current.size}
189+ other := &attachedClient{tr: otherTransport, output: newOutputStateStream(), geometry: domain.Geometry{Size: current.geometry.Size}}
190190 other.initOverlays()
191191 other.setSession(sess)
192192 sess.mu.Lock()
M internal/usecase/daemon/move_pane_admission.go +1 -1
@@ -1,6 +1,6 @@
11 package daemon
22
3-import "github.com/bnema/vev/internal/usecase/layout"
3+import "github.com/bnema/vev/internal/domain/layout"
44
55 // movePaneTopology is the private pane topology Implementation consumed by the
66 // shared moveTransaction Module.
M internal/usecase/daemon/move_pane_candidate.go +1 -1
@@ -6,7 +6,7 @@ import (
66 "math"
77
88 "github.com/bnema/vev/internal/domain"
9- "github.com/bnema/vev/internal/usecase/layout"
9+ "github.com/bnema/vev/internal/domain/layout"
1010 )
1111
1212 // movePaneCandidate contains only unpublished topology and identity changes.
M internal/usecase/daemon/move_pane_commit.go +1 -1
@@ -1,6 +1,6 @@
11 package daemon
22
3-import "github.com/bnema/vev/internal/usecase/layout"
3+import "github.com/bnema/vev/internal/domain/layout"
44
55 // movePaneCommit owns pane-private validation and publication. The shared
66 // moveTransaction already holds daemon, routing, ordered Session, and resize
M internal/usecase/daemon/move_picker_test.go +2 -2
@@ -390,10 +390,10 @@ func TestMovePickerCompositeFollow(t *testing.T) {
390390 source.tabs = append(source.tabs, newTabWithStableID("remaining", "remaining-pane", pty, movedTab.size))
391391 publishTiledPaneOwners(source, source.tabs[1])
392392 }
393- peer := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: ac.size}
393+ peer := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), geometry: domain.Geometry{Size: ac.geometry.Size}}
394394 peer.setSession(source)
395395 source.registerAttachment(peer)
396- destinationPeer := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: ac.size}
396+ destinationPeer := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), geometry: domain.Geometry{Size: ac.geometry.Size}}
397397 destinationPeer.setSession(destination)
398398 destination.registerAttachment(destinationPeer)
399399 destination.selectAttachmentTabLocked(destinationPeer, domain.TabStableID(destinationTab.stableID))
M internal/usecase/daemon/move_rejection.go +1 -1
@@ -4,8 +4,8 @@ import (
44 "errors"
55
66 "github.com/bnema/vev/internal/domain"
7+ "github.com/bnema/vev/internal/domain/layout"
78 "github.com/bnema/vev/internal/protocol"
8- "github.com/bnema/vev/internal/usecase/layout"
99 )
1010
1111 // moveRejectionReason is the daemon API category for a rejected move. It keeps
M internal/usecase/daemon/move_rejection_test.go +1 -1
@@ -7,8 +7,8 @@ import (
77 "github.com/stretchr/testify/require"
88
99 "github.com/bnema/vev/internal/domain"
10+ "github.com/bnema/vev/internal/domain/layout"
1011 "github.com/bnema/vev/internal/protocol"
11- "github.com/bnema/vev/internal/usecase/layout"
1212 )
1313
1414 func TestMoveRejectionCategoriesPreserveLegacyIdentity(t *testing.T) {
M internal/usecase/daemon/move_resize_fences_test.go +1 -1
@@ -7,7 +7,7 @@ import (
77
88 "github.com/stretchr/testify/require"
99
10- "github.com/bnema/vev/internal/usecase/layout"
10+ "github.com/bnema/vev/internal/domain/layout"
1111 )
1212
1313 func requireMutexLocked(t *testing.T, mu *sync.Mutex, message string) {
M internal/usecase/daemon/move_tab.go +1 -1
@@ -4,7 +4,7 @@ import (
44 "context"
55
66 "github.com/bnema/vev/internal/domain"
7- "github.com/bnema/vev/internal/usecase/layout"
7+ "github.com/bnema/vev/internal/domain/layout"
88 )
99
1010 // moveTabTopology is the private whole-Tab topology Implementation consumed by
M internal/usecase/daemon/move_test.go +2 -2
@@ -8,7 +8,7 @@ import (
88 "github.com/stretchr/testify/require"
99
1010 "github.com/bnema/vev/internal/domain"
11- "github.com/bnema/vev/internal/usecase/layout"
11+ "github.com/bnema/vev/internal/domain/layout"
1212 )
1313
1414 func TestMovePaneMutationSameSession(t *testing.T) {
@@ -142,7 +142,7 @@ func TestMovePaneRetiresSourceParkedClients(t *testing.T) {
142142 movedTab.stableID = "source-tab"
143143
144144 transport := &closeTrackingTransport{}
145- parked := &attachedClient{tr: transport, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}, resumeCapable: true}
145+ parked := &attachedClient{tr: transport, output: newOutputStateStream(), geometry: domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}}, resumeCapable: true}
146146 parked.initOverlays()
147147 require.True(t, d.parkAttachment(source, parked))
148148 destinationPTY := newQuietPTY()
M internal/usecase/daemon/multi_attachment_callers_test.go +1 -1
@@ -5,7 +5,7 @@ import (
55 "testing"
66
77 "github.com/bnema/vev/internal/domain"
8- "github.com/bnema/vev/internal/usecase/layout"
8+ "github.com/bnema/vev/internal/domain/layout"
99 "github.com/stretchr/testify/require"
1010 )
1111
M internal/usecase/daemon/navigation_attachment_handoff_test.go +2 -2
@@ -6,8 +6,8 @@ import (
66 "time"
77
88 "github.com/bnema/vev/internal/domain"
9+ "github.com/bnema/vev/internal/domain/layout"
910 "github.com/bnema/vev/internal/protocol"
10- "github.com/bnema/vev/internal/usecase/layout"
1111 "github.com/bnema/vev/internal/usecase/picker"
1212 "github.com/stretchr/testify/require"
1313 )
@@ -172,7 +172,7 @@ func TestStoppedSessionHandoffDoesNotResumeAfterInitiatorReplacement(t *testing.
172172 t.Fatal("stopped-session handoff did not release its role ticket")
173173 }
174174
175- next := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: old.size}
175+ next := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), geometry: domain.Geometry{Size: old.geometry.Size}}
176176 next.initOverlays()
177177 replacement, err := d.transitionAttachment(attachmentTransitionRequest{
178178 target: source, next: next, expectedTransport: next.transportSnapshot(), ready: true,
M internal/usecase/daemon/navigation_overflow.go +1 -1
@@ -4,7 +4,7 @@ import (
44 "sort"
55
66 "github.com/bnema/vev/internal/domain"
7- "github.com/bnema/vev/internal/usecase/layout"
7+ "github.com/bnema/vev/internal/domain/layout"
88 "github.com/bnema/vev/internal/usecase/picker"
99 )
1010
M internal/usecase/daemon/navigation_overflow_test.go +1 -1
@@ -7,8 +7,8 @@ import (
77 "github.com/stretchr/testify/require"
88
99 "github.com/bnema/vev/internal/domain"
10+ "github.com/bnema/vev/internal/domain/layout"
1011 "github.com/bnema/vev/internal/usecase/keys"
11- "github.com/bnema/vev/internal/usecase/layout"
1212 "github.com/bnema/vev/internal/usecase/picker"
1313 )
1414
M internal/usecase/daemon/nil_pty_test.go +1 -1
@@ -9,8 +9,8 @@ import (
99 "github.com/stretchr/testify/require"
1010
1111 "github.com/bnema/vev/internal/domain"
12+ "github.com/bnema/vev/internal/domain/layout"
1213 "github.com/bnema/vev/internal/protocol"
13- "github.com/bnema/vev/internal/usecase/layout"
1414 )
1515
1616 func TestNilPTYLifecyclePathsDoNotPanic(t *testing.T) {
M internal/usecase/daemon/notice_render_test.go +3 -3
@@ -81,14 +81,14 @@ func containsTopRight(rows []string, text string, minCol int) bool {
8181 func TestPaintComposesNoticeToastTopRightAndExpiresOnTTL(t *testing.T) {
8282 clk := newNoticeClock()
8383 d, sess, ac, sends := newNoticeFixture(t, clk)
84- screen := vt.NewScreen(ac.size.Cols, ac.size.Rows)
84+ screen := vt.NewScreen(ac.geometry.Size.Cols, ac.geometry.Size.Rows)
8585
8686 d.notify(sess, domain.NoticeError, domain.NoticePaneSpawn, "couldn't open pane", nil)
8787 awaitToastCount(t, ac, 1)
8888 replayOutputFrames(t, screen, drainAllFrames(sends))
8989
9090 rows := frameRows(captureTestFrame(screen))
91- if !containsTopRight(rows, "couldn't open pane", ac.size.Cols/2) {
91+ if !containsTopRight(rows, "couldn't open pane", ac.geometry.Size.Cols/2) {
9292 t.Fatalf("composed frame missing toast message top-right:\n%s", strings.Join(rows, "\n"))
9393 }
9494
@@ -102,7 +102,7 @@ func TestPaintComposesNoticeToastTopRightAndExpiresOnTTL(t *testing.T) {
102102 }
103103
104104 rows = frameRows(captureTestFrame(screen))
105- if containsTopRight(rows, "couldn't open pane", ac.size.Cols/2) {
105+ if containsTopRight(rows, "couldn't open pane", ac.geometry.Size.Cols/2) {
106106 t.Fatalf("toast message still present after TTL expired:\n%s", strings.Join(rows, "\n"))
107107 }
108108 }
M internal/usecase/daemon/notify_test.go +5 -5
@@ -278,7 +278,7 @@ func newNoticeFixture(t *testing.T, clk ports.Clock) (*Daemon, *session, *attach
278278 p, _ := newBlockingPTY(t)
279279 d := newTestDaemon(t, nil, clk)
280280 tr, sends := newCapturingTransport(t)
281- ac := &attachedClient{tr: tr, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}}
281+ ac := &attachedClient{tr: tr, output: newOutputStateStream(), geometry: domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}}}
282282 ac.initOverlays()
283283 sctx, cancel := context.WithCancel(d.serveCtx)
284284 wctx, wcancel := context.WithCancel(sctx)
@@ -443,7 +443,7 @@ func TestReportErrorNonBenignIsNeverDropped(t *testing.T) {
443443 func TestNotifyRoutesToSessionClientOnly(t *testing.T) {
444444 d, sess, ac, _ := newNoticeFixture(t, newNoticeClock())
445445
446- other := &attachedClient{output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}}
446+ other := &attachedClient{output: newOutputStateStream(), geometry: domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}}}
447447 other.initOverlays()
448448 sess2 := &session{sessionCore: sessionCore{id: "manual-2", name: "other", attachments: map[*attachedClient]struct{}{other: {}}}, ctx: sess.ctx, cancel: func() {}}
449449 other.setSession(sess2)
@@ -533,7 +533,7 @@ func TestSessionScopedNoticeQueuedWhileDetached(t *testing.T) {
533533 clearAttachmentsForTestLocked(sessA)
534534 sessA.mu.Unlock()
535535
536- clientB := &attachedClient{output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}}
536+ clientB := &attachedClient{output: newOutputStateStream(), geometry: domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}}}
537537 clientB.initOverlays()
538538 sessB := &session{sessionCore: sessionCore{id: "manual-2", name: "other"}, ctx: sessA.ctx, cancel: func() {}}
539539 clientB.setSession(sessB)
@@ -572,7 +572,7 @@ func TestSessionScopedNoticeQueuedWhileDetached(t *testing.T) {
572572 require.Equal(t, "hello from a script", aToasts[0].Message)
573573 require.Equal(t, 1, aToasts[0].Count)
574574
575- secondA := &attachedClient{output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}}
575+ secondA := &attachedClient{output: newOutputStateStream(), geometry: domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}}}
576576 secondA.initOverlays()
577577 secondA.setSession(sessA)
578578 sessA.mu.Lock()
@@ -586,7 +586,7 @@ func TestSessionScopedNoticeQueuedWhileDetached(t *testing.T) {
586586 func TestNotifyGlobalFansOutToAttachedClients(t *testing.T) {
587587 d, sess, ac, _ := newNoticeFixture(t, newNoticeClock())
588588
589- second := &attachedClient{output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}}
589+ second := &attachedClient{output: newOutputStateStream(), geometry: domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}}}
590590 second.initOverlays()
591591 sess2 := &session{sessionCore: sessionCore{id: "manual-2", name: "other", attachments: map[*attachedClient]struct{}{second: {}}}, ctx: sess.ctx, cancel: func() {}}
592592 second.setSession(sess2)
M internal/usecase/daemon/output_state_sync_test.go +2 -2
@@ -358,7 +358,7 @@ func TestResizeWithoutPTYOutputFlushesOneFullFrameAtDeadline(t *testing.T) {
358358 require.Zero(t, out.Base)
359359 require.Contains(t, screenLineText(client, 0), "1")
360360 require.Contains(t, screenLineText(client, 23), "work")
361- require.Equal(t, domain.Size{Cols: 120, Rows: 24}, ac.size)
361+ require.Equal(t, domain.Size{Cols: 120, Rows: 24}, ac.geometry.Size)
362362 requireNoOutputFrame(t, sends)
363363 }
364364
@@ -392,7 +392,7 @@ func TestResizeBurstFlushesOnlyLatestGeometry(t *testing.T) {
392392 latest.ch <- time.Now()
393393 awaitTestCompletion(t, latestDone, "latest resize callback did not complete")
394394 awaitFrame(t, sends, "Output")
395- require.Equal(t, domain.Size{Cols: 120, Rows: 24}, ac.size)
395+ require.Equal(t, domain.Size{Cols: 120, Rows: 24}, ac.geometry.Size)
396396 require.Equal(t, 120, testAttachmentTab(sess).focusedPane().screen.Columns())
397397 requireNoOutputFrame(t, sends)
398398 }
M internal/usecase/daemon/palette.go +1 -1
@@ -7,9 +7,9 @@ import (
77
88 renderer "github.com/bnema/vev-vt"
99 "github.com/bnema/vev/internal/domain"
10+ "github.com/bnema/vev/internal/domain/layout"
1011 "github.com/bnema/vev/internal/protocol"
1112 "github.com/bnema/vev/internal/usecase/command"
12- "github.com/bnema/vev/internal/usecase/layout"
1313 "github.com/bnema/vev/internal/usecase/palette"
1414 "github.com/bnema/vev/internal/usecase/picker"
1515 themeui "github.com/bnema/vev/internal/usecase/theme"
M internal/usecase/daemon/pane.go +1 -1
@@ -7,8 +7,8 @@ import (
77
88 vt "github.com/bnema/vev-vt"
99 "github.com/bnema/vev/internal/domain"
10+ "github.com/bnema/vev/internal/domain/layout"
1011 "github.com/bnema/vev/internal/ports"
11- "github.com/bnema/vev/internal/usecase/layout"
1212 )
1313
1414 // paneProcessLifetime owns the context passed to PTYFactory.Open. Its daemon
M internal/usecase/daemon/pane_actions.go +1 -1
@@ -6,7 +6,7 @@ import (
66 "slices"
77
88 "github.com/bnema/vev/internal/domain"
9- "github.com/bnema/vev/internal/usecase/layout"
9+ "github.com/bnema/vev/internal/domain/layout"
1010 )
1111
1212 type paneFocusChange struct {
M internal/usecase/daemon/pane_actions_test.go +4 -4
@@ -14,11 +14,11 @@ import (
1414
1515 renderer "github.com/bnema/vev-vt"
1616 "github.com/bnema/vev/internal/domain"
17+ "github.com/bnema/vev/internal/domain/layout"
1718 "github.com/bnema/vev/internal/ports"
1819 portsmocks "github.com/bnema/vev/internal/ports/mocks"
1920 "github.com/bnema/vev/internal/protocol/wire"
2021 scopy "github.com/bnema/vev/internal/usecase/copy"
21- "github.com/bnema/vev/internal/usecase/layout"
2222 )
2323
2424 func TestLayoutGenerationMutationBookkeeping(t *testing.T) {
@@ -660,7 +660,7 @@ func TestFocusDirMovesFocusAndExitsCopyMode(t *testing.T) {
660660 tb.panes["pane-1"].rect = domain.Rect{Width: 20, Height: 10}
661661 tb.panes["pane-2"].rect = domain.Rect{X: 21, Width: 20, Height: 10}
662662 tr, _ := newCapturingTransport(t)
663- ac := &attachedClient{tr: tr, output: newOutputStateStream(), size: domain.Size{Cols: 41, Rows: 12}}
663+ ac := &attachedClient{tr: tr, output: newOutputStateStream(), geometry: domain.Geometry{Size: domain.Size{Cols: 41, Rows: 12}}}
664664 ac.initOverlays()
665665 ac.setSession(sess)
666666 require.True(t, sess.registerAttachment(ac))
@@ -699,7 +699,7 @@ func TestClosePaneRepaintFanoutRespectsAttachmentScope(t *testing.T) {
699699 sends := make([]chan wire.Envelope, 2)
700700 for i := range clients {
701701 tr, sent := newCapturingTransport(t)
702- clients[i] = &attachedClient{tr: tr, output: newOutputStateStream(), size: domain.Size{Cols: 41, Rows: 12}}
702+ clients[i] = &attachedClient{tr: tr, output: newOutputStateStream(), geometry: domain.Geometry{Size: domain.Size{Cols: 41, Rows: 12}}}
703703 clients[i].clientID[0] = byte(i + 1)
704704 clients[i].initOverlays()
705705 clients[i].setSession(sess)
@@ -737,7 +737,7 @@ func TestFinalPaneReapClearsCopyModeForEveryAttachment(t *testing.T) {
737737
738738 for i := range 2 {
739739 tr, _ := newCapturingTransport(t)
740- ac := &attachedClient{tr: tr, output: newOutputStateStream(), size: domain.Size{Cols: 41, Rows: 12}}
740+ ac := &attachedClient{tr: tr, output: newOutputStateStream(), geometry: domain.Geometry{Size: domain.Size{Cols: 41, Rows: 12}}}
741741 ac.clientID[0] = byte(i + 1)
742742 ac.initOverlays()
743743 ac.setSession(sess)
M internal/usecase/daemon/pane_lifetime_test.go +1 -1
@@ -12,8 +12,8 @@ import (
1212 "github.com/stretchr/testify/require"
1313
1414 "github.com/bnema/vev/internal/domain"
15+ "github.com/bnema/vev/internal/domain/layout"
1516 "github.com/bnema/vev/internal/ports"
16- "github.com/bnema/vev/internal/usecase/layout"
1717 )
1818
1919 type lifetimePTY struct {
M internal/usecase/daemon/pane_owner_reader_test.go +1 -1
@@ -6,8 +6,8 @@ import (
66 "testing"
77
88 "github.com/bnema/vev/internal/domain"
9+ "github.com/bnema/vev/internal/domain/layout"
910 "github.com/bnema/vev/internal/ports"
10- "github.com/bnema/vev/internal/usecase/layout"
1111 "github.com/stretchr/testify/require"
1212 )
1313
M internal/usecase/daemon/pane_owner_test.go +1 -1
@@ -4,7 +4,7 @@ import (
44 "testing"
55
66 "github.com/bnema/vev/internal/domain"
7- "github.com/bnema/vev/internal/usecase/layout"
7+ "github.com/bnema/vev/internal/domain/layout"
88 "github.com/stretchr/testify/require"
99 )
1010
M internal/usecase/daemon/pane_rearrange.go +1 -1
@@ -4,7 +4,7 @@ import (
44 "errors"
55
66 "github.com/bnema/vev/internal/domain"
7- "github.com/bnema/vev/internal/usecase/layout"
7+ "github.com/bnema/vev/internal/domain/layout"
88 )
99
1010 var errDaemonActionNoChange = errors.New("daemon action made no change")
M internal/usecase/daemon/pane_rearrange_test.go +1 -1
@@ -11,10 +11,10 @@ import (
1111 "github.com/stretchr/testify/require"
1212
1313 "github.com/bnema/vev/internal/domain"
14+ "github.com/bnema/vev/internal/domain/layout"
1415 "github.com/bnema/vev/internal/ports"
1516 portsmocks "github.com/bnema/vev/internal/ports/mocks"
1617 "github.com/bnema/vev/internal/usecase/keys"
17- "github.com/bnema/vev/internal/usecase/layout"
1818 )
1919
2020 func TestMutateTargetLayoutPublishesOnlyChangedCandidates(t *testing.T) {
M internal/usecase/daemon/pane_titles.go +1 -1
@@ -4,7 +4,7 @@ import (
44 "strings"
55 "time"
66
7- "github.com/bnema/vev/internal/usecase/layout"
7+ "github.com/bnema/vev/internal/domain/layout"
88 )
99
1010 const paneTitleCacheTTL = time.Second
M internal/usecase/daemon/performance_benchmark_test.go +7 -7
@@ -14,11 +14,11 @@ import (
1414
1515 renderer "github.com/bnema/vev-vt"
1616 "github.com/bnema/vev/internal/domain"
17+ "github.com/bnema/vev/internal/domain/layout"
1718 "github.com/bnema/vev/internal/ports"
1819 "github.com/bnema/vev/internal/protocol"
1920 "github.com/bnema/vev/internal/protocol/wire"
20- "github.com/bnema/vev/internal/usecase/layout"
21- snapcodec "github.com/bnema/vev/internal/usecase/snapshot"
21+ snapcodec "github.com/bnema/vev/internal/snapshotcodec"
2222 )
2323
2424 func TestCountingSnapshotRepositoryPublishCountsAndRetainsManifest(t *testing.T) {
@@ -265,7 +265,7 @@ func TestPerformanceFixtureResize(t *testing.T) {
265265
266266 for _, want := range sequence {
267267 fixture.resizeTo(want)
268- require.Equal(t, want, fixture.ac.size)
268+ require.Equal(t, want, fixture.ac.geometry.Size)
269269 require.True(t, fixture.resized())
270270 }
271271 metrics := fixture.metrics()
@@ -301,7 +301,7 @@ func TestTransactionalResizeMetrics(t *testing.T) {
301301 require.Equal(t, uint64(3), metrics.outputFrames, "commit, resize-failure notice toast, and successful full-reset retry")
302302 require.Zero(t, metrics.frameGapEpochs, "every committed epoch emitted its required frame")
303303 require.Equal(t, []domain.Size{{Cols: 50, Rows: 28}, {Cols: 50, Rows: 28}}, fixture.pty.requested())
304- require.Equal(t, domain.Size{Cols: 100, Rows: 30}, fixture.ac.size)
304+ require.Equal(t, domain.Size{Cols: 100, Rows: 30}, fixture.ac.geometry.Size)
305305 }
306306
307307 func TestPerformanceFixtureLargeHistoryTopology(t *testing.T) {
@@ -1077,7 +1077,7 @@ func newPerformanceFixtureWithCleanup(t testing.TB, config performanceConfig, re
10771077 d.clock = clock
10781078 output := &countingOutputTransport{}
10791079 ac.tr = output
1080- ac.size = config.size
1080+ ac.geometry.Size = config.size
10811081 sess.name = "performance"
10821082 sess.ephemeral = false
10831083 sess.incarnation = domain.IncarnationID{1}
@@ -1300,7 +1300,7 @@ func (f *performanceFixture) retryLatest() {
13001300 members := make([]resizeMember, 0)
13011301 for _, tb := range tabs {
13021302 tb.mu.Lock()
1303- plan := prepareTabLayoutForSizeLocked(f.sess, tb, tabSize(f.ac.size))
1303+ plan := prepareTabLayoutForSizeLocked(f.sess, tb, tabSize(f.ac.geometry.Size))
13041304 tb.mu.Unlock()
13051305 members = append(members, plan.members...)
13061306 }
@@ -1314,7 +1314,7 @@ func (f *performanceFixture) retryLatest() {
13141314 }
13151315
13161316 func (f *performanceFixture) resized() bool {
1317- if f.ac.size != f.resizedSize {
1317+ if f.ac.geometry.Size != f.resizedSize {
13181318 return false
13191319 }
13201320 for _, tb := range f.sess.tabs {
M internal/usecase/daemon/process_restore.go +1 -1
@@ -5,7 +5,7 @@ import (
55 "strings"
66 "unicode"
77
8- snapcodec "github.com/bnema/vev/internal/usecase/snapshot"
8+ snapcodec "github.com/bnema/vev/internal/snapshotcodec"
99 )
1010
1111 const (
M internal/usecase/daemon/process_restore_test.go +1 -1
@@ -3,7 +3,7 @@ package daemon
33 import (
44 "testing"
55
6- snapcodec "github.com/bnema/vev/internal/usecase/snapshot"
6+ snapcodec "github.com/bnema/vev/internal/snapshotcodec"
77 )
88
99 func TestProcessRestorePlan(t *testing.T) {
M internal/usecase/daemon/purge_test.go +1 -1
@@ -20,8 +20,8 @@ import (
2020 "github.com/bnema/vev/internal/ports"
2121 portsmocks "github.com/bnema/vev/internal/ports/mocks"
2222 "github.com/bnema/vev/internal/protocol"
23+ snapcodec "github.com/bnema/vev/internal/snapshotcodec"
2324 recoveryusecase "github.com/bnema/vev/internal/usecase/recovery"
24- snapcodec "github.com/bnema/vev/internal/usecase/snapshot"
2525 )
2626
2727 // selectivePurgeRepository fails incarnation deletion only for the configured
M internal/usecase/daemon/reduced_color_test.go +2 -2
@@ -11,10 +11,10 @@ import (
1111 renderer "github.com/bnema/vev-vt"
1212 ansirenderer "github.com/bnema/vev-vt/ansi"
1313 "github.com/bnema/vev/internal/domain"
14+ "github.com/bnema/vev/internal/domain/layout"
1415 "github.com/bnema/vev/internal/domain/terminalcap"
1516 "github.com/bnema/vev/internal/protocol"
1617 "github.com/bnema/vev/internal/usecase/colorprofile"
17- "github.com/bnema/vev/internal/usecase/layout"
1818 themeui "github.com/bnema/vev/internal/usecase/theme"
1919 "github.com/bnema/vev/internal/usecase/ui"
2020 )
@@ -219,7 +219,7 @@ func TestAppliedThemeIsPerAttachment(t *testing.T) {
219219 d, sess, ac16, _ := newManualSessionWithPTYs(t, p)
220220 ac16.terminalCapabilities = terminalcap.Capabilities{Color: terminalcap.ColorCapabilities{Mode: terminalcap.ANSI16}}
221221 tr, _ := newCapturingTransport(t)
222- acTrue := &attachedClient{tr: tr, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}}
222+ acTrue := &attachedClient{tr: tr, output: newOutputStateStream(), geometry: domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}}}
223223 acTrue.output.attachment = acTrue
224224 acTrue.initOverlays()
225225 sess.mu.Lock()
M internal/usecase/daemon/render.go +1 -1
@@ -6,9 +6,9 @@ import (
66
77 renderer "github.com/bnema/vev-vt/ansi"
88 "github.com/bnema/vev/internal/domain"
9+ "github.com/bnema/vev/internal/domain/layout"
910 "github.com/bnema/vev/internal/domain/terminalcap"
1011 "github.com/bnema/vev/internal/ports"
11- "github.com/bnema/vev/internal/usecase/layout"
1212 themeui "github.com/bnema/vev/internal/usecase/theme"
1313 )
1414
M internal/usecase/daemon/render_capture.go +1 -1
@@ -4,9 +4,9 @@ import (
44 renderer "github.com/bnema/vev-vt"
55 vevgraphics "github.com/bnema/vev-vt/graphics"
66 "github.com/bnema/vev/internal/domain"
7+ "github.com/bnema/vev/internal/domain/layout"
78 "github.com/bnema/vev/internal/protocol"
89 scopy "github.com/bnema/vev/internal/usecase/copy"
9- "github.com/bnema/vev/internal/usecase/layout"
1010 themeui "github.com/bnema/vev/internal/usecase/theme"
1111 "github.com/bnema/vev/internal/usecase/ui"
1212 )
M internal/usecase/daemon/render_coordinator.go +11 -25
@@ -991,9 +991,6 @@ func (c *renderCoordinator) attachmentAckReady(ac *attachedClient) bool {
991991 if c.opts.ackReadyFor != nil {
992992 return c.opts.ackReadyFor(ac)
993993 }
994- if c.opts.ackReady != nil {
995- return c.opts.ackReady()
996- }
997994 return true
998995 }
999996
@@ -1014,25 +1011,15 @@ func (c *renderCoordinator) fireWithTimerTokenAndLease(token *timerToken, gen ui
10141011 leases[ac] = lease
10151012 }
10161013 }
1017- previewOnly := len(leases) == 0 && len(c.previewWakes) != 0
1018- legacyAckReady := c.opts.ackReady
10191014 c.mu.Unlock()
10201015
10211016 ready := make(map[*attachmentLease]bool, len(leases))
1022- if len(leases) != 0 {
1023- for ac, lease := range leases {
1024- // Welcome holds attachment.sendMu while its transport Send is in
1025- // flight. Do not probe an incarnation before it is ready.
1026- if lease.ready {
1027- ready[lease] = c.attachmentAckReady(ac)
1028- }
1017+ for ac, lease := range leases {
1018+ // Welcome holds attachment.sendMu while its transport Send is in
1019+ // flight. Do not probe an incarnation before it is ready.
1020+ if lease.ready {
1021+ ready[lease] = c.attachmentAckReady(ac)
10291022 }
1030- } else if !previewOnly && legacyAckReady != nil {
1031- // Keep coordinator-only/headless callers compatible; production always
1032- // has attachment leases and uses the per-attachment callback above.
1033- ready[nil] = legacyAckReady()
1034- } else {
1035- ready[nil] = true
10361023 }
10371024
10381025 // The readiness callback can detach, replace, or publish a sync batch.
@@ -1098,12 +1085,11 @@ func (c *renderCoordinator) fireWithTimerTokenAndLease(token *timerToken, gen ui
10981085 }
10991086 c.ackDeferredFor[ac] = true
11001087 }
1101- } else if !ready[nil] {
1102- blockedByAck = true
1103- allDelivered = false
11041088 }
1105- legacyOutput := len(leases) == 0 && ready[nil] && (c.pendingShared || len(c.pendingTargets) == 0)
1106- outputReady := len(attachmentLeases) != 0 || legacyOutput
1089+ // Without any attachment lease (headless sessions and coordinator-only
1090+ // callers) nothing gates output, so a shared invalidation renders directly.
1091+ leaselessOutput := len(leases) == 0 && (c.pendingShared || len(c.pendingTargets) == 0)
1092+ outputReady := len(attachmentLeases) != 0 || leaselessOutput
11071093 if len(leases) != 0 {
11081094 for ac, lease := range leases {
11091095 if !c.pendingShared && len(c.pendingTargets) != 0 {
@@ -1118,7 +1104,7 @@ func (c *renderCoordinator) fireWithTimerTokenAndLease(token *timerToken, gen ui
11181104 }
11191105 }
11201106 }
1121- if len(leases) == 0 && legacyOutput {
1107+ if leaselessOutput {
11221108 allDelivered = true
11231109 }
11241110 wakeGeneration := uint64(0)
@@ -1143,7 +1129,7 @@ func (c *renderCoordinator) fireWithTimerTokenAndLease(token *timerToken, gen ui
11431129 ackStart = newACKBlockedSpan(c.opts.observer)
11441130 c.ackBlocked = ackStart
11451131 }
1146- c.ackDeferred = len(c.ackDeferredFor) != 0 || (len(leases) == 0 && blockedByAck && deadline)
1132+ c.ackDeferred = len(c.ackDeferredFor) != 0
11471133 if !outputReady && !allDelivered {
11481134 c.mu.Unlock()
11491135 stopDetachedTimer(worker)
M internal/usecase/daemon/render_coordinator_handshake_test.go +3 -3
@@ -83,9 +83,9 @@ func TestRenderCoordinatorFanoutDoesNotWaitForSlowTransport(t *testing.T) {
8383 d, sess, healthy, healthySends, _ := newManualTabSession(t, 1)
8484 slowTransport := newFanoutBlockingTransport()
8585 slow := &attachedClient{
86- tr: slowTransport,
87- output: newOutputStateStream(),
88- size: domain.Size{Cols: 80, Rows: 24},
86+ tr: slowTransport,
87+ output: newOutputStateStream(),
88+ geometry: domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}},
8989 }
9090 slow.initOverlays()
9191 slow.setSession(sess)
M internal/usecase/daemon/render_coordinator_test.go +13 -10
@@ -12,12 +12,12 @@ import (
1212 "github.com/stretchr/testify/require"
1313
1414 "github.com/bnema/vev/internal/domain"
15+ "github.com/bnema/vev/internal/domain/layout"
1516 "github.com/bnema/vev/internal/ports"
1617 portsmocks "github.com/bnema/vev/internal/ports/mocks"
1718 "github.com/bnema/vev/internal/protocol"
1819 "github.com/bnema/vev/internal/protocol/wire"
1920 "github.com/bnema/vev/internal/usecase/keys"
20- "github.com/bnema/vev/internal/usecase/layout"
2121 )
2222
2323 // --- coordinator harness ------------------------------------------------------
@@ -97,7 +97,7 @@ func TestRenderCoordinatorDetachDoesNotJoinSelectedDeadlineWorker(t *testing.T)
9797 release := make(chan struct{})
9898 rc := newRenderCoordinator(renderCoordinatorOptions{
9999 clock: clock,
100- ackReady: func() bool {
100+ ackReadyFor: func(*attachedClient) bool {
101101 close(entered)
102102 <-release
103103 return true
@@ -166,10 +166,10 @@ func newCoordinatorHarness(t *testing.T) *coordinatorHarness {
166166 }
167167 h.ackReady.Store(true)
168168 h.rc = newRenderCoordinator(renderCoordinatorOptions{
169- clock: h.clk.clock,
170- wake: func(w renderWake) { h.wakes <- w },
171- ackReady: func() bool { return h.ackReady.Load() },
172- syncActive: func() bool { return h.syncActive.Load() },
169+ clock: h.clk.clock,
170+ wake: func(w renderWake) { h.wakes <- w },
171+ ackReadyFor: func(*attachedClient) bool { return h.ackReady.Load() },
172+ syncActive: func() bool { return h.syncActive.Load() },
173173 })
174174 return h
175175 }
@@ -449,7 +449,7 @@ func TestRenderCoordinatorAckReadinessReentersWithoutBlockingResize(t *testing.T
449449
450450 var rc *renderCoordinator
451451 rc = newRenderCoordinator(renderCoordinatorOptions{
452- ackReady: func() bool {
452+ ackReadyFor: func(*attachedClient) bool {
453453 close(ackEntered)
454454 // The readiness probe models the output send path reading coordinator
455455 // metadata after sendMu is held. It must never run under c.mu.
@@ -496,6 +496,7 @@ func TestRenderCoordinatorAckDoesNotBypassAnUnexpiredDeadline(t *testing.T) {
496496 func TestRenderCoordinatorAckFlushesOnlyExpiredAckDeferredWork(t *testing.T) {
497497 t.Run("expired deadline flushes exactly once after readiness", func(t *testing.T) {
498498 h := newCoordinatorHarness(t)
499+ h.rc.attach(&attachedClient{})
499500 h.ackReady.Store(false)
500501 h.rc.invalidate(renderInvalidation{class: invalidateOutput, reset: true, producer: "render.go"})
501502 timer := awaitCoordinatorScheduledTimer(t, h.clk)
@@ -516,6 +517,7 @@ func TestRenderCoordinatorAckFlushesOnlyExpiredAckDeferredWork(t *testing.T) {
516517
517518 t.Run("lifecycle clears deferred work and urgent explicit fires stay immediate", func(t *testing.T) {
518519 h := newCoordinatorHarness(t)
520+ h.rc.attach(&attachedClient{})
519521 h.ackReady.Store(false)
520522 h.rc.invalidate(renderInvalidation{class: invalidateOutput})
521523 timer := awaitCoordinatorScheduledTimer(t, h.clk)
@@ -536,6 +538,7 @@ func TestRenderCoordinatorAckFlushesOnlyExpiredAckDeferredWork(t *testing.T) {
536538
537539 func TestRenderCoordinatorAckGateBlocksCompositionUntilAck(t *testing.T) {
538540 h := newCoordinatorHarness(t)
541+ h.rc.attach(&attachedClient{})
539542 h.ackReady.Store(false)
540543
541544 h.rc.invalidate(renderInvalidation{class: invalidateOutput})
@@ -1451,9 +1454,9 @@ func TestRenderCoordinatorInertTimerFiresSynchronouslyWithoutWorker(t *testing.T
14511454
14521455 wakes := make(chan renderWake, 1)
14531456 rc := newRenderCoordinator(renderCoordinatorOptions{
1454- clock: clock,
1455- ackReady: func() bool { return true },
1456- wake: func(w renderWake) { wakes <- w },
1457+ clock: clock,
1458+ ackReadyFor: func(*attachedClient) bool { return true },
1459+ wake: func(w renderWake) { wakes <- w },
14571460 })
14581461 rc.invalidate(renderInvalidation{class: invalidateOutput})
14591462
M internal/usecase/daemon/render_coordinator_types.go +3 -5
@@ -105,11 +105,9 @@ type renderCoordinatorOptions struct {
105105 observer ports.RuntimeObserver
106106 // wake composes a coalesced render request.
107107 wake func(renderWake)
108- // ackReady reports whether the attachment may compose another output
109- // state (the attachmentOutput window has capacity). It is retained for
110- // coordinator-only tests and headless callers; production uses ackReadyFor
111- // so one attachment cannot gate another.
112- ackReady func() bool
108+ // ackReadyFor reports whether one attachment may compose another output
109+ // state (its attachmentOutput window has capacity), so one attachment
110+ // cannot gate another. Nil means every attachment is ready.
113111 ackReadyFor func(*attachedClient) bool
114112 // syncRenderable reports whether a pane's synchronized batch currently
115113 // contributes to the attached composition. It must not acquire c.mu.
M internal/usecase/daemon/render_damage_receipt_test.go +1 -1
@@ -5,8 +5,8 @@ import (
55
66 renderer "github.com/bnema/vev-vt"
77 "github.com/bnema/vev/internal/domain"
8+ "github.com/bnema/vev/internal/domain/layout"
89 "github.com/bnema/vev/internal/ports"
9- "github.com/bnema/vev/internal/usecase/layout"
1010 "github.com/stretchr/testify/require"
1111 )
1212
M internal/usecase/daemon/render_divider_geometry_test.go +1 -1
@@ -5,7 +5,7 @@ import (
55
66 renderer "github.com/bnema/vev-vt"
77 "github.com/bnema/vev/internal/domain"
8- "github.com/bnema/vev/internal/usecase/layout"
8+ "github.com/bnema/vev/internal/domain/layout"
99 "github.com/stretchr/testify/require"
1010 )
1111
M internal/usecase/daemon/render_pipeline.go +1 -1
@@ -6,9 +6,9 @@ import (
66
77 renderer "github.com/bnema/vev-vt"
88 "github.com/bnema/vev/internal/domain"
9+ "github.com/bnema/vev/internal/domain/layout"
910 "github.com/bnema/vev/internal/ports"
1011 "github.com/bnema/vev/internal/protocol"
11- "github.com/bnema/vev/internal/usecase/layout"
1212 "github.com/bnema/vev/internal/usecase/notices"
1313 "github.com/bnema/vev/internal/usecase/palette"
1414 "github.com/bnema/vev/internal/usecase/prompt"
M internal/usecase/daemon/render_pipeline_cache_test.go +1 -1
@@ -8,9 +8,9 @@ import (
88
99 renderer "github.com/bnema/vev-vt"
1010 "github.com/bnema/vev/internal/domain"
11+ "github.com/bnema/vev/internal/domain/layout"
1112 "github.com/bnema/vev/internal/protocol"
1213 "github.com/bnema/vev/internal/protocol/wire"
13- "github.com/bnema/vev/internal/usecase/layout"
1414 themeui "github.com/bnema/vev/internal/usecase/theme"
1515 "github.com/bnema/vev/internal/usecase/ui"
1616 "github.com/stretchr/testify/require"
M internal/usecase/daemon/render_pipeline_test.go +1 -1
@@ -14,11 +14,11 @@ import (
1414 vt "github.com/bnema/vev-vt"
1515 renderer "github.com/bnema/vev-vt/ansi"
1616 "github.com/bnema/vev/internal/domain"
17+ "github.com/bnema/vev/internal/domain/layout"
1718 "github.com/bnema/vev/internal/protocol"
1819 "github.com/bnema/vev/internal/protocol/wire"
1920 "github.com/bnema/vev/internal/testutil/replaytest"
2021 scopy "github.com/bnema/vev/internal/usecase/copy"
21- "github.com/bnema/vev/internal/usecase/layout"
2222 "github.com/bnema/vev/internal/usecase/notices"
2323 "github.com/bnema/vev/internal/usecase/palette"
2424 promptui "github.com/bnema/vev/internal/usecase/prompt"
M internal/usecase/daemon/render_test.go +3 -3
@@ -19,11 +19,11 @@ import (
1919 vt "github.com/bnema/vev-vt"
2020 renderer "github.com/bnema/vev-vt/ansi"
2121 "github.com/bnema/vev/internal/domain"
22+ "github.com/bnema/vev/internal/domain/layout"
2223 portsmocks "github.com/bnema/vev/internal/ports/mocks"
2324 "github.com/bnema/vev/internal/protocol"
2425 "github.com/bnema/vev/internal/protocol/wire"
2526 scopy "github.com/bnema/vev/internal/usecase/copy"
26- "github.com/bnema/vev/internal/usecase/layout"
2727 themeui "github.com/bnema/vev/internal/usecase/theme"
2828 )
2929
@@ -73,7 +73,7 @@ func TestFirstPaintRetainedFloatingPaneEmitsOneReset(t *testing.T) {
7373 defer releasePTY()
7474 d, sess, ac, sends := newManualSessionWithPTYs(t, pty)
7575 ac.sendMu.Lock()
76- ac.size = tc.clientSize
76+ ac.geometry.Size = tc.clientSize
7777 ac.sendMu.Unlock()
7878
7979 // A retained visible popup still needs activation warmup. When the
@@ -404,7 +404,7 @@ func TestPTYReaderRepublishesSynchronizedCompletionAfterAttachmentLifecycle(t *t
404404 rc := d.attachCoordinator(target, nil, detached, true)
405405
406406 secondTransport, secondSends := newCapturingTransport(t)
407- remaining := &attachedClient{tr: secondTransport, output: newOutputStateStream(), size: detached.size}
407+ remaining := &attachedClient{tr: secondTransport, output: newOutputStateStream(), geometry: domain.Geometry{Size: detached.geometry.Size}}
408408 remaining.initOverlays()
409409 remaining.setSession(target)
410410 target.mu.Lock()
M internal/usecase/daemon/resize_mode.go +1 -1
@@ -4,9 +4,9 @@ import (
44 "errors"
55
66 "github.com/bnema/vev/internal/domain"
7+ "github.com/bnema/vev/internal/domain/layout"
78 "github.com/bnema/vev/internal/ports"
89 "github.com/bnema/vev/internal/usecase/keys"
9- "github.com/bnema/vev/internal/usecase/layout"
1010 )
1111
1212 const (
M internal/usecase/daemon/resize_mode_test.go +1 -1
@@ -5,8 +5,8 @@ import (
55 "testing"
66
77 "github.com/bnema/vev/internal/domain"
8+ "github.com/bnema/vev/internal/domain/layout"
89 "github.com/bnema/vev/internal/protocol"
9- "github.com/bnema/vev/internal/usecase/layout"
1010 "github.com/stretchr/testify/require"
1111 )
1212
M internal/usecase/daemon/resume_test.go +2 -2
@@ -14,12 +14,12 @@ import (
1414
1515 vt "github.com/bnema/vev-vt"
1616 "github.com/bnema/vev/internal/domain"
17+ "github.com/bnema/vev/internal/domain/layout"
1718 "github.com/bnema/vev/internal/domain/terminalcap"
1819 "github.com/bnema/vev/internal/ports"
1920 portsmocks "github.com/bnema/vev/internal/ports/mocks"
2021 "github.com/bnema/vev/internal/protocol"
2122 "github.com/bnema/vev/internal/protocol/wire"
22- "github.com/bnema/vev/internal/usecase/layout"
2323 )
2424
2525 type closeTrackingTransport struct {
@@ -1247,7 +1247,7 @@ func TestParkingReleasesPaneCapturesBeforeHeadlessCloseAndResume(t *testing.T) {
12471247 require.Len(t, sends, 1)
12481248 output := unmarshalTestOutput(t, sends[0].Payload)
12491249 require.Zero(t, output.Base, "resume must start with a complete frame")
1250- terminal := vt.NewScreen(resumedAC.size.Cols, resumedAC.size.Rows)
1250+ terminal := vt.NewScreen(resumedAC.geometry.Size.Cols, resumedAC.geometry.Size.Rows)
12511251 terminal.Write(output.Data)
12521252 contents := strings.Join(frameRows(terminal), "\n")
12531253 require.Contains(t, contents, "survivor", "resume first paint must contain current headless content")
M internal/usecase/daemon/session.go +1 -7
@@ -17,9 +17,9 @@ import (
1717
1818 vt "github.com/bnema/vev-vt"
1919 "github.com/bnema/vev/internal/domain"
20+ "github.com/bnema/vev/internal/domain/layout"
2021 "github.com/bnema/vev/internal/ports"
2122 "github.com/bnema/vev/internal/protocol"
22- "github.com/bnema/vev/internal/usecase/layout"
2323 )
2424
2525 var (
@@ -2169,12 +2169,6 @@ func (d *Daemon) refreshSessionCwd(sess *session) {
21692169 markSnapshotDirty(sess)
21702170 }
21712171
2172-// childEnv retains the daemon-environment helper for daemon-local legacy callers.
2173-// Interactive PTY launch paths use childEnvFrom with their session snapshot.
2174-func (d *Daemon) childEnv(name, tabStableID, paneStableID string) []string {
2175- return childEnvFrom(d.baseEnv, name, tabStableID, paneStableID)
2176-}
2177-
21782172 func copyEnvironment(env []string) []string {
21792173 return append([]string(nil), env...)
21802174 }
M internal/usecase/daemon/session_env_test.go +1 -1
@@ -5,7 +5,7 @@ import (
55
66 "github.com/bnema/vev/internal/ports"
77 "github.com/bnema/vev/internal/protocol"
8- snapcodec "github.com/bnema/vev/internal/usecase/snapshot"
8+ snapcodec "github.com/bnema/vev/internal/snapshotcodec"
99 "github.com/stretchr/testify/require"
1010 )
1111
M internal/usecase/daemon/session_test.go +7 -7
@@ -1255,7 +1255,7 @@ func TestEphemeralPromotionLifecyclePreventsStaleSameNamePaletteTarget(t *testin
12551255 d.ptys = newFactorySeq(t, firstPTY, secondPTY)
12561256 WithStore(t, store)(d)
12571257
1258- first, err := createSessionForTest(d, "0", true, "/tmp", ac.size, terminalEnv{}, d.baseEnv)
1258+ first, err := createSessionForTest(d, "0", true, "/tmp", ac.geometry.Size, terminalEnv{}, d.baseEnv)
12591259 require.NoError(t, err)
12601260 require.NoError(t, d.renameSession(first, "named"))
12611261 staleCreatedAt := first.createdAt
@@ -1263,7 +1263,7 @@ func TestEphemeralPromotionLifecyclePreventsStaleSameNamePaletteTarget(t *testin
12631263 require.NoError(t, d.killSession(first, protocol.ReasonSessionKilled, true))
12641264 d.sessWg.Wait()
12651265
1266- second, err := createSessionForTest(d, "0", true, "/tmp", ac.size, terminalEnv{}, d.baseEnv)
1266+ second, err := createSessionForTest(d, "0", true, "/tmp", ac.geometry.Size, terminalEnv{}, d.baseEnv)
12671267 require.NoError(t, err)
12681268 require.NoError(t, d.renameSession(second, "named"))
12691269 require.NotEqual(t, staleCreatedAt, second.createdAt)
@@ -1414,7 +1414,7 @@ func TestPickerStoppedTargetKillPurges(t *testing.T) {
14141414 func TestChildEnvEscapesLegacySessionName(t *testing.T) {
14151415 d := newTestDaemon(t, nil, stubClock{})
14161416
1417- got := d.childEnv("legacy,name=value", "t_alpha", "p_beta")
1417+ got := childEnvFrom(d.baseEnv, "legacy,name=value", "t_alpha", "p_beta")
14181418
14191419 require.Contains(t, got, "VEV=session=legacy%2Cname%3Dvalue,tab=t_alpha,pane=p_beta")
14201420 }
@@ -1580,7 +1580,7 @@ func TestChildEnvTrueColorCapability(t *testing.T) {
15801580 d := newTestDaemon(t, nil, stubClock{})
15811581 d.baseEnv = tt.baseEnv
15821582
1583- got := d.childEnv("work", "t_alpha", "p_beta")
1583+ got := childEnvFrom(d.baseEnv, "work", "t_alpha", "p_beta")
15841584
15851585 for _, want := range tt.wantContain {
15861586 require.Contains(t, got, want)
@@ -1629,7 +1629,7 @@ func TestAttachUpdatesFutureChildEnvTrueColor(t *testing.T) {
16291629 require.Contains(t, opens[0], "TERM=xterm-256color")
16301630 require.Contains(t, opens[0], "COLORTERM=truecolor")
16311631 require.Contains(t, opens[0], "TERM_PROGRAM=vev")
1632- require.NoError(t, d.createTab(sess, ac.size))
1632+ require.NoError(t, d.createTab(sess, ac.geometry.Size))
16331633 require.Contains(t, opens[1], "TERM=xterm-256color")
16341634 require.Contains(t, opens[1], "COLORTERM=truecolor")
16351635 require.Contains(t, opens[1], "TERM_PROGRAM=vev")
@@ -1675,7 +1675,7 @@ func TestLiveAttachUpdatesFutureChildEnvTrueColor(t *testing.T) {
16751675
16761676 require.Contains(t, opens[0], "TERM=xterm-256color")
16771677 require.Contains(t, opens[0], "COLORTERM=truecolor")
1678- require.NoError(t, d.createTab(sess, ac.size))
1678+ require.NoError(t, d.createTab(sess, ac.geometry.Size))
16791679 require.Contains(t, opens[1], "TERM=xterm-256color")
16801680 require.Contains(t, opens[1], "COLORTERM=truecolor")
16811681 require.Contains(t, opens[1], "TERM_PROGRAM=vev")
@@ -1792,7 +1792,7 @@ func TestAttachEnvironmentRefreshesOnlySessionBoundFuturePTYInputs(t *testing.T)
17921792 require.Equal(t, "/usr/bin/fish", commands[0])
17931793 require.Equal(t, []string{"SECRET=first", "TERM_PROGRAM_extra=keep", "SHELL=/usr/bin/fish", "A=a=b", "WAYLAND_DISPLAY=wayland-1", "SSH_AUTH_SOCK=/run/first-agent", "TERM=xterm-256color", "COLORTERM=truecolor", "TERM_PROGRAM=vev", "VEV=session=work,tab=" + sess.tabs[0].stableID + ",pane=" + sess.tabs[0].panes["pane-1"].stableID}, envs[0])
17941794 require.Equal(t, "fish", sess.tabs[0].focusedPaneTitle(false), "the initial tab title identifies the shell requested by its creator")
1795- require.NoError(t, d.createTab(sess, ac.size))
1795+ require.NoError(t, d.createTab(sess, ac.geometry.Size))
17961796 // Only session-bound variables follow the attaching client: the X11-only
17971797 // display group replaces the Wayland one, the absent agent socket is
17981798 // removed, and the creator's shell and ordinary variables stay.
M internal/usecase/daemon/snapshot.go +2 -2
@@ -9,8 +9,8 @@ import (
99
1010 vt "github.com/bnema/vev-vt"
1111 "github.com/bnema/vev/internal/domain"
12- "github.com/bnema/vev/internal/usecase/layout"
13- snapcodec "github.com/bnema/vev/internal/usecase/snapshot"
12+ "github.com/bnema/vev/internal/domain/layout"
13+ snapcodec "github.com/bnema/vev/internal/snapshotcodec"
1414 )
1515
1616 // snapshotQueueCapacity bounds retained immutable captures. A full queue never
M internal/usecase/daemon/snapshot_capture.go +1 -1
@@ -1,6 +1,6 @@
11 package daemon
22
3-import snapcodec "github.com/bnema/vev/internal/usecase/snapshot"
3+import snapcodec "github.com/bnema/vev/internal/snapshotcodec"
44
55 // captureSnapshotState copies history tails and recovery transcripts while holding
66 // each pane lock. The returned capture contains only immutable state; encoding
M internal/usecase/daemon/snapshot_incremental.go +1 -1
@@ -6,7 +6,7 @@ import (
66 vt "github.com/bnema/vev-vt"
77 "github.com/bnema/vev/internal/domain"
88 "github.com/bnema/vev/internal/ports"
9- snapcodec "github.com/bnema/vev/internal/usecase/snapshot"
9+ snapcodec "github.com/bnema/vev/internal/snapshotcodec"
1010 )
1111
1212 // snapshotChunkCacheLimit is deliberately small: history chunks remain owned
M internal/usecase/daemon/snapshot_incremental_test.go +1 -1
@@ -10,7 +10,7 @@ import (
1010 vt "github.com/bnema/vev-vt"
1111 renderer "github.com/bnema/vev-vt/ansi"
1212 "github.com/bnema/vev/internal/domain"
13- snapcodec "github.com/bnema/vev/internal/usecase/snapshot"
13+ snapcodec "github.com/bnema/vev/internal/snapshotcodec"
1414 )
1515
1616 func TestMarshalSnapshotTailSelectsCanonicalEncoder(t *testing.T) {
M internal/usecase/daemon/snapshot_restore.go +2 -2
@@ -9,9 +9,9 @@ import (
99
1010 vt "github.com/bnema/vev-vt"
1111 "github.com/bnema/vev/internal/domain"
12+ "github.com/bnema/vev/internal/domain/layout"
1213 "github.com/bnema/vev/internal/protocol"
13- "github.com/bnema/vev/internal/usecase/layout"
14- snapcodec "github.com/bnema/vev/internal/usecase/snapshot"
14+ snapcodec "github.com/bnema/vev/internal/snapshotcodec"
1515 )
1616
1717 func (d *Daemon) closeRestoreDone() {
M internal/usecase/daemon/snapshot_restore_fixture_test.go +1 -1
@@ -5,7 +5,7 @@ import (
55
66 "github.com/bnema/vev/internal/domain"
77 "github.com/bnema/vev/internal/ports"
8- snapcodec "github.com/bnema/vev/internal/usecase/snapshot"
8+ snapcodec "github.com/bnema/vev/internal/snapshotcodec"
99 )
1010
1111 func acceptancePublication(snapshot snapcodec.Session) (ports.SnapshotPublication, error) {
M internal/usecase/daemon/snapshot_restore_incremental.go +1 -1
@@ -12,7 +12,7 @@ import (
1212 "github.com/bnema/vev/internal/domain"
1313 "github.com/bnema/vev/internal/ports"
1414 "github.com/bnema/vev/internal/protocol"
15- snapcodec "github.com/bnema/vev/internal/usecase/snapshot"
15+ snapcodec "github.com/bnema/vev/internal/snapshotcodec"
1616 )
1717
1818 const catalogueRestoreConcurrency = 8
M internal/usecase/daemon/snapshot_restore_test.go +2 -2
@@ -14,11 +14,11 @@ import (
1414 vt "github.com/bnema/vev-vt"
1515 renderer "github.com/bnema/vev-vt/ansi"
1616 "github.com/bnema/vev/internal/domain"
17+ "github.com/bnema/vev/internal/domain/layout"
1718 "github.com/bnema/vev/internal/ports"
1819 portsmocks "github.com/bnema/vev/internal/ports/mocks"
19- "github.com/bnema/vev/internal/usecase/layout"
20+ snapcodec "github.com/bnema/vev/internal/snapshotcodec"
2021 recoveryusecase "github.com/bnema/vev/internal/usecase/recovery"
21- snapcodec "github.com/bnema/vev/internal/usecase/snapshot"
2222 )
2323
2424 type snapshotAcceptanceRepository struct {
M internal/usecase/daemon/status_test.go +1 -1
@@ -13,10 +13,10 @@ import (
1313
1414 renderer "github.com/bnema/vev-vt/ansi"
1515 "github.com/bnema/vev/internal/domain"
16+ "github.com/bnema/vev/internal/domain/layout"
1617 "github.com/bnema/vev/internal/protocol"
1718 "github.com/bnema/vev/internal/protocol/wire"
1819 "github.com/bnema/vev/internal/usecase/command"
19- "github.com/bnema/vev/internal/usecase/layout"
2020 "github.com/bnema/vev/internal/usecase/palette"
2121 themeui "github.com/bnema/vev/internal/usecase/theme"
2222 "github.com/bnema/vev/internal/usecase/ui"
M internal/usecase/daemon/theme_cache_test.go +1 -1
@@ -7,8 +7,8 @@ import (
77
88 renderer "github.com/bnema/vev-vt"
99 "github.com/bnema/vev/internal/domain"
10+ "github.com/bnema/vev/internal/domain/layout"
1011 "github.com/bnema/vev/internal/protocol"
11- "github.com/bnema/vev/internal/usecase/layout"
1212 themeui "github.com/bnema/vev/internal/usecase/theme"
1313 )
1414
M internal/usecase/daemon/title_cache_regression_test.go +1 -1
@@ -7,7 +7,7 @@ import (
77 "github.com/stretchr/testify/require"
88
99 "github.com/bnema/vev/internal/domain"
10- "github.com/bnema/vev/internal/usecase/layout"
10+ "github.com/bnema/vev/internal/domain/layout"
1111 )
1212
1313 func TestComposeFramePrunesTitleGenerationsAfterLayoutChurn(t *testing.T) {
M internal/usecase/daemon/toast_damage_test.go +1 -1
@@ -6,7 +6,7 @@ import (
66 vt "github.com/bnema/vev-vt"
77 renderer "github.com/bnema/vev-vt/ansi"
88 "github.com/bnema/vev/internal/domain"
9- "github.com/bnema/vev/internal/usecase/layout"
9+ "github.com/bnema/vev/internal/domain/layout"
1010 "github.com/stretchr/testify/require"
1111 )
1212
M internal/usecase/daemon/transactional_resize.go +1 -1
@@ -5,8 +5,8 @@ import (
55
66 vt "github.com/bnema/vev-vt"
77 "github.com/bnema/vev/internal/domain"
8+ "github.com/bnema/vev/internal/domain/layout"
89 "github.com/bnema/vev/internal/ports"
9- "github.com/bnema/vev/internal/usecase/layout"
1010 )
1111
1212 type resizeOwnerPostEffect uint8
M internal/usecase/daemon/transactional_resize_test.go +3 -3
@@ -11,8 +11,8 @@ import (
1111 "github.com/stretchr/testify/require"
1212
1313 "github.com/bnema/vev/internal/domain"
14+ "github.com/bnema/vev/internal/domain/layout"
1415 "github.com/bnema/vev/internal/ports"
15- "github.com/bnema/vev/internal/usecase/layout"
1616 "github.com/bnema/vev/internal/usecase/ui"
1717 "github.com/bnema/vev/internal/usecase/visualsearch"
1818 )
@@ -474,7 +474,7 @@ func TestTransactionalResizeRechecksLeaseAtAttachmentPublication(t *testing.T) {
474474 lease := rc.attachmentLease(ac)
475475 epoch := rc.recordResizeRequestForLease(domain.Size{Cols: 100, Rows: 30}, ac, lease)
476476 require.NotZero(t, epoch)
477- initialSize := ac.size
477+ initialSize := ac.geometry.Size
478478
479479 entered := make(chan struct{})
480480 release := make(chan struct{})
@@ -492,7 +492,7 @@ func TestTransactionalResizeRechecksLeaseAtAttachmentPublication(t *testing.T) {
492492 require.NotZero(t, newer)
493493 close(release)
494494 require.False(t, <-done, "stale resize generation must be rejected")
495- require.Equal(t, initialSize, ac.size, "stale resize must not publish attachment size")
495+ require.Equal(t, initialSize, ac.geometry.Size, "stale resize must not publish attachment size")
496496 if tc.effect == resizeOwnerPostSnapshotDirty {
497497 require.False(t, sess.snapDirty.Load(), "stale resize must not publish snapshot dirtiness")
498498 }
M internal/usecase/daemon/transport_observability_test.go +10 -9
@@ -188,8 +188,8 @@ func TestTransportObservabilityACKBlockedSpansEndExactlyOnce(t *testing.T) {
188188 observer := &daemonRuntimeObserver{}
189189 ready := false
190190 rc := newRenderCoordinator(renderCoordinatorOptions{
191- observer: observer,
192- ackReady: func() bool { return ready },
191+ observer: observer,
192+ ackReadyFor: func(*attachedClient) bool { return ready },
193193 })
194194 rc.attach(&attachedClient{})
195195 rc.invalidate(renderInvalidation{class: invalidateOutput})
@@ -233,9 +233,9 @@ func TestTransportObservabilityBlockedACKEndDoesNotDelayNotifyAck(t *testing.T)
233233 wakes := make(chan renderWake, 1)
234234 var ready atomic.Bool
235235 rc := newRenderCoordinator(renderCoordinatorOptions{
236- observer: d.runtimeObserver,
237- ackReady: ready.Load,
238- wake: func(w renderWake) { wakes <- w },
236+ observer: d.runtimeObserver,
237+ ackReadyFor: func(*attachedClient) bool { return ready.Load() },
238+ wake: func(w renderWake) { wakes <- w },
239239 })
240240 rc.attach(&attachedClient{})
241241 rc.invalidate(renderInvalidation{class: invalidateOutput})
@@ -284,8 +284,8 @@ func (o *blockingACKEndObserver) ObserveRuntime(mark ports.RuntimeMark) {
284284 func TestTransportObservabilityBlockedCoordinatorObserverReleasesLock(t *testing.T) {
285285 observer := newBlockingDaemonRuntimeObserver()
286286 rc := newRenderCoordinator(renderCoordinatorOptions{
287- observer: observer,
288- ackReady: func() bool { return false },
287+ observer: observer,
288+ ackReadyFor: func(*attachedClient) bool { return false },
289289 })
290290 rc.attach(&attachedClient{})
291291 rc.mu.Lock()
@@ -347,9 +347,10 @@ func TestTransportObservabilityACKBlockedStartPublishesBeforeEnd(t *testing.T) {
347347 observer := newACKSpanInterleavingObserver()
348348 ready := false
349349 rc := newRenderCoordinator(renderCoordinatorOptions{
350- observer: observer,
351- ackReady: func() bool { return ready },
350+ observer: observer,
351+ ackReadyFor: func(*attachedClient) bool { return ready },
352352 })
353+ rc.attach(&attachedClient{})
353354 rc.mu.Lock()
354355 rc.pending = true
355356 gen := rc.normalLane.generation
A internal/usecase/fuzzy/fuzzy.go +91 -0
@@ -0,0 +1,91 @@
1+// Package fuzzy scores one already case-folded text against a folded query.
2+//
3+// A field matches exactly, by prefix, or as an in-order rune subsequence.
4+// Callers map each Kind to their own rank and order matches with Less.
5+package fuzzy
6+
7+import "strings"
8+
9+// Kind is how a field matched the query.
10+type Kind uint8
11+
12+const (
13+ Exact Kind = iota
14+ Prefix
15+ Subsequence
16+)
17+
18+// Score is one field match. Positions are rune indexes into the text.
19+type Score struct {
20+ Kind Kind
21+ Positions []int
22+ // Span is the rune distance from the first to the last matched position.
23+ Span int
24+ First int
25+}
26+
27+// Match scores folded text against query, whose runes are needle. An empty
28+// needle matches as Exact with no positions.
29+func Match(text, query string, needle []rune) (Score, bool) {
30+ if len(needle) == 0 {
31+ return Score{Kind: Exact}, true
32+ }
33+ var score Score
34+ switch {
35+ case text == query:
36+ score = Score{Kind: Exact, Positions: Range(len(needle))}
37+ case strings.HasPrefix(text, query):
38+ score = Score{Kind: Prefix, Positions: Range(len(needle))}
39+ default:
40+ positions, ok := SubsequencePositions([]rune(text), needle)
41+ if !ok {
42+ return Score{}, false
43+ }
44+ score = Score{Kind: Subsequence, Positions: positions}
45+ }
46+ score.Span = score.Positions[len(score.Positions)-1] - score.Positions[0] + 1
47+ score.First = score.Positions[0]
48+ return score, true
49+}
50+
51+// Less orders two scores whose ranks the caller derived from their Kind:
52+// lower rank, then tighter span, then earlier first position.
53+func Less(leftRank int, left Score, rightRank int, right Score) bool {
54+ if leftRank != rightRank {
55+ return leftRank < rightRank
56+ }
57+ if left.Span != right.Span {
58+ return left.Span < right.Span
59+ }
60+ return left.First < right.First
61+}
62+
63+// Range returns the positions 0..n-1.
64+func Range(n int) []int {
65+ positions := make([]int, n)
66+ for i := range positions {
67+ positions[i] = i
68+ }
69+ return positions
70+}
71+
72+// SubsequencePositions returns the first in-order positions of needle in
73+// haystack.
74+func SubsequencePositions(haystack, needle []rune) ([]int, bool) {
75+ if len(needle) == 0 {
76+ return nil, true
77+ }
78+ positions := make([]int, 0, len(needle))
79+ next := 0
80+ for idx, r := range haystack {
81+ if r != needle[next] {
82+ continue
83+ }
84+ positions = append(positions, idx)
85+ next++
86+ if next == len(needle) {
87+ return positions, true
88+ }
89+ }
90+ return nil, false
91+}
A internal/usecase/fuzzy/fuzzy_test.go +55 -0
@@ -0,0 +1,55 @@
1+package fuzzy
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/require"
7+)
8+
9+func TestMatch(t *testing.T) {
10+ tests := []struct {
11+ name string
12+ text string
13+ query string
14+ want Score
15+ ok bool
16+ }{
17+ {name: "empty query", text: "work", query: "", want: Score{Kind: Exact}, ok: true},
18+ {name: "exact", text: "work", query: "work", want: Score{Kind: Exact, Positions: []int{0, 1, 2, 3}, Span: 4}, ok: true},
19+ {name: "prefix", text: "workspace", query: "wor", want: Score{Kind: Prefix, Positions: []int{0, 1, 2}, Span: 3}, ok: true},
20+ {name: "subsequence", text: "a-b-c", query: "bc", want: Score{Kind: Subsequence, Positions: []int{2, 4}, Span: 3, First: 2}, ok: true},
21+ {name: "runes not bytes", text: "été-x", query: "x", want: Score{Kind: Subsequence, Positions: []int{4}, Span: 1, First: 4}, ok: true},
22+ {name: "no match", text: "work", query: "z", ok: false},
23+ {name: "out of order", text: "ab", query: "ba", ok: false},
24+ }
25+ for _, tt := range tests {
26+ t.Run(tt.name, func(t *testing.T) {
27+ got, ok := Match(tt.text, tt.query, []rune(tt.query))
28+ require.Equal(t, tt.ok, ok)
29+ require.Equal(t, tt.want, got)
30+ })
31+ }
32+}
33+
34+func TestLess(t *testing.T) {
35+ tests := []struct {
36+ name string
37+ leftRank int
38+ left Score
39+ rightRank int
40+ right Score
41+ wantLess bool
42+ wantGreater bool
43+ }{
44+ {name: "rank wins", leftRank: 0, left: Score{Span: 9}, rightRank: 1, right: Score{Span: 1}, wantLess: true},
45+ {name: "span breaks rank tie", left: Score{Span: 2, First: 5}, right: Score{Span: 3}, wantLess: true},
46+ {name: "first breaks span tie", left: Score{Span: 2, First: 1}, right: Score{Span: 2, First: 3}, wantLess: true},
47+ {name: "equal", left: Score{Span: 2}, right: Score{Span: 2}},
48+ }
49+ for _, tt := range tests {
50+ t.Run(tt.name, func(t *testing.T) {
51+ require.Equal(t, tt.wantLess, Less(tt.leftRank, tt.left, tt.rightRank, tt.right))
52+ require.Equal(t, tt.wantGreater, Less(tt.rightRank, tt.right, tt.leftRank, tt.left))
53+ })
54+ }
55+}
M internal/usecase/palette/fuzzy.go +12 -49
@@ -3,7 +3,8 @@ package palette
33 import (
44 "sort"
55 "strings"
6- "unicode/utf8"
6+
7+ "github.com/bnema/vev/internal/usecase/fuzzy"
78 )
89
910 // Match is a fuzzy-matched immutable palette result.
@@ -85,7 +86,7 @@ func score(result Result, needle string, needleRunes []rune, order int) (Match,
8586 return match, true
8687 }
8788 if cmd, ok := result.Command(); ok {
88- if positions, ok := subsequencePositions([]rune(strings.ToLower(cmd.Desc)), needleRunes); ok {
89+ if positions, ok := fuzzy.SubsequencePositions([]rune(strings.ToLower(cmd.Desc)), needleRunes); ok {
8990 match.rank, match.span, match.first = 5, positions[len(positions)-1]-positions[0]+1, positions[0]
9091 return match, true
9192 }
@@ -101,25 +102,19 @@ type fieldScore struct {
101102 }
102103
103104 func scoreField(text, needle string, needleRunes []rune, displayOffset, exactRank, prefixRank, subsequenceRank int) (fieldScore, bool) {
104- text = strings.ToLower(text)
105- var scored fieldScore
106- switch {
107- case text == needle:
108- scored.positions = rangePositions(utf8.RuneCountInString(text))
105+ matched, ok := fuzzy.Match(strings.ToLower(text), needle, needleRunes)
106+ if !ok {
107+ return fieldScore{}, false
108+ }
109+ scored := fieldScore{positions: matched.Positions, span: matched.Span, first: matched.First}
110+ switch matched.Kind {
111+ case fuzzy.Exact:
109112 scored.rank = exactRank
110- case strings.HasPrefix(text, needle):
111- scored.positions = rangePositions(utf8.RuneCountInString(needle))
113+ case fuzzy.Prefix:
112114 scored.rank = prefixRank
113115 default:
114- positions, ok := subsequencePositions([]rune(text), needleRunes)
115- if !ok {
116- return fieldScore{}, false
117- }
118- scored.positions = positions
119116 scored.rank = subsequenceRank
120117 }
121- scored.span = scored.positions[len(scored.positions)-1] - scored.positions[0] + 1
122- scored.first = scored.positions[0]
123118 for i := range scored.positions {
124119 scored.positions[i] += displayOffset
125120 }
@@ -127,13 +122,7 @@ func scoreField(text, needle string, needleRunes []rune, displayOffset, exactRan
127122 }
128123
129124 func (s fieldScore) betterThan(other fieldScore) bool {
130- if s.rank != other.rank {
131- return s.rank < other.rank
132- }
133- if s.span != other.span {
134- return s.span < other.span
135- }
136- return s.first < other.first
125+ return fuzzy.Less(s.rank, fuzzy.Score{Span: s.span, First: s.first}, other.rank, fuzzy.Score{Span: other.span, First: other.first})
137126 }
138127
139128 func (s fieldScore) apply(match *Match) {
@@ -146,29 +135,3 @@ func (s fieldScore) apply(match *Match) {
146135 func newMatch(result Result, order int) Match {
147136 return Match{Result: result, order: order, search: strings.ToLower(result.SearchText())}
148137 }
149-
150-func rangePositions(n int) []int {
151- positions := make([]int, n)
152- for i := range positions {
153- positions[i] = i
154- }
155- return positions
156-}
157-
158-func subsequencePositions(haystack, needle []rune) ([]int, bool) {
159- if len(needle) == 0 {
160- return nil, true
161- }
162- positions := make([]int, 0, len(needle))
163- j := 0
164- for i, r := range haystack {
165- if r == needle[j] {
166- positions = append(positions, i)
167- j++
168- if j == len(needle) {
169- return positions, true
170- }
171- }
172- }
173- return nil, false
174-}
M internal/usecase/picker/search.go +5 -48
@@ -6,6 +6,7 @@ import (
66 "unicode/utf8"
77
88 renderer "github.com/bnema/vev-vt"
9+ "github.com/bnema/vev/internal/usecase/fuzzy"
910 )
1011
1112 const MaxSearchQueryRunes = 256
@@ -282,60 +283,16 @@ func matchRow(row row, query string, needleRunes []rune) (searchMatch, bool) {
282283 }
283284
284285 func scoreField(field matchField, text, query string, needle []rune) (fieldMatch, bool) {
285- if len(needle) == 0 {
286- return fieldMatch{field: field}, true
287- }
288- if text == query {
289- positions := rangeRunePositions(len(needle))
290- return fieldMatch{field: field, positions: positions, rank: 0, span: len(positions)}, true
291- }
292- if strings.HasPrefix(text, query) {
293- positions := rangeRunePositions(len(needle))
294- return fieldMatch{field: field, positions: positions, rank: 1, span: len(positions)}, true
295- }
296- positions, ok := subsequenceRunePositions([]rune(text), needle)
286+ matched, ok := fuzzy.Match(text, query, needle)
297287 if !ok {
298288 return fieldMatch{}, false
299289 }
300- return fieldMatch{field: field, positions: positions, rank: 2, span: positions[len(positions)-1] - positions[0] + 1, first: positions[0]}, true
290+ return fieldMatch{field: field, positions: matched.Positions, rank: int(matched.Kind), span: matched.Span, first: matched.First}, true
301291 }
302292
303293 func lessFieldMatch(left, right fieldMatch) bool {
304- if left.rank != right.rank {
305- return left.rank < right.rank
306- }
307- if left.span != right.span {
308- return left.span < right.span
309- }
310- if left.first != right.first {
311- return left.first < right.first
294+ if left.rank != right.rank || left.span != right.span || left.first != right.first {
295+ return fuzzy.Less(left.rank, fuzzy.Score{Span: left.span, First: left.first}, right.rank, fuzzy.Score{Span: right.span, First: right.first})
312296 }
313297 return left.field < right.field
314298 }
315-
316-func rangeRunePositions(length int) []int {
317- positions := make([]int, length)
318- for i := range positions {
319- positions[i] = i
320- }
321- return positions
322-}
323-
324-func subsequenceRunePositions(haystack, needle []rune) ([]int, bool) {
325- if len(needle) == 0 {
326- return nil, true
327- }
328- positions := make([]int, 0, len(needle))
329- next := 0
330- for idx, r := range haystack {
331- if r != needle[next] {
332- continue
333- }
334- positions = append(positions, idx)
335- next++
336- if next == len(needle) {
337- return positions, true
338- }
339- }
340- return nil, false
341-}

Keyboard shortcuts

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

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

Branches 12

Tags 11

↑↓ moveenter openesc close