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
31 The exhaustive production matrix and separate test-import policy are enforced by `boundary_test.go`; package ownership is documented in `docs/architecture.md`.31 The exhaustive production matrix and separate test-import policy are enforced by `boundary_test.go`; package ownership is documented in `docs/architecture.md`.
32 32
33 - `pkg/` is reusable and never imports `internal/`.33 - `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.
35 - Production use cases never import `internal/protocol/wire`, concrete adapters, `internal/app`, `internal/persist`, or `internal/platform`.35 - Production use cases never import `internal/protocol/wire`, concrete adapters, `internal/app`, `internal/persist`, or `internal/platform`.
36 - `internal/ports` owns application seams, not codecs, raw frames, environment policy, or worker implementations.36 - `internal/ports` owns application seams, not codecs, raw frames, environment policy, or worker implementations.
37 37
38 Layer map:38 Layer map:
39 39
40 - `main.go` → `internal/app`: CLI parsing, composition, daemon startup, and hidden subcommands.40 - `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.
42 - `internal/protocol`: typed session messages; `protocol/catalogue` owns remote JSON schema; `protocol/wire` owns binary encoding and raw carriage contracts.43 - `internal/protocol`: typed session messages; `protocol/catalogue` owns remote JSON schema; `protocol/wire` owns binary encoding and raw carriage contracts.
43 - `internal/ports`: typed application connections and infrastructure-facing use-case seams.44 - `internal/ports`: typed application connections and infrastructure-facing use-case seams.
44 - `internal/adapters/sessionwire`: typed message ↔ raw frame adaptation.45 - `internal/adapters/sessionwire`: typed message ↔ raw frame adaptation.
M boundary_test.go +18 -7
@@ -30,6 +30,7 @@ const (
30 layerWire packageLayer = "wire"30 layerWire packageLayer = "wire"
31 layerDomain packageLayer = "domain"31 layerDomain packageLayer = "domain"
32 layerPersist packageLayer = "persist"32 layerPersist packageLayer = "persist"
33+ layerSnapshot packageLayer = "snapshot-codec"
33 layerPlatform packageLayer = "platform"34 layerPlatform packageLayer = "platform"
34 layerLogging packageLayer = "logging"35 layerLogging packageLayer = "logging"
35 layerPkg packageLayer = "pkg"36 layerPkg packageLayer = "pkg"
@@ -40,15 +41,16 @@ var productionDependencies = map[packageLayer]map[packageLayer]bool{
40 layerRoot: {layerApp: true},41 layerRoot: {layerApp: true},
41 layerCommand: {layerPkg: true},42 layerCommand: {layerPkg: true},
42 layerScript: {layerAdapter: true, layerDomain: true, layerPorts: true, layerProtocol: true, layerCatalogue: true, layerWire: true, layerPkg: true},43 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+ 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},
44- layerAdapter: {layerAdapter: true, layerDomain: true, layerPorts: true, layerProtocol: true, layerCatalogue: true, layerWire: true, layerPlatform: true, layerPkg: true},45+ layerAdapter: {layerAdapter: true, layerDomain: true, layerPorts: true, layerProtocol: true, layerCatalogue: true, layerWire: true, layerSnapshot: true, layerPlatform: true, layerPkg: true},
45- layerUsecase: {layerUsecase: true, layerDomain: true, layerPorts: true, layerProtocol: true, layerCatalogue: true},46+ layerUsecase: {layerUsecase: true, layerDomain: true, layerPorts: true, layerProtocol: true, layerCatalogue: true, layerSnapshot: true},
46 layerPorts: {layerDomain: true, layerProtocol: true, layerCatalogue: true},47 layerPorts: {layerDomain: true, layerProtocol: true, layerCatalogue: true},
47 layerProtocol: {layerDomain: true},48 layerProtocol: {layerDomain: true},
48 layerCatalogue: {layerDomain: true, layerProtocol: true},49 layerCatalogue: {layerDomain: true, layerProtocol: true},
49 layerWire: {layerDomain: true, layerProtocol: true},50 layerWire: {layerDomain: true, layerProtocol: true},
50 layerDomain: {layerDomain: true},51 layerDomain: {layerDomain: true},
51 layerPersist: {layerDomain: true, layerPorts: true, layerProtocol: true, layerPkg: true},52 layerPersist: {layerDomain: true, layerPorts: true, layerProtocol: true, layerPkg: true},
53+ layerSnapshot: {layerDomain: true, layerPorts: true},
52 layerPlatform: {},54 layerPlatform: {},
53 layerLogging: {layerPkg: true},55 layerLogging: {layerPkg: true},
54 layerPkg: {layerPkg: true},56 layerPkg: {layerPkg: true},
@@ -91,7 +93,12 @@ func TestImportBoundaryNegativeFixtures(t *testing.T) {
91 {"usecase rejects adapter", modulePath + "/internal/usecase/client", modulePath + "/internal/adapters/ipc", false, false},93 {"usecase rejects adapter", modulePath + "/internal/usecase/client", modulePath + "/internal/adapters/ipc", false, false},
92 {"domain rejects ports", modulePath + "/internal/domain", modulePath + "/internal/ports", false, false},94 {"domain rejects ports", modulePath + "/internal/domain", modulePath + "/internal/ports", false, false},
93 {"pkg rejects internal", modulePath + "/pkg/rawterm", modulePath + "/internal/domain", false, false},95 {"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},
95 {"other adapter rejects usecase", modulePath + "/internal/adapters/ipc", modulePath + "/internal/usecase/client", false, false},102 {"other adapter rejects usecase", modulePath + "/internal/adapters/ipc", modulePath + "/internal/usecase/client", false, false},
96 {"usecase test may use wire fixture", modulePath + "/internal/usecase/client", modulePath + "/internal/protocol/wire", true, true},103 {"usecase test may use wire fixture", modulePath + "/internal/usecase/client", modulePath + "/internal/protocol/wire", true, true},
97 {"pkg test still rejects internal", modulePath + "/pkg/rawterm", modulePath + "/internal/domain", true, false},104 {"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) {
201 if sourceLayer == layerTestSupport {208 if sourceLayer == layerTestSupport {
202 return testSupportDependencyAllowed(source, targetLayer), nil209 return testSupportDependencyAllowed(source, targetLayer), nil
203 }210 }
204- if source == modulePath+"/internal/adapters/snapshot" && target == modulePath+"/internal/usecase/snapshot" {
205- return true, nil
206- }
207 if packageImportDenied(source, target) {211 if packageImportDenied(source, target) {
208 return false, nil212 return false, nil
209 }213 }
@@ -222,6 +226,11 @@ func packageImportDenied(source, target string) bool {
222 under := func(path, root string) bool {226 under := func(path, root string) bool {
223 return path == root || strings.HasPrefix(path, root+"/")227 return path == root || strings.HasPrefix(path, root+"/")
224 }228 }
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+ }
225 switch {234 switch {
226 case under(source, brokerPkg):235 case under(source, brokerPkg):
227 return !under(target, brokerPkg) && strings.HasPrefix(target, usecasePrefix)236 return !under(target, brokerPkg) && strings.HasPrefix(target, usecasePrefix)
@@ -275,6 +284,8 @@ func classifyPackage(path string) (packageLayer, error) {
275 return layerUsecase, nil284 return layerUsecase, nil
276 case path == modulePath+"/internal/persist":285 case path == modulePath+"/internal/persist":
277 return layerPersist, nil286 return layerPersist, nil
287+ case path == modulePath+"/internal/snapshotcodec":
288+ return layerSnapshot, nil
278 case path == modulePath+"/internal/platform":289 case path == modulePath+"/internal/platform":
279 return layerPlatform, nil290 return layerPlatform, nil
280 case path == modulePath+"/internal/logging":291 case path == modulePath+"/internal/logging":
M docs/architecture.md +3 -2
@@ -14,7 +14,7 @@ Every result keeps its request ID for strict correlation.
14 14
15 ## Package ownership15 ## Package ownership
16 16
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.
18 - `internal/protocol`: typed, transport-neutral client/daemon messages, protocol version, semantic validation, and handshake policy.18 - `internal/protocol`: typed, transport-neutral client/daemon messages, protocol version, semantic validation, and handshake policy.
19 - `internal/protocol/catalogue`: independently versioned remote discovery JSON schema and bounded validation.19 - `internal/protocol/catalogue`: independently versioned remote discovery JSON schema and bounded validation.
20 - `internal/protocol/wire`: Protobuf wire contract. Sources of truth are20 - `internal/protocol/wire`: Protobuf wire contract. Sources of truth are
@@ -25,6 +25,7 @@ Every result keeps its request ID for strict correlation.
25 envelope, no type byte).25 envelope, no type byte).
26 - `internal/ports`: application-facing interfaces and the values required by those interfaces. It contains no codecs, raw frames, environment policy, or worker implementations.26 - `internal/ports`: application-facing interfaces and the values required by those interfaces. It contains no codecs, raw frames, environment policy, or worker implementations.
27 - `internal/usecase/colorprofile`: the single `terminalcap.ColorCapabilities` → vev-vt color profile mapping shared by the client picker and daemon output renderer.27 - `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.
28 - `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.29 - `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.
29 - `internal/adapters/sessionwire`: translates between typed session30 - `internal/adapters/sessionwire`: translates between typed session
30 connections and raw wire transports: directional `oneof` envelope31 connections and raw wire transports: directional `oneof` envelope
@@ -59,7 +60,7 @@ main → app → usecase → ports
59 carriage adapters60 carriage adapters
60 ```61 ```
61 62
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`.
63 64
64 ## UI observation and control65 ## UI observation and control
65 66
M internal/adapters/sessionwire/client.go +3 -116
@@ -1,9 +1,7 @@
1 package sessionwire1 package sessionwire
2 2
3 import (3 import (
4- "context"
5 "errors"4 "errors"
6- "sync"
7 "time"5 "time"
8 6
9 "google.golang.org/protobuf/proto"7 "google.golang.org/protobuf/proto"
@@ -14,27 +12,7 @@ import (
14 )12 )
15 13
16 type clientConnection struct {14 type clientConnection struct {
17- raw wire.Transport15+ handshake
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()
38 }16 }
39 17
40 var _ ports.ClientConnection = (*clientConnection)(nil)18 var _ ports.ClientConnection = (*clientConnection)(nil)
@@ -46,102 +24,11 @@ func NewClientConnection(raw wire.Transport) ports.ClientConnection {
46 if raw == nil {24 if raw == nil {
47 return nil25 return nil
48 }26 }
49- return &clientConnection{raw: raw, ceilings: defaultProtoCeilings(), deadline: time.Now().Add(protocol.HandshakeTimeout), preambleDone: make(chan struct{})}27+ return &clientConnection{handshake: newHandshake(raw, time.Now().Add(protocol.HandshakeTimeout))}
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- }
114 }28 }
115 29
116 func (c *clientConnection) ensurePreamble() error {30 func (c *clientConnection) ensurePreamble() error {
117- c.preambleOnce.Do(func() {31+ return c.handshake.ensurePreamble(runProtoClientPreamble)
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()
145 }32 }
146 33
147 func (c *clientConnection) SendClient(message protocol.ClientMessage) error {34 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) {
227 require.ErrorIs(t, checkCategoryCeiling(oversizeRaw, categoryControl, wire.AbsoluteEnvelopeLimit), wire.ErrScanLength,227 require.ErrorIs(t, checkCategoryCeiling(oversizeRaw, categoryControl, wire.AbsoluteEnvelopeLimit), wire.ErrScanLength,
228 "a CommandResult above the control ceiling must fail even under an absolute negotiated ceiling")228 "a CommandResult above the control ceiling must fail even under an absolute negotiated ceiling")
229 229
230- send := &serverConnection{raw: &scriptedTransport{}, ceilings: defaultProtoCeilings()}230+ send := &serverConnection{handshake: handshake{raw: &scriptedTransport{}, ceilings: defaultProtoCeilings()}}
231 require.NoError(t, send.SendServer(message))231 require.NoError(t, send.SendServer(message))
232 require.ErrorIs(t, send.SendServer(oversize), wire.ErrScanLength)232 require.ErrorIs(t, send.SendServer(oversize), wire.ErrScanLength)
233 233
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()}}
235 recv.preambleOnce.Do(func() {})235 recv.preambleOnce.Do(func() {})
236 _, err = recv.ReceiveServer()236 _, err = recv.ReceiveServer()
237 require.Error(t, err)237 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) {
239 require.ErrorIs(t, checkCategoryCeiling(oversize, categoryControl, wire.AbsoluteEnvelopeLimit), wire.ErrScanLength,239 require.ErrorIs(t, checkCategoryCeiling(oversize, categoryControl, wire.AbsoluteEnvelopeLimit), wire.ErrScanLength,
240 "a KillResult above the control ceiling must fail even under an absolute negotiated ceiling")240 "a KillResult above the control ceiling must fail even under an absolute negotiated ceiling")
241 241
242- send := &serverConnection{raw: &scriptedTransport{}, ceilings: defaultProtoCeilings()}242+ send := &serverConnection{handshake: handshake{raw: &scriptedTransport{}, ceilings: defaultProtoCeilings()}}
243 require.NoError(t, send.SendServer(message))243 require.NoError(t, send.SendServer(message))
244 require.ErrorIs(t, send.SendServer(protocol.KillResult{RequestID: 1, Outcome: protocol.KillSucceeded, Text: string(make([]byte, wire.ControlEnvelopeLimit))}), wire.ErrScanLength)244 require.ErrorIs(t, send.SendServer(protocol.KillResult{RequestID: 1, Outcome: protocol.KillSucceeded, Text: string(make([]byte, wire.ControlEnvelopeLimit))}), wire.ErrScanLength)
245 245
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()}}
247 recv.preambleOnce.Do(func() {})247 recv.preambleOnce.Do(func() {})
248 _, err = recv.ReceiveServer()248 _, err = recv.ReceiveServer()
249 require.Error(t, err)249 require.Error(t, err)
M internal/adapters/sessionwire/proto_limits_category_test.go +5 -5
@@ -133,7 +133,7 @@ func TestClientSendCategoryEnvelopeCeilings(t *testing.T) {
133 for _, tt := range tests {133 for _, tt := range tests {
134 t.Run(tt.name, func(t *testing.T) {134 t.Run(tt.name, func(t *testing.T) {
135 raw := &scriptedTransport{}135 raw := &scriptedTransport{}
136- conn := &clientConnection{raw: raw, ceilings: tt.ceilings}136+ conn := &clientConnection{handshake: handshake{raw: raw, ceilings: tt.ceilings}}
137 conn.preambleOnce.Do(func() {})137 conn.preambleOnce.Do(func() {})
138 err := conn.SendClient(tt.message)138 err := conn.SendClient(tt.message)
139 if tt.wantErr != nil {139 if tt.wantErr != nil {
@@ -194,7 +194,7 @@ func TestServerSendCategoryEnvelopeCeilings(t *testing.T) {
194 for _, tt := range tests {194 for _, tt := range tests {
195 t.Run(tt.name, func(t *testing.T) {195 t.Run(tt.name, func(t *testing.T) {
196 raw := &scriptedTransport{}196 raw := &scriptedTransport{}
197- conn := &serverConnection{raw: raw, ceilings: tt.ceilings}197+ conn := &serverConnection{handshake: handshake{raw: raw, ceilings: tt.ceilings}}
198 err := conn.SendServer(tt.message)198 err := conn.SendServer(tt.message)
199 if tt.wantErr != nil {199 if tt.wantErr != nil {
200 require.ErrorIs(t, err, tt.wantErr)200 require.ErrorIs(t, err, tt.wantErr)
@@ -212,7 +212,7 @@ func TestServerSendCategoryEnvelopeCeilings(t *testing.T) {
212 func TestServerSendModesShareControlCategoryCap(t *testing.T) {212 func TestServerSendModesShareControlCategoryCap(t *testing.T) {
213 controlOver := protocol.Sessions{Sessions: []protocol.SessionInfo{{Name: strings.Repeat("x", overControlCap)}}}213 controlOver := protocol.Sessions{Sessions: []protocol.SessionInfo{{Name: strings.Repeat("x", overControlCap)}}}
214 raw := &asyncScriptedTransport{scriptedTransport: &scriptedTransport{}}214 raw := &asyncScriptedTransport{scriptedTransport: &scriptedTransport{}}
215- conn := &serverConnection{raw: raw, ceilings: defaultProtoCeilings()}215+ conn := &serverConnection{handshake: handshake{raw: raw, ceilings: defaultProtoCeilings()}}
216 216
217 require.ErrorIs(t, conn.SendServerAsync(controlOver), wire.ErrScanLength)217 require.ErrorIs(t, conn.SendServerAsync(controlOver), wire.ErrScanLength)
218 require.ErrorIs(t, conn.SendServerSynchronous(controlOver), wire.ErrScanLength)218 require.ErrorIs(t, conn.SendServerSynchronous(controlOver), wire.ErrScanLength)
@@ -331,11 +331,11 @@ func TestReceiveCategoryEnvelopeCeilings(t *testing.T) {
331 raw := &scriptedTransport{recv: []wire.Envelope{{Payload: tt.payload}}}331 raw := &scriptedTransport{recv: []wire.Envelope{{Payload: tt.payload}}}
332 var err error332 var err error
333 if tt.receiver == "server" {333 if tt.receiver == "server" {
334- conn := &serverConnection{raw: raw, ceilings: tt.ceilings}334+ conn := &serverConnection{handshake: handshake{raw: raw, ceilings: tt.ceilings}}
335 conn.preambleOnce.Do(func() {})335 conn.preambleOnce.Do(func() {})
336 _, err = conn.ReceiveClient()336 _, err = conn.ReceiveClient()
337 } else {337 } else {
338- conn := &clientConnection{raw: raw, ceilings: tt.ceilings}338+ conn := &clientConnection{handshake: handshake{raw: raw, ceilings: tt.ceilings}}
339 conn.preambleOnce.Do(func() {})339 conn.preambleOnce.Do(func() {})
340 _, err = conn.ReceiveServer()340 _, err = conn.ReceiveServer()
341 }341 }
M internal/adapters/sessionwire/proto_limits_test.go +10 -10
@@ -42,7 +42,7 @@ func TestServerSendEnforcesNegotiatedCeilings(t *testing.T) {
42 42
43 t.Run("normal send", func(t *testing.T) {43 t.Run("normal send", func(t *testing.T) {
44 raw := &scriptedTransport{}44 raw := &scriptedTransport{}
45- conn := &serverConnection{raw: raw, ceilings: envelopeBound}45+ conn := &serverConnection{handshake: handshake{raw: raw, ceilings: envelopeBound}}
46 require.ErrorIs(t, conn.SendServer(protocol.ErrorMsg{Code: protocol.ErrInternal, Text: "oversized"}), wire.ErrScanLength)46 require.ErrorIs(t, conn.SendServer(protocol.ErrorMsg{Code: protocol.ErrInternal, Text: "oversized"}), wire.ErrScanLength)
47 require.Zero(t, raw.sentLen())47 require.Zero(t, raw.sentLen())
48 conn.ceilings = outputBound48 conn.ceilings = outputBound
@@ -51,7 +51,7 @@ func TestServerSendEnforcesNegotiatedCeilings(t *testing.T) {
51 })51 })
52 t.Run("async and owned synchronous send", func(t *testing.T) {52 t.Run("async and owned synchronous send", func(t *testing.T) {
53 raw := &asyncScriptedTransport{scriptedTransport: &scriptedTransport{}}53 raw := &asyncScriptedTransport{scriptedTransport: &scriptedTransport{}}
54- conn := &serverConnection{raw: raw, ceilings: envelopeBound}54+ conn := &serverConnection{handshake: handshake{raw: raw, ceilings: envelopeBound}}
55 require.ErrorIs(t, conn.SendServerAsync(protocol.ErrorMsg{Code: protocol.ErrInternal, Text: "oversized"}), wire.ErrScanLength)55 require.ErrorIs(t, conn.SendServerAsync(protocol.ErrorMsg{Code: protocol.ErrInternal, Text: "oversized"}), wire.ErrScanLength)
56 require.ErrorIs(t, conn.SendServerSynchronous(protocol.ErrorMsg{Code: protocol.ErrInternal, Text: "oversized"}), wire.ErrScanLength)56 require.ErrorIs(t, conn.SendServerSynchronous(protocol.ErrorMsg{Code: protocol.ErrInternal, Text: "oversized"}), wire.ErrScanLength)
57 require.Empty(t, raw.async)57 require.Empty(t, raw.async)
@@ -64,7 +64,7 @@ func TestServerSendEnforcesNegotiatedCeilings(t *testing.T) {
64 })64 })
65 t.Run("within ceilings", func(t *testing.T) {65 t.Run("within ceilings", func(t *testing.T) {
66 raw := &scriptedTransport{}66 raw := &scriptedTransport{}
67- conn := &serverConnection{raw: raw, ceilings: defaultProtoCeilings()}67+ conn := &serverConnection{handshake: handshake{raw: raw, ceilings: defaultProtoCeilings()}}
68 require.NoError(t, conn.SendServer(protocol.Pong{}))68 require.NoError(t, conn.SendServer(protocol.Pong{}))
69 require.NoError(t, conn.SendOutput(testOutput([]byte("12345"))))69 require.NoError(t, conn.SendOutput(testOutput([]byte("12345"))))
70 require.Equal(t, 2, raw.sentLen())70 require.Equal(t, 2, raw.sentLen())
@@ -81,7 +81,7 @@ func TestTypedReceiveEnforcesNegotiatedCeilings(t *testing.T) {
81 81
82 t.Run("server receive envelope ceiling", func(t *testing.T) {82 t.Run("server receive envelope ceiling", func(t *testing.T) {
83 raw := &scriptedTransport{recv: []wire.Envelope{{Payload: largeInput}}}83 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)}}}
85 conn.preambleOnce.Do(func() {})85 conn.preambleOnce.Do(func() {})
86 _, err := conn.ReceiveClient()86 _, err := conn.ReceiveClient()
87 var failure *protocol.DecodeFailure87 var failure *protocol.DecodeFailure
@@ -90,7 +90,7 @@ func TestTypedReceiveEnforcesNegotiatedCeilings(t *testing.T) {
90 })90 })
91 t.Run("client receive envelope ceiling", func(t *testing.T) {91 t.Run("client receive envelope ceiling", func(t *testing.T) {
92 raw := &scriptedTransport{recv: []wire.Envelope{{Payload: largeOutput}}}92 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)}}}
94 conn.preambleOnce.Do(func() {})94 conn.preambleOnce.Do(func() {})
95 _, err := conn.ReceiveServer()95 _, err := conn.ReceiveServer()
96 var failure *protocol.DecodeFailure96 var failure *protocol.DecodeFailure
@@ -99,7 +99,7 @@ func TestTypedReceiveEnforcesNegotiatedCeilings(t *testing.T) {
99 })99 })
100 t.Run("client receive output data ceiling", func(t *testing.T) {100 t.Run("client receive output data ceiling", func(t *testing.T) {
101 raw := &scriptedTransport{recv: []wire.Envelope{{Payload: smallOutput}}}101 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}}}
103 conn.preambleOnce.Do(func() {})103 conn.preambleOnce.Do(func() {})
104 _, err := conn.ReceiveServer()104 _, err := conn.ReceiveServer()
105 var failure *protocol.DecodeFailure105 var failure *protocol.DecodeFailure
@@ -108,14 +108,14 @@ func TestTypedReceiveEnforcesNegotiatedCeilings(t *testing.T) {
108 })108 })
109 t.Run("within ceilings decodes", func(t *testing.T) {109 t.Run("within ceilings decodes", func(t *testing.T) {
110 serverRaw := &scriptedTransport{recv: []wire.Envelope{{Payload: largeInput}}}110 serverRaw := &scriptedTransport{recv: []wire.Envelope{{Payload: largeInput}}}
111- server := &serverConnection{raw: serverRaw, ceilings: defaultProtoCeilings()}111+ server := &serverConnection{handshake: handshake{raw: serverRaw, ceilings: defaultProtoCeilings()}}
112 server.preambleOnce.Do(func() {})112 server.preambleOnce.Do(func() {})
113 got, err := server.ReceiveClient()113 got, err := server.ReceiveClient()
114 require.NoError(t, err)114 require.NoError(t, err)
115 require.Equal(t, []byte(bytes.Repeat([]byte("x"), 64)), got.(protocol.Input).Data)115 require.Equal(t, []byte(bytes.Repeat([]byte("x"), 64)), got.(protocol.Input).Data)
116 116
117 clientRaw := &scriptedTransport{recv: []wire.Envelope{{Payload: smallOutput}}}117 clientRaw := &scriptedTransport{recv: []wire.Envelope{{Payload: smallOutput}}}
118- client := &clientConnection{raw: clientRaw, ceilings: defaultProtoCeilings()}118+ client := &clientConnection{handshake: handshake{raw: clientRaw, ceilings: defaultProtoCeilings()}}
119 client.preambleOnce.Do(func() {})119 client.preambleOnce.Do(func() {})
120 decoded, err := client.ReceiveServer()120 decoded, err := client.ReceiveServer()
121 require.NoError(t, err)121 require.NoError(t, err)
@@ -127,12 +127,12 @@ func TestTypedReceiveEnforcesNegotiatedCeilings(t *testing.T) {
127 // path and advertised capabilities reflect the negotiated ceilings.127 // path and advertised capabilities reflect the negotiated ceilings.
128 func TestClientSendAndCapabilitiesUseNegotiatedCeilings(t *testing.T) {128 func TestClientSendAndCapabilitiesUseNegotiatedCeilings(t *testing.T) {
129 raw := &scriptedTransport{}129 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}}}
131 conn.preambleOnce.Do(func() {})131 conn.preambleOnce.Do(func() {})
132 require.ErrorIs(t, conn.SendClient(protocol.Input{InputSeq: 1, Data: []byte("hello")}), wire.ErrScanLength)132 require.ErrorIs(t, conn.SendClient(protocol.Input{InputSeq: 1, Data: []byte("hello")}), wire.ErrScanLength)
133 require.Zero(t, raw.sentLen())133 require.Zero(t, raw.sentLen())
134 require.Equal(t, 1234, conn.Capabilities().OutputDataLimit)134 require.Equal(t, 1234, conn.Capabilities().OutputDataLimit)
135 135
136- server := &serverConnection{raw: &scriptedTransport{}, ceilings: protoCeilings{maxReceiveEnvelopeBytes: 1, outputDataLimit: 4321}}136+ server := &serverConnection{handshake: handshake{raw: &scriptedTransport{}, ceilings: protoCeilings{maxReceiveEnvelopeBytes: 1, outputDataLimit: 4321}}}
137 require.Equal(t, 4321, server.Capabilities().OutputDataLimit)137 require.Equal(t, 4321, server.Capabilities().OutputDataLimit)
138 }138 }
M internal/adapters/sessionwire/server.go +7 -124
@@ -2,9 +2,7 @@
2 package sessionwire2 package sessionwire
3 3
4 import (4 import (
5- "context"
6 "errors"5 "errors"
7- "sync"
8 "time"6 "time"
9 7
10 "google.golang.org/protobuf/encoding/protowire"8 "google.golang.org/protobuf/encoding/protowire"
@@ -22,35 +20,15 @@ var (
22 )20 )
23 21
24 type serverConnection struct {22 type serverConnection struct {
25- raw wire.Transport23+ handshake
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
36 24
37 // admission is the provisioned admission metadata the accepting side25 // admission is the provisioned admission metadata the accepting side
38- // stamped, or the zero value with hasAdmission=false for a connection built26+ // stamped, or the zero value with hasAdmission=false for a connection whose
39- // by a legacy constructor that carried no admission. It is never mutated27+ // accepting side provisioned none (a direct local daemon socket). It is
40- // after construction and is exposed only through SessionAdmission, which28+ // never mutated after construction and is exposed only through
41- // returns a deep copy.29+ // SessionAdmission, which returns a deep copy.
42 admission ports.SessionAdmission30 admission ports.SessionAdmission
43 hasAdmission bool31 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()
54 }32 }
55 33
56 var (34 var (
@@ -114,12 +92,9 @@ func newServerConnection(raw wire.Transport, deadline time.Time, admission ports
114 deadline = time.Now().Add(protocol.HandshakeTimeout)92 deadline = time.Now().Add(protocol.HandshakeTimeout)
115 }93 }
116 return &serverConnection{94 return &serverConnection{
117- raw: raw,95+ handshake: newHandshake(raw, deadline),
118- ceilings: defaultProtoCeilings(),
119- deadline: deadline,
120 admission: admission.Clone(),96 admission: admission.Clone(),
121 hasAdmission: hasAdmission,97 hasAdmission: hasAdmission,
122- preambleDone: make(chan struct{}),
123 }98 }
124 }99 }
125 100
@@ -135,100 +110,8 @@ func (c *serverConnection) SessionAdmission() (ports.SessionAdmission, bool) {
135 return c.admission.Clone(), true110 return c.admission.Clone(), true
136 }111 }
137 112
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-
202 func (c *serverConnection) ensurePreamble() error {113 func (c *serverConnection) ensurePreamble() error {
203- c.preambleOnce.Do(func() {114+ return c.handshake.ensurePreamble(runProtoServerPreamble)
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()
232 }115 }
233 116
234 func (c *serverConnection) ReceiveClient() (protocol.ClientMessage, error) {117 func (c *serverConnection) ReceiveClient() (protocol.ClientMessage, error) {
M internal/adapters/snapshot/direct_load_test.go +1 -1
@@ -11,8 +11,8 @@ import (
11 11
12 "github.com/bnema/vev/internal/domain"12 "github.com/bnema/vev/internal/domain"
13 "github.com/bnema/vev/internal/ports"13 "github.com/bnema/vev/internal/ports"
14+ codec "github.com/bnema/vev/internal/snapshotcodec"
14 recoveryusecase "github.com/bnema/vev/internal/usecase/recovery"15 recoveryusecase "github.com/bnema/vev/internal/usecase/recovery"
15- codec "github.com/bnema/vev/internal/usecase/snapshot"
16 "github.com/stretchr/testify/require"16 "github.com/stretchr/testify/require"
17 )17 )
18 18
M internal/adapters/snapshot/load.go +1 -1
@@ -10,7 +10,7 @@ import (
10 10
11 "github.com/bnema/vev/internal/domain"11 "github.com/bnema/vev/internal/domain"
12 "github.com/bnema/vev/internal/ports"12 "github.com/bnema/vev/internal/ports"
13- codec "github.com/bnema/vev/internal/usecase/snapshot"13+ codec "github.com/bnema/vev/internal/snapshotcodec"
14 )14 )
15 15
16 var ErrInvalidHEAD = errors.New("invalid snapshot HEAD")16 var ErrInvalidHEAD = errors.New("invalid snapshot HEAD")
M internal/adapters/snapshot/maintenance_gc.go +1 -1
@@ -10,7 +10,7 @@ import (
10 10
11 "github.com/bnema/vev/internal/domain"11 "github.com/bnema/vev/internal/domain"
12 "github.com/bnema/vev/internal/ports"12 "github.com/bnema/vev/internal/ports"
13- codec "github.com/bnema/vev/internal/usecase/snapshot"13+ codec "github.com/bnema/vev/internal/snapshotcodec"
14 )14 )
15 15
16 // SnapshotIncarnations lists the canonical incarnation directories.16 // SnapshotIncarnations lists the canonical incarnation directories.
M internal/adapters/snapshot/path_security_test.go +1 -1
@@ -10,7 +10,7 @@ import (
10 "testing"10 "testing"
11 11
12 "github.com/bnema/vev/internal/domain"12 "github.com/bnema/vev/internal/domain"
13- codec "github.com/bnema/vev/internal/usecase/snapshot"13+ codec "github.com/bnema/vev/internal/snapshotcodec"
14 )14 )
15 15
16 func TestRepositoryRejectsSymlinkedRoot(t *testing.T) {16 func TestRepositoryRejectsSymlinkedRoot(t *testing.T) {
M internal/adapters/snapshot/publish.go +1 -1
@@ -10,7 +10,7 @@ import (
10 10
11 "github.com/bnema/vev/internal/domain"11 "github.com/bnema/vev/internal/domain"
12 "github.com/bnema/vev/internal/ports"12 "github.com/bnema/vev/internal/ports"
13- codec "github.com/bnema/vev/internal/usecase/snapshot"13+ codec "github.com/bnema/vev/internal/snapshotcodec"
14 )14 )
15 15
16 func (r *Repository) Publish(ctx context.Context, publication ports.SnapshotPublication) error {16 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 (
10 10
11 "github.com/bnema/vev/internal/domain"11 "github.com/bnema/vev/internal/domain"
12 "github.com/bnema/vev/internal/ports"12 "github.com/bnema/vev/internal/ports"
13- codec "github.com/bnema/vev/internal/usecase/snapshot"13+ codec "github.com/bnema/vev/internal/snapshotcodec"
14 )14 )
15 15
16 func TestRepositoryPublishRevalidatesUnchangedTenThousandObjectHistory(t *testing.T) {16 func TestRepositoryPublishRevalidatesUnchangedTenThousandObjectHistory(t *testing.T) {
M internal/adapters/snapshot/repository_hardening_test.go +1 -1
@@ -8,7 +8,7 @@ import (
8 "syscall"8 "syscall"
9 "testing"9 "testing"
10 10
11- codec "github.com/bnema/vev/internal/usecase/snapshot"11+ codec "github.com/bnema/vev/internal/snapshotcodec"
12 )12 )
13 13
14 func TestReadBoundedRejectsUnsafeFiles(t *testing.T) {14 func TestReadBoundedRejectsUnsafeFiles(t *testing.T) {
M internal/adapters/snapshot/repository_test.go +1 -1
@@ -8,7 +8,7 @@ import (
8 "github.com/bnema/vev/internal/domain"8 "github.com/bnema/vev/internal/domain"
9 9
10 "github.com/bnema/vev/internal/ports"10 "github.com/bnema/vev/internal/ports"
11- codec "github.com/bnema/vev/internal/usecase/snapshot"11+ codec "github.com/bnema/vev/internal/snapshotcodec"
12 )12 )
13 13
14 func TestRepositoryPublishesAndLoadsCompleteGeneration(t *testing.T) {14 func TestRepositoryPublishesAndLoadsCompleteGeneration(t *testing.T) {
M internal/app/broker_offline_mux.go +3 -3
@@ -65,9 +65,9 @@ import (
65 65
66 const (66 const (
67 // brokerMuxStdioCommand is the hidden remote SSH stdio mux bridge.67 // brokerMuxStdioCommand is the hidden remote SSH stdio mux bridge.
68- brokerMuxStdioCommand = "_broker-mux-stdio"68+ brokerMuxStdioCommand = ports.BrokerMuxStdioCommand
69 // brokerMuxQUICBootstrapCommand is the hidden remote QUIC mux bootstrap.69 // brokerMuxQUICBootstrapCommand is the hidden remote QUIC mux bootstrap.
70- brokerMuxQUICBootstrapCommand = "_broker-mux-quic-bootstrap"70+ brokerMuxQUICBootstrapCommand = ports.BrokerMuxQUICBootstrapCommand
71 // brokerMuxQUICProxyCommand is the hidden detached remote QUIC mux proxy.71 // brokerMuxQUICProxyCommand is the hidden detached remote QUIC mux proxy.
72 brokerMuxQUICProxyCommand = "_broker-mux-quic-proxy"72 brokerMuxQUICProxyCommand = "_broker-mux-quic-proxy"
73 )73 )
@@ -105,7 +105,7 @@ const brokerDaemonStartArg = "--daemon-start"
105 105
106 // brokerScopeArg is the scope flag carried by every durable remote route argv106 // brokerScopeArg is the scope flag carried by every durable remote route argv
107 // (see ports.BrokerRouteForTransport). Stored routes keep it verbatim.107 // (see ports.BrokerRouteForTransport). Stored routes keep it verbatim.
108-const brokerScopeArg = "--production"108+const brokerScopeArg = ports.BrokerRouteScopeArg
109 109
110 // brokerMuxOptions is the parsed hidden mux helper invocation.110 // brokerMuxOptions is the parsed hidden mux helper invocation.
111 type brokerMuxOptions struct {111 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 {
90 return r90 return r
91 }91 }
92 92
93-// BrokerRouteForTransport maps the closed legacy policy transport vocabulary93+// Remote helper argv vocabulary. Durable SSH routes store this argv verbatim,
94-// to canonical remote routes.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.
95 func BrokerRouteForTransport(transport, endpoint string) (BrokerRouteSpec, error) {104 func BrokerRouteForTransport(transport, endpoint string) (BrokerRouteSpec, error) {
96 switch transport {105 switch transport {
97 case "ssh-stdio", "stdio":106 case "ssh-stdio", "stdio":
98- return BrokerRouteSpec{Kind: BrokerRouteSSHStdio, Target: endpoint, Argv: []string{"vev", "_broker-mux-stdio", "--production"}}, nil107+ return BrokerRouteSpec{Kind: BrokerRouteSSHStdio, Target: endpoint, Argv: []string{"vev", BrokerMuxStdioCommand, BrokerRouteScopeArg}}, nil
99 case "ssh-quic", "quic":108 case "ssh-quic", "quic":
100- return BrokerRouteSpec{Kind: BrokerRouteSSHQUIC, Target: endpoint, Argv: []string{"vev", "_broker-mux-quic-bootstrap", "--production"}}, nil109+ return BrokerRouteSpec{Kind: BrokerRouteSSHQUIC, Target: endpoint, Argv: []string{"vev", BrokerMuxQUICBootstrapCommand, BrokerRouteScopeArg}}, nil
101 default:110 default:
102 return BrokerRouteSpec{}, fmt.Errorf("ports: unsupported broker transport %q", transport)111 return BrokerRouteSpec{}, fmt.Errorf("ports: unsupported broker transport %q", transport)
103 }112 }
M internal/ports/session_admission.go +3 -2
@@ -165,8 +165,9 @@ func (a SessionAdmission) Clone() SessionAdmission {
165 // SessionAdmissionProvider is the optional boundary-safe port a typed accepted165 // SessionAdmissionProvider is the optional boundary-safe port a typed accepted
166 // server connection implements when the accepting side stamped admission166 // server connection implements when the accepting side stamped admission
167 // metadata onto it. A daemon-consuming use case asserts it structurally; a167 // metadata onto it. A daemon-consuming use case asserts it structurally; a
168-// connection built by a legacy constructor that carries no provisioned168+// connection accepted without provisioned admission (the direct local daemon
169-// admission reports ok=false rather than a zero admission.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.
170 //171 //
171 // The returned value is a defensive copy: mutating it never alters the172 // The returned value is a defensive copy: mutating it never alters the
172 // connection's admitted authority, and concurrent readers each receive their173 // 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 snapshot1+package snapshotcodec
2 2
3 import (3 import (
4 "encoding/binary"4 "encoding/binary"
@@ -7,7 +7,7 @@ import (
7 "hash/crc32"7 "hash/crc32"
8 "math"8 "math"
9 9
10- "github.com/bnema/vev/internal/usecase/layout"10+ "github.com/bnema/vev/internal/domain/layout"
11 )11 )
12 12
13 var (13 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 snapshot1+package snapshotcodec
2 2
3 import (3 import (
4 "encoding/binary"4 "encoding/binary"
@@ -6,7 +6,7 @@ import (
6 "hash/crc32"6 "hash/crc32"
7 "math"7 "math"
8 8
9- "github.com/bnema/vev/internal/usecase/layout"9+ "github.com/bnema/vev/internal/domain/layout"
10 )10 )
11 11
12 // marshalTest is a test-only v4 encoder. Production retains only the decoder12 // 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 snapshot1+package snapshotcodec
2 2
3 import (3 import (
4 "encoding/hex"4 "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 snapshot1+package snapshotcodec
2 2
3 import (3 import (
4 "bytes"4 "bytes"
@@ -11,7 +11,7 @@ import (
11 11
12 vt "github.com/bnema/vev-vt"12 vt "github.com/bnema/vev-vt"
13 renderer "github.com/bnema/vev-vt/ansi"13 renderer "github.com/bnema/vev-vt/ansi"
14- "github.com/bnema/vev/internal/usecase/layout"14+ "github.com/bnema/vev/internal/domain/layout"
15 )15 )
16 16
17 func TestPayloadReaderGetBytes(t *testing.T) {17 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 snapshot1+package snapshotcodec
2 2
3 import (3 import (
4 "errors"4 "errors"
R internal/usecase/snapshot/manifest.go → internal/snapshotcodec/manifest.go +2 -2 renamed, 99% similar
@@ -1,4 +1,4 @@
1-package snapshot1+package snapshotcodec
2 2
3 import (3 import (
4 "crypto/sha256"4 "crypto/sha256"
@@ -8,8 +8,8 @@ import (
8 "math"8 "math"
9 9
10 "github.com/bnema/vev/internal/domain"10 "github.com/bnema/vev/internal/domain"
11+ "github.com/bnema/vev/internal/domain/layout"
11 "github.com/bnema/vev/internal/ports"12 "github.com/bnema/vev/internal/ports"
12- "github.com/bnema/vev/internal/usecase/layout"
13 )13 )
14 14
15 const (15 const (
R internal/usecase/snapshot/manifest_preflight.go → internal/snapshotcodec/manifest_preflight.go +2 -2 renamed, 99% similar
@@ -1,10 +1,10 @@
1-package snapshot1+package snapshotcodec
2 2
3 import (3 import (
4 "fmt"4 "fmt"
5 5
6 "github.com/bnema/vev/internal/domain"6 "github.com/bnema/vev/internal/domain"
7- "github.com/bnema/vev/internal/usecase/layout"7+ "github.com/bnema/vev/internal/domain/layout"
8 )8 )
9 9
10 // preflightManifest rejects hostile declarations before UnmarshalManifest10 // 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 snapshot1+package snapshotcodec
2 2
3 import (3 import (
4 "encoding/binary"4 "encoding/binary"
@@ -7,7 +7,7 @@ import (
7 "testing"7 "testing"
8 8
9 "github.com/bnema/vev/internal/domain"9 "github.com/bnema/vev/internal/domain"
10- "github.com/bnema/vev/internal/usecase/layout"10+ "github.com/bnema/vev/internal/domain/layout"
11 )11 )
12 12
13 func TestManifestGrammarRoundTrip(t *testing.T) {13 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 snapshot1+package snapshotcodec
2 2
3 import (3 import (
4 "bytes"4 "bytes"
@@ -9,8 +9,8 @@ import (
9 "testing"9 "testing"
10 10
11 "github.com/bnema/vev/internal/domain"11 "github.com/bnema/vev/internal/domain"
12+ "github.com/bnema/vev/internal/domain/layout"
12 "github.com/bnema/vev/internal/ports"13 "github.com/bnema/vev/internal/ports"
13- "github.com/bnema/vev/internal/usecase/layout"
14 )14 )
15 15
16 func TestManifestIncarnation(t *testing.T) {16 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 snapshot1+package snapshotcodec
2 2
3 import (3 import (
4 "bytes"4 "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 snapshot1+package snapshotcodec
2 2
3 import (3 import (
4 "bytes"4 "bytes"
R internal/usecase/snapshot/preflight.go → internal/snapshotcodec/preflight.go +2 -2 renamed, 99% similar
@@ -1,11 +1,11 @@
1-package snapshot1+package snapshotcodec
2 2
3 import (3 import (
4 "fmt"4 "fmt"
5 "math"5 "math"
6 6
7 vt "github.com/bnema/vev-vt"7 vt "github.com/bnema/vev-vt"
8- "github.com/bnema/vev/internal/usecase/layout"8+ "github.com/bnema/vev/internal/domain/layout"
9 )9 )
10 10
11 func preflightSession(body []byte) error {11 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 snapshot1+package snapshotcodec
2 2
3 import (3 import (
4 "errors"4 "errors"
5 "testing"5 "testing"
6 6
7 renderer "github.com/bnema/vev-vt"7 renderer "github.com/bnema/vev-vt"
8- "github.com/bnema/vev/internal/usecase/layout"8+ "github.com/bnema/vev/internal/domain/layout"
9 )9 )
10 10
11 func TestPreflightRejectsDanglingTreeAndFocusReferencesBeforeDecode(t *testing.T) {11 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 snapshot1+// 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
2 6
3-import "github.com/bnema/vev/internal/usecase/layout"7+import "github.com/bnema/vev/internal/domain/layout"
4 8
5 // Session is the durable transfer representation for a named daemon session.9 // Session is the durable transfer representation for a named daemon session.
6 type Session struct {10 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 snapshot1+package snapshotcodec
2 2
3 import (3 import (
4 "bytes"4 "bytes"
@@ -6,7 +6,7 @@ import (
6 "testing"6 "testing"
7 7
8 vt "github.com/bnema/vev-vt"8 vt "github.com/bnema/vev-vt"
9- "github.com/bnema/vev/internal/usecase/layout"9+ "github.com/bnema/vev/internal/domain/layout"
10 )10 )
11 11
12 const compactVEVSGolden = "564556530005000000000104dbb0765f0007726573746f7265000000000000007b0000000100037461620050001800000000000000020001700000017000010001700006737461626c6500042f746d70000000010000003f5654433100000001000000000000002a0000000200000001000000010000000000000000000000290000000100000000410000000000000000200000000000000000105654433100000000000000000000002a000000645654433100000001000000000000002b000000020000000100000002000000000900000000000000000000ffffffffffffffff010203000000000000000000000000000000000000000000002a00000002000000754c000000010000000000000000010100"12 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 snapshot1+package snapshotcodec
2 2
3 import (3 import (
4 "bytes"4 "bytes"
@@ -7,7 +7,7 @@ import (
7 "math"7 "math"
8 "testing"8 "testing"
9 9
10- "github.com/bnema/vev/internal/usecase/layout"10+ "github.com/bnema/vev/internal/domain/layout"
11 )11 )
12 12
13 func TestManifestWeightRoundTripAndOldCompatibility(t *testing.T) {13 func TestManifestWeightRoundTripAndOldCompatibility(t *testing.T) {
M internal/usecase/broker/doc.go +7 -0
@@ -2,4 +2,11 @@
2 // reachability and catalogue snapshots, pooled physical transports, and2 // reachability and catalogue snapshots, pooled physical transports, and
3 // multiplexed logical streams. It does not own picker UI, sessions, PTYs,3 // multiplexed logical streams. It does not own picker UI, sessions, PTYs,
4 // terminal geometry, or attachment semantics.4 // 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).
5 package broker12 package broker
M internal/usecase/broker/registry.go +0 -1163
@@ -3,14 +3,10 @@ package broker
3 import (3 import (
4 "context"4 "context"
5 "crypto/rand"5 "crypto/rand"
6- "encoding/binary"
7 "errors"6 "errors"
8 "fmt"7 "fmt"
9- "hash/fnv"
10 "log/slog"8 "log/slog"
11 "reflect"9 "reflect"
12- "slices"
13- "sort"
14 "sync"10 "sync"
15 "sync/atomic"11 "sync/atomic"
16 "time"12 "time"
@@ -424,1162 +420,3 @@ func (r *Registry) restore(snapshot ports.BrokerSnapshot) error {
424 }420 }
425 return nil421 return nil
426 }422 }
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
198 first.replaceTransport(firstTransport)198 first.replaceTransport(firstTransport)
199 199
200 blockedTransport := &closeTrackingTransport{}200 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}}
202 blocked.initOverlays()202 blocked.initOverlays()
203 blocked.setSession(sess)203 blocked.setSession(sess)
204 laterTransport := &closeTrackingTransport{}204 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}}
206 later.initOverlays()206 later.initOverlays()
207 later.setSession(sess)207 later.setSession(sess)
208 sess.mu.Lock()208 sess.mu.Lock()
@@ -320,7 +320,7 @@ func TestKillSessionInterruptsOnlyExactParticipantBlockedSends(t *testing.T) {
320 snatched.installTestAttachmentCapability(sess.captureAttachmentCapability(snatched, snatchedTransport))320 snatched.installTestAttachmentCapability(sess.captureAttachmentCapability(snatched, snatchedTransport))
321 321
322 activeTransport := newTeardownBlockingTransport()322 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}}
324 active.initOverlays()324 active.initOverlays()
325 transition, err := d.transitionAttachment(attachmentTransitionRequest{325 transition, err := d.transitionAttachment(attachmentTransitionRequest{
326 target: sess, next: active, expectedTransport: active.transportSnapshot(), ready: true,326 target: sess, next: active, expectedTransport: active.transportSnapshot(), ready: true,
@@ -329,7 +329,7 @@ func TestKillSessionInterruptsOnlyExactParticipantBlockedSends(t *testing.T) {
329 defer d.deferAttachmentTransitionCleanups(transition)329 defer d.deferAttachmentTransitionCleanups(transition)
330 330
331 unrelatedTransport := &closeTrackingTransport{}331 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}}
333 unrelatedClient.initOverlays()333 unrelatedClient.initOverlays()
334 unrelated := &session{sessionCore: sessionCore{id: "unrelated", name: "unrelated", attachments: map[*attachedClient]struct{}{unrelatedClient: {}}}, ctx: sess.ctx, cancel: func() {}}334 unrelated := &session{sessionCore: sessionCore{id: "unrelated", name: "unrelated", attachments: map[*attachedClient]struct{}{unrelatedClient: {}}}, ctx: sess.ctx, cancel: func() {}}
335 unrelatedClient.setSession(unrelated)335 unrelatedClient.setSession(unrelated)
@@ -505,7 +505,7 @@ func TestAttachmentEffectGateReplacementInterruptsBlockedOldRenderBeforePublicat
505 <-oldTransport.sendEntered505 <-oldTransport.sendEntered
506 506
507 newTransport := &closeTrackingTransport{}507 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}}
509 next.initOverlays()509 next.initOverlays()
510 transitionDone := make(chan attachmentTransitionResult, 1)510 transitionDone := make(chan attachmentTransitionResult, 1)
511 transitionErr := make(chan error, 1)511 transitionErr := make(chan error, 1)
@@ -554,7 +554,7 @@ func TestAttachmentEffectGateAdmittedEffectCompletesBeforeConflictingTransition(
554 ticket, ok := old.beginAttachmentEffect(token)554 ticket, ok := old.beginAttachmentEffect(token)
555 require.True(t, ok)555 require.True(t, ok)
556 556
557- next := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: old.size}557+ next := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), geometry: domain.Geometry{Size: old.geometry.Size}}
558 next.initOverlays()558 next.initOverlays()
559 done := make(chan error, 1)559 done := make(chan error, 1)
560 go func() {560 go func() {
@@ -668,7 +668,7 @@ func TestAttachmentEffectGateAdmittedActiveEffectsFinishBeforeReplacement(t *tes
668 }()668 }()
669 <-admitted669 <-admitted
670 670
671- next := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: old.size}671+ next := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), geometry: domain.Geometry{Size: old.geometry.Size}}
672 next.initOverlays()672 next.initOverlays()
673 transitionDone := make(chan error, 1)673 transitionDone := make(chan error, 1)
674 go func() {674 go func() {
@@ -817,7 +817,7 @@ func TestPickerDeleteDoesNotDeleteSourceAfterInitiatorReplacement(t *testing.T)
817 }()817 }()
818 <-admissionEnded818 <-admissionEnded
819 819
820- next := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: old.size}820+ next := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), geometry: domain.Geometry{Size: old.geometry.Size}}
821 next.initOverlays()821 next.initOverlays()
822 result, err := d.transitionAttachment(attachmentTransitionRequest{822 result, err := d.transitionAttachment(attachmentTransitionRequest{
823 target: sess, next: next, expectedTransport: next.transportSnapshot(), ready: true,823 target: sess, next: next, expectedTransport: next.transportSnapshot(), ready: true,
@@ -873,7 +873,7 @@ func TestAttachmentEffectGateAdmittedFirstPaintFinishesBeforeReplacement(t *test
873 go func() { paintDone <- d.firstPaintForTransition(token) }()873 go func() { paintDone <- d.firstPaintForTransition(token) }()
874 <-admitted874 <-admitted
875 875
876- next := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: old.size}876+ next := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), geometry: domain.Geometry{Size: old.geometry.Size}}
877 next.initOverlays()877 next.initOverlays()
878 transitionDone := make(chan error, 1)878 transitionDone := make(chan error, 1)
879 go func() {879 go func() {
@@ -897,7 +897,7 @@ func TestAttachmentEffectGateReversedConcurrentTransitionsDoNotDeadlock(t *testi
897 first.captureAttachmentCapability(a, aTransport)897 first.captureAttachmentCapability(a, aTransport)
898 898
899 bTransport := newDatagramTestTransport()899 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}}
901 b.initOverlays()901 b.initOverlays()
902 second := &session{sessionCore: sessionCore{id: "second", name: "second", attachments: map[*attachedClient]struct{}{b: {}}}}902 second := &session{sessionCore: sessionCore{id: "second", name: "second", attachments: map[*attachedClient]struct{}{b: {}}}}
903 b.setSession(second)903 b.setSession(second)
M internal/usecase/daemon/attachment_output_test.go +3 -3
@@ -9,10 +9,10 @@ import (
9 renderer "github.com/bnema/vev-vt/ansi"9 renderer "github.com/bnema/vev-vt/ansi"
10 "github.com/bnema/vev-vt/graphics"10 "github.com/bnema/vev-vt/graphics"
11 "github.com/bnema/vev/internal/domain"11 "github.com/bnema/vev/internal/domain"
12+ "github.com/bnema/vev/internal/domain/layout"
12 "github.com/bnema/vev/internal/domain/terminalcap"13 "github.com/bnema/vev/internal/domain/terminalcap"
13 "github.com/bnema/vev/internal/protocol"14 "github.com/bnema/vev/internal/protocol"
14 "github.com/bnema/vev/internal/protocol/wire"15 "github.com/bnema/vev/internal/protocol/wire"
15- "github.com/bnema/vev/internal/usecase/layout"
16 "github.com/stretchr/testify/require"16 "github.com/stretchr/testify/require"
17 )17 )
18 18
@@ -122,7 +122,7 @@ func TestAttachmentOutputEpochsAreAttachmentLocalAndPreparedFramesAreFenced(t *t
122 fillOutputStateRows(frame, []string{"abc"})122 fillOutputStateRows(frame, []string{"abc"})
123 newAttachment := func() *attachmentOutput {123 newAttachment := func() *attachmentOutput {
124 stream := newOutputStateStream()124 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}}}
126 stream.attachment = ac126 stream.attachment = ac
127 return stream127 return stream
128 }128 }
@@ -195,7 +195,7 @@ func TestPreparedOutputDropsReplacedConnectionAndView(t *testing.T) {
195 } {195 } {
196 t.Run(tt.name, func(t *testing.T) {196 t.Run(tt.name, func(t *testing.T) {
197 stream := newOutputStateStream()197 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}}}
199 stream.attachment = ac199 stream.attachment = ac
200 frame := renderer.NewFrame(3, 1)200 frame := renderer.NewFrame(3, 1)
201 fillOutputStateRows(frame, []string{"abc"})201 fillOutputStateRows(frame, []string{"abc"})
M internal/usecase/daemon/attachment_registry_test.go +2 -2
@@ -77,7 +77,7 @@ func TestAttachmentRegistryRepairsRemovedStableTarget(t *testing.T) {
77 func TestTabForAttachmentRepairRebasesOutputBeforePublishingRevision(t *testing.T) {77 func TestTabForAttachmentRepairRebasesOutputBeforePublishingRevision(t *testing.T) {
78 sess := registryTestSession()78 sess := registryTestSession()
79 ac := registryTestAttachment(1)79 ac := registryTestAttachment(1)
80- ac.size = domain.Size{Cols: 80, Rows: 23}80+ ac.geometry.Size = domain.Size{Cols: 80, Rows: 23}
81 ac.output = newOutputStateStream()81 ac.output = newOutputStateStream()
82 ac.output.attachment = ac82 ac.output.attachment = ac
83 require.True(t, sess.registerAttachment(ac))83 require.True(t, sess.registerAttachment(ac))
@@ -105,7 +105,7 @@ func TestTabForAttachmentRepairRebasesOutputBeforePublishingRevision(t *testing.
105 func TestPrepareRemovedTabViewRebasesOutputBeforeSideEffect(t *testing.T) {105 func TestPrepareRemovedTabViewRebasesOutputBeforeSideEffect(t *testing.T) {
106 sess := registryTestSession()106 sess := registryTestSession()
107 ac := registryTestAttachment(1)107 ac := registryTestAttachment(1)
108- ac.size = domain.Size{Cols: 80, Rows: 23}108+ ac.geometry.Size = domain.Size{Cols: 80, Rows: 23}
109 ac.output = newOutputStateStream()109 ac.output = newOutputStateStream()
110 ac.output.attachment = ac110 ac.output.attachment = ac
111 require.True(t, sess.registerAttachment(ac))111 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) {
118 require.True(t, sess.repairAttachmentView(second))118 require.True(t, sess.repairAttachmentView(second))
119 119
120 tb := sess.tabs[0]120 tb := sess.tabs[0]
121- firstSize := first.size121+ firstSize := first.geometry.Size
122 secondRevision := second.viewSnapshot().revision122 secondRevision := second.viewSnapshot().revision
123 secondEpoch := second.output.currentEpoch()123 secondEpoch := second.output.currentEpoch()
124 124
@@ -136,8 +136,8 @@ func TestAttachmentResizeKeepsPeerWindowAndExpandsSharedContent(t *testing.T) {
136 tb.mu.Lock()136 tb.mu.Lock()
137 require.Equal(t, domain.Size{Cols: 120, Rows: 48}, tb.size)137 require.Equal(t, domain.Size{Cols: 120, Rows: 48}, tb.size)
138 tb.mu.Unlock()138 tb.mu.Unlock()
139- require.Equal(t, firstSize, first.size)139+ require.Equal(t, firstSize, first.geometry.Size)
140- require.Equal(t, domain.Size{Cols: 120, Rows: 50}, second.size)140+ require.Equal(t, domain.Size{Cols: 120, Rows: 50}, second.geometry.Size)
141 require.Equal(t, secondRevision+1, second.viewSnapshot().revision)141 require.Equal(t, secondRevision+1, second.viewSnapshot().revision)
142 require.Greater(t, second.output.currentEpoch(), secondEpoch)142 require.Greater(t, second.output.currentEpoch(), secondEpoch)
143 }143 }
@@ -292,8 +292,8 @@ func TestAttachmentResizeUsesLatestClaimedSessionGeometry(t *testing.T) {
292 tb.mu.Lock()292 tb.mu.Lock()
293 require.Equal(t, domain.Size{Cols: 120, Rows: 48}, tb.size)293 require.Equal(t, domain.Size{Cols: 120, Rows: 48}, tb.size)
294 tb.mu.Unlock()294 tb.mu.Unlock()
295- require.Equal(t, domain.Size{Cols: 80, Rows: 24}, first.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.size)296+ require.Equal(t, domain.Size{Cols: 120, Rows: 50}, second.geometry.Size)
297 297
298 // A same-size resize is still a claim, so the smaller peer can take298 // A same-size resize is still a claim, so the smaller peer can take
299 // authority without changing its local window state first.299 // authority without changing its local window state first.
@@ -308,8 +308,8 @@ func TestAttachmentResizeUsesLatestClaimedSessionGeometry(t *testing.T) {
308 tb.mu.Lock()308 tb.mu.Lock()
309 require.Equal(t, domain.Size{Cols: 70, Rows: 18}, tb.size)309 require.Equal(t, domain.Size{Cols: 70, Rows: 18}, tb.size)
310 tb.mu.Unlock()310 tb.mu.Unlock()
311- require.Equal(t, domain.Size{Cols: 70, Rows: 20}, first.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.size)312+ require.Equal(t, domain.Size{Cols: 90, Rows: 30}, second.geometry.Size)
313 313
314 // Detaching the latest claimant falls back to the most recent remaining314 // Detaching the latest claimant falls back to the most recent remaining
315 // attachment claim rather than reverting to the historical maximum.315 // 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) {
19 return19 return
20 }20 }
21 21
22- now := d.clock.Now()22+ now := d.daemonNow()
23- if now.IsZero() {
24- now = time.Now()
25- }
26 23
27 sess.mu.Lock()24 sess.mu.Lock()
28 if !tb.attention {25 if !tb.attention {
M internal/usecase/daemon/attention_test.go +2 -2
@@ -12,11 +12,11 @@ import (
12 12
13 renderer "github.com/bnema/vev-vt"13 renderer "github.com/bnema/vev-vt"
14 "github.com/bnema/vev/internal/domain"14 "github.com/bnema/vev/internal/domain"
15+ "github.com/bnema/vev/internal/domain/layout"
15 "github.com/bnema/vev/internal/ports"16 "github.com/bnema/vev/internal/ports"
16 "github.com/bnema/vev/internal/protocol"17 "github.com/bnema/vev/internal/protocol"
17 "github.com/bnema/vev/internal/protocol/wire"18 "github.com/bnema/vev/internal/protocol/wire"
18 "github.com/bnema/vev/internal/usecase/keys"19 "github.com/bnema/vev/internal/usecase/keys"
19- "github.com/bnema/vev/internal/usecase/layout"
20 "github.com/bnema/vev/internal/usecase/ui"20 "github.com/bnema/vev/internal/usecase/ui"
21 )21 )
22 22
@@ -172,7 +172,7 @@ func TestNoteAttentionDoesNotBlockOnWedgedOtherClient(t *testing.T) {
172 return nil172 return nil
173 }).Maybe()173 }).Maybe()
174 trW.EXPECT().Close().Return(nil).Maybe()174 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}}}
176 acW.initOverlays()176 acW.initOverlays()
177 sctxW, cancelW := context.WithCancel(d.serveCtx)177 sctxW, cancelW := context.WithCancel(d.serveCtx)
178 t.Cleanup(cancelW)178 t.Cleanup(cancelW)
M internal/usecase/daemon/bar_refresh_test.go +15 -15
@@ -11,9 +11,9 @@ import (
11 "time"11 "time"
12 12
13 "github.com/bnema/vev/internal/domain"13 "github.com/bnema/vev/internal/domain"
14+ "github.com/bnema/vev/internal/domain/layout"
14 "github.com/bnema/vev/internal/ports"15 "github.com/bnema/vev/internal/ports"
15 portsmocks "github.com/bnema/vev/internal/ports/mocks"16 portsmocks "github.com/bnema/vev/internal/ports/mocks"
16- "github.com/bnema/vev/internal/usecase/layout"
17 "github.com/stretchr/testify/mock"17 "github.com/stretchr/testify/mock"
18 "github.com/stretchr/testify/require"18 "github.com/stretchr/testify/require"
19 )19 )
@@ -64,7 +64,7 @@ func TestBarScriptRefreshIntervalClampingTickAndLastGoodOnFailure(t *testing.T)
64 r := &fakeBarRunner{outs: []string{"top1", "bot1", "top2", "bot2"}}64 r := &fakeBarRunner{outs: []string{"top1", "bot1", "top2", "bot2"}}
65 d := newBarRefreshTestDaemon(r, 100*time.Millisecond)65 d := newBarRefreshTestDaemon(r, 100*time.Millisecond)
66 sess := newBarRefreshTestSession()66 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}}})
68 68
69 require.True(t, d.refreshBarScriptsIfDue(sess, time.Unix(0, 0), false))69 require.True(t, d.refreshBarScriptsIfDue(sess, time.Unix(0, 0), false))
70 waitBarRefreshIdle(t, d)70 waitBarRefreshIdle(t, d)
@@ -91,7 +91,7 @@ func TestBarScriptContextUsesFirstOrderedPaneAndClientCols(t *testing.T) {
91 sess := newBarRefreshTestSession()91 sess := newBarRefreshTestSession()
92 sess.name = "work"92 sess.name = "work"
93 sess.cwd = "/repo"93 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}}})
95 sess.tabs[0].size = domain.Size{Cols: 132, Rows: 38}95 sess.tabs[0].size = domain.Size{Cols: 132, Rows: 38}
96 active := sess.tabs[0].focusedPane()96 active := sess.tabs[0].focusedPane()
97 active.stableID = "pane-default"97 active.stableID = "pane-default"
@@ -130,7 +130,7 @@ func TestBarScriptRefreshSkipsRunnerWhenCommandsDisabled(t *testing.T) {
130 d.barScripts.cfg.topRight = ""130 d.barScripts.cfg.topRight = ""
131 d.barScripts.cfg.bottomRight = ""131 d.barScripts.cfg.bottomRight = ""
132 sess := newBarRefreshTestSession()132 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}}})
134 d.barScripts.outputs[sess.id] = barScriptOutputs{topRight: "old", bottomRight: "old"}134 d.barScripts.outputs[sess.id] = barScriptOutputs{topRight: "old", bottomRight: "old"}
135 135
136 require.False(t, d.refreshBarScriptsIfDue(sess, time.Unix(0, 0), true))136 require.False(t, d.refreshBarScriptsIfDue(sess, time.Unix(0, 0), true))
@@ -145,7 +145,7 @@ func TestDefaultBarConfigDoesNotInvokeRunner(t *testing.T) {
145 d := newBarRefreshTestDaemon(r, time.Second)145 d := newBarRefreshTestDaemon(r, time.Second)
146 d.barScripts.cfg = barConfigFromDomain(domain.Defaults().Bar)146 d.barScripts.cfg = barConfigFromDomain(domain.Defaults().Bar)
147 sess := newBarRefreshTestSession()147 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}}})
149 149
150 require.False(t, d.refreshBarScriptsIfDue(sess, time.Unix(0, 0), true))150 require.False(t, d.refreshBarScriptsIfDue(sess, time.Unix(0, 0), true))
151 require.Empty(t, r.calls)151 require.Empty(t, r.calls)
@@ -155,7 +155,7 @@ func TestBarScriptForcedRefreshRespectsMinimumInterval(t *testing.T) {
155 r := &fakeBarRunner{outs: []string{"top1", "bottom1", "top2", "bottom2"}}155 r := &fakeBarRunner{outs: []string{"top1", "bottom1", "top2", "bottom2"}}
156 d := newBarRefreshTestDaemon(r, 5*time.Second)156 d := newBarRefreshTestDaemon(r, 5*time.Second)
157 sess := newBarRefreshTestSession()157 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}}})
159 159
160 require.True(t, d.refreshBarScriptsIfDue(sess, time.Unix(10, 0), true))160 require.True(t, d.refreshBarScriptsIfDue(sess, time.Unix(10, 0), true))
161 waitBarRefreshIdle(t, d)161 waitBarRefreshIdle(t, d)
@@ -172,7 +172,7 @@ func TestBarScriptContextChangeDebouncesUntilMinimumInterval(t *testing.T) {
172 r := &fakeBarRunner{outs: []string{"top1", "bottom1", "top2", "bottom2"}}172 r := &fakeBarRunner{outs: []string{"top1", "bottom1", "top2", "bottom2"}}
173 d := newBarRefreshTestDaemon(r, 5*time.Second)173 d := newBarRefreshTestDaemon(r, 5*time.Second)
174 sess := newBarRefreshTestSession()174 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}}})
176 176
177 require.True(t, d.refreshBarScriptsIfDue(sess, time.Now(), false))177 require.True(t, d.refreshBarScriptsIfDue(sess, time.Now(), false))
178 waitBarRefreshIdle(t, d)178 waitBarRefreshIdle(t, d)
@@ -190,7 +190,7 @@ func TestBarScriptRefreshForcesWhenContextChanges(t *testing.T) {
190 r := &fakeBarRunner{outs: []string{"top1", "bottom1", "top2", "bottom2"}}190 r := &fakeBarRunner{outs: []string{"top1", "bottom1", "top2", "bottom2"}}
191 d := newBarRefreshTestDaemon(r, 5*time.Second)191 d := newBarRefreshTestDaemon(r, 5*time.Second)
192 sess := newBarRefreshTestSession()192 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}}})
194 194
195 require.True(t, d.refreshBarScriptsIfDue(sess, time.Unix(0, 0), false))195 require.True(t, d.refreshBarScriptsIfDue(sess, time.Unix(0, 0), false))
196 waitBarRefreshIdle(t, d)196 waitBarRefreshIdle(t, d)
@@ -218,7 +218,7 @@ func TestRefreshBarScriptsAllSessionsForcesRun(t *testing.T) {
218 r := &fakeBarRunner{outs: []string{"top1", "bottom1"}}218 r := &fakeBarRunner{outs: []string{"top1", "bottom1"}}
219 d := newBarRefreshTestDaemon(r, 60*time.Second)219 d := newBarRefreshTestDaemon(r, 60*time.Second)
220 sess := newBarRefreshTestSession()220 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}}})
222 d.sessions = map[domain.SessionID]*session{sess.id: sess}222 d.sessions = map[domain.SessionID]*session{sess.id: sess}
223 223
224 // Verifies refreshBarScriptsAllSessions iterates every live session and224 // Verifies refreshBarScriptsAllSessions iterates every live session and
@@ -251,7 +251,7 @@ func TestBarScriptFailureLogsAndRetainsLastGood(t *testing.T) {
251 d := newBarRefreshTestDaemon(r, time.Second)251 d := newBarRefreshTestDaemon(r, time.Second)
252 d.log = slog.New(slog.NewTextHandler(&logs, nil))252 d.log = slog.New(slog.NewTextHandler(&logs, nil))
253 sess := newBarRefreshTestSession()253 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}}})
255 255
256 require.True(t, d.refreshBarScriptsIfDue(sess, time.Unix(10, 0), true))256 require.True(t, d.refreshBarScriptsIfDue(sess, time.Unix(10, 0), true))
257 waitBarRefreshIdle(t, d)257 waitBarRefreshIdle(t, d)
@@ -286,7 +286,7 @@ func TestBarScriptRunConsumesPendingAfterRunningClears(t *testing.T) {
286 r := newBlockingBarRunner([]string{"top1", "bottom1", "top2", "bottom2"})286 r := newBlockingBarRunner([]string{"top1", "bottom1", "top2", "bottom2"})
287 d := newBarRefreshTestDaemon(r, time.Second)287 d := newBarRefreshTestDaemon(r, time.Second)
288 sess := newBarRefreshTestSession()288 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}}})
290 290
291 require.True(t, d.refreshBarScriptsIfDue(sess, time.Unix(10, 0), true))291 require.True(t, d.refreshBarScriptsIfDue(sess, time.Unix(10, 0), true))
292 select {292 select {
@@ -336,7 +336,7 @@ func TestBarScriptFailureLogsOnceUntilSignatureChanges(t *testing.T) {
336 d.log = logger336 d.log = logger
337 d.baseEnv = []string{"PATH=/usr/bin"}337 d.baseEnv = []string{"PATH=/usr/bin"}
338 sess := newBarRefreshTestSession()338 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}}})
340 340
341 readBuf := func() string {341 readBuf := func() string {
342 mu.Lock()342 mu.Lock()
@@ -419,11 +419,11 @@ func TestBarScriptRefreshIsPerSession(t *testing.T) {
419 a := newBarRefreshTestSession()419 a := newBarRefreshTestSession()
420 a.id = "a"420 a.id = "a"
421 a.name = "a"421 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}}})
423 b := newBarRefreshTestSession()423 b := newBarRefreshTestSession()
424 b.id = "b"424 b.id = "b"
425 b.name = "b"425 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}}})
427 427
428 require.True(t, d.refreshBarScriptsIfDue(a, time.Unix(0, 0), false))428 require.True(t, d.refreshBarScriptsIfDue(a, time.Unix(0, 0), false))
429 waitBarRefreshIdle(t, d)429 waitBarRefreshIdle(t, d)
@@ -644,7 +644,7 @@ func TestBarScriptsUseSessionEnvNotDaemonBaseEnv(t *testing.T) {
644 d.baseEnv = tc.baseEnv644 d.baseEnv = tc.baseEnv
645 sess := newBarRefreshTestSession()645 sess := newBarRefreshTestSession()
646 sess.env = tc.sessEnv646 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}}})
648 648
649 require.True(t, d.refreshBarScriptsIfDue(sess, time.Unix(0, 0), true))649 require.True(t, d.refreshBarScriptsIfDue(sess, time.Unix(0, 0), true))
650 waitBarRefreshIdle(t, d)650 waitBarRefreshIdle(t, d)
M internal/usecase/daemon/client.go +2 -6
@@ -94,7 +94,6 @@ type attachedClient struct {
94 // sizeMu lets shared-geometry snapshots read attachment windows without94 // sizeMu lets shared-geometry snapshots read attachment windows without
95 // waiting behind a blocked transport send.95 // waiting behind a blocked transport send.
96 sizeMu sync.RWMutex96 sizeMu sync.RWMutex
97- size domain.Size // retained for legacy fixtures; live paths publish geometry
98 geometry domain.Geometry // controlling-terminal geometry for this Attachment97 geometry domain.Geometry // controlling-terminal geometry for this Attachment
99 keys *keys.Router98 keys *keys.Router
100 // view is attachment-local navigation state. It is never inferred from a99 // view is attachment-local navigation state. It is never inferred from a
@@ -181,7 +180,7 @@ func (ac *attachedClient) sizeSnapshot() domain.Size {
181 }180 }
182 ac.sizeMu.RLock()181 ac.sizeMu.RLock()
183 defer ac.sizeMu.RUnlock()182 defer ac.sizeMu.RUnlock()
184- return ac.size183+ return ac.geometry.Size
185 }184 }
186 185
187 func (ac *attachedClient) setSize(size domain.Size) {186 func (ac *attachedClient) setSize(size domain.Size) {
@@ -189,7 +188,6 @@ func (ac *attachedClient) setSize(size domain.Size) {
189 return188 return
190 }189 }
191 ac.sizeMu.Lock()190 ac.sizeMu.Lock()
192- ac.size = size
193 ac.geometry.Size = size191 ac.geometry.Size = size
194 ac.geometry = ac.geometry.NormalizePixels()192 ac.geometry = ac.geometry.NormalizePixels()
195 ac.sizeMu.Unlock()193 ac.sizeMu.Unlock()
@@ -204,7 +202,7 @@ func (ac *attachedClient) geometrySnapshot() domain.Geometry {
204 if ac.geometry.Valid() {202 if ac.geometry.Valid() {
205 return ac.geometry203 return ac.geometry
206 }204 }
207- return domain.Geometry{Size: ac.size}205+ return domain.Geometry{Size: ac.geometry.Size}
208 }206 }
209 207
210 func (ac *attachedClient) setGeometry(geometry domain.Geometry) {208 func (ac *attachedClient) setGeometry(geometry domain.Geometry) {
@@ -213,7 +211,6 @@ func (ac *attachedClient) setGeometry(geometry domain.Geometry) {
213 }211 }
214 geometry = geometry.NormalizePixels()212 geometry = geometry.NormalizePixels()
215 ac.sizeMu.Lock()213 ac.sizeMu.Lock()
216- ac.size = geometry.Size
217 ac.geometry = geometry214 ac.geometry = geometry
218 ac.sizeMu.Unlock()215 ac.sizeMu.Unlock()
219 }216 }
@@ -772,7 +769,6 @@ func (d *Daemon) prepareAttachedClientLocked(sess *session, tr ports.ServerConne
772 ac := &attachedClient{769 ac := &attachedClient{
773 tr: tr,770 tr: tr,
774 output: output,771 output: output,
775- size: geometry.Size,
776 geometry: geometry,772 geometry: geometry,
777 view: attachmentView{windowRows: geometry.Rows, windowSet: true},773 view: attachmentView{windowRows: geometry.Rows, windowSet: true},
778 clientID: opts.clientID,774 clientID: opts.clientID,
M internal/usecase/daemon/coderabbit_regression_test.go +1 -1
@@ -15,9 +15,9 @@ import (
15 "github.com/bnema/vev/internal/ports"15 "github.com/bnema/vev/internal/ports"
16 portsmocks "github.com/bnema/vev/internal/ports/mocks"16 portsmocks "github.com/bnema/vev/internal/ports/mocks"
17 "github.com/bnema/vev/internal/protocol"17 "github.com/bnema/vev/internal/protocol"
18+ snapcodec "github.com/bnema/vev/internal/snapshotcodec"
18 "github.com/bnema/vev/internal/usecase/picker"19 "github.com/bnema/vev/internal/usecase/picker"
19 recoveryusecase "github.com/bnema/vev/internal/usecase/recovery"20 recoveryusecase "github.com/bnema/vev/internal/usecase/recovery"
20- snapcodec "github.com/bnema/vev/internal/usecase/snapshot"
21 )21 )
22 22
23 func TestRestoreAmbiguousBadVersionLoadFailurePreservesCheckpoint(t *testing.T) {23 func TestRestoreAmbiguousBadVersionLoadFailurePreservesCheckpoint(t *testing.T) {
M internal/usecase/daemon/control.go +1 -1
@@ -11,11 +11,11 @@ import (
11 "strings"11 "strings"
12 12
13 "github.com/bnema/vev/internal/domain"13 "github.com/bnema/vev/internal/domain"
14+ "github.com/bnema/vev/internal/domain/layout"
14 "github.com/bnema/vev/internal/ports"15 "github.com/bnema/vev/internal/ports"
15 "github.com/bnema/vev/internal/protocol"16 "github.com/bnema/vev/internal/protocol"
16 "github.com/bnema/vev/internal/protocol/catalogue"17 "github.com/bnema/vev/internal/protocol/catalogue"
17 "github.com/bnema/vev/internal/usecase/command"18 "github.com/bnema/vev/internal/usecase/command"
18- "github.com/bnema/vev/internal/usecase/layout"
19 )19 )
20 20
21 // handleCommand serves one typed one-shot control request.21 // handleCommand serves one typed one-shot control request.
M internal/usecase/daemon/control_test.go +1 -1
@@ -14,13 +14,13 @@ import (
14 "time"14 "time"
15 15
16 "github.com/bnema/vev/internal/domain"16 "github.com/bnema/vev/internal/domain"
17+ "github.com/bnema/vev/internal/domain/layout"
17 appports "github.com/bnema/vev/internal/ports"18 appports "github.com/bnema/vev/internal/ports"
18 portsmocks "github.com/bnema/vev/internal/ports/mocks"19 portsmocks "github.com/bnema/vev/internal/ports/mocks"
19 "github.com/bnema/vev/internal/protocol"20 "github.com/bnema/vev/internal/protocol"
20 "github.com/bnema/vev/internal/protocol/catalogue"21 "github.com/bnema/vev/internal/protocol/catalogue"
21 "github.com/bnema/vev/internal/protocol/wire"22 "github.com/bnema/vev/internal/protocol/wire"
22 "github.com/bnema/vev/internal/usecase/command"23 "github.com/bnema/vev/internal/usecase/command"
23- "github.com/bnema/vev/internal/usecase/layout"
24 "github.com/stretchr/testify/mock"24 "github.com/stretchr/testify/mock"
25 "github.com/stretchr/testify/require"25 "github.com/stretchr/testify/require"
26 )26 )
M internal/usecase/daemon/copy_scroll_ownership_test.go +2 -1
@@ -4,6 +4,7 @@ import (
4 "testing"4 "testing"
5 5
6 renderer "github.com/bnema/vev-vt"6 renderer "github.com/bnema/vev-vt"
7+ "github.com/bnema/vev/internal/domain"
7 "github.com/bnema/vev/internal/protocol"8 "github.com/bnema/vev/internal/protocol"
8 scopy "github.com/bnema/vev/internal/usecase/copy"9 scopy "github.com/bnema/vev/internal/usecase/copy"
9 "github.com/stretchr/testify/require"10 "github.com/stretchr/testify/require"
@@ -94,7 +95,7 @@ func TestCopyExitRefreshesAfterOtherAttachmentConsumesDamage(t *testing.T) {
94 copyBefore := captureTestFrame(ac.render.cache.frame)95 copyBefore := captureTestFrame(ac.render.cache.frame)
95 96
96 tr, peerSends := newCapturingTransport(t)97 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}}
98 peer.output.attachment = peer99 peer.output.attachment = peer
99 peer.initOverlays()100 peer.initOverlays()
100 peer.setSession(sess)101 peer.setSession(sess)
M internal/usecase/daemon/copymode_lifecycle_test.go +1 -1
@@ -8,9 +8,9 @@ import (
8 "github.com/stretchr/testify/require"8 "github.com/stretchr/testify/require"
9 9
10 "github.com/bnema/vev/internal/domain"10 "github.com/bnema/vev/internal/domain"
11+ "github.com/bnema/vev/internal/domain/layout"
11 portsmocks "github.com/bnema/vev/internal/ports/mocks"12 portsmocks "github.com/bnema/vev/internal/ports/mocks"
12 scopy "github.com/bnema/vev/internal/usecase/copy"13 scopy "github.com/bnema/vev/internal/usecase/copy"
13- "github.com/bnema/vev/internal/usecase/layout"
14 )14 )
15 15
16 func TestCopyModeLifecycleResizeExitsBeforeGeometryChange(t *testing.T) {16 func TestCopyModeLifecycleResizeExitsBeforeGeometryChange(t *testing.T) {
M internal/usecase/daemon/copymode_test.go +1 -1
@@ -17,12 +17,12 @@ import (
17 vt "github.com/bnema/vev-vt"17 vt "github.com/bnema/vev-vt"
18 renderer "github.com/bnema/vev-vt/ansi"18 renderer "github.com/bnema/vev-vt/ansi"
19 "github.com/bnema/vev/internal/domain"19 "github.com/bnema/vev/internal/domain"
20+ "github.com/bnema/vev/internal/domain/layout"
20 "github.com/bnema/vev/internal/ports"21 "github.com/bnema/vev/internal/ports"
21 portsmocks "github.com/bnema/vev/internal/ports/mocks"22 portsmocks "github.com/bnema/vev/internal/ports/mocks"
22 "github.com/bnema/vev/internal/protocol"23 "github.com/bnema/vev/internal/protocol"
23 "github.com/bnema/vev/internal/protocol/wire"24 "github.com/bnema/vev/internal/protocol/wire"
24 scopy "github.com/bnema/vev/internal/usecase/copy"25 scopy "github.com/bnema/vev/internal/usecase/copy"
25- "github.com/bnema/vev/internal/usecase/layout"
26 "github.com/bnema/vev/internal/usecase/ui"26 "github.com/bnema/vev/internal/usecase/ui"
27 )27 )
28 28
M internal/usecase/daemon/daemon_test.go +4 -3
@@ -26,10 +26,11 @@ import (
26 26
27 // stubClock returns timers whose channel never fires, so a scheduler under it27 // stubClock returns timers whose channel never fires, so a scheduler under it
28 // blocks in its debounce loop until the session context is cancelled. Used by28 // 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.
30 type stubClock struct{}31 type stubClock struct{}
31 32
32-func (stubClock) Now() time.Time { return time.Time{} }33+func (stubClock) Now() time.Time { return time.Unix(1_700_000_000, 0) }
33 func (stubClock) NewTimer(time.Duration) ports.Timer { return stubTimer{} }34 func (stubClock) NewTimer(time.Duration) ports.Timer { return stubTimer{} }
34 35
35 type stubTimer struct{}36 type stubTimer struct{}
@@ -416,7 +417,7 @@ func newManualSessionWithPTYsClockCleanup(t testing.TB, clock ports.Clock, regis
416 t.Helper()417 t.Helper()
417 d := newTestDaemonWithCleanup(t, nil, clock, registerCleanup)418 d := newTestDaemonWithCleanup(t, nil, clock, registerCleanup)
418 tr, sends := newCapturingTransport(t)419 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}}}
420 ac.output.attachment = ac421 ac.output.attachment = ac
421 ac.initOverlays()422 ac.initOverlays()
422 sctx, cancel := context.WithCancel(d.serveCtx)423 sctx, cancel := context.WithCancel(d.serveCtx)
M internal/usecase/daemon/durable_recovery_test.go +1 -1
@@ -21,8 +21,8 @@ import (
21 "github.com/bnema/vev/internal/ports"21 "github.com/bnema/vev/internal/ports"
22 portsmocks "github.com/bnema/vev/internal/ports/mocks"22 portsmocks "github.com/bnema/vev/internal/ports/mocks"
23 "github.com/bnema/vev/internal/protocol"23 "github.com/bnema/vev/internal/protocol"
24+ snapcodec "github.com/bnema/vev/internal/snapshotcodec"
24 recoveryusecase "github.com/bnema/vev/internal/usecase/recovery"25 recoveryusecase "github.com/bnema/vev/internal/usecase/recovery"
25- snapcodec "github.com/bnema/vev/internal/usecase/snapshot"
26 )26 )
27 27
28 type durableRecoveryCatalogue struct {28 type durableRecoveryCatalogue struct {
M internal/usecase/daemon/final_acceptance_test.go +10 -10
@@ -5,8 +5,8 @@ import (
5 5
6 vt "github.com/bnema/vev-vt"6 vt "github.com/bnema/vev-vt"
7 "github.com/bnema/vev/internal/domain"7 "github.com/bnema/vev/internal/domain"
8+ "github.com/bnema/vev/internal/domain/layout"
8 "github.com/bnema/vev/internal/protocol"9 "github.com/bnema/vev/internal/protocol"
9- "github.com/bnema/vev/internal/usecase/layout"
10 "github.com/stretchr/testify/require"10 "github.com/stretchr/testify/require"
11 )11 )
12 12
@@ -19,9 +19,9 @@ func TestAcceptancePaletteNewTabUsesSessionGeometryAfterAttachmentResize(t *test
19 19
20 secondTransport, _ := newCapturingTransport(t)20 secondTransport, _ := newCapturingTransport(t)
21 second := &attachedClient{21 second := &attachedClient{
22- tr: secondTransport,22+ tr: secondTransport,
23- output: newOutputStateStream(),23+ output: newOutputStateStream(),
24- size: domain.Size{Cols: 80, Rows: 24},24+ geometry: domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}},
25 }25 }
26 second.initOverlays()26 second.initOverlays()
27 second.clientID[0] = 227 second.clientID[0] = 2
@@ -85,7 +85,7 @@ func TestAcceptancePaletteNewTabUsesSessionGeometryAfterAttachmentResize(t *test
85 first.sendMu.Unlock()85 first.sendMu.Unlock()
86 require.Equal(t, firstEpoch, gotFirstEpoch, "new tab render crossed attachment boundary")86 require.Equal(t, firstEpoch, gotFirstEpoch, "new tab render crossed attachment boundary")
87 second.sendMu.Lock()87 second.sendMu.Lock()
88- gotSecondSize := second.size88+ gotSecondSize := second.geometry.Size
89 second.sendMu.Unlock()89 second.sendMu.Unlock()
90 require.Equal(t, domain.Size{Cols: 120, Rows: 40}, gotSecondSize)90 require.Equal(t, domain.Size{Cols: 120, Rows: 40}, gotSecondSize)
91 }91 }
@@ -99,9 +99,9 @@ func TestAcceptanceAttachmentStateIsolationAcrossResetResizeAndDetach(t *testing
99 99
100 secondTransport, _ := newCapturingTransport(t)100 secondTransport, _ := newCapturingTransport(t)
101 second := &attachedClient{101 second := &attachedClient{
102- tr: secondTransport,102+ tr: secondTransport,
103- output: newOutputStateStream(),103+ output: newOutputStateStream(),
104- size: domain.Size{Cols: 80, Rows: 24},104+ geometry: domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}},
105 }105 }
106 second.initOverlays()106 second.initOverlays()
107 second.clientID[0] = 2107 second.clientID[0] = 2
@@ -156,8 +156,8 @@ func TestAcceptanceAttachmentStateIsolationAcrossResetResizeAndDetach(t *testing
156 require.True(t, admitted)156 require.True(t, admitted)
157 require.True(t, d.resizeAttachmentForLease(secondEffect, domain.Size{Cols: 120, Rows: 40}))157 require.True(t, d.resizeAttachmentForLease(secondEffect, domain.Size{Cols: 120, Rows: 40}))
158 secondEffect.End()158 secondEffect.End()
159- require.Equal(t, domain.Size{Cols: 120, Rows: 40}, second.size)159+ require.Equal(t, domain.Size{Cols: 120, Rows: 40}, second.geometry.Size)
160- require.Equal(t, domain.Size{Cols: 80, Rows: 24}, first.size, "peer resize crossed attachment boundary")160+ require.Equal(t, domain.Size{Cols: 80, Rows: 24}, first.geometry.Size, "peer resize crossed attachment boundary")
161 sess.mu.Lock()161 sess.mu.Lock()
162 tb := sess.tabs[0]162 tb := sess.tabs[0]
163 sess.mu.Unlock()163 sess.mu.Unlock()
M internal/usecase/daemon/floating.go +1 -1
@@ -6,7 +6,7 @@ import (
6 "strings"6 "strings"
7 7
8 "github.com/bnema/vev/internal/domain"8 "github.com/bnema/vev/internal/domain"
9- "github.com/bnema/vev/internal/usecase/layout"9+ "github.com/bnema/vev/internal/domain/layout"
10 )10 )
11 11
12 // floatingState describes the lifecycle of a tab's independent floating pane.12 // 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.
226 d := newTestDaemon(t, factory, stubClock{})226 d := newTestDaemon(t, factory, stubClock{})
227 tb := newFloatingTestTab(t)227 tb := newFloatingTestTab(t)
228 tr, _ := newCapturingTransport(t)228 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}}}
230 ac.initOverlays()230 ac.initOverlays()
231 sessCtx, cancelSession := context.WithCancel(t.Context())231 sessCtx, cancelSession := context.WithCancel(t.Context())
232 sess := &session{sessionCore: sessionCore{id: "floating-open-error", name: "work", attachments: map[*attachedClient]struct{}{ac: {}}}, tabs: []*tab{tb}, ctx: sessCtx, cancel: cancelSession}232 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 (
11 11
12 renderer "github.com/bnema/vev-vt/ansi"12 renderer "github.com/bnema/vev-vt/ansi"
13 "github.com/bnema/vev/internal/domain"13 "github.com/bnema/vev/internal/domain"
14- "github.com/bnema/vev/internal/usecase/layout"14+ "github.com/bnema/vev/internal/domain/layout"
15 themeui "github.com/bnema/vev/internal/usecase/theme"15 themeui "github.com/bnema/vev/internal/usecase/theme"
16 "github.com/bnema/vev/internal/usecase/ui"16 "github.com/bnema/vev/internal/usecase/ui"
17 )17 )
M internal/usecase/daemon/floating_spare.go +1 -1
@@ -1,7 +1,7 @@
1 package daemon1 package daemon
2 2
3 import (3 import (
4- "github.com/bnema/vev/internal/usecase/layout"4+ "github.com/bnema/vev/internal/domain/layout"
5 )5 )
6 6
7 // floatingSpare is one pre-started floating shell owned by a session rather7 // 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 (
15 "github.com/stretchr/testify/require"15 "github.com/stretchr/testify/require"
16 16
17 "github.com/bnema/vev/internal/domain"17 "github.com/bnema/vev/internal/domain"
18+ "github.com/bnema/vev/internal/domain/layout"
18 "github.com/bnema/vev/internal/ports"19 "github.com/bnema/vev/internal/ports"
19 portsmocks "github.com/bnema/vev/internal/ports/mocks"20 portsmocks "github.com/bnema/vev/internal/ports/mocks"
20 "github.com/bnema/vev/internal/protocol"21 "github.com/bnema/vev/internal/protocol"
21 "github.com/bnema/vev/internal/protocol/wire"22 "github.com/bnema/vev/internal/protocol/wire"
22 scopy "github.com/bnema/vev/internal/usecase/copy"23 scopy "github.com/bnema/vev/internal/usecase/copy"
23- "github.com/bnema/vev/internal/usecase/layout"
24 themeui "github.com/bnema/vev/internal/usecase/theme"24 themeui "github.com/bnema/vev/internal/usecase/theme"
25 )25 )
26 26
@@ -768,7 +768,7 @@ func TestFloatingAsyncSpawnFailureToastsOnlyForUserOpen(t *testing.T) {
768 d := newTestDaemon(t, factory, stubClock{})768 d := newTestDaemon(t, factory, stubClock{})
769 tb := newFloatingTestTab(t)769 tb := newFloatingTestTab(t)
770 tr, _ := newCapturingTransport(t)770 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}}}
772 ac.initOverlays()772 ac.initOverlays()
773 sess := &session{sessionCore: sessionCore{id: "floating-spawn", name: "work", attachments: map[*attachedClient]struct{}{ac: {}}}, tabs: []*tab{tb}, ctx: t.Context()}773 sess := &session{sessionCore: sessionCore{id: "floating-spawn", name: "work", attachments: map[*attachedClient]struct{}{ac: {}}}, tabs: []*tab{tb}, ctx: t.Context()}
774 ac.setSession(sess)774 ac.setSession(sess)
M internal/usecase/daemon/graphics_output.go +1 -1
@@ -15,8 +15,8 @@ import (
15 renderer "github.com/bnema/vev-vt"15 renderer "github.com/bnema/vev-vt"
16 "github.com/bnema/vev-vt/graphics"16 "github.com/bnema/vev-vt/graphics"
17 "github.com/bnema/vev/internal/domain"17 "github.com/bnema/vev/internal/domain"
18+ "github.com/bnema/vev/internal/domain/layout"
18 "github.com/bnema/vev/internal/protocol"19 "github.com/bnema/vev/internal/protocol"
19- "github.com/bnema/vev/internal/usecase/layout"
20 themeui "github.com/bnema/vev/internal/usecase/theme"20 themeui "github.com/bnema/vev/internal/usecase/theme"
21 )21 )
22 22
M internal/usecase/daemon/graphics_output_test.go +1 -1
@@ -13,10 +13,10 @@ import (
13 vt "github.com/bnema/vev-vt"13 vt "github.com/bnema/vev-vt"
14 "github.com/bnema/vev-vt/graphics"14 "github.com/bnema/vev-vt/graphics"
15 "github.com/bnema/vev/internal/domain"15 "github.com/bnema/vev/internal/domain"
16+ "github.com/bnema/vev/internal/domain/layout"
16 "github.com/bnema/vev/internal/domain/terminalcap"17 "github.com/bnema/vev/internal/domain/terminalcap"
17 "github.com/bnema/vev/internal/protocol"18 "github.com/bnema/vev/internal/protocol"
18 "github.com/bnema/vev/internal/protocol/wire"19 "github.com/bnema/vev/internal/protocol/wire"
19- "github.com/bnema/vev/internal/usecase/layout"
20 "github.com/stretchr/testify/require"20 "github.com/stretchr/testify/require"
21 )21 )
22 22
M internal/usecase/daemon/input.go +1 -1
@@ -7,10 +7,10 @@ import (
7 "strconv"7 "strconv"
8 8
9 "github.com/bnema/vev/internal/domain"9 "github.com/bnema/vev/internal/domain"
10+ "github.com/bnema/vev/internal/domain/layout"
10 "github.com/bnema/vev/internal/usecase/command"11 "github.com/bnema/vev/internal/usecase/command"
11 "github.com/bnema/vev/internal/usecase/keys"12 "github.com/bnema/vev/internal/usecase/keys"
12 "github.com/bnema/vev/internal/usecase/keys/kittykey"13 "github.com/bnema/vev/internal/usecase/keys/kittykey"
13- "github.com/bnema/vev/internal/usecase/layout"
14 "github.com/bnema/vev/internal/usecase/mouse"14 "github.com/bnema/vev/internal/usecase/mouse"
15 )15 )
16 16
M internal/usecase/daemon/input_test.go +2 -2
@@ -15,12 +15,12 @@ import (
15 15
16 vt "github.com/bnema/vev-vt"16 vt "github.com/bnema/vev-vt"
17 "github.com/bnema/vev/internal/domain"17 "github.com/bnema/vev/internal/domain"
18+ "github.com/bnema/vev/internal/domain/layout"
18 portsmocks "github.com/bnema/vev/internal/ports/mocks"19 portsmocks "github.com/bnema/vev/internal/ports/mocks"
19 "github.com/bnema/vev/internal/protocol"20 "github.com/bnema/vev/internal/protocol"
20 "github.com/bnema/vev/internal/protocol/wire"21 "github.com/bnema/vev/internal/protocol/wire"
21 scopy "github.com/bnema/vev/internal/usecase/copy"22 scopy "github.com/bnema/vev/internal/usecase/copy"
22 "github.com/bnema/vev/internal/usecase/keys"23 "github.com/bnema/vev/internal/usecase/keys"
23- "github.com/bnema/vev/internal/usecase/layout"
24 "github.com/bnema/vev/internal/usecase/mouse"24 "github.com/bnema/vev/internal/usecase/mouse"
25 "github.com/bnema/vev/internal/usecase/picker"25 "github.com/bnema/vev/internal/usecase/picker"
26 "github.com/bnema/vev/internal/usecase/ui"26 "github.com/bnema/vev/internal/usecase/ui"
@@ -191,7 +191,7 @@ func TestSwitchTabFirstFrameDoesNotReuseSamePaneIDCapture(t *testing.T) {
191 d.paint(sess, ac, true, nil)191 d.paint(sess, ac, true, nil)
192 first := awaitFrame(t, sends, "Output")192 first := awaitFrame(t, sends, "Output")
193 firstOutput := unmarshalTestOutput(t, first.Payload)193 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)
195 terminal.Write(firstOutput.Data)195 terminal.Write(firstOutput.Data)
196 196
197 // A clean pane relies on the attachment capture cache for its retained197 // 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 (
4 "slices"4 "slices"
5 5
6 "github.com/bnema/vev/internal/domain"6 "github.com/bnema/vev/internal/domain"
7- "github.com/bnema/vev/internal/usecase/layout"7+ "github.com/bnema/vev/internal/domain/layout"
8 )8 )
9 9
10 // mutateTargetLayout preserves the publication semantics used by resize and10 // mutateTargetLayout preserves the publication semantics used by resize and
M internal/usecase/daemon/mouse_panes.go +1 -1
@@ -6,7 +6,7 @@ import (
6 "strings"6 "strings"
7 7
8 "github.com/bnema/vev/internal/domain"8 "github.com/bnema/vev/internal/domain"
9- "github.com/bnema/vev/internal/usecase/layout"9+ "github.com/bnema/vev/internal/domain/layout"
10 "github.com/bnema/vev/internal/usecase/mouse"10 "github.com/bnema/vev/internal/usecase/mouse"
11 )11 )
12 12
M internal/usecase/daemon/move_acceptance_test.go +1 -1
@@ -7,7 +7,7 @@ import (
7 "github.com/stretchr/testify/require"7 "github.com/stretchr/testify/require"
8 8
9 "github.com/bnema/vev/internal/domain"9 "github.com/bnema/vev/internal/domain"
10- "github.com/bnema/vev/internal/usecase/layout"10+ "github.com/bnema/vev/internal/domain/layout"
11 )11 )
12 12
13 func catalogueUpdateNames(updates []domain.CatalogueMetadataUpdate) []string {13 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) {
62 62
63 replacementTransport, _ := newCapturingTransport(t)63 replacementTransport, _ := newCapturingTransport(t)
64 replacement := &attachedClient{64 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}},
66 }66 }
67 replacement.initOverlays()67 replacement.initOverlays()
68 replacement.setSession(source)68 replacement.setSession(source)
M internal/usecase/daemon/move_gate_race_test.go +1 -1
@@ -186,7 +186,7 @@ func TestPaletteFinalCloseEndsCurrentEffectBeforeDrainingPeers(t *testing.T) {
186 d, sess, current, _ := newManualSessionWithPTYs(t, newQuietPTY())186 d, sess, current, _ := newManualSessionWithPTYs(t, newQuietPTY())
187 d.attachCoordinator(sess, nil, current, true)187 d.attachCoordinator(sess, nil, current, true)
188 otherTransport := &closeTrackingTransport{}188 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}}
190 other.initOverlays()190 other.initOverlays()
191 other.setSession(sess)191 other.setSession(sess)
192 sess.mu.Lock()192 sess.mu.Lock()
M internal/usecase/daemon/move_pane_admission.go +1 -1
@@ -1,6 +1,6 @@
1 package daemon1 package daemon
2 2
3-import "github.com/bnema/vev/internal/usecase/layout"3+import "github.com/bnema/vev/internal/domain/layout"
4 4
5 // movePaneTopology is the private pane topology Implementation consumed by the5 // movePaneTopology is the private pane topology Implementation consumed by the
6 // shared moveTransaction Module.6 // shared moveTransaction Module.
M internal/usecase/daemon/move_pane_candidate.go +1 -1
@@ -6,7 +6,7 @@ import (
6 "math"6 "math"
7 7
8 "github.com/bnema/vev/internal/domain"8 "github.com/bnema/vev/internal/domain"
9- "github.com/bnema/vev/internal/usecase/layout"9+ "github.com/bnema/vev/internal/domain/layout"
10 )10 )
11 11
12 // movePaneCandidate contains only unpublished topology and identity changes.12 // movePaneCandidate contains only unpublished topology and identity changes.
M internal/usecase/daemon/move_pane_commit.go +1 -1
@@ -1,6 +1,6 @@
1 package daemon1 package daemon
2 2
3-import "github.com/bnema/vev/internal/usecase/layout"3+import "github.com/bnema/vev/internal/domain/layout"
4 4
5 // movePaneCommit owns pane-private validation and publication. The shared5 // movePaneCommit owns pane-private validation and publication. The shared
6 // moveTransaction already holds daemon, routing, ordered Session, and resize6 // 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) {
390 source.tabs = append(source.tabs, newTabWithStableID("remaining", "remaining-pane", pty, movedTab.size))390 source.tabs = append(source.tabs, newTabWithStableID("remaining", "remaining-pane", pty, movedTab.size))
391 publishTiledPaneOwners(source, source.tabs[1])391 publishTiledPaneOwners(source, source.tabs[1])
392 }392 }
393- peer := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: ac.size}393+ peer := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), geometry: domain.Geometry{Size: ac.geometry.Size}}
394 peer.setSession(source)394 peer.setSession(source)
395 source.registerAttachment(peer)395 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}}
397 destinationPeer.setSession(destination)397 destinationPeer.setSession(destination)
398 destination.registerAttachment(destinationPeer)398 destination.registerAttachment(destinationPeer)
399 destination.selectAttachmentTabLocked(destinationPeer, domain.TabStableID(destinationTab.stableID))399 destination.selectAttachmentTabLocked(destinationPeer, domain.TabStableID(destinationTab.stableID))
M internal/usecase/daemon/move_rejection.go +1 -1
@@ -4,8 +4,8 @@ import (
4 "errors"4 "errors"
5 5
6 "github.com/bnema/vev/internal/domain"6 "github.com/bnema/vev/internal/domain"
7+ "github.com/bnema/vev/internal/domain/layout"
7 "github.com/bnema/vev/internal/protocol"8 "github.com/bnema/vev/internal/protocol"
8- "github.com/bnema/vev/internal/usecase/layout"
9 )9 )
10 10
11 // moveRejectionReason is the daemon API category for a rejected move. It keeps11 // 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 (
7 "github.com/stretchr/testify/require"7 "github.com/stretchr/testify/require"
8 8
9 "github.com/bnema/vev/internal/domain"9 "github.com/bnema/vev/internal/domain"
10+ "github.com/bnema/vev/internal/domain/layout"
10 "github.com/bnema/vev/internal/protocol"11 "github.com/bnema/vev/internal/protocol"
11- "github.com/bnema/vev/internal/usecase/layout"
12 )12 )
13 13
14 func TestMoveRejectionCategoriesPreserveLegacyIdentity(t *testing.T) {14 func TestMoveRejectionCategoriesPreserveLegacyIdentity(t *testing.T) {
M internal/usecase/daemon/move_resize_fences_test.go +1 -1
@@ -7,7 +7,7 @@ import (
7 7
8 "github.com/stretchr/testify/require"8 "github.com/stretchr/testify/require"
9 9
10- "github.com/bnema/vev/internal/usecase/layout"10+ "github.com/bnema/vev/internal/domain/layout"
11 )11 )
12 12
13 func requireMutexLocked(t *testing.T, mu *sync.Mutex, message string) {13 func requireMutexLocked(t *testing.T, mu *sync.Mutex, message string) {
M internal/usecase/daemon/move_tab.go +1 -1
@@ -4,7 +4,7 @@ import (
4 "context"4 "context"
5 5
6 "github.com/bnema/vev/internal/domain"6 "github.com/bnema/vev/internal/domain"
7- "github.com/bnema/vev/internal/usecase/layout"7+ "github.com/bnema/vev/internal/domain/layout"
8 )8 )
9 9
10 // moveTabTopology is the private whole-Tab topology Implementation consumed by10 // moveTabTopology is the private whole-Tab topology Implementation consumed by
M internal/usecase/daemon/move_test.go +2 -2
@@ -8,7 +8,7 @@ import (
8 "github.com/stretchr/testify/require"8 "github.com/stretchr/testify/require"
9 9
10 "github.com/bnema/vev/internal/domain"10 "github.com/bnema/vev/internal/domain"
11- "github.com/bnema/vev/internal/usecase/layout"11+ "github.com/bnema/vev/internal/domain/layout"
12 )12 )
13 13
14 func TestMovePaneMutationSameSession(t *testing.T) {14 func TestMovePaneMutationSameSession(t *testing.T) {
@@ -142,7 +142,7 @@ func TestMovePaneRetiresSourceParkedClients(t *testing.T) {
142 movedTab.stableID = "source-tab"142 movedTab.stableID = "source-tab"
143 143
144 transport := &closeTrackingTransport{}144 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}
146 parked.initOverlays()146 parked.initOverlays()
147 require.True(t, d.parkAttachment(source, parked))147 require.True(t, d.parkAttachment(source, parked))
148 destinationPTY := newQuietPTY()148 destinationPTY := newQuietPTY()
M internal/usecase/daemon/multi_attachment_callers_test.go +1 -1
@@ -5,7 +5,7 @@ import (
5 "testing"5 "testing"
6 6
7 "github.com/bnema/vev/internal/domain"7 "github.com/bnema/vev/internal/domain"
8- "github.com/bnema/vev/internal/usecase/layout"8+ "github.com/bnema/vev/internal/domain/layout"
9 "github.com/stretchr/testify/require"9 "github.com/stretchr/testify/require"
10 )10 )
11 11
M internal/usecase/daemon/navigation_attachment_handoff_test.go +2 -2
@@ -6,8 +6,8 @@ import (
6 "time"6 "time"
7 7
8 "github.com/bnema/vev/internal/domain"8 "github.com/bnema/vev/internal/domain"
9+ "github.com/bnema/vev/internal/domain/layout"
9 "github.com/bnema/vev/internal/protocol"10 "github.com/bnema/vev/internal/protocol"
10- "github.com/bnema/vev/internal/usecase/layout"
11 "github.com/bnema/vev/internal/usecase/picker"11 "github.com/bnema/vev/internal/usecase/picker"
12 "github.com/stretchr/testify/require"12 "github.com/stretchr/testify/require"
13 )13 )
@@ -172,7 +172,7 @@ func TestStoppedSessionHandoffDoesNotResumeAfterInitiatorReplacement(t *testing.
172 t.Fatal("stopped-session handoff did not release its role ticket")172 t.Fatal("stopped-session handoff did not release its role ticket")
173 }173 }
174 174
175- next := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: old.size}175+ next := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), geometry: domain.Geometry{Size: old.geometry.Size}}
176 next.initOverlays()176 next.initOverlays()
177 replacement, err := d.transitionAttachment(attachmentTransitionRequest{177 replacement, err := d.transitionAttachment(attachmentTransitionRequest{
178 target: source, next: next, expectedTransport: next.transportSnapshot(), ready: true,178 target: source, next: next, expectedTransport: next.transportSnapshot(), ready: true,
M internal/usecase/daemon/navigation_overflow.go +1 -1
@@ -4,7 +4,7 @@ import (
4 "sort"4 "sort"
5 5
6 "github.com/bnema/vev/internal/domain"6 "github.com/bnema/vev/internal/domain"
7- "github.com/bnema/vev/internal/usecase/layout"7+ "github.com/bnema/vev/internal/domain/layout"
8 "github.com/bnema/vev/internal/usecase/picker"8 "github.com/bnema/vev/internal/usecase/picker"
9 )9 )
10 10
M internal/usecase/daemon/navigation_overflow_test.go +1 -1
@@ -7,8 +7,8 @@ import (
7 "github.com/stretchr/testify/require"7 "github.com/stretchr/testify/require"
8 8
9 "github.com/bnema/vev/internal/domain"9 "github.com/bnema/vev/internal/domain"
10+ "github.com/bnema/vev/internal/domain/layout"
10 "github.com/bnema/vev/internal/usecase/keys"11 "github.com/bnema/vev/internal/usecase/keys"
11- "github.com/bnema/vev/internal/usecase/layout"
12 "github.com/bnema/vev/internal/usecase/picker"12 "github.com/bnema/vev/internal/usecase/picker"
13 )13 )
14 14
M internal/usecase/daemon/nil_pty_test.go +1 -1
@@ -9,8 +9,8 @@ import (
9 "github.com/stretchr/testify/require"9 "github.com/stretchr/testify/require"
10 10
11 "github.com/bnema/vev/internal/domain"11 "github.com/bnema/vev/internal/domain"
12+ "github.com/bnema/vev/internal/domain/layout"
12 "github.com/bnema/vev/internal/protocol"13 "github.com/bnema/vev/internal/protocol"
13- "github.com/bnema/vev/internal/usecase/layout"
14 )14 )
15 15
16 func TestNilPTYLifecyclePathsDoNotPanic(t *testing.T) {16 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 {
81 func TestPaintComposesNoticeToastTopRightAndExpiresOnTTL(t *testing.T) {81 func TestPaintComposesNoticeToastTopRightAndExpiresOnTTL(t *testing.T) {
82 clk := newNoticeClock()82 clk := newNoticeClock()
83 d, sess, ac, sends := newNoticeFixture(t, clk)83 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)
85 85
86 d.notify(sess, domain.NoticeError, domain.NoticePaneSpawn, "couldn't open pane", nil)86 d.notify(sess, domain.NoticeError, domain.NoticePaneSpawn, "couldn't open pane", nil)
87 awaitToastCount(t, ac, 1)87 awaitToastCount(t, ac, 1)
88 replayOutputFrames(t, screen, drainAllFrames(sends))88 replayOutputFrames(t, screen, drainAllFrames(sends))
89 89
90 rows := frameRows(captureTestFrame(screen))90 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) {
92 t.Fatalf("composed frame missing toast message top-right:\n%s", strings.Join(rows, "\n"))92 t.Fatalf("composed frame missing toast message top-right:\n%s", strings.Join(rows, "\n"))
93 }93 }
94 94
@@ -102,7 +102,7 @@ func TestPaintComposesNoticeToastTopRightAndExpiresOnTTL(t *testing.T) {
102 }102 }
103 103
104 rows = frameRows(captureTestFrame(screen))104 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) {
106 t.Fatalf("toast message still present after TTL expired:\n%s", strings.Join(rows, "\n"))106 t.Fatalf("toast message still present after TTL expired:\n%s", strings.Join(rows, "\n"))
107 }107 }
108 }108 }
M internal/usecase/daemon/notify_test.go +5 -5
@@ -278,7 +278,7 @@ func newNoticeFixture(t *testing.T, clk ports.Clock) (*Daemon, *session, *attach
278 p, _ := newBlockingPTY(t)278 p, _ := newBlockingPTY(t)
279 d := newTestDaemon(t, nil, clk)279 d := newTestDaemon(t, nil, clk)
280 tr, sends := newCapturingTransport(t)280 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}}}
282 ac.initOverlays()282 ac.initOverlays()
283 sctx, cancel := context.WithCancel(d.serveCtx)283 sctx, cancel := context.WithCancel(d.serveCtx)
284 wctx, wcancel := context.WithCancel(sctx)284 wctx, wcancel := context.WithCancel(sctx)
@@ -443,7 +443,7 @@ func TestReportErrorNonBenignIsNeverDropped(t *testing.T) {
443 func TestNotifyRoutesToSessionClientOnly(t *testing.T) {443 func TestNotifyRoutesToSessionClientOnly(t *testing.T) {
444 d, sess, ac, _ := newNoticeFixture(t, newNoticeClock())444 d, sess, ac, _ := newNoticeFixture(t, newNoticeClock())
445 445
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}}}
447 other.initOverlays()447 other.initOverlays()
448 sess2 := &session{sessionCore: sessionCore{id: "manual-2", name: "other", attachments: map[*attachedClient]struct{}{other: {}}}, ctx: sess.ctx, cancel: func() {}}448 sess2 := &session{sessionCore: sessionCore{id: "manual-2", name: "other", attachments: map[*attachedClient]struct{}{other: {}}}, ctx: sess.ctx, cancel: func() {}}
449 other.setSession(sess2)449 other.setSession(sess2)
@@ -533,7 +533,7 @@ func TestSessionScopedNoticeQueuedWhileDetached(t *testing.T) {
533 clearAttachmentsForTestLocked(sessA)533 clearAttachmentsForTestLocked(sessA)
534 sessA.mu.Unlock()534 sessA.mu.Unlock()
535 535
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}}}
537 clientB.initOverlays()537 clientB.initOverlays()
538 sessB := &session{sessionCore: sessionCore{id: "manual-2", name: "other"}, ctx: sessA.ctx, cancel: func() {}}538 sessB := &session{sessionCore: sessionCore{id: "manual-2", name: "other"}, ctx: sessA.ctx, cancel: func() {}}
539 clientB.setSession(sessB)539 clientB.setSession(sessB)
@@ -572,7 +572,7 @@ func TestSessionScopedNoticeQueuedWhileDetached(t *testing.T) {
572 require.Equal(t, "hello from a script", aToasts[0].Message)572 require.Equal(t, "hello from a script", aToasts[0].Message)
573 require.Equal(t, 1, aToasts[0].Count)573 require.Equal(t, 1, aToasts[0].Count)
574 574
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}}}
576 secondA.initOverlays()576 secondA.initOverlays()
577 secondA.setSession(sessA)577 secondA.setSession(sessA)
578 sessA.mu.Lock()578 sessA.mu.Lock()
@@ -586,7 +586,7 @@ func TestSessionScopedNoticeQueuedWhileDetached(t *testing.T) {
586 func TestNotifyGlobalFansOutToAttachedClients(t *testing.T) {586 func TestNotifyGlobalFansOutToAttachedClients(t *testing.T) {
587 d, sess, ac, _ := newNoticeFixture(t, newNoticeClock())587 d, sess, ac, _ := newNoticeFixture(t, newNoticeClock())
588 588
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}}}
590 second.initOverlays()590 second.initOverlays()
591 sess2 := &session{sessionCore: sessionCore{id: "manual-2", name: "other", attachments: map[*attachedClient]struct{}{second: {}}}, ctx: sess.ctx, cancel: func() {}}591 sess2 := &session{sessionCore: sessionCore{id: "manual-2", name: "other", attachments: map[*attachedClient]struct{}{second: {}}}, ctx: sess.ctx, cancel: func() {}}
592 second.setSession(sess2)592 second.setSession(sess2)
M internal/usecase/daemon/output_state_sync_test.go +2 -2
@@ -358,7 +358,7 @@ func TestResizeWithoutPTYOutputFlushesOneFullFrameAtDeadline(t *testing.T) {
358 require.Zero(t, out.Base)358 require.Zero(t, out.Base)
359 require.Contains(t, screenLineText(client, 0), "1")359 require.Contains(t, screenLineText(client, 0), "1")
360 require.Contains(t, screenLineText(client, 23), "work")360 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)
362 requireNoOutputFrame(t, sends)362 requireNoOutputFrame(t, sends)
363 }363 }
364 364
@@ -392,7 +392,7 @@ func TestResizeBurstFlushesOnlyLatestGeometry(t *testing.T) {
392 latest.ch <- time.Now()392 latest.ch <- time.Now()
393 awaitTestCompletion(t, latestDone, "latest resize callback did not complete")393 awaitTestCompletion(t, latestDone, "latest resize callback did not complete")
394 awaitFrame(t, sends, "Output")394 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)
396 require.Equal(t, 120, testAttachmentTab(sess).focusedPane().screen.Columns())396 require.Equal(t, 120, testAttachmentTab(sess).focusedPane().screen.Columns())
397 requireNoOutputFrame(t, sends)397 requireNoOutputFrame(t, sends)
398 }398 }
M internal/usecase/daemon/palette.go +1 -1
@@ -7,9 +7,9 @@ import (
7 7
8 renderer "github.com/bnema/vev-vt"8 renderer "github.com/bnema/vev-vt"
9 "github.com/bnema/vev/internal/domain"9 "github.com/bnema/vev/internal/domain"
10+ "github.com/bnema/vev/internal/domain/layout"
10 "github.com/bnema/vev/internal/protocol"11 "github.com/bnema/vev/internal/protocol"
11 "github.com/bnema/vev/internal/usecase/command"12 "github.com/bnema/vev/internal/usecase/command"
12- "github.com/bnema/vev/internal/usecase/layout"
13 "github.com/bnema/vev/internal/usecase/palette"13 "github.com/bnema/vev/internal/usecase/palette"
14 "github.com/bnema/vev/internal/usecase/picker"14 "github.com/bnema/vev/internal/usecase/picker"
15 themeui "github.com/bnema/vev/internal/usecase/theme"15 themeui "github.com/bnema/vev/internal/usecase/theme"
M internal/usecase/daemon/pane.go +1 -1
@@ -7,8 +7,8 @@ import (
7 7
8 vt "github.com/bnema/vev-vt"8 vt "github.com/bnema/vev-vt"
9 "github.com/bnema/vev/internal/domain"9 "github.com/bnema/vev/internal/domain"
10+ "github.com/bnema/vev/internal/domain/layout"
10 "github.com/bnema/vev/internal/ports"11 "github.com/bnema/vev/internal/ports"
11- "github.com/bnema/vev/internal/usecase/layout"
12 )12 )
13 13
14 // paneProcessLifetime owns the context passed to PTYFactory.Open. Its daemon14 // paneProcessLifetime owns the context passed to PTYFactory.Open. Its daemon
M internal/usecase/daemon/pane_actions.go +1 -1
@@ -6,7 +6,7 @@ import (
6 "slices"6 "slices"
7 7
8 "github.com/bnema/vev/internal/domain"8 "github.com/bnema/vev/internal/domain"
9- "github.com/bnema/vev/internal/usecase/layout"9+ "github.com/bnema/vev/internal/domain/layout"
10 )10 )
11 11
12 type paneFocusChange struct {12 type paneFocusChange struct {
M internal/usecase/daemon/pane_actions_test.go +4 -4
@@ -14,11 +14,11 @@ import (
14 14
15 renderer "github.com/bnema/vev-vt"15 renderer "github.com/bnema/vev-vt"
16 "github.com/bnema/vev/internal/domain"16 "github.com/bnema/vev/internal/domain"
17+ "github.com/bnema/vev/internal/domain/layout"
17 "github.com/bnema/vev/internal/ports"18 "github.com/bnema/vev/internal/ports"
18 portsmocks "github.com/bnema/vev/internal/ports/mocks"19 portsmocks "github.com/bnema/vev/internal/ports/mocks"
19 "github.com/bnema/vev/internal/protocol/wire"20 "github.com/bnema/vev/internal/protocol/wire"
20 scopy "github.com/bnema/vev/internal/usecase/copy"21 scopy "github.com/bnema/vev/internal/usecase/copy"
21- "github.com/bnema/vev/internal/usecase/layout"
22 )22 )
23 23
24 func TestLayoutGenerationMutationBookkeeping(t *testing.T) {24 func TestLayoutGenerationMutationBookkeeping(t *testing.T) {
@@ -660,7 +660,7 @@ func TestFocusDirMovesFocusAndExitsCopyMode(t *testing.T) {
660 tb.panes["pane-1"].rect = domain.Rect{Width: 20, Height: 10}660 tb.panes["pane-1"].rect = domain.Rect{Width: 20, Height: 10}
661 tb.panes["pane-2"].rect = domain.Rect{X: 21, Width: 20, Height: 10}661 tb.panes["pane-2"].rect = domain.Rect{X: 21, Width: 20, Height: 10}
662 tr, _ := newCapturingTransport(t)662 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}}}
664 ac.initOverlays()664 ac.initOverlays()
665 ac.setSession(sess)665 ac.setSession(sess)
666 require.True(t, sess.registerAttachment(ac))666 require.True(t, sess.registerAttachment(ac))
@@ -699,7 +699,7 @@ func TestClosePaneRepaintFanoutRespectsAttachmentScope(t *testing.T) {
699 sends := make([]chan wire.Envelope, 2)699 sends := make([]chan wire.Envelope, 2)
700 for i := range clients {700 for i := range clients {
701 tr, sent := newCapturingTransport(t)701 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}}}
703 clients[i].clientID[0] = byte(i + 1)703 clients[i].clientID[0] = byte(i + 1)
704 clients[i].initOverlays()704 clients[i].initOverlays()
705 clients[i].setSession(sess)705 clients[i].setSession(sess)
@@ -737,7 +737,7 @@ func TestFinalPaneReapClearsCopyModeForEveryAttachment(t *testing.T) {
737 737
738 for i := range 2 {738 for i := range 2 {
739 tr, _ := newCapturingTransport(t)739 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}}}
741 ac.clientID[0] = byte(i + 1)741 ac.clientID[0] = byte(i + 1)
742 ac.initOverlays()742 ac.initOverlays()
743 ac.setSession(sess)743 ac.setSession(sess)
M internal/usecase/daemon/pane_lifetime_test.go +1 -1
@@ -12,8 +12,8 @@ import (
12 "github.com/stretchr/testify/require"12 "github.com/stretchr/testify/require"
13 13
14 "github.com/bnema/vev/internal/domain"14 "github.com/bnema/vev/internal/domain"
15+ "github.com/bnema/vev/internal/domain/layout"
15 "github.com/bnema/vev/internal/ports"16 "github.com/bnema/vev/internal/ports"
16- "github.com/bnema/vev/internal/usecase/layout"
17 )17 )
18 18
19 type lifetimePTY struct {19 type lifetimePTY struct {
M internal/usecase/daemon/pane_owner_reader_test.go +1 -1
@@ -6,8 +6,8 @@ import (
6 "testing"6 "testing"
7 7
8 "github.com/bnema/vev/internal/domain"8 "github.com/bnema/vev/internal/domain"
9+ "github.com/bnema/vev/internal/domain/layout"
9 "github.com/bnema/vev/internal/ports"10 "github.com/bnema/vev/internal/ports"
10- "github.com/bnema/vev/internal/usecase/layout"
11 "github.com/stretchr/testify/require"11 "github.com/stretchr/testify/require"
12 )12 )
13 13
M internal/usecase/daemon/pane_owner_test.go +1 -1
@@ -4,7 +4,7 @@ import (
4 "testing"4 "testing"
5 5
6 "github.com/bnema/vev/internal/domain"6 "github.com/bnema/vev/internal/domain"
7- "github.com/bnema/vev/internal/usecase/layout"7+ "github.com/bnema/vev/internal/domain/layout"
8 "github.com/stretchr/testify/require"8 "github.com/stretchr/testify/require"
9 )9 )
10 10
M internal/usecase/daemon/pane_rearrange.go +1 -1
@@ -4,7 +4,7 @@ import (
4 "errors"4 "errors"
5 5
6 "github.com/bnema/vev/internal/domain"6 "github.com/bnema/vev/internal/domain"
7- "github.com/bnema/vev/internal/usecase/layout"7+ "github.com/bnema/vev/internal/domain/layout"
8 )8 )
9 9
10 var errDaemonActionNoChange = errors.New("daemon action made no change")10 var errDaemonActionNoChange = errors.New("daemon action made no change")
M internal/usecase/daemon/pane_rearrange_test.go +1 -1
@@ -11,10 +11,10 @@ import (
11 "github.com/stretchr/testify/require"11 "github.com/stretchr/testify/require"
12 12
13 "github.com/bnema/vev/internal/domain"13 "github.com/bnema/vev/internal/domain"
14+ "github.com/bnema/vev/internal/domain/layout"
14 "github.com/bnema/vev/internal/ports"15 "github.com/bnema/vev/internal/ports"
15 portsmocks "github.com/bnema/vev/internal/ports/mocks"16 portsmocks "github.com/bnema/vev/internal/ports/mocks"
16 "github.com/bnema/vev/internal/usecase/keys"17 "github.com/bnema/vev/internal/usecase/keys"
17- "github.com/bnema/vev/internal/usecase/layout"
18 )18 )
19 19
20 func TestMutateTargetLayoutPublishesOnlyChangedCandidates(t *testing.T) {20 func TestMutateTargetLayoutPublishesOnlyChangedCandidates(t *testing.T) {
M internal/usecase/daemon/pane_titles.go +1 -1
@@ -4,7 +4,7 @@ import (
4 "strings"4 "strings"
5 "time"5 "time"
6 6
7- "github.com/bnema/vev/internal/usecase/layout"7+ "github.com/bnema/vev/internal/domain/layout"
8 )8 )
9 9
10 const paneTitleCacheTTL = time.Second10 const paneTitleCacheTTL = time.Second
M internal/usecase/daemon/performance_benchmark_test.go +7 -7
@@ -14,11 +14,11 @@ import (
14 14
15 renderer "github.com/bnema/vev-vt"15 renderer "github.com/bnema/vev-vt"
16 "github.com/bnema/vev/internal/domain"16 "github.com/bnema/vev/internal/domain"
17+ "github.com/bnema/vev/internal/domain/layout"
17 "github.com/bnema/vev/internal/ports"18 "github.com/bnema/vev/internal/ports"
18 "github.com/bnema/vev/internal/protocol"19 "github.com/bnema/vev/internal/protocol"
19 "github.com/bnema/vev/internal/protocol/wire"20 "github.com/bnema/vev/internal/protocol/wire"
20- "github.com/bnema/vev/internal/usecase/layout"21+ snapcodec "github.com/bnema/vev/internal/snapshotcodec"
21- snapcodec "github.com/bnema/vev/internal/usecase/snapshot"
22 )22 )
23 23
24 func TestCountingSnapshotRepositoryPublishCountsAndRetainsManifest(t *testing.T) {24 func TestCountingSnapshotRepositoryPublishCountsAndRetainsManifest(t *testing.T) {
@@ -265,7 +265,7 @@ func TestPerformanceFixtureResize(t *testing.T) {
265 265
266 for _, want := range sequence {266 for _, want := range sequence {
267 fixture.resizeTo(want)267 fixture.resizeTo(want)
268- require.Equal(t, want, fixture.ac.size)268+ require.Equal(t, want, fixture.ac.geometry.Size)
269 require.True(t, fixture.resized())269 require.True(t, fixture.resized())
270 }270 }
271 metrics := fixture.metrics()271 metrics := fixture.metrics()
@@ -301,7 +301,7 @@ func TestTransactionalResizeMetrics(t *testing.T) {
301 require.Equal(t, uint64(3), metrics.outputFrames, "commit, resize-failure notice toast, and successful full-reset retry")301 require.Equal(t, uint64(3), metrics.outputFrames, "commit, resize-failure notice toast, and successful full-reset retry")
302 require.Zero(t, metrics.frameGapEpochs, "every committed epoch emitted its required frame")302 require.Zero(t, metrics.frameGapEpochs, "every committed epoch emitted its required frame")
303 require.Equal(t, []domain.Size{{Cols: 50, Rows: 28}, {Cols: 50, Rows: 28}}, fixture.pty.requested())303 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)
305 }305 }
306 306
307 func TestPerformanceFixtureLargeHistoryTopology(t *testing.T) {307 func TestPerformanceFixtureLargeHistoryTopology(t *testing.T) {
@@ -1077,7 +1077,7 @@ func newPerformanceFixtureWithCleanup(t testing.TB, config performanceConfig, re
1077 d.clock = clock1077 d.clock = clock
1078 output := &countingOutputTransport{}1078 output := &countingOutputTransport{}
1079 ac.tr = output1079 ac.tr = output
1080- ac.size = config.size1080+ ac.geometry.Size = config.size
1081 sess.name = "performance"1081 sess.name = "performance"
1082 sess.ephemeral = false1082 sess.ephemeral = false
1083 sess.incarnation = domain.IncarnationID{1}1083 sess.incarnation = domain.IncarnationID{1}
@@ -1300,7 +1300,7 @@ func (f *performanceFixture) retryLatest() {
1300 members := make([]resizeMember, 0)1300 members := make([]resizeMember, 0)
1301 for _, tb := range tabs {1301 for _, tb := range tabs {
1302 tb.mu.Lock()1302 tb.mu.Lock()
1303- plan := prepareTabLayoutForSizeLocked(f.sess, tb, tabSize(f.ac.size))1303+ plan := prepareTabLayoutForSizeLocked(f.sess, tb, tabSize(f.ac.geometry.Size))
1304 tb.mu.Unlock()1304 tb.mu.Unlock()
1305 members = append(members, plan.members...)1305 members = append(members, plan.members...)
1306 }1306 }
@@ -1314,7 +1314,7 @@ func (f *performanceFixture) retryLatest() {
1314 }1314 }
1315 1315
1316 func (f *performanceFixture) resized() bool {1316 func (f *performanceFixture) resized() bool {
1317- if f.ac.size != f.resizedSize {1317+ if f.ac.geometry.Size != f.resizedSize {
1318 return false1318 return false
1319 }1319 }
1320 for _, tb := range f.sess.tabs {1320 for _, tb := range f.sess.tabs {
M internal/usecase/daemon/process_restore.go +1 -1
@@ -5,7 +5,7 @@ import (
5 "strings"5 "strings"
6 "unicode"6 "unicode"
7 7
8- snapcodec "github.com/bnema/vev/internal/usecase/snapshot"8+ snapcodec "github.com/bnema/vev/internal/snapshotcodec"
9 )9 )
10 10
11 const (11 const (
M internal/usecase/daemon/process_restore_test.go +1 -1
@@ -3,7 +3,7 @@ package daemon
3 import (3 import (
4 "testing"4 "testing"
5 5
6- snapcodec "github.com/bnema/vev/internal/usecase/snapshot"6+ snapcodec "github.com/bnema/vev/internal/snapshotcodec"
7 )7 )
8 8
9 func TestProcessRestorePlan(t *testing.T) {9 func TestProcessRestorePlan(t *testing.T) {
M internal/usecase/daemon/purge_test.go +1 -1
@@ -20,8 +20,8 @@ import (
20 "github.com/bnema/vev/internal/ports"20 "github.com/bnema/vev/internal/ports"
21 portsmocks "github.com/bnema/vev/internal/ports/mocks"21 portsmocks "github.com/bnema/vev/internal/ports/mocks"
22 "github.com/bnema/vev/internal/protocol"22 "github.com/bnema/vev/internal/protocol"
23+ snapcodec "github.com/bnema/vev/internal/snapshotcodec"
23 recoveryusecase "github.com/bnema/vev/internal/usecase/recovery"24 recoveryusecase "github.com/bnema/vev/internal/usecase/recovery"
24- snapcodec "github.com/bnema/vev/internal/usecase/snapshot"
25 )25 )
26 26
27 // selectivePurgeRepository fails incarnation deletion only for the configured27 // selectivePurgeRepository fails incarnation deletion only for the configured
M internal/usecase/daemon/reduced_color_test.go +2 -2
@@ -11,10 +11,10 @@ import (
11 renderer "github.com/bnema/vev-vt"11 renderer "github.com/bnema/vev-vt"
12 ansirenderer "github.com/bnema/vev-vt/ansi"12 ansirenderer "github.com/bnema/vev-vt/ansi"
13 "github.com/bnema/vev/internal/domain"13 "github.com/bnema/vev/internal/domain"
14+ "github.com/bnema/vev/internal/domain/layout"
14 "github.com/bnema/vev/internal/domain/terminalcap"15 "github.com/bnema/vev/internal/domain/terminalcap"
15 "github.com/bnema/vev/internal/protocol"16 "github.com/bnema/vev/internal/protocol"
16 "github.com/bnema/vev/internal/usecase/colorprofile"17 "github.com/bnema/vev/internal/usecase/colorprofile"
17- "github.com/bnema/vev/internal/usecase/layout"
18 themeui "github.com/bnema/vev/internal/usecase/theme"18 themeui "github.com/bnema/vev/internal/usecase/theme"
19 "github.com/bnema/vev/internal/usecase/ui"19 "github.com/bnema/vev/internal/usecase/ui"
20 )20 )
@@ -219,7 +219,7 @@ func TestAppliedThemeIsPerAttachment(t *testing.T) {
219 d, sess, ac16, _ := newManualSessionWithPTYs(t, p)219 d, sess, ac16, _ := newManualSessionWithPTYs(t, p)
220 ac16.terminalCapabilities = terminalcap.Capabilities{Color: terminalcap.ColorCapabilities{Mode: terminalcap.ANSI16}}220 ac16.terminalCapabilities = terminalcap.Capabilities{Color: terminalcap.ColorCapabilities{Mode: terminalcap.ANSI16}}
221 tr, _ := newCapturingTransport(t)221 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}}}
223 acTrue.output.attachment = acTrue223 acTrue.output.attachment = acTrue
224 acTrue.initOverlays()224 acTrue.initOverlays()
225 sess.mu.Lock()225 sess.mu.Lock()
M internal/usecase/daemon/render.go +1 -1
@@ -6,9 +6,9 @@ import (
6 6
7 renderer "github.com/bnema/vev-vt/ansi"7 renderer "github.com/bnema/vev-vt/ansi"
8 "github.com/bnema/vev/internal/domain"8 "github.com/bnema/vev/internal/domain"
9+ "github.com/bnema/vev/internal/domain/layout"
9 "github.com/bnema/vev/internal/domain/terminalcap"10 "github.com/bnema/vev/internal/domain/terminalcap"
10 "github.com/bnema/vev/internal/ports"11 "github.com/bnema/vev/internal/ports"
11- "github.com/bnema/vev/internal/usecase/layout"
12 themeui "github.com/bnema/vev/internal/usecase/theme"12 themeui "github.com/bnema/vev/internal/usecase/theme"
13 )13 )
14 14
M internal/usecase/daemon/render_capture.go +1 -1
@@ -4,9 +4,9 @@ import (
4 renderer "github.com/bnema/vev-vt"4 renderer "github.com/bnema/vev-vt"
5 vevgraphics "github.com/bnema/vev-vt/graphics"5 vevgraphics "github.com/bnema/vev-vt/graphics"
6 "github.com/bnema/vev/internal/domain"6 "github.com/bnema/vev/internal/domain"
7+ "github.com/bnema/vev/internal/domain/layout"
7 "github.com/bnema/vev/internal/protocol"8 "github.com/bnema/vev/internal/protocol"
8 scopy "github.com/bnema/vev/internal/usecase/copy"9 scopy "github.com/bnema/vev/internal/usecase/copy"
9- "github.com/bnema/vev/internal/usecase/layout"
10 themeui "github.com/bnema/vev/internal/usecase/theme"10 themeui "github.com/bnema/vev/internal/usecase/theme"
11 "github.com/bnema/vev/internal/usecase/ui"11 "github.com/bnema/vev/internal/usecase/ui"
12 )12 )
M internal/usecase/daemon/render_coordinator.go +11 -25
@@ -991,9 +991,6 @@ func (c *renderCoordinator) attachmentAckReady(ac *attachedClient) bool {
991 if c.opts.ackReadyFor != nil {991 if c.opts.ackReadyFor != nil {
992 return c.opts.ackReadyFor(ac)992 return c.opts.ackReadyFor(ac)
993 }993 }
994- if c.opts.ackReady != nil {
995- return c.opts.ackReady()
996- }
997 return true994 return true
998 }995 }
999 996
@@ -1014,25 +1011,15 @@ func (c *renderCoordinator) fireWithTimerTokenAndLease(token *timerToken, gen ui
1014 leases[ac] = lease1011 leases[ac] = lease
1015 }1012 }
1016 }1013 }
1017- previewOnly := len(leases) == 0 && len(c.previewWakes) != 0
1018- legacyAckReady := c.opts.ackReady
1019 c.mu.Unlock()1014 c.mu.Unlock()
1020 1015
1021 ready := make(map[*attachmentLease]bool, len(leases))1016 ready := make(map[*attachmentLease]bool, len(leases))
1022- if len(leases) != 0 {1017+ for ac, lease := range leases {
1023- for ac, lease := range leases {1018+ // Welcome holds attachment.sendMu while its transport Send is in
1024- // Welcome holds attachment.sendMu while its transport Send is in1019+ // flight. Do not probe an incarnation before it is ready.
1025- // flight. Do not probe an incarnation before it is ready.1020+ if lease.ready {
1026- if lease.ready {1021+ ready[lease] = c.attachmentAckReady(ac)
1027- ready[lease] = c.attachmentAckReady(ac)
1028- }
1029 }1022 }
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
1036 }1023 }
1037 1024
1038 // The readiness callback can detach, replace, or publish a sync batch.1025 // The readiness callback can detach, replace, or publish a sync batch.
@@ -1098,12 +1085,11 @@ func (c *renderCoordinator) fireWithTimerTokenAndLease(token *timerToken, gen ui
1098 }1085 }
1099 c.ackDeferredFor[ac] = true1086 c.ackDeferredFor[ac] = true
1100 }1087 }
1101- } else if !ready[nil] {
1102- blockedByAck = true
1103- allDelivered = false
1104 }1088 }
1105- legacyOutput := len(leases) == 0 && ready[nil] && (c.pendingShared || len(c.pendingTargets) == 0)1089+ // Without any attachment lease (headless sessions and coordinator-only
1106- outputReady := len(attachmentLeases) != 0 || legacyOutput1090+ // 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
1107 if len(leases) != 0 {1093 if len(leases) != 0 {
1108 for ac, lease := range leases {1094 for ac, lease := range leases {
1109 if !c.pendingShared && len(c.pendingTargets) != 0 {1095 if !c.pendingShared && len(c.pendingTargets) != 0 {
@@ -1118,7 +1104,7 @@ func (c *renderCoordinator) fireWithTimerTokenAndLease(token *timerToken, gen ui
1118 }1104 }
1119 }1105 }
1120 }1106 }
1121- if len(leases) == 0 && legacyOutput {1107+ if leaselessOutput {
1122 allDelivered = true1108 allDelivered = true
1123 }1109 }
1124 wakeGeneration := uint64(0)1110 wakeGeneration := uint64(0)
@@ -1143,7 +1129,7 @@ func (c *renderCoordinator) fireWithTimerTokenAndLease(token *timerToken, gen ui
1143 ackStart = newACKBlockedSpan(c.opts.observer)1129 ackStart = newACKBlockedSpan(c.opts.observer)
1144 c.ackBlocked = ackStart1130 c.ackBlocked = ackStart
1145 }1131 }
1146- c.ackDeferred = len(c.ackDeferredFor) != 0 || (len(leases) == 0 && blockedByAck && deadline)1132+ c.ackDeferred = len(c.ackDeferredFor) != 0
1147 if !outputReady && !allDelivered {1133 if !outputReady && !allDelivered {
1148 c.mu.Unlock()1134 c.mu.Unlock()
1149 stopDetachedTimer(worker)1135 stopDetachedTimer(worker)
M internal/usecase/daemon/render_coordinator_handshake_test.go +3 -3
@@ -83,9 +83,9 @@ func TestRenderCoordinatorFanoutDoesNotWaitForSlowTransport(t *testing.T) {
83 d, sess, healthy, healthySends, _ := newManualTabSession(t, 1)83 d, sess, healthy, healthySends, _ := newManualTabSession(t, 1)
84 slowTransport := newFanoutBlockingTransport()84 slowTransport := newFanoutBlockingTransport()
85 slow := &attachedClient{85 slow := &attachedClient{
86- tr: slowTransport,86+ tr: slowTransport,
87- output: newOutputStateStream(),87+ output: newOutputStateStream(),
88- size: domain.Size{Cols: 80, Rows: 24},88+ geometry: domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}},
89 }89 }
90 slow.initOverlays()90 slow.initOverlays()
91 slow.setSession(sess)91 slow.setSession(sess)
M internal/usecase/daemon/render_coordinator_test.go +13 -10
@@ -12,12 +12,12 @@ import (
12 "github.com/stretchr/testify/require"12 "github.com/stretchr/testify/require"
13 13
14 "github.com/bnema/vev/internal/domain"14 "github.com/bnema/vev/internal/domain"
15+ "github.com/bnema/vev/internal/domain/layout"
15 "github.com/bnema/vev/internal/ports"16 "github.com/bnema/vev/internal/ports"
16 portsmocks "github.com/bnema/vev/internal/ports/mocks"17 portsmocks "github.com/bnema/vev/internal/ports/mocks"
17 "github.com/bnema/vev/internal/protocol"18 "github.com/bnema/vev/internal/protocol"
18 "github.com/bnema/vev/internal/protocol/wire"19 "github.com/bnema/vev/internal/protocol/wire"
19 "github.com/bnema/vev/internal/usecase/keys"20 "github.com/bnema/vev/internal/usecase/keys"
20- "github.com/bnema/vev/internal/usecase/layout"
21 )21 )
22 22
23 // --- coordinator harness ------------------------------------------------------23 // --- coordinator harness ------------------------------------------------------
@@ -97,7 +97,7 @@ func TestRenderCoordinatorDetachDoesNotJoinSelectedDeadlineWorker(t *testing.T)
97 release := make(chan struct{})97 release := make(chan struct{})
98 rc := newRenderCoordinator(renderCoordinatorOptions{98 rc := newRenderCoordinator(renderCoordinatorOptions{
99 clock: clock,99 clock: clock,
100- ackReady: func() bool {100+ ackReadyFor: func(*attachedClient) bool {
101 close(entered)101 close(entered)
102 <-release102 <-release
103 return true103 return true
@@ -166,10 +166,10 @@ func newCoordinatorHarness(t *testing.T) *coordinatorHarness {
166 }166 }
167 h.ackReady.Store(true)167 h.ackReady.Store(true)
168 h.rc = newRenderCoordinator(renderCoordinatorOptions{168 h.rc = newRenderCoordinator(renderCoordinatorOptions{
169- clock: h.clk.clock,169+ clock: h.clk.clock,
170- wake: func(w renderWake) { h.wakes <- w },170+ wake: func(w renderWake) { h.wakes <- w },
171- ackReady: func() bool { return h.ackReady.Load() },171+ ackReadyFor: func(*attachedClient) bool { return h.ackReady.Load() },
172- syncActive: func() bool { return h.syncActive.Load() },172+ syncActive: func() bool { return h.syncActive.Load() },
173 })173 })
174 return h174 return h
175 }175 }
@@ -449,7 +449,7 @@ func TestRenderCoordinatorAckReadinessReentersWithoutBlockingResize(t *testing.T
449 449
450 var rc *renderCoordinator450 var rc *renderCoordinator
451 rc = newRenderCoordinator(renderCoordinatorOptions{451 rc = newRenderCoordinator(renderCoordinatorOptions{
452- ackReady: func() bool {452+ ackReadyFor: func(*attachedClient) bool {
453 close(ackEntered)453 close(ackEntered)
454 // The readiness probe models the output send path reading coordinator454 // The readiness probe models the output send path reading coordinator
455 // metadata after sendMu is held. It must never run under c.mu.455 // metadata after sendMu is held. It must never run under c.mu.
@@ -496,6 +496,7 @@ func TestRenderCoordinatorAckDoesNotBypassAnUnexpiredDeadline(t *testing.T) {
496 func TestRenderCoordinatorAckFlushesOnlyExpiredAckDeferredWork(t *testing.T) {496 func TestRenderCoordinatorAckFlushesOnlyExpiredAckDeferredWork(t *testing.T) {
497 t.Run("expired deadline flushes exactly once after readiness", func(t *testing.T) {497 t.Run("expired deadline flushes exactly once after readiness", func(t *testing.T) {
498 h := newCoordinatorHarness(t)498 h := newCoordinatorHarness(t)
499+ h.rc.attach(&attachedClient{})
499 h.ackReady.Store(false)500 h.ackReady.Store(false)
500 h.rc.invalidate(renderInvalidation{class: invalidateOutput, reset: true, producer: "render.go"})501 h.rc.invalidate(renderInvalidation{class: invalidateOutput, reset: true, producer: "render.go"})
501 timer := awaitCoordinatorScheduledTimer(t, h.clk)502 timer := awaitCoordinatorScheduledTimer(t, h.clk)
@@ -516,6 +517,7 @@ func TestRenderCoordinatorAckFlushesOnlyExpiredAckDeferredWork(t *testing.T) {
516 517
517 t.Run("lifecycle clears deferred work and urgent explicit fires stay immediate", func(t *testing.T) {518 t.Run("lifecycle clears deferred work and urgent explicit fires stay immediate", func(t *testing.T) {
518 h := newCoordinatorHarness(t)519 h := newCoordinatorHarness(t)
520+ h.rc.attach(&attachedClient{})
519 h.ackReady.Store(false)521 h.ackReady.Store(false)
520 h.rc.invalidate(renderInvalidation{class: invalidateOutput})522 h.rc.invalidate(renderInvalidation{class: invalidateOutput})
521 timer := awaitCoordinatorScheduledTimer(t, h.clk)523 timer := awaitCoordinatorScheduledTimer(t, h.clk)
@@ -536,6 +538,7 @@ func TestRenderCoordinatorAckFlushesOnlyExpiredAckDeferredWork(t *testing.T) {
536 538
537 func TestRenderCoordinatorAckGateBlocksCompositionUntilAck(t *testing.T) {539 func TestRenderCoordinatorAckGateBlocksCompositionUntilAck(t *testing.T) {
538 h := newCoordinatorHarness(t)540 h := newCoordinatorHarness(t)
541+ h.rc.attach(&attachedClient{})
539 h.ackReady.Store(false)542 h.ackReady.Store(false)
540 543
541 h.rc.invalidate(renderInvalidation{class: invalidateOutput})544 h.rc.invalidate(renderInvalidation{class: invalidateOutput})
@@ -1451,9 +1454,9 @@ func TestRenderCoordinatorInertTimerFiresSynchronouslyWithoutWorker(t *testing.T
1451 1454
1452 wakes := make(chan renderWake, 1)1455 wakes := make(chan renderWake, 1)
1453 rc := newRenderCoordinator(renderCoordinatorOptions{1456 rc := newRenderCoordinator(renderCoordinatorOptions{
1454- clock: clock,1457+ clock: clock,
1455- ackReady: func() bool { return true },1458+ ackReadyFor: func(*attachedClient) bool { return true },
1456- wake: func(w renderWake) { wakes <- w },1459+ wake: func(w renderWake) { wakes <- w },
1457 })1460 })
1458 rc.invalidate(renderInvalidation{class: invalidateOutput})1461 rc.invalidate(renderInvalidation{class: invalidateOutput})
1459 1462
M internal/usecase/daemon/render_coordinator_types.go +3 -5
@@ -105,11 +105,9 @@ type renderCoordinatorOptions struct {
105 observer ports.RuntimeObserver105 observer ports.RuntimeObserver
106 // wake composes a coalesced render request.106 // wake composes a coalesced render request.
107 wake func(renderWake)107 wake func(renderWake)
108- // ackReady reports whether the attachment may compose another output108+ // ackReadyFor reports whether one attachment may compose another output
109- // state (the attachmentOutput window has capacity). It is retained for109+ // state (its attachmentOutput window has capacity), so one attachment
110- // coordinator-only tests and headless callers; production uses ackReadyFor110+ // cannot gate another. Nil means every attachment is ready.
111- // so one attachment cannot gate another.
112- ackReady func() bool
113 ackReadyFor func(*attachedClient) bool111 ackReadyFor func(*attachedClient) bool
114 // syncRenderable reports whether a pane's synchronized batch currently112 // syncRenderable reports whether a pane's synchronized batch currently
115 // contributes to the attached composition. It must not acquire c.mu.113 // 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 (
5 5
6 renderer "github.com/bnema/vev-vt"6 renderer "github.com/bnema/vev-vt"
7 "github.com/bnema/vev/internal/domain"7 "github.com/bnema/vev/internal/domain"
8+ "github.com/bnema/vev/internal/domain/layout"
8 "github.com/bnema/vev/internal/ports"9 "github.com/bnema/vev/internal/ports"
9- "github.com/bnema/vev/internal/usecase/layout"
10 "github.com/stretchr/testify/require"10 "github.com/stretchr/testify/require"
11 )11 )
12 12
M internal/usecase/daemon/render_divider_geometry_test.go +1 -1
@@ -5,7 +5,7 @@ import (
5 5
6 renderer "github.com/bnema/vev-vt"6 renderer "github.com/bnema/vev-vt"
7 "github.com/bnema/vev/internal/domain"7 "github.com/bnema/vev/internal/domain"
8- "github.com/bnema/vev/internal/usecase/layout"8+ "github.com/bnema/vev/internal/domain/layout"
9 "github.com/stretchr/testify/require"9 "github.com/stretchr/testify/require"
10 )10 )
11 11
M internal/usecase/daemon/render_pipeline.go +1 -1
@@ -6,9 +6,9 @@ import (
6 6
7 renderer "github.com/bnema/vev-vt"7 renderer "github.com/bnema/vev-vt"
8 "github.com/bnema/vev/internal/domain"8 "github.com/bnema/vev/internal/domain"
9+ "github.com/bnema/vev/internal/domain/layout"
9 "github.com/bnema/vev/internal/ports"10 "github.com/bnema/vev/internal/ports"
10 "github.com/bnema/vev/internal/protocol"11 "github.com/bnema/vev/internal/protocol"
11- "github.com/bnema/vev/internal/usecase/layout"
12 "github.com/bnema/vev/internal/usecase/notices"12 "github.com/bnema/vev/internal/usecase/notices"
13 "github.com/bnema/vev/internal/usecase/palette"13 "github.com/bnema/vev/internal/usecase/palette"
14 "github.com/bnema/vev/internal/usecase/prompt"14 "github.com/bnema/vev/internal/usecase/prompt"
M internal/usecase/daemon/render_pipeline_cache_test.go +1 -1
@@ -8,9 +8,9 @@ import (
8 8
9 renderer "github.com/bnema/vev-vt"9 renderer "github.com/bnema/vev-vt"
10 "github.com/bnema/vev/internal/domain"10 "github.com/bnema/vev/internal/domain"
11+ "github.com/bnema/vev/internal/domain/layout"
11 "github.com/bnema/vev/internal/protocol"12 "github.com/bnema/vev/internal/protocol"
12 "github.com/bnema/vev/internal/protocol/wire"13 "github.com/bnema/vev/internal/protocol/wire"
13- "github.com/bnema/vev/internal/usecase/layout"
14 themeui "github.com/bnema/vev/internal/usecase/theme"14 themeui "github.com/bnema/vev/internal/usecase/theme"
15 "github.com/bnema/vev/internal/usecase/ui"15 "github.com/bnema/vev/internal/usecase/ui"
16 "github.com/stretchr/testify/require"16 "github.com/stretchr/testify/require"
M internal/usecase/daemon/render_pipeline_test.go +1 -1
@@ -14,11 +14,11 @@ import (
14 vt "github.com/bnema/vev-vt"14 vt "github.com/bnema/vev-vt"
15 renderer "github.com/bnema/vev-vt/ansi"15 renderer "github.com/bnema/vev-vt/ansi"
16 "github.com/bnema/vev/internal/domain"16 "github.com/bnema/vev/internal/domain"
17+ "github.com/bnema/vev/internal/domain/layout"
17 "github.com/bnema/vev/internal/protocol"18 "github.com/bnema/vev/internal/protocol"
18 "github.com/bnema/vev/internal/protocol/wire"19 "github.com/bnema/vev/internal/protocol/wire"
19 "github.com/bnema/vev/internal/testutil/replaytest"20 "github.com/bnema/vev/internal/testutil/replaytest"
20 scopy "github.com/bnema/vev/internal/usecase/copy"21 scopy "github.com/bnema/vev/internal/usecase/copy"
21- "github.com/bnema/vev/internal/usecase/layout"
22 "github.com/bnema/vev/internal/usecase/notices"22 "github.com/bnema/vev/internal/usecase/notices"
23 "github.com/bnema/vev/internal/usecase/palette"23 "github.com/bnema/vev/internal/usecase/palette"
24 promptui "github.com/bnema/vev/internal/usecase/prompt"24 promptui "github.com/bnema/vev/internal/usecase/prompt"
M internal/usecase/daemon/render_test.go +3 -3
@@ -19,11 +19,11 @@ import (
19 vt "github.com/bnema/vev-vt"19 vt "github.com/bnema/vev-vt"
20 renderer "github.com/bnema/vev-vt/ansi"20 renderer "github.com/bnema/vev-vt/ansi"
21 "github.com/bnema/vev/internal/domain"21 "github.com/bnema/vev/internal/domain"
22+ "github.com/bnema/vev/internal/domain/layout"
22 portsmocks "github.com/bnema/vev/internal/ports/mocks"23 portsmocks "github.com/bnema/vev/internal/ports/mocks"
23 "github.com/bnema/vev/internal/protocol"24 "github.com/bnema/vev/internal/protocol"
24 "github.com/bnema/vev/internal/protocol/wire"25 "github.com/bnema/vev/internal/protocol/wire"
25 scopy "github.com/bnema/vev/internal/usecase/copy"26 scopy "github.com/bnema/vev/internal/usecase/copy"
26- "github.com/bnema/vev/internal/usecase/layout"
27 themeui "github.com/bnema/vev/internal/usecase/theme"27 themeui "github.com/bnema/vev/internal/usecase/theme"
28 )28 )
29 29
@@ -73,7 +73,7 @@ func TestFirstPaintRetainedFloatingPaneEmitsOneReset(t *testing.T) {
73 defer releasePTY()73 defer releasePTY()
74 d, sess, ac, sends := newManualSessionWithPTYs(t, pty)74 d, sess, ac, sends := newManualSessionWithPTYs(t, pty)
75 ac.sendMu.Lock()75 ac.sendMu.Lock()
76- ac.size = tc.clientSize76+ ac.geometry.Size = tc.clientSize
77 ac.sendMu.Unlock()77 ac.sendMu.Unlock()
78 78
79 // A retained visible popup still needs activation warmup. When the79 // A retained visible popup still needs activation warmup. When the
@@ -404,7 +404,7 @@ func TestPTYReaderRepublishesSynchronizedCompletionAfterAttachmentLifecycle(t *t
404 rc := d.attachCoordinator(target, nil, detached, true)404 rc := d.attachCoordinator(target, nil, detached, true)
405 405
406 secondTransport, secondSends := newCapturingTransport(t)406 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}}
408 remaining.initOverlays()408 remaining.initOverlays()
409 remaining.setSession(target)409 remaining.setSession(target)
410 target.mu.Lock()410 target.mu.Lock()
M internal/usecase/daemon/resize_mode.go +1 -1
@@ -4,9 +4,9 @@ import (
4 "errors"4 "errors"
5 5
6 "github.com/bnema/vev/internal/domain"6 "github.com/bnema/vev/internal/domain"
7+ "github.com/bnema/vev/internal/domain/layout"
7 "github.com/bnema/vev/internal/ports"8 "github.com/bnema/vev/internal/ports"
8 "github.com/bnema/vev/internal/usecase/keys"9 "github.com/bnema/vev/internal/usecase/keys"
9- "github.com/bnema/vev/internal/usecase/layout"
10 )10 )
11 11
12 const (12 const (
M internal/usecase/daemon/resize_mode_test.go +1 -1
@@ -5,8 +5,8 @@ import (
5 "testing"5 "testing"
6 6
7 "github.com/bnema/vev/internal/domain"7 "github.com/bnema/vev/internal/domain"
8+ "github.com/bnema/vev/internal/domain/layout"
8 "github.com/bnema/vev/internal/protocol"9 "github.com/bnema/vev/internal/protocol"
9- "github.com/bnema/vev/internal/usecase/layout"
10 "github.com/stretchr/testify/require"10 "github.com/stretchr/testify/require"
11 )11 )
12 12
M internal/usecase/daemon/resume_test.go +2 -2
@@ -14,12 +14,12 @@ import (
14 14
15 vt "github.com/bnema/vev-vt"15 vt "github.com/bnema/vev-vt"
16 "github.com/bnema/vev/internal/domain"16 "github.com/bnema/vev/internal/domain"
17+ "github.com/bnema/vev/internal/domain/layout"
17 "github.com/bnema/vev/internal/domain/terminalcap"18 "github.com/bnema/vev/internal/domain/terminalcap"
18 "github.com/bnema/vev/internal/ports"19 "github.com/bnema/vev/internal/ports"
19 portsmocks "github.com/bnema/vev/internal/ports/mocks"20 portsmocks "github.com/bnema/vev/internal/ports/mocks"
20 "github.com/bnema/vev/internal/protocol"21 "github.com/bnema/vev/internal/protocol"
21 "github.com/bnema/vev/internal/protocol/wire"22 "github.com/bnema/vev/internal/protocol/wire"
22- "github.com/bnema/vev/internal/usecase/layout"
23 )23 )
24 24
25 type closeTrackingTransport struct {25 type closeTrackingTransport struct {
@@ -1247,7 +1247,7 @@ func TestParkingReleasesPaneCapturesBeforeHeadlessCloseAndResume(t *testing.T) {
1247 require.Len(t, sends, 1)1247 require.Len(t, sends, 1)
1248 output := unmarshalTestOutput(t, sends[0].Payload)1248 output := unmarshalTestOutput(t, sends[0].Payload)
1249 require.Zero(t, output.Base, "resume must start with a complete frame")1249 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)
1251 terminal.Write(output.Data)1251 terminal.Write(output.Data)
1252 contents := strings.Join(frameRows(terminal), "\n")1252 contents := strings.Join(frameRows(terminal), "\n")
1253 require.Contains(t, contents, "survivor", "resume first paint must contain current headless content")1253 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 (
17 17
18 vt "github.com/bnema/vev-vt"18 vt "github.com/bnema/vev-vt"
19 "github.com/bnema/vev/internal/domain"19 "github.com/bnema/vev/internal/domain"
20+ "github.com/bnema/vev/internal/domain/layout"
20 "github.com/bnema/vev/internal/ports"21 "github.com/bnema/vev/internal/ports"
21 "github.com/bnema/vev/internal/protocol"22 "github.com/bnema/vev/internal/protocol"
22- "github.com/bnema/vev/internal/usecase/layout"
23 )23 )
24 24
25 var (25 var (
@@ -2169,12 +2169,6 @@ func (d *Daemon) refreshSessionCwd(sess *session) {
2169 markSnapshotDirty(sess)2169 markSnapshotDirty(sess)
2170 }2170 }
2171 2171
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-
2178 func copyEnvironment(env []string) []string {2172 func copyEnvironment(env []string) []string {
2179 return append([]string(nil), env...)2173 return append([]string(nil), env...)
2180 }2174 }
M internal/usecase/daemon/session_env_test.go +1 -1
@@ -5,7 +5,7 @@ import (
5 5
6 "github.com/bnema/vev/internal/ports"6 "github.com/bnema/vev/internal/ports"
7 "github.com/bnema/vev/internal/protocol"7 "github.com/bnema/vev/internal/protocol"
8- snapcodec "github.com/bnema/vev/internal/usecase/snapshot"8+ snapcodec "github.com/bnema/vev/internal/snapshotcodec"
9 "github.com/stretchr/testify/require"9 "github.com/stretchr/testify/require"
10 )10 )
11 11
M internal/usecase/daemon/session_test.go +7 -7
@@ -1255,7 +1255,7 @@ func TestEphemeralPromotionLifecyclePreventsStaleSameNamePaletteTarget(t *testin
1255 d.ptys = newFactorySeq(t, firstPTY, secondPTY)1255 d.ptys = newFactorySeq(t, firstPTY, secondPTY)
1256 WithStore(t, store)(d)1256 WithStore(t, store)(d)
1257 1257
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)
1259 require.NoError(t, err)1259 require.NoError(t, err)
1260 require.NoError(t, d.renameSession(first, "named"))1260 require.NoError(t, d.renameSession(first, "named"))
1261 staleCreatedAt := first.createdAt1261 staleCreatedAt := first.createdAt
@@ -1263,7 +1263,7 @@ func TestEphemeralPromotionLifecyclePreventsStaleSameNamePaletteTarget(t *testin
1263 require.NoError(t, d.killSession(first, protocol.ReasonSessionKilled, true))1263 require.NoError(t, d.killSession(first, protocol.ReasonSessionKilled, true))
1264 d.sessWg.Wait()1264 d.sessWg.Wait()
1265 1265
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)
1267 require.NoError(t, err)1267 require.NoError(t, err)
1268 require.NoError(t, d.renameSession(second, "named"))1268 require.NoError(t, d.renameSession(second, "named"))
1269 require.NotEqual(t, staleCreatedAt, second.createdAt)1269 require.NotEqual(t, staleCreatedAt, second.createdAt)
@@ -1414,7 +1414,7 @@ func TestPickerStoppedTargetKillPurges(t *testing.T) {
1414 func TestChildEnvEscapesLegacySessionName(t *testing.T) {1414 func TestChildEnvEscapesLegacySessionName(t *testing.T) {
1415 d := newTestDaemon(t, nil, stubClock{})1415 d := newTestDaemon(t, nil, stubClock{})
1416 1416
1417- got := d.childEnv("legacy,name=value", "t_alpha", "p_beta")1417+ got := childEnvFrom(d.baseEnv, "legacy,name=value", "t_alpha", "p_beta")
1418 1418
1419 require.Contains(t, got, "VEV=session=legacy%2Cname%3Dvalue,tab=t_alpha,pane=p_beta")1419 require.Contains(t, got, "VEV=session=legacy%2Cname%3Dvalue,tab=t_alpha,pane=p_beta")
1420 }1420 }
@@ -1580,7 +1580,7 @@ func TestChildEnvTrueColorCapability(t *testing.T) {
1580 d := newTestDaemon(t, nil, stubClock{})1580 d := newTestDaemon(t, nil, stubClock{})
1581 d.baseEnv = tt.baseEnv1581 d.baseEnv = tt.baseEnv
1582 1582
1583- got := d.childEnv("work", "t_alpha", "p_beta")1583+ got := childEnvFrom(d.baseEnv, "work", "t_alpha", "p_beta")
1584 1584
1585 for _, want := range tt.wantContain {1585 for _, want := range tt.wantContain {
1586 require.Contains(t, got, want)1586 require.Contains(t, got, want)
@@ -1629,7 +1629,7 @@ func TestAttachUpdatesFutureChildEnvTrueColor(t *testing.T) {
1629 require.Contains(t, opens[0], "TERM=xterm-256color")1629 require.Contains(t, opens[0], "TERM=xterm-256color")
1630 require.Contains(t, opens[0], "COLORTERM=truecolor")1630 require.Contains(t, opens[0], "COLORTERM=truecolor")
1631 require.Contains(t, opens[0], "TERM_PROGRAM=vev")1631 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))
1633 require.Contains(t, opens[1], "TERM=xterm-256color")1633 require.Contains(t, opens[1], "TERM=xterm-256color")
1634 require.Contains(t, opens[1], "COLORTERM=truecolor")1634 require.Contains(t, opens[1], "COLORTERM=truecolor")
1635 require.Contains(t, opens[1], "TERM_PROGRAM=vev")1635 require.Contains(t, opens[1], "TERM_PROGRAM=vev")
@@ -1675,7 +1675,7 @@ func TestLiveAttachUpdatesFutureChildEnvTrueColor(t *testing.T) {
1675 1675
1676 require.Contains(t, opens[0], "TERM=xterm-256color")1676 require.Contains(t, opens[0], "TERM=xterm-256color")
1677 require.Contains(t, opens[0], "COLORTERM=truecolor")1677 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))
1679 require.Contains(t, opens[1], "TERM=xterm-256color")1679 require.Contains(t, opens[1], "TERM=xterm-256color")
1680 require.Contains(t, opens[1], "COLORTERM=truecolor")1680 require.Contains(t, opens[1], "COLORTERM=truecolor")
1681 require.Contains(t, opens[1], "TERM_PROGRAM=vev")1681 require.Contains(t, opens[1], "TERM_PROGRAM=vev")
@@ -1792,7 +1792,7 @@ func TestAttachEnvironmentRefreshesOnlySessionBoundFuturePTYInputs(t *testing.T)
1792 require.Equal(t, "/usr/bin/fish", commands[0])1792 require.Equal(t, "/usr/bin/fish", commands[0])
1793 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])1793 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])
1794 require.Equal(t, "fish", sess.tabs[0].focusedPaneTitle(false), "the initial tab title identifies the shell requested by its creator")1794 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))
1796 // Only session-bound variables follow the attaching client: the X11-only1796 // Only session-bound variables follow the attaching client: the X11-only
1797 // display group replaces the Wayland one, the absent agent socket is1797 // display group replaces the Wayland one, the absent agent socket is
1798 // removed, and the creator's shell and ordinary variables stay.1798 // removed, and the creator's shell and ordinary variables stay.
M internal/usecase/daemon/snapshot.go +2 -2
@@ -9,8 +9,8 @@ import (
9 9
10 vt "github.com/bnema/vev-vt"10 vt "github.com/bnema/vev-vt"
11 "github.com/bnema/vev/internal/domain"11 "github.com/bnema/vev/internal/domain"
12- "github.com/bnema/vev/internal/usecase/layout"12+ "github.com/bnema/vev/internal/domain/layout"
13- snapcodec "github.com/bnema/vev/internal/usecase/snapshot"13+ snapcodec "github.com/bnema/vev/internal/snapshotcodec"
14 )14 )
15 15
16 // snapshotQueueCapacity bounds retained immutable captures. A full queue never16 // snapshotQueueCapacity bounds retained immutable captures. A full queue never
M internal/usecase/daemon/snapshot_capture.go +1 -1
@@ -1,6 +1,6 @@
1 package daemon1 package daemon
2 2
3-import snapcodec "github.com/bnema/vev/internal/usecase/snapshot"3+import snapcodec "github.com/bnema/vev/internal/snapshotcodec"
4 4
5 // captureSnapshotState copies history tails and recovery transcripts while holding5 // captureSnapshotState copies history tails and recovery transcripts while holding
6 // each pane lock. The returned capture contains only immutable state; encoding6 // 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 (
6 vt "github.com/bnema/vev-vt"6 vt "github.com/bnema/vev-vt"
7 "github.com/bnema/vev/internal/domain"7 "github.com/bnema/vev/internal/domain"
8 "github.com/bnema/vev/internal/ports"8 "github.com/bnema/vev/internal/ports"
9- snapcodec "github.com/bnema/vev/internal/usecase/snapshot"9+ snapcodec "github.com/bnema/vev/internal/snapshotcodec"
10 )10 )
11 11
12 // snapshotChunkCacheLimit is deliberately small: history chunks remain owned12 // snapshotChunkCacheLimit is deliberately small: history chunks remain owned
M internal/usecase/daemon/snapshot_incremental_test.go +1 -1
@@ -10,7 +10,7 @@ import (
10 vt "github.com/bnema/vev-vt"10 vt "github.com/bnema/vev-vt"
11 renderer "github.com/bnema/vev-vt/ansi"11 renderer "github.com/bnema/vev-vt/ansi"
12 "github.com/bnema/vev/internal/domain"12 "github.com/bnema/vev/internal/domain"
13- snapcodec "github.com/bnema/vev/internal/usecase/snapshot"13+ snapcodec "github.com/bnema/vev/internal/snapshotcodec"
14 )14 )
15 15
16 func TestMarshalSnapshotTailSelectsCanonicalEncoder(t *testing.T) {16 func TestMarshalSnapshotTailSelectsCanonicalEncoder(t *testing.T) {
M internal/usecase/daemon/snapshot_restore.go +2 -2
@@ -9,9 +9,9 @@ import (
9 9
10 vt "github.com/bnema/vev-vt"10 vt "github.com/bnema/vev-vt"
11 "github.com/bnema/vev/internal/domain"11 "github.com/bnema/vev/internal/domain"
12+ "github.com/bnema/vev/internal/domain/layout"
12 "github.com/bnema/vev/internal/protocol"13 "github.com/bnema/vev/internal/protocol"
13- "github.com/bnema/vev/internal/usecase/layout"14+ snapcodec "github.com/bnema/vev/internal/snapshotcodec"
14- snapcodec "github.com/bnema/vev/internal/usecase/snapshot"
15 )15 )
16 16
17 func (d *Daemon) closeRestoreDone() {17 func (d *Daemon) closeRestoreDone() {
M internal/usecase/daemon/snapshot_restore_fixture_test.go +1 -1
@@ -5,7 +5,7 @@ import (
5 5
6 "github.com/bnema/vev/internal/domain"6 "github.com/bnema/vev/internal/domain"
7 "github.com/bnema/vev/internal/ports"7 "github.com/bnema/vev/internal/ports"
8- snapcodec "github.com/bnema/vev/internal/usecase/snapshot"8+ snapcodec "github.com/bnema/vev/internal/snapshotcodec"
9 )9 )
10 10
11 func acceptancePublication(snapshot snapcodec.Session) (ports.SnapshotPublication, error) {11 func acceptancePublication(snapshot snapcodec.Session) (ports.SnapshotPublication, error) {
M internal/usecase/daemon/snapshot_restore_incremental.go +1 -1
@@ -12,7 +12,7 @@ import (
12 "github.com/bnema/vev/internal/domain"12 "github.com/bnema/vev/internal/domain"
13 "github.com/bnema/vev/internal/ports"13 "github.com/bnema/vev/internal/ports"
14 "github.com/bnema/vev/internal/protocol"14 "github.com/bnema/vev/internal/protocol"
15- snapcodec "github.com/bnema/vev/internal/usecase/snapshot"15+ snapcodec "github.com/bnema/vev/internal/snapshotcodec"
16 )16 )
17 17
18 const catalogueRestoreConcurrency = 818 const catalogueRestoreConcurrency = 8
M internal/usecase/daemon/snapshot_restore_test.go +2 -2
@@ -14,11 +14,11 @@ import (
14 vt "github.com/bnema/vev-vt"14 vt "github.com/bnema/vev-vt"
15 renderer "github.com/bnema/vev-vt/ansi"15 renderer "github.com/bnema/vev-vt/ansi"
16 "github.com/bnema/vev/internal/domain"16 "github.com/bnema/vev/internal/domain"
17+ "github.com/bnema/vev/internal/domain/layout"
17 "github.com/bnema/vev/internal/ports"18 "github.com/bnema/vev/internal/ports"
18 portsmocks "github.com/bnema/vev/internal/ports/mocks"19 portsmocks "github.com/bnema/vev/internal/ports/mocks"
19- "github.com/bnema/vev/internal/usecase/layout"20+ snapcodec "github.com/bnema/vev/internal/snapshotcodec"
20 recoveryusecase "github.com/bnema/vev/internal/usecase/recovery"21 recoveryusecase "github.com/bnema/vev/internal/usecase/recovery"
21- snapcodec "github.com/bnema/vev/internal/usecase/snapshot"
22 )22 )
23 23
24 type snapshotAcceptanceRepository struct {24 type snapshotAcceptanceRepository struct {
M internal/usecase/daemon/status_test.go +1 -1
@@ -13,10 +13,10 @@ import (
13 13
14 renderer "github.com/bnema/vev-vt/ansi"14 renderer "github.com/bnema/vev-vt/ansi"
15 "github.com/bnema/vev/internal/domain"15 "github.com/bnema/vev/internal/domain"
16+ "github.com/bnema/vev/internal/domain/layout"
16 "github.com/bnema/vev/internal/protocol"17 "github.com/bnema/vev/internal/protocol"
17 "github.com/bnema/vev/internal/protocol/wire"18 "github.com/bnema/vev/internal/protocol/wire"
18 "github.com/bnema/vev/internal/usecase/command"19 "github.com/bnema/vev/internal/usecase/command"
19- "github.com/bnema/vev/internal/usecase/layout"
20 "github.com/bnema/vev/internal/usecase/palette"20 "github.com/bnema/vev/internal/usecase/palette"
21 themeui "github.com/bnema/vev/internal/usecase/theme"21 themeui "github.com/bnema/vev/internal/usecase/theme"
22 "github.com/bnema/vev/internal/usecase/ui"22 "github.com/bnema/vev/internal/usecase/ui"
M internal/usecase/daemon/theme_cache_test.go +1 -1
@@ -7,8 +7,8 @@ import (
7 7
8 renderer "github.com/bnema/vev-vt"8 renderer "github.com/bnema/vev-vt"
9 "github.com/bnema/vev/internal/domain"9 "github.com/bnema/vev/internal/domain"
10+ "github.com/bnema/vev/internal/domain/layout"
10 "github.com/bnema/vev/internal/protocol"11 "github.com/bnema/vev/internal/protocol"
11- "github.com/bnema/vev/internal/usecase/layout"
12 themeui "github.com/bnema/vev/internal/usecase/theme"12 themeui "github.com/bnema/vev/internal/usecase/theme"
13 )13 )
14 14
M internal/usecase/daemon/title_cache_regression_test.go +1 -1
@@ -7,7 +7,7 @@ import (
7 "github.com/stretchr/testify/require"7 "github.com/stretchr/testify/require"
8 8
9 "github.com/bnema/vev/internal/domain"9 "github.com/bnema/vev/internal/domain"
10- "github.com/bnema/vev/internal/usecase/layout"10+ "github.com/bnema/vev/internal/domain/layout"
11 )11 )
12 12
13 func TestComposeFramePrunesTitleGenerationsAfterLayoutChurn(t *testing.T) {13 func TestComposeFramePrunesTitleGenerationsAfterLayoutChurn(t *testing.T) {
M internal/usecase/daemon/toast_damage_test.go +1 -1
@@ -6,7 +6,7 @@ import (
6 vt "github.com/bnema/vev-vt"6 vt "github.com/bnema/vev-vt"
7 renderer "github.com/bnema/vev-vt/ansi"7 renderer "github.com/bnema/vev-vt/ansi"
8 "github.com/bnema/vev/internal/domain"8 "github.com/bnema/vev/internal/domain"
9- "github.com/bnema/vev/internal/usecase/layout"9+ "github.com/bnema/vev/internal/domain/layout"
10 "github.com/stretchr/testify/require"10 "github.com/stretchr/testify/require"
11 )11 )
12 12
M internal/usecase/daemon/transactional_resize.go +1 -1
@@ -5,8 +5,8 @@ import (
5 5
6 vt "github.com/bnema/vev-vt"6 vt "github.com/bnema/vev-vt"
7 "github.com/bnema/vev/internal/domain"7 "github.com/bnema/vev/internal/domain"
8+ "github.com/bnema/vev/internal/domain/layout"
8 "github.com/bnema/vev/internal/ports"9 "github.com/bnema/vev/internal/ports"
9- "github.com/bnema/vev/internal/usecase/layout"
10 )10 )
11 11
12 type resizeOwnerPostEffect uint812 type resizeOwnerPostEffect uint8
M internal/usecase/daemon/transactional_resize_test.go +3 -3
@@ -11,8 +11,8 @@ import (
11 "github.com/stretchr/testify/require"11 "github.com/stretchr/testify/require"
12 12
13 "github.com/bnema/vev/internal/domain"13 "github.com/bnema/vev/internal/domain"
14+ "github.com/bnema/vev/internal/domain/layout"
14 "github.com/bnema/vev/internal/ports"15 "github.com/bnema/vev/internal/ports"
15- "github.com/bnema/vev/internal/usecase/layout"
16 "github.com/bnema/vev/internal/usecase/ui"16 "github.com/bnema/vev/internal/usecase/ui"
17 "github.com/bnema/vev/internal/usecase/visualsearch"17 "github.com/bnema/vev/internal/usecase/visualsearch"
18 )18 )
@@ -474,7 +474,7 @@ func TestTransactionalResizeRechecksLeaseAtAttachmentPublication(t *testing.T) {
474 lease := rc.attachmentLease(ac)474 lease := rc.attachmentLease(ac)
475 epoch := rc.recordResizeRequestForLease(domain.Size{Cols: 100, Rows: 30}, ac, lease)475 epoch := rc.recordResizeRequestForLease(domain.Size{Cols: 100, Rows: 30}, ac, lease)
476 require.NotZero(t, epoch)476 require.NotZero(t, epoch)
477- initialSize := ac.size477+ initialSize := ac.geometry.Size
478 478
479 entered := make(chan struct{})479 entered := make(chan struct{})
480 release := make(chan struct{})480 release := make(chan struct{})
@@ -492,7 +492,7 @@ func TestTransactionalResizeRechecksLeaseAtAttachmentPublication(t *testing.T) {
492 require.NotZero(t, newer)492 require.NotZero(t, newer)
493 close(release)493 close(release)
494 require.False(t, <-done, "stale resize generation must be rejected")494 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")
496 if tc.effect == resizeOwnerPostSnapshotDirty {496 if tc.effect == resizeOwnerPostSnapshotDirty {
497 require.False(t, sess.snapDirty.Load(), "stale resize must not publish snapshot dirtiness")497 require.False(t, sess.snapDirty.Load(), "stale resize must not publish snapshot dirtiness")
498 }498 }
M internal/usecase/daemon/transport_observability_test.go +10 -9
@@ -188,8 +188,8 @@ func TestTransportObservabilityACKBlockedSpansEndExactlyOnce(t *testing.T) {
188 observer := &daemonRuntimeObserver{}188 observer := &daemonRuntimeObserver{}
189 ready := false189 ready := false
190 rc := newRenderCoordinator(renderCoordinatorOptions{190 rc := newRenderCoordinator(renderCoordinatorOptions{
191- observer: observer,191+ observer: observer,
192- ackReady: func() bool { return ready },192+ ackReadyFor: func(*attachedClient) bool { return ready },
193 })193 })
194 rc.attach(&attachedClient{})194 rc.attach(&attachedClient{})
195 rc.invalidate(renderInvalidation{class: invalidateOutput})195 rc.invalidate(renderInvalidation{class: invalidateOutput})
@@ -233,9 +233,9 @@ func TestTransportObservabilityBlockedACKEndDoesNotDelayNotifyAck(t *testing.T)
233 wakes := make(chan renderWake, 1)233 wakes := make(chan renderWake, 1)
234 var ready atomic.Bool234 var ready atomic.Bool
235 rc := newRenderCoordinator(renderCoordinatorOptions{235 rc := newRenderCoordinator(renderCoordinatorOptions{
236- observer: d.runtimeObserver,236+ observer: d.runtimeObserver,
237- ackReady: ready.Load,237+ ackReadyFor: func(*attachedClient) bool { return ready.Load() },
238- wake: func(w renderWake) { wakes <- w },238+ wake: func(w renderWake) { wakes <- w },
239 })239 })
240 rc.attach(&attachedClient{})240 rc.attach(&attachedClient{})
241 rc.invalidate(renderInvalidation{class: invalidateOutput})241 rc.invalidate(renderInvalidation{class: invalidateOutput})
@@ -284,8 +284,8 @@ func (o *blockingACKEndObserver) ObserveRuntime(mark ports.RuntimeMark) {
284 func TestTransportObservabilityBlockedCoordinatorObserverReleasesLock(t *testing.T) {284 func TestTransportObservabilityBlockedCoordinatorObserverReleasesLock(t *testing.T) {
285 observer := newBlockingDaemonRuntimeObserver()285 observer := newBlockingDaemonRuntimeObserver()
286 rc := newRenderCoordinator(renderCoordinatorOptions{286 rc := newRenderCoordinator(renderCoordinatorOptions{
287- observer: observer,287+ observer: observer,
288- ackReady: func() bool { return false },288+ ackReadyFor: func(*attachedClient) bool { return false },
289 })289 })
290 rc.attach(&attachedClient{})290 rc.attach(&attachedClient{})
291 rc.mu.Lock()291 rc.mu.Lock()
@@ -347,9 +347,10 @@ func TestTransportObservabilityACKBlockedStartPublishesBeforeEnd(t *testing.T) {
347 observer := newACKSpanInterleavingObserver()347 observer := newACKSpanInterleavingObserver()
348 ready := false348 ready := false
349 rc := newRenderCoordinator(renderCoordinatorOptions{349 rc := newRenderCoordinator(renderCoordinatorOptions{
350- observer: observer,350+ observer: observer,
351- ackReady: func() bool { return ready },351+ ackReadyFor: func(*attachedClient) bool { return ready },
352 })352 })
353+ rc.attach(&attachedClient{})
353 rc.mu.Lock()354 rc.mu.Lock()
354 rc.pending = true355 rc.pending = true
355 gen := rc.normalLane.generation356 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
3 import (3 import (
4 "sort"4 "sort"
5 "strings"5 "strings"
6- "unicode/utf8"6+
7+ "github.com/bnema/vev/internal/usecase/fuzzy"
7 )8 )
8 9
9 // Match is a fuzzy-matched immutable palette result.10 // Match is a fuzzy-matched immutable palette result.
@@ -85,7 +86,7 @@ func score(result Result, needle string, needleRunes []rune, order int) (Match,
85 return match, true86 return match, true
86 }87 }
87 if cmd, ok := result.Command(); ok {88 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 {
89 match.rank, match.span, match.first = 5, positions[len(positions)-1]-positions[0]+1, positions[0]90 match.rank, match.span, match.first = 5, positions[len(positions)-1]-positions[0]+1, positions[0]
90 return match, true91 return match, true
91 }92 }
@@ -101,25 +102,19 @@ type fieldScore struct {
101 }102 }
102 103
103 func scoreField(text, needle string, needleRunes []rune, displayOffset, exactRank, prefixRank, subsequenceRank int) (fieldScore, bool) {104 func scoreField(text, needle string, needleRunes []rune, displayOffset, exactRank, prefixRank, subsequenceRank int) (fieldScore, bool) {
104- text = strings.ToLower(text)105+ matched, ok := fuzzy.Match(strings.ToLower(text), needle, needleRunes)
105- var scored fieldScore106+ if !ok {
106- switch {107+ return fieldScore{}, false
107- case text == needle:108+ }
108- scored.positions = rangePositions(utf8.RuneCountInString(text))109+ scored := fieldScore{positions: matched.Positions, span: matched.Span, first: matched.First}
110+ switch matched.Kind {
111+ case fuzzy.Exact:
109 scored.rank = exactRank112 scored.rank = exactRank
110- case strings.HasPrefix(text, needle):113+ case fuzzy.Prefix:
111- scored.positions = rangePositions(utf8.RuneCountInString(needle))
112 scored.rank = prefixRank114 scored.rank = prefixRank
113 default:115 default:
114- positions, ok := subsequencePositions([]rune(text), needleRunes)
115- if !ok {
116- return fieldScore{}, false
117- }
118- scored.positions = positions
119 scored.rank = subsequenceRank116 scored.rank = subsequenceRank
120 }117 }
121- scored.span = scored.positions[len(scored.positions)-1] - scored.positions[0] + 1
122- scored.first = scored.positions[0]
123 for i := range scored.positions {118 for i := range scored.positions {
124 scored.positions[i] += displayOffset119 scored.positions[i] += displayOffset
125 }120 }
@@ -127,13 +122,7 @@ func scoreField(text, needle string, needleRunes []rune, displayOffset, exactRan
127 }122 }
128 123
129 func (s fieldScore) betterThan(other fieldScore) bool {124 func (s fieldScore) betterThan(other fieldScore) bool {
130- if s.rank != other.rank {125+ return fuzzy.Less(s.rank, fuzzy.Score{Span: s.span, First: s.first}, other.rank, fuzzy.Score{Span: other.span, First: other.first})
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
137 }126 }
138 127
139 func (s fieldScore) apply(match *Match) {128 func (s fieldScore) apply(match *Match) {
@@ -146,29 +135,3 @@ func (s fieldScore) apply(match *Match) {
146 func newMatch(result Result, order int) Match {135 func newMatch(result Result, order int) Match {
147 return Match{Result: result, order: order, search: strings.ToLower(result.SearchText())}136 return Match{Result: result, order: order, search: strings.ToLower(result.SearchText())}
148 }137 }
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 (
6 "unicode/utf8"6 "unicode/utf8"
7 7
8 renderer "github.com/bnema/vev-vt"8 renderer "github.com/bnema/vev-vt"
9+ "github.com/bnema/vev/internal/usecase/fuzzy"
9 )10 )
10 11
11 const MaxSearchQueryRunes = 25612 const MaxSearchQueryRunes = 256
@@ -282,60 +283,16 @@ func matchRow(row row, query string, needleRunes []rune) (searchMatch, bool) {
282 }283 }
283 284
284 func scoreField(field matchField, text, query string, needle []rune) (fieldMatch, bool) {285 func scoreField(field matchField, text, query string, needle []rune) (fieldMatch, bool) {
285- if len(needle) == 0 {286+ matched, ok := fuzzy.Match(text, query, needle)
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)
297 if !ok {287 if !ok {
298 return fieldMatch{}, false288 return fieldMatch{}, false
299 }289 }
300- return fieldMatch{field: field, positions: positions, rank: 2, span: positions[len(positions)-1] - positions[0] + 1, first: positions[0]}, true290+ return fieldMatch{field: field, positions: matched.Positions, rank: int(matched.Kind), span: matched.Span, first: matched.First}, true
301 }291 }
302 292
303 func lessFieldMatch(left, right fieldMatch) bool {293 func lessFieldMatch(left, right fieldMatch) bool {
304- if left.rank != right.rank {294+ if left.rank != right.rank || left.span != right.span || left.first != right.first {
305- return left.rank < right.rank295+ return fuzzy.Less(left.rank, fuzzy.Score{Span: left.span, First: left.first}, right.rank, fuzzy.Score{Span: right.span, First: right.first})
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
312 }296 }
313 return left.field < right.field297 return left.field < right.field
314 }298 }
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