Merge pull request 'refactor: architecture cleanup' (#10) from refactor/architecture-cleanup into main
Reviewed-on: https://git.bnema.dev/bnema/vev/pulls/10
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), nil | 209 | 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, nil | 212 | 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, nil | 284 | return layerUsecase, nil | ||
| 276 | case path == modulePath+"/internal/persist": | 285 | case path == modulePath+"/internal/persist": | ||
| 277 | return layerPersist, nil | 286 | 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, nil | 290 | 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 ownership | 15 | ## 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 are | 20 | - `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 session | 30 | - `internal/adapters/sessionwire`: translates between typed session | ||
| 30 | connections and raw wire transports: directional `oneof` envelope | 31 | connections and raw wire transports: directional `oneof` envelope | ||
| @@ -59,7 +60,7 @@ main → app → usecase → ports | |||||
| 59 | carriage adapters | 60 | 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 control | 65 | ## UI observation and control | ||
| 65 | 66 | ||||
M internal/adapters/sessionwire/client.go +3 -116
| @@ -1,9 +1,7 @@ | |||||
| 1 | package sessionwire | 1 | 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.Transport | 15 | + | 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 nil | 25 | 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 error | 332 | 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 = outputBound | 48 | 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.DecodeFailure | 87 | 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.DecodeFailure | 96 | 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.DecodeFailure | 105 | 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 sessionwire | 2 | 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.Transport | 23 | + | 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 side | 25 | // admission is the provisioned admission metadata the accepting side | ||
| 38 | - | // stamped, or the zero value with hasAdmission=false for a connection built | 26 | + | // stamped, or the zero value with hasAdmission=false for a connection whose |
| 39 | - | // by a legacy constructor that carried no admission. It is never mutated | 27 | + | // accepting side provisioned none (a direct local daemon socket). It is |
| 40 | - | // after construction and is exposed only through SessionAdmission, which | 28 | + | // never mutated after construction and is exposed only through |
| 41 | - | // returns a deep copy. | 29 | + | // SessionAdmission, which returns a deep copy. |
| 42 | admission ports.SessionAdmission | 30 | admission ports.SessionAdmission | ||
| 43 | hasAdmission bool | 31 | 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(), true | 110 | 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 argv | 106 | // 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 r | 90 | return r | ||
| 91 | } | 91 | } | ||
| 92 | 92 | ||||
| 93 | - | // BrokerRouteForTransport maps the closed legacy policy transport vocabulary | 93 | + | // 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"}}, nil | 107 | + | 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"}}, nil | 109 | + | 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 accepted | 165 | // SessionAdmissionProvider is the optional boundary-safe port a typed accepted | ||
| 166 | // server connection implements when the accepting side stamped admission | 166 | // server connection implements when the accepting side stamped admission | ||
| 167 | // metadata onto it. A daemon-consuming use case asserts it structurally; a | 167 | // metadata onto it. A daemon-consuming use case asserts it structurally; a | ||
| 168 | - | // connection built by a legacy constructor that carries no provisioned | 168 | + | // 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 the | 172 | // The returned value is a defensive copy: mutating it never alters the | ||
| 172 | // connection's admitted authority, and concurrent readers each receive their | 173 | // connection's admitted authority, and concurrent readers each receive their | ||
R internal/usecase/snapshot/codec.go → internal/snapshotcodec/codec.go +2 -2 renamed, 99% similar
| @@ -1,4 +1,4 @@ | |||||
| 1 | - | package snapshot | 1 | + | package snapshotcodec |
| 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 snapshot | 1 | + | 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 decoder | 12 | // marshalTest is a test-only v4 encoder. Production retains only the decoder | ||
R internal/usecase/snapshot/codec_fuzz_test.go → internal/snapshotcodec/codec_fuzz_test.go +1 -1 renamed, 97% similar
| @@ -1,4 +1,4 @@ | |||||
| 1 | - | package snapshot | 1 | + | package snapshotcodec |
| 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 snapshot | 1 | + | 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 snapshot | 1 | + | 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 snapshot | 1 | + | 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 snapshot | 1 | + | 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 UnmarshalManifest | 10 | // preflightManifest rejects hostile declarations before UnmarshalManifest | ||
R internal/usecase/snapshot/manifest_preflight_test.go → internal/snapshotcodec/manifest_preflight_test.go +2 -2 renamed, 99% similar
| @@ -1,4 +1,4 @@ | |||||
| 1 | - | package snapshot | 1 | + | package snapshotcodec |
| 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 snapshot | 1 | + | 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 snapshot | 1 | + | 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 snapshot | 1 | + | 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 snapshot | 1 | + | 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 snapshot | 1 | + | 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 snapshot | 1 | + | // Package snapshotcodec owns the durable snapshot format: the session codec, |
| 2 | + | // checkpoint manifests, content-addressed objects, and their bounded preflight. | |||
| 3 | + | // It depends only on domain values and ports, so both the daemon use case and | |||
| 4 | + | // the snapshot repository adapter can consume it. | |||
| 5 | + | package snapshotcodec | |||
| 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 snapshot | 1 | + | 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 snapshot | 1 | + | 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, and | 2 | // 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 broker | 12 | 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 nil | 421 | 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.sendEntered | 505 | <-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 | <-admitted | 669 | <-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 | <-admissionEnded | 818 | <-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 | <-admitted | 874 | <-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 = ac | 126 | stream.attachment = ac | ||
| 127 | return stream | 127 | 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 = ac | 199 | 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 = ac | 82 | 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 = ac | 110 | 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.size | 121 | + | firstSize := first.geometry.Size |
| 122 | secondRevision := second.viewSnapshot().revision | 122 | 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 take | 298 | // 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 remaining | 314 | // 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 | return | 19 | 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 nil | 172 | 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 and | 224 | // 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 = logger | 336 | 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.baseEnv | 644 | d.baseEnv = tc.baseEnv | ||
| 645 | sess := newBarRefreshTestSession() | 645 | sess := newBarRefreshTestSession() | ||
| 646 | sess.env = tc.sessEnv | 646 | 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 without | 94 | // 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.RWMutex | 96 | sizeMu sync.RWMutex | ||
| 97 | - | size domain.Size // retained for legacy fixtures; live paths publish geometry | |||
| 98 | geometry domain.Geometry // controlling-terminal geometry for this Attachment | 97 | geometry domain.Geometry // controlling-terminal geometry for this Attachment | ||
| 99 | keys *keys.Router | 98 | keys *keys.Router | ||
| 100 | // view is attachment-local navigation state. It is never inferred from a | 99 | // 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.size | 183 | + | 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 | return | 188 | return | ||
| 190 | } | 189 | } | ||
| 191 | ac.sizeMu.Lock() | 190 | ac.sizeMu.Lock() | ||
| 192 | - | ac.size = size | |||
| 193 | ac.geometry.Size = size | 191 | 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.geometry | 203 | 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 = geometry | 214 | 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 = peer | 99 | 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 it | 27 | // 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 by | 28 | // 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 = ac | 421 | 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] = 2 | 27 | 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.size | 88 | + | 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] = 2 | 107 | 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 daemon | 1 | 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 rather | 7 | // 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 retained | 197 | // 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 and | 10 | // 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 daemon | 1 | 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 the | 5 | // 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 daemon | 1 | 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 shared | 5 | // movePaneCommit owns pane-private validation and publication. The shared | ||
| 6 | // moveTransaction already holds daemon, routing, ordered Session, and resize | 6 | // 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 keeps | 11 | // 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 by | 10 | // 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 daemon | 14 | // 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.Second | 10 | 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 = clock | 1077 | d.clock = clock | ||
| 1078 | output := &countingOutputTransport{} | 1078 | output := &countingOutputTransport{} | ||
| 1079 | ac.tr = output | 1079 | ac.tr = output | ||
| 1080 | - | ac.size = config.size | 1080 | + | ac.geometry.Size = config.size |
| 1081 | sess.name = "performance" | 1081 | sess.name = "performance" | ||
| 1082 | sess.ephemeral = false | 1082 | 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 false | 1318 | 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 configured | 27 | // 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 = acTrue | 223 | 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 true | 994 | return true | ||
| 998 | } | 995 | } | ||
| 999 | 996 | ||||
| @@ -1014,25 +1011,15 @@ func (c *renderCoordinator) fireWithTimerTokenAndLease(token *timerToken, gen ui | |||||
| 1014 | leases[ac] = lease | 1011 | 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 in | 1019 | + | // 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] = true | 1086 | 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 || legacyOutput | 1090 | + | // callers) nothing gates output, so a shared invalidation renders directly. |
| 1091 | + | leaselessOutput := len(leases) == 0 && (c.pendingShared || len(c.pendingTargets) == 0) | |||
| 1092 | + | outputReady := len(attachmentLeases) != 0 || leaselessOutput | |||
| 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 = true | 1108 | 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 = ackStart | 1130 | 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 | <-release | 102 | <-release | ||
| 103 | return true | 103 | 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 h | 174 | return h | ||
| 175 | } | 175 | } | ||
| @@ -449,7 +449,7 @@ func TestRenderCoordinatorAckReadinessReentersWithoutBlockingResize(t *testing.T | |||||
| 449 | 449 | ||||
| 450 | var rc *renderCoordinator | 450 | 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 coordinator | 454 | // 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.RuntimeObserver | 105 | 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 output | 108 | + | // ackReadyFor reports whether one attachment may compose another output |
| 109 | - | // state (the attachmentOutput window has capacity). It is retained for | 109 | + | // state (its attachmentOutput window has capacity), so one attachment |
| 110 | - | // coordinator-only tests and headless callers; production uses ackReadyFor | 110 | + | // cannot gate another. Nil means every attachment is ready. |
| 111 | - | // so one attachment cannot gate another. | |||
| 112 | - | ackReady func() bool | |||
| 113 | ackReadyFor func(*attachedClient) bool | 111 | ackReadyFor func(*attachedClient) bool | ||
| 114 | // syncRenderable reports whether a pane's synchronized batch currently | 112 | // 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.clientSize | 76 | + | 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 the | 79 | // 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.createdAt | 1261 | 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.baseEnv | 1581 | 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-only | 1796 | // Only session-bound variables follow the attaching client: the X11-only | ||
| 1797 | // display group replaces the Wayland one, the absent agent socket is | 1797 | // 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 never | 16 | // snapshotQueueCapacity bounds retained immutable captures. A full queue never | ||
M internal/usecase/daemon/snapshot_capture.go +1 -1
| @@ -1,6 +1,6 @@ | |||||
| 1 | package daemon | 1 | 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 holding | 5 | // captureSnapshotState copies history tails and recovery transcripts while holding | ||
| 6 | // each pane lock. The returned capture contains only immutable state; encoding | 6 | // 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 owned | 12 | // 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 = 8 | 18 | 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 uint8 | 12 | 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.size | 477 | + | 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 := false | 189 | 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.Bool | 234 | 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 := false | 348 | 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 = true | 355 | rc.pending = true | ||
| 355 | gen := rc.normalLane.generation | 356 | 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, true | 86 | 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, true | 91 | 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 fieldScore | 106 | + | 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 = exactRank | 112 | scored.rank = exactRank | ||
| 110 | - | case strings.HasPrefix(text, needle): | 113 | + | case fuzzy.Prefix: |
| 111 | - | scored.positions = rangePositions(utf8.RuneCountInString(needle)) | |||
| 112 | scored.rank = prefixRank | 114 | 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 = subsequenceRank | 116 | 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] += displayOffset | 119 | 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 = 256 | 12 | 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{}, false | 288 | 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]}, true | 290 | + | 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.rank | 295 | + | 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.field | 297 | 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 | - | } | |||