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 | 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 | 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 | 35 | - Production use cases never import `internal/protocol/wire`, concrete adapters, `internal/app`, `internal/persist`, or `internal/platform`. | |
| 36 | 36 | - `internal/ports` owns application seams, not codecs, raw frames, environment policy, or worker implementations. | |
| 37 | 37 | ||
| 38 | 38 | Layer map: | |
| 39 | 39 | ||
| 40 | 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 | 43 | - `internal/protocol`: typed session messages; `protocol/catalogue` owns remote JSON schema; `protocol/wire` owns binary encoding and raw carriage contracts. | |
| 43 | 44 | - `internal/ports`: typed application connections and infrastructure-facing use-case seams. | |
| 44 | 45 | - `internal/adapters/sessionwire`: typed message ↔ raw frame adaptation. | |
M boundary_test.go +18 -7
| @@ -30,6 +30,7 @@ const ( | |||
| 30 | 30 | layerWire packageLayer = "wire" | |
| 31 | 31 | layerDomain packageLayer = "domain" | |
| 32 | 32 | layerPersist packageLayer = "persist" | |
| 33 | + | layerSnapshot packageLayer = "snapshot-codec" | |
| 33 | 34 | layerPlatform packageLayer = "platform" | |
| 34 | 35 | layerLogging packageLayer = "logging" | |
| 35 | 36 | layerPkg packageLayer = "pkg" | |
| @@ -40,15 +41,16 @@ var productionDependencies = map[packageLayer]map[packageLayer]bool{ | |||
| 40 | 41 | layerRoot: {layerApp: true}, | |
| 41 | 42 | layerCommand: {layerPkg: true}, | |
| 42 | 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 | - | layerAdapter: {layerAdapter: true, layerDomain: true, layerPorts: true, layerProtocol: true, layerCatalogue: true, layerWire: true, layerPlatform: true, layerPkg: true}, | |
| 45 | - | layerUsecase: {layerUsecase: true, layerDomain: true, layerPorts: true, layerProtocol: true, layerCatalogue: true}, | |
| 44 | + | layerApp: {layerAdapter: true, layerDomain: true, layerPorts: true, layerProtocol: true, layerCatalogue: true, layerWire: true, layerUsecase: true, layerPersist: true, layerSnapshot: true, layerPlatform: true, layerLogging: true, layerPkg: true}, | |
| 45 | + | layerAdapter: {layerAdapter: true, layerDomain: true, layerPorts: true, layerProtocol: true, layerCatalogue: true, layerWire: true, layerSnapshot: true, layerPlatform: true, layerPkg: true}, | |
| 46 | + | layerUsecase: {layerUsecase: true, layerDomain: true, layerPorts: true, layerProtocol: true, layerCatalogue: true, layerSnapshot: true}, | |
| 46 | 47 | layerPorts: {layerDomain: true, layerProtocol: true, layerCatalogue: true}, | |
| 47 | 48 | layerProtocol: {layerDomain: true}, | |
| 48 | 49 | layerCatalogue: {layerDomain: true, layerProtocol: true}, | |
| 49 | 50 | layerWire: {layerDomain: true, layerProtocol: true}, | |
| 50 | 51 | layerDomain: {layerDomain: true}, | |
| 51 | 52 | layerPersist: {layerDomain: true, layerPorts: true, layerProtocol: true, layerPkg: true}, | |
| 53 | + | layerSnapshot: {layerDomain: true, layerPorts: true}, | |
| 52 | 54 | layerPlatform: {}, | |
| 53 | 55 | layerLogging: {layerPkg: true}, | |
| 54 | 56 | layerPkg: {layerPkg: true}, | |
| @@ -91,7 +93,12 @@ func TestImportBoundaryNegativeFixtures(t *testing.T) { | |||
| 91 | 93 | {"usecase rejects adapter", modulePath + "/internal/usecase/client", modulePath + "/internal/adapters/ipc", false, false}, | |
| 92 | 94 | {"domain rejects ports", modulePath + "/internal/domain", modulePath + "/internal/ports", false, false}, | |
| 93 | 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 | 102 | {"other adapter rejects usecase", modulePath + "/internal/adapters/ipc", modulePath + "/internal/usecase/client", false, false}, | |
| 96 | 103 | {"usecase test may use wire fixture", modulePath + "/internal/usecase/client", modulePath + "/internal/protocol/wire", true, true}, | |
| 97 | 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 | 208 | if sourceLayer == layerTestSupport { | |
| 202 | 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 | 211 | if packageImportDenied(source, target) { | |
| 208 | 212 | return false, nil | |
| 209 | 213 | } | |
| @@ -222,6 +226,11 @@ func packageImportDenied(source, target string) bool { | |||
| 222 | 226 | under := func(path, root string) bool { | |
| 223 | 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 | 234 | switch { | |
| 226 | 235 | case under(source, brokerPkg): | |
| 227 | 236 | return !under(target, brokerPkg) && strings.HasPrefix(target, usecasePrefix) | |
| @@ -275,6 +284,8 @@ func classifyPackage(path string) (packageLayer, error) { | |||
| 275 | 284 | return layerUsecase, nil | |
| 276 | 285 | case path == modulePath+"/internal/persist": | |
| 277 | 286 | return layerPersist, nil | |
| 287 | + | case path == modulePath+"/internal/snapshotcodec": | |
| 288 | + | return layerSnapshot, nil | |
| 278 | 289 | case path == modulePath+"/internal/platform": | |
| 279 | 290 | return layerPlatform, nil | |
| 280 | 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 | 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 | 18 | - `internal/protocol`: typed, transport-neutral client/daemon messages, protocol version, semantic validation, and handshake policy. | |
| 19 | 19 | - `internal/protocol/catalogue`: independently versioned remote discovery JSON schema and bounded validation. | |
| 20 | 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 | 25 | envelope, no type byte). | |
| 26 | 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 | 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 | 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 | 30 | - `internal/adapters/sessionwire`: translates between typed session | |
| 30 | 31 | connections and raw wire transports: directional `oneof` envelope | |
| @@ -59,7 +60,7 @@ main → app → usecase → ports | |||
| 59 | 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 | 65 | ## UI observation and control | |
| 65 | 66 | ||
M internal/adapters/sessionwire/client.go +3 -116
| @@ -1,9 +1,7 @@ | |||
| 1 | 1 | package sessionwire | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | - | "context" | |
| 5 | 4 | "errors" | |
| 6 | - | "sync" | |
| 7 | 5 | "time" | |
| 8 | 6 | ||
| 9 | 7 | "google.golang.org/protobuf/proto" | |
| @@ -14,27 +12,7 @@ import ( | |||
| 14 | 12 | ) | |
| 15 | 13 | ||
| 16 | 14 | type clientConnection struct { | |
| 17 | - | raw wire.Transport | |
| 18 | - | ceilings protoCeilings | |
| 19 | - | // ceilingsMu guards ceilings. The lazy preamble publishes the negotiated | |
| 20 | - | // values from whichever goroutine first uses the connection, while another | |
| 21 | - | // goroutine may already be asking for capabilities or starting a send, so | |
| 22 | - | // the once-guarded write is not by itself a happens-before edge for them. | |
| 23 | - | ceilingsMu sync.Mutex | |
| 24 | - | ||
| 25 | - | preambleOnce sync.Once | |
| 26 | - | preambleErr error | |
| 27 | - | deadline time.Time | |
| 28 | - | ||
| 29 | - | // preambleDone closes exactly once when the handshake has run, whether it | |
| 30 | - | // succeeded or failed. It backs the handshake completion hook that a logical | |
| 31 | - | // mux connection exposes; the absolute deadline is never restarted. | |
| 32 | - | preambleDone chan struct{} | |
| 33 | - | ||
| 34 | - | hooksMu sync.Mutex | |
| 35 | - | handshakeDone bool | |
| 36 | - | hooksRun bool | |
| 37 | - | hooks []func() | |
| 15 | + | handshake | |
| 38 | 16 | } | |
| 39 | 17 | ||
| 40 | 18 | var _ ports.ClientConnection = (*clientConnection)(nil) | |
| @@ -46,102 +24,11 @@ func NewClientConnection(raw wire.Transport) ports.ClientConnection { | |||
| 46 | 24 | if raw == nil { | |
| 47 | 25 | return nil | |
| 48 | 26 | } | |
| 49 | - | return &clientConnection{raw: raw, ceilings: defaultProtoCeilings(), deadline: time.Now().Add(protocol.HandshakeTimeout), preambleDone: make(chan struct{})} | |
| 50 | - | } | |
| 51 | - | ||
| 52 | - | // HandshakeDeadline returns the absolute local deadline of the session | |
| 53 | - | // handshake this connection started with. It is the one accepted deadline the | |
| 54 | - | // preamble, Hello/Welcome, and first committed publication share; the value is | |
| 55 | - | // fixed for the connection's lifetime and is never restarted. | |
| 56 | - | func (c *clientConnection) HandshakeDeadline() time.Time { return c.deadline } | |
| 57 | - | ||
| 58 | - | // HandshakeDone returns a channel that is closed exactly once when the | |
| 59 | - | // handshake has run, whether it succeeded or failed. | |
| 60 | - | func (c *clientConnection) HandshakeDone() <-chan struct{} { return c.preambleDone } | |
| 61 | - | ||
| 62 | - | // OnHandshakeComplete registers fn to run exactly once when the handshake has | |
| 63 | - | // run. Registering after completion runs fn immediately; a nil fn is ignored. | |
| 64 | - | func (c *clientConnection) OnHandshakeComplete(fn func()) { | |
| 65 | - | if fn == nil { | |
| 66 | - | return | |
| 67 | - | } | |
| 68 | - | c.hooksMu.Lock() | |
| 69 | - | if c.handshakeDone { | |
| 70 | - | c.hooksMu.Unlock() | |
| 71 | - | fn() | |
| 72 | - | return | |
| 73 | - | } | |
| 74 | - | c.hooks = append(c.hooks, fn) | |
| 75 | - | c.hooksMu.Unlock() | |
| 76 | - | } | |
| 77 | - | ||
| 78 | - | // finishPreamble publishes handshake completion from inside the preamble's | |
| 79 | - | // sync.Once: it closes the done channel and marks the handshake complete so a | |
| 80 | - | // later registration runs immediately. It deliberately runs no registered hook, | |
| 81 | - | // because a hook that calls back into the connection would re-enter sync.Once | |
| 82 | - | // and deadlock; runHandshakeHooks runs the hooks after the Once body returns. | |
| 83 | - | // It tolerates a zero-value connection that never allocated the done channel | |
| 84 | - | // (test literals and seam-only construction), so closing is nil-safe. | |
| 85 | - | func (c *clientConnection) finishPreamble() { | |
| 86 | - | if c.preambleDone != nil { | |
| 87 | - | close(c.preambleDone) | |
| 88 | - | } | |
| 89 | - | c.hooksMu.Lock() | |
| 90 | - | c.handshakeDone = true | |
| 91 | - | c.hooksMu.Unlock() | |
| 92 | - | } | |
| 93 | - | ||
| 94 | - | // runHandshakeHooks runs the completion hooks registered before the handshake | |
| 95 | - | // finished exactly once, after the preamble's sync.Once body has returned. | |
| 96 | - | // Running them outside the Once is what lets a hook call back into the | |
| 97 | - | // connection - for example to send a message - without re-entering sync.Once | |
| 98 | - | // and deadlocking. Every ensurePreamble caller invokes it; only the first | |
| 99 | - | // drains the hooks, and a registrant that arrives after completion observes the | |
| 100 | - | // completed flag and runs itself. | |
| 101 | - | func (c *clientConnection) runHandshakeHooks() { | |
| 102 | - | c.hooksMu.Lock() | |
| 103 | - | if c.hooksRun { | |
| 104 | - | c.hooksMu.Unlock() | |
| 105 | - | return | |
| 106 | - | } | |
| 107 | - | c.hooksRun = true | |
| 108 | - | hooks := c.hooks | |
| 109 | - | c.hooks = nil | |
| 110 | - | c.hooksMu.Unlock() | |
| 111 | - | for _, fn := range hooks { | |
| 112 | - | fn() | |
| 113 | - | } | |
| 27 | + | return &clientConnection{handshake: newHandshake(raw, time.Now().Add(protocol.HandshakeTimeout))} | |
| 114 | 28 | } | |
| 115 | 29 | ||
| 116 | 30 | func (c *clientConnection) ensurePreamble() error { | |
| 117 | - | c.preambleOnce.Do(func() { | |
| 118 | - | ctx, cancel := context.WithDeadline(context.Background(), c.deadline) | |
| 119 | - | defer cancel() | |
| 120 | - | next, err := runProtoClientPreamble(ctx, c.raw, c.limits()) | |
| 121 | - | c.publishLimits(next) | |
| 122 | - | c.preambleErr = err | |
| 123 | - | if err != nil { | |
| 124 | - | _ = c.raw.Close() | |
| 125 | - | } | |
| 126 | - | c.finishPreamble() | |
| 127 | - | }) | |
| 128 | - | c.runHandshakeHooks() | |
| 129 | - | return c.preambleErr | |
| 130 | - | } | |
| 131 | - | ||
| 132 | - | // limits returns the negotiated ceilings under the lock the preamble publishes | |
| 133 | - | // them through. | |
| 134 | - | func (c *clientConnection) limits() protoCeilings { | |
| 135 | - | c.ceilingsMu.Lock() | |
| 136 | - | defer c.ceilingsMu.Unlock() | |
| 137 | - | return c.ceilings | |
| 138 | - | } | |
| 139 | - | ||
| 140 | - | // publishLimits records the ceilings one preamble negotiation produced. | |
| 141 | - | func (c *clientConnection) publishLimits(next protoCeilings) { | |
| 142 | - | c.ceilingsMu.Lock() | |
| 143 | - | c.ceilings = next | |
| 144 | - | c.ceilingsMu.Unlock() | |
| 31 | + | return c.handshake.ensurePreamble(runProtoClientPreamble) | |
| 145 | 32 | } | |
| 146 | 33 | ||
| 147 | 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 | 227 | require.ErrorIs(t, checkCategoryCeiling(oversizeRaw, categoryControl, wire.AbsoluteEnvelopeLimit), wire.ErrScanLength, | |
| 228 | 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 | 231 | require.NoError(t, send.SendServer(message)) | |
| 232 | 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 | 235 | recv.preambleOnce.Do(func() {}) | |
| 236 | 236 | _, err = recv.ReceiveServer() | |
| 237 | 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 | 239 | require.ErrorIs(t, checkCategoryCeiling(oversize, categoryControl, wire.AbsoluteEnvelopeLimit), wire.ErrScanLength, | |
| 240 | 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 | 243 | require.NoError(t, send.SendServer(message)) | |
| 244 | 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 | 247 | recv.preambleOnce.Do(func() {}) | |
| 248 | 248 | _, err = recv.ReceiveServer() | |
| 249 | 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 | 133 | for _, tt := range tests { | |
| 134 | 134 | t.Run(tt.name, func(t *testing.T) { | |
| 135 | 135 | raw := &scriptedTransport{} | |
| 136 | - | conn := &clientConnection{raw: raw, ceilings: tt.ceilings} | |
| 136 | + | conn := &clientConnection{handshake: handshake{raw: raw, ceilings: tt.ceilings}} | |
| 137 | 137 | conn.preambleOnce.Do(func() {}) | |
| 138 | 138 | err := conn.SendClient(tt.message) | |
| 139 | 139 | if tt.wantErr != nil { | |
| @@ -194,7 +194,7 @@ func TestServerSendCategoryEnvelopeCeilings(t *testing.T) { | |||
| 194 | 194 | for _, tt := range tests { | |
| 195 | 195 | t.Run(tt.name, func(t *testing.T) { | |
| 196 | 196 | raw := &scriptedTransport{} | |
| 197 | - | conn := &serverConnection{raw: raw, ceilings: tt.ceilings} | |
| 197 | + | conn := &serverConnection{handshake: handshake{raw: raw, ceilings: tt.ceilings}} | |
| 198 | 198 | err := conn.SendServer(tt.message) | |
| 199 | 199 | if tt.wantErr != nil { | |
| 200 | 200 | require.ErrorIs(t, err, tt.wantErr) | |
| @@ -212,7 +212,7 @@ func TestServerSendCategoryEnvelopeCeilings(t *testing.T) { | |||
| 212 | 212 | func TestServerSendModesShareControlCategoryCap(t *testing.T) { | |
| 213 | 213 | controlOver := protocol.Sessions{Sessions: []protocol.SessionInfo{{Name: strings.Repeat("x", overControlCap)}}} | |
| 214 | 214 | raw := &asyncScriptedTransport{scriptedTransport: &scriptedTransport{}} | |
| 215 | - | conn := &serverConnection{raw: raw, ceilings: defaultProtoCeilings()} | |
| 215 | + | conn := &serverConnection{handshake: handshake{raw: raw, ceilings: defaultProtoCeilings()}} | |
| 216 | 216 | ||
| 217 | 217 | require.ErrorIs(t, conn.SendServerAsync(controlOver), wire.ErrScanLength) | |
| 218 | 218 | require.ErrorIs(t, conn.SendServerSynchronous(controlOver), wire.ErrScanLength) | |
| @@ -331,11 +331,11 @@ func TestReceiveCategoryEnvelopeCeilings(t *testing.T) { | |||
| 331 | 331 | raw := &scriptedTransport{recv: []wire.Envelope{{Payload: tt.payload}}} | |
| 332 | 332 | var err error | |
| 333 | 333 | if tt.receiver == "server" { | |
| 334 | - | conn := &serverConnection{raw: raw, ceilings: tt.ceilings} | |
| 334 | + | conn := &serverConnection{handshake: handshake{raw: raw, ceilings: tt.ceilings}} | |
| 335 | 335 | conn.preambleOnce.Do(func() {}) | |
| 336 | 336 | _, err = conn.ReceiveClient() | |
| 337 | 337 | } else { | |
| 338 | - | conn := &clientConnection{raw: raw, ceilings: tt.ceilings} | |
| 338 | + | conn := &clientConnection{handshake: handshake{raw: raw, ceilings: tt.ceilings}} | |
| 339 | 339 | conn.preambleOnce.Do(func() {}) | |
| 340 | 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 | 43 | t.Run("normal send", func(t *testing.T) { | |
| 44 | 44 | raw := &scriptedTransport{} | |
| 45 | - | conn := &serverConnection{raw: raw, ceilings: envelopeBound} | |
| 45 | + | conn := &serverConnection{handshake: handshake{raw: raw, ceilings: envelopeBound}} | |
| 46 | 46 | require.ErrorIs(t, conn.SendServer(protocol.ErrorMsg{Code: protocol.ErrInternal, Text: "oversized"}), wire.ErrScanLength) | |
| 47 | 47 | require.Zero(t, raw.sentLen()) | |
| 48 | 48 | conn.ceilings = outputBound | |
| @@ -51,7 +51,7 @@ func TestServerSendEnforcesNegotiatedCeilings(t *testing.T) { | |||
| 51 | 51 | }) | |
| 52 | 52 | t.Run("async and owned synchronous send", func(t *testing.T) { | |
| 53 | 53 | raw := &asyncScriptedTransport{scriptedTransport: &scriptedTransport{}} | |
| 54 | - | conn := &serverConnection{raw: raw, ceilings: envelopeBound} | |
| 54 | + | conn := &serverConnection{handshake: handshake{raw: raw, ceilings: envelopeBound}} | |
| 55 | 55 | require.ErrorIs(t, conn.SendServerAsync(protocol.ErrorMsg{Code: protocol.ErrInternal, Text: "oversized"}), wire.ErrScanLength) | |
| 56 | 56 | require.ErrorIs(t, conn.SendServerSynchronous(protocol.ErrorMsg{Code: protocol.ErrInternal, Text: "oversized"}), wire.ErrScanLength) | |
| 57 | 57 | require.Empty(t, raw.async) | |
| @@ -64,7 +64,7 @@ func TestServerSendEnforcesNegotiatedCeilings(t *testing.T) { | |||
| 64 | 64 | }) | |
| 65 | 65 | t.Run("within ceilings", func(t *testing.T) { | |
| 66 | 66 | raw := &scriptedTransport{} | |
| 67 | - | conn := &serverConnection{raw: raw, ceilings: defaultProtoCeilings()} | |
| 67 | + | conn := &serverConnection{handshake: handshake{raw: raw, ceilings: defaultProtoCeilings()}} | |
| 68 | 68 | require.NoError(t, conn.SendServer(protocol.Pong{})) | |
| 69 | 69 | require.NoError(t, conn.SendOutput(testOutput([]byte("12345")))) | |
| 70 | 70 | require.Equal(t, 2, raw.sentLen()) | |
| @@ -81,7 +81,7 @@ func TestTypedReceiveEnforcesNegotiatedCeilings(t *testing.T) { | |||
| 81 | 81 | ||
| 82 | 82 | t.Run("server receive envelope ceiling", func(t *testing.T) { | |
| 83 | 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 | 85 | conn.preambleOnce.Do(func() {}) | |
| 86 | 86 | _, err := conn.ReceiveClient() | |
| 87 | 87 | var failure *protocol.DecodeFailure | |
| @@ -90,7 +90,7 @@ func TestTypedReceiveEnforcesNegotiatedCeilings(t *testing.T) { | |||
| 90 | 90 | }) | |
| 91 | 91 | t.Run("client receive envelope ceiling", func(t *testing.T) { | |
| 92 | 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 | 94 | conn.preambleOnce.Do(func() {}) | |
| 95 | 95 | _, err := conn.ReceiveServer() | |
| 96 | 96 | var failure *protocol.DecodeFailure | |
| @@ -99,7 +99,7 @@ func TestTypedReceiveEnforcesNegotiatedCeilings(t *testing.T) { | |||
| 99 | 99 | }) | |
| 100 | 100 | t.Run("client receive output data ceiling", func(t *testing.T) { | |
| 101 | 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 | 103 | conn.preambleOnce.Do(func() {}) | |
| 104 | 104 | _, err := conn.ReceiveServer() | |
| 105 | 105 | var failure *protocol.DecodeFailure | |
| @@ -108,14 +108,14 @@ func TestTypedReceiveEnforcesNegotiatedCeilings(t *testing.T) { | |||
| 108 | 108 | }) | |
| 109 | 109 | t.Run("within ceilings decodes", func(t *testing.T) { | |
| 110 | 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 | 112 | server.preambleOnce.Do(func() {}) | |
| 113 | 113 | got, err := server.ReceiveClient() | |
| 114 | 114 | require.NoError(t, err) | |
| 115 | 115 | require.Equal(t, []byte(bytes.Repeat([]byte("x"), 64)), got.(protocol.Input).Data) | |
| 116 | 116 | ||
| 117 | 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 | 119 | client.preambleOnce.Do(func() {}) | |
| 120 | 120 | decoded, err := client.ReceiveServer() | |
| 121 | 121 | require.NoError(t, err) | |
| @@ -127,12 +127,12 @@ func TestTypedReceiveEnforcesNegotiatedCeilings(t *testing.T) { | |||
| 127 | 127 | // path and advertised capabilities reflect the negotiated ceilings. | |
| 128 | 128 | func TestClientSendAndCapabilitiesUseNegotiatedCeilings(t *testing.T) { | |
| 129 | 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 | 131 | conn.preambleOnce.Do(func() {}) | |
| 132 | 132 | require.ErrorIs(t, conn.SendClient(protocol.Input{InputSeq: 1, Data: []byte("hello")}), wire.ErrScanLength) | |
| 133 | 133 | require.Zero(t, raw.sentLen()) | |
| 134 | 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 | 137 | require.Equal(t, 4321, server.Capabilities().OutputDataLimit) | |
| 138 | 138 | } | |
M internal/adapters/sessionwire/server.go +7 -124
| @@ -2,9 +2,7 @@ | |||
| 2 | 2 | package sessionwire | |
| 3 | 3 | ||
| 4 | 4 | import ( | |
| 5 | - | "context" | |
| 6 | 5 | "errors" | |
| 7 | - | "sync" | |
| 8 | 6 | "time" | |
| 9 | 7 | ||
| 10 | 8 | "google.golang.org/protobuf/encoding/protowire" | |
| @@ -22,35 +20,15 @@ var ( | |||
| 22 | 20 | ) | |
| 23 | 21 | ||
| 24 | 22 | type serverConnection struct { | |
| 25 | - | raw wire.Transport | |
| 26 | - | ceilings protoCeilings | |
| 27 | - | // ceilingsMu guards ceilings. The lazy preamble publishes the negotiated | |
| 28 | - | // values from whichever goroutine first uses the connection, while another | |
| 29 | - | // goroutine may already be asking for capabilities, so the once-guarded write | |
| 30 | - | // is not by itself a happens-before edge for that reader. | |
| 31 | - | ceilingsMu sync.Mutex | |
| 32 | - | ||
| 33 | - | preambleOnce sync.Once | |
| 34 | - | preambleErr error | |
| 35 | - | deadline time.Time | |
| 23 | + | handshake | |
| 36 | 24 | ||
| 37 | 25 | // admission is the provisioned admission metadata the accepting side | |
| 38 | - | // stamped, or the zero value with hasAdmission=false for a connection built | |
| 39 | - | // by a legacy constructor that carried no admission. It is never mutated | |
| 40 | - | // after construction and is exposed only through SessionAdmission, which | |
| 41 | - | // returns a deep copy. | |
| 26 | + | // stamped, or the zero value with hasAdmission=false for a connection whose | |
| 27 | + | // accepting side provisioned none (a direct local daemon socket). It is | |
| 28 | + | // never mutated after construction and is exposed only through | |
| 29 | + | // SessionAdmission, which returns a deep copy. | |
| 42 | 30 | admission ports.SessionAdmission | |
| 43 | 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 | 34 | var ( | |
| @@ -114,12 +92,9 @@ func newServerConnection(raw wire.Transport, deadline time.Time, admission ports | |||
| 114 | 92 | deadline = time.Now().Add(protocol.HandshakeTimeout) | |
| 115 | 93 | } | |
| 116 | 94 | return &serverConnection{ | |
| 117 | - | raw: raw, | |
| 118 | - | ceilings: defaultProtoCeilings(), | |
| 119 | - | deadline: deadline, | |
| 95 | + | handshake: newHandshake(raw, deadline), | |
| 120 | 96 | admission: admission.Clone(), | |
| 121 | 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 | 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 | 113 | func (c *serverConnection) ensurePreamble() error { | |
| 203 | - | c.preambleOnce.Do(func() { | |
| 204 | - | ctx, cancel := context.WithDeadline(context.Background(), c.deadline) | |
| 205 | - | defer cancel() | |
| 206 | - | next, err := runProtoServerPreamble(ctx, c.raw, c.limits()) | |
| 207 | - | c.publishLimits(next) | |
| 208 | - | c.preambleErr = err | |
| 209 | - | if err != nil { | |
| 210 | - | _ = c.raw.Close() | |
| 211 | - | } | |
| 212 | - | c.finishPreamble() | |
| 213 | - | }) | |
| 214 | - | c.runHandshakeHooks() | |
| 215 | - | return c.preambleErr | |
| 216 | - | } | |
| 217 | - | ||
| 218 | - | // limits returns the negotiated ceilings under the lock the preamble publishes | |
| 219 | - | // them through. Send paths may read them without the lock once ensurePreamble | |
| 220 | - | // has returned, because that call orders the publish before the read. | |
| 221 | - | func (c *serverConnection) limits() protoCeilings { | |
| 222 | - | c.ceilingsMu.Lock() | |
| 223 | - | defer c.ceilingsMu.Unlock() | |
| 224 | - | return c.ceilings | |
| 225 | - | } | |
| 226 | - | ||
| 227 | - | // publishLimits records the ceilings one preamble negotiation produced. | |
| 228 | - | func (c *serverConnection) publishLimits(next protoCeilings) { | |
| 229 | - | c.ceilingsMu.Lock() | |
| 230 | - | c.ceilings = next | |
| 231 | - | c.ceilingsMu.Unlock() | |
| 114 | + | return c.handshake.ensurePreamble(runProtoServerPreamble) | |
| 232 | 115 | } | |
| 233 | 116 | ||
| 234 | 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 | 12 | "github.com/bnema/vev/internal/domain" | |
| 13 | 13 | "github.com/bnema/vev/internal/ports" | |
| 14 | + | codec "github.com/bnema/vev/internal/snapshotcodec" | |
| 14 | 15 | recoveryusecase "github.com/bnema/vev/internal/usecase/recovery" | |
| 15 | - | codec "github.com/bnema/vev/internal/usecase/snapshot" | |
| 16 | 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 | 11 | "github.com/bnema/vev/internal/domain" | |
| 12 | 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 | 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 | 11 | "github.com/bnema/vev/internal/domain" | |
| 12 | 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 | 16 | // SnapshotIncarnations lists the canonical incarnation directories. | |
M internal/adapters/snapshot/path_security_test.go +1 -1
| @@ -10,7 +10,7 @@ import ( | |||
| 10 | 10 | "testing" | |
| 11 | 11 | ||
| 12 | 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 | 16 | func TestRepositoryRejectsSymlinkedRoot(t *testing.T) { | |
M internal/adapters/snapshot/publish.go +1 -1
| @@ -10,7 +10,7 @@ import ( | |||
| 10 | 10 | ||
| 11 | 11 | "github.com/bnema/vev/internal/domain" | |
| 12 | 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 | 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 | 11 | "github.com/bnema/vev/internal/domain" | |
| 12 | 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 | 16 | func TestRepositoryPublishRevalidatesUnchangedTenThousandObjectHistory(t *testing.T) { | |
M internal/adapters/snapshot/repository_hardening_test.go +1 -1
| @@ -8,7 +8,7 @@ import ( | |||
| 8 | 8 | "syscall" | |
| 9 | 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 | 14 | func TestReadBoundedRejectsUnsafeFiles(t *testing.T) { | |
M internal/adapters/snapshot/repository_test.go +1 -1
| @@ -8,7 +8,7 @@ import ( | |||
| 8 | 8 | "github.com/bnema/vev/internal/domain" | |
| 9 | 9 | ||
| 10 | 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 | 14 | func TestRepositoryPublishesAndLoadsCompleteGeneration(t *testing.T) { | |
M internal/app/broker_offline_mux.go +3 -3
| @@ -65,9 +65,9 @@ import ( | |||
| 65 | 65 | ||
| 66 | 66 | const ( | |
| 67 | 67 | // brokerMuxStdioCommand is the hidden remote SSH stdio mux bridge. | |
| 68 | - | brokerMuxStdioCommand = "_broker-mux-stdio" | |
| 68 | + | brokerMuxStdioCommand = ports.BrokerMuxStdioCommand | |
| 69 | 69 | // brokerMuxQUICBootstrapCommand is the hidden remote QUIC mux bootstrap. | |
| 70 | - | brokerMuxQUICBootstrapCommand = "_broker-mux-quic-bootstrap" | |
| 70 | + | brokerMuxQUICBootstrapCommand = ports.BrokerMuxQUICBootstrapCommand | |
| 71 | 71 | // brokerMuxQUICProxyCommand is the hidden detached remote QUIC mux proxy. | |
| 72 | 72 | brokerMuxQUICProxyCommand = "_broker-mux-quic-proxy" | |
| 73 | 73 | ) | |
| @@ -105,7 +105,7 @@ const brokerDaemonStartArg = "--daemon-start" | |||
| 105 | 105 | ||
| 106 | 106 | // brokerScopeArg is the scope flag carried by every durable remote route argv | |
| 107 | 107 | // (see ports.BrokerRouteForTransport). Stored routes keep it verbatim. | |
| 108 | - | const brokerScopeArg = "--production" | |
| 108 | + | const brokerScopeArg = ports.BrokerRouteScopeArg | |
| 109 | 109 | ||
| 110 | 110 | // brokerMuxOptions is the parsed hidden mux helper invocation. | |
| 111 | 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 | 90 | return r | |
| 91 | 91 | } | |
| 92 | 92 | ||
| 93 | - | // BrokerRouteForTransport maps the closed legacy policy transport vocabulary | |
| 94 | - | // to canonical remote routes. | |
| 93 | + | // Remote helper argv vocabulary. Durable SSH routes store this argv verbatim, | |
| 94 | + | // so these spellings are part of the persisted route contract; the CLI parses | |
| 95 | + | // the same constants. | |
| 96 | + | const ( | |
| 97 | + | BrokerMuxStdioCommand = "_broker-mux-stdio" | |
| 98 | + | BrokerMuxQUICBootstrapCommand = "_broker-mux-quic-bootstrap" | |
| 99 | + | BrokerRouteScopeArg = "--production" | |
| 100 | + | ) | |
| 101 | + | ||
| 102 | + | // BrokerRouteForTransport maps the closed policy transport vocabulary to the | |
| 103 | + | // canonical remote route. | |
| 95 | 104 | func BrokerRouteForTransport(transport, endpoint string) (BrokerRouteSpec, error) { | |
| 96 | 105 | switch transport { | |
| 97 | 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 | 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 | 110 | default: | |
| 102 | 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 | 165 | // SessionAdmissionProvider is the optional boundary-safe port a typed accepted | |
| 166 | 166 | // server connection implements when the accepting side stamped admission | |
| 167 | 167 | // metadata onto it. A daemon-consuming use case asserts it structurally; a | |
| 168 | - | // connection built by a legacy constructor that carries no provisioned | |
| 169 | - | // admission reports ok=false rather than a zero admission. | |
| 168 | + | // connection accepted without provisioned admission (the direct local daemon | |
| 169 | + | // socket used by broker-less list and stop commands) reports ok=false rather | |
| 170 | + | // than a zero admission, and the daemon applies standard Hello validation. | |
| 170 | 171 | // | |
| 171 | 172 | // The returned value is a defensive copy: mutating it never alters the | |
| 172 | 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 | 3 | import ( | |
| 4 | 4 | "encoding/binary" | |
| @@ -7,7 +7,7 @@ import ( | |||
| 7 | 7 | "hash/crc32" | |
| 8 | 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 | 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 | 3 | import ( | |
| 4 | 4 | "encoding/binary" | |
| @@ -6,7 +6,7 @@ import ( | |||
| 6 | 6 | "hash/crc32" | |
| 7 | 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 | 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 | 3 | import ( | |
| 4 | 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 | 3 | import ( | |
| 4 | 4 | "bytes" | |
| @@ -11,7 +11,7 @@ import ( | |||
| 11 | 11 | ||
| 12 | 12 | vt "github.com/bnema/vev-vt" | |
| 13 | 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 | 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 | 3 | import ( | |
| 4 | 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 | 3 | import ( | |
| 4 | 4 | "crypto/sha256" | |
| @@ -8,8 +8,8 @@ import ( | |||
| 8 | 8 | "math" | |
| 9 | 9 | ||
| 10 | 10 | "github.com/bnema/vev/internal/domain" | |
| 11 | + | "github.com/bnema/vev/internal/domain/layout" | |
| 11 | 12 | "github.com/bnema/vev/internal/ports" | |
| 12 | - | "github.com/bnema/vev/internal/usecase/layout" | |
| 13 | 13 | ) | |
| 14 | 14 | ||
| 15 | 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 | 3 | import ( | |
| 4 | 4 | "fmt" | |
| 5 | 5 | ||
| 6 | 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 | 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 | 3 | import ( | |
| 4 | 4 | "encoding/binary" | |
| @@ -7,7 +7,7 @@ import ( | |||
| 7 | 7 | "testing" | |
| 8 | 8 | ||
| 9 | 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 | 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 | 3 | import ( | |
| 4 | 4 | "bytes" | |
| @@ -9,8 +9,8 @@ import ( | |||
| 9 | 9 | "testing" | |
| 10 | 10 | ||
| 11 | 11 | "github.com/bnema/vev/internal/domain" | |
| 12 | + | "github.com/bnema/vev/internal/domain/layout" | |
| 12 | 13 | "github.com/bnema/vev/internal/ports" | |
| 13 | - | "github.com/bnema/vev/internal/usecase/layout" | |
| 14 | 14 | ) | |
| 15 | 15 | ||
| 16 | 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 | 3 | import ( | |
| 4 | 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 | 3 | import ( | |
| 4 | 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 | 3 | import ( | |
| 4 | 4 | "fmt" | |
| 5 | 5 | "math" | |
| 6 | 6 | ||
| 7 | 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 | 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 | 3 | import ( | |
| 4 | 4 | "errors" | |
| 5 | 5 | "testing" | |
| 6 | 6 | ||
| 7 | 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 | 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 | 9 | // Session is the durable transfer representation for a named daemon session. | |
| 6 | 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 | 3 | import ( | |
| 4 | 4 | "bytes" | |
| @@ -6,7 +6,7 @@ import ( | |||
| 6 | 6 | "testing" | |
| 7 | 7 | ||
| 8 | 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 | 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 | 3 | import ( | |
| 4 | 4 | "bytes" | |
| @@ -7,7 +7,7 @@ import ( | |||
| 7 | 7 | "math" | |
| 8 | 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 | 13 | func TestManifestWeightRoundTripAndOldCompatibility(t *testing.T) { | |
M internal/usecase/broker/doc.go +7 -0
| @@ -2,4 +2,11 @@ | |||
| 2 | 2 | // reachability and catalogue snapshots, pooled physical transports, and | |
| 3 | 3 | // multiplexed logical streams. It does not own picker UI, sessions, PTYs, | |
| 4 | 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 | 12 | package broker | |
M internal/usecase/broker/registry.go +0 -1163
| @@ -3,14 +3,10 @@ package broker | |||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | 5 | "crypto/rand" | |
| 6 | - | "encoding/binary" | |
| 7 | 6 | "errors" | |
| 8 | 7 | "fmt" | |
| 9 | - | "hash/fnv" | |
| 10 | 8 | "log/slog" | |
| 11 | 9 | "reflect" | |
| 12 | - | "slices" | |
| 13 | - | "sort" | |
| 14 | 10 | "sync" | |
| 15 | 11 | "sync/atomic" | |
| 16 | 12 | "time" | |
| @@ -424,1162 +420,3 @@ func (r *Registry) restore(snapshot ports.BrokerSnapshot) error { | |||
| 424 | 420 | } | |
| 425 | 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 | 198 | first.replaceTransport(firstTransport) | |
| 199 | 199 | ||
| 200 | 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 | 202 | blocked.initOverlays() | |
| 203 | 203 | blocked.setSession(sess) | |
| 204 | 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 | 206 | later.initOverlays() | |
| 207 | 207 | later.setSession(sess) | |
| 208 | 208 | sess.mu.Lock() | |
| @@ -320,7 +320,7 @@ func TestKillSessionInterruptsOnlyExactParticipantBlockedSends(t *testing.T) { | |||
| 320 | 320 | snatched.installTestAttachmentCapability(sess.captureAttachmentCapability(snatched, snatchedTransport)) | |
| 321 | 321 | ||
| 322 | 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 | 324 | active.initOverlays() | |
| 325 | 325 | transition, err := d.transitionAttachment(attachmentTransitionRequest{ | |
| 326 | 326 | target: sess, next: active, expectedTransport: active.transportSnapshot(), ready: true, | |
| @@ -329,7 +329,7 @@ func TestKillSessionInterruptsOnlyExactParticipantBlockedSends(t *testing.T) { | |||
| 329 | 329 | defer d.deferAttachmentTransitionCleanups(transition) | |
| 330 | 330 | ||
| 331 | 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 | 333 | unrelatedClient.initOverlays() | |
| 334 | 334 | unrelated := &session{sessionCore: sessionCore{id: "unrelated", name: "unrelated", attachments: map[*attachedClient]struct{}{unrelatedClient: {}}}, ctx: sess.ctx, cancel: func() {}} | |
| 335 | 335 | unrelatedClient.setSession(unrelated) | |
| @@ -505,7 +505,7 @@ func TestAttachmentEffectGateReplacementInterruptsBlockedOldRenderBeforePublicat | |||
| 505 | 505 | <-oldTransport.sendEntered | |
| 506 | 506 | ||
| 507 | 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 | 509 | next.initOverlays() | |
| 510 | 510 | transitionDone := make(chan attachmentTransitionResult, 1) | |
| 511 | 511 | transitionErr := make(chan error, 1) | |
| @@ -554,7 +554,7 @@ func TestAttachmentEffectGateAdmittedEffectCompletesBeforeConflictingTransition( | |||
| 554 | 554 | ticket, ok := old.beginAttachmentEffect(token) | |
| 555 | 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 | 558 | next.initOverlays() | |
| 559 | 559 | done := make(chan error, 1) | |
| 560 | 560 | go func() { | |
| @@ -668,7 +668,7 @@ func TestAttachmentEffectGateAdmittedActiveEffectsFinishBeforeReplacement(t *tes | |||
| 668 | 668 | }() | |
| 669 | 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 | 672 | next.initOverlays() | |
| 673 | 673 | transitionDone := make(chan error, 1) | |
| 674 | 674 | go func() { | |
| @@ -817,7 +817,7 @@ func TestPickerDeleteDoesNotDeleteSourceAfterInitiatorReplacement(t *testing.T) | |||
| 817 | 817 | }() | |
| 818 | 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 | 821 | next.initOverlays() | |
| 822 | 822 | result, err := d.transitionAttachment(attachmentTransitionRequest{ | |
| 823 | 823 | target: sess, next: next, expectedTransport: next.transportSnapshot(), ready: true, | |
| @@ -873,7 +873,7 @@ func TestAttachmentEffectGateAdmittedFirstPaintFinishesBeforeReplacement(t *test | |||
| 873 | 873 | go func() { paintDone <- d.firstPaintForTransition(token) }() | |
| 874 | 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 | 877 | next.initOverlays() | |
| 878 | 878 | transitionDone := make(chan error, 1) | |
| 879 | 879 | go func() { | |
| @@ -897,7 +897,7 @@ func TestAttachmentEffectGateReversedConcurrentTransitionsDoNotDeadlock(t *testi | |||
| 897 | 897 | first.captureAttachmentCapability(a, aTransport) | |
| 898 | 898 | ||
| 899 | 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 | 901 | b.initOverlays() | |
| 902 | 902 | second := &session{sessionCore: sessionCore{id: "second", name: "second", attachments: map[*attachedClient]struct{}{b: {}}}} | |
| 903 | 903 | b.setSession(second) | |
M internal/usecase/daemon/attachment_output_test.go +3 -3
| @@ -9,10 +9,10 @@ import ( | |||
| 9 | 9 | renderer "github.com/bnema/vev-vt/ansi" | |
| 10 | 10 | "github.com/bnema/vev-vt/graphics" | |
| 11 | 11 | "github.com/bnema/vev/internal/domain" | |
| 12 | + | "github.com/bnema/vev/internal/domain/layout" | |
| 12 | 13 | "github.com/bnema/vev/internal/domain/terminalcap" | |
| 13 | 14 | "github.com/bnema/vev/internal/protocol" | |
| 14 | 15 | "github.com/bnema/vev/internal/protocol/wire" | |
| 15 | - | "github.com/bnema/vev/internal/usecase/layout" | |
| 16 | 16 | "github.com/stretchr/testify/require" | |
| 17 | 17 | ) | |
| 18 | 18 | ||
| @@ -122,7 +122,7 @@ func TestAttachmentOutputEpochsAreAttachmentLocalAndPreparedFramesAreFenced(t *t | |||
| 122 | 122 | fillOutputStateRows(frame, []string{"abc"}) | |
| 123 | 123 | newAttachment := func() *attachmentOutput { | |
| 124 | 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 | 126 | stream.attachment = ac | |
| 127 | 127 | return stream | |
| 128 | 128 | } | |
| @@ -195,7 +195,7 @@ func TestPreparedOutputDropsReplacedConnectionAndView(t *testing.T) { | |||
| 195 | 195 | } { | |
| 196 | 196 | t.Run(tt.name, func(t *testing.T) { | |
| 197 | 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 | 199 | stream.attachment = ac | |
| 200 | 200 | frame := renderer.NewFrame(3, 1) | |
| 201 | 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 | 77 | func TestTabForAttachmentRepairRebasesOutputBeforePublishingRevision(t *testing.T) { | |
| 78 | 78 | sess := registryTestSession() | |
| 79 | 79 | ac := registryTestAttachment(1) | |
| 80 | - | ac.size = domain.Size{Cols: 80, Rows: 23} | |
| 80 | + | ac.geometry.Size = domain.Size{Cols: 80, Rows: 23} | |
| 81 | 81 | ac.output = newOutputStateStream() | |
| 82 | 82 | ac.output.attachment = ac | |
| 83 | 83 | require.True(t, sess.registerAttachment(ac)) | |
| @@ -105,7 +105,7 @@ func TestTabForAttachmentRepairRebasesOutputBeforePublishingRevision(t *testing. | |||
| 105 | 105 | func TestPrepareRemovedTabViewRebasesOutputBeforeSideEffect(t *testing.T) { | |
| 106 | 106 | sess := registryTestSession() | |
| 107 | 107 | ac := registryTestAttachment(1) | |
| 108 | - | ac.size = domain.Size{Cols: 80, Rows: 23} | |
| 108 | + | ac.geometry.Size = domain.Size{Cols: 80, Rows: 23} | |
| 109 | 109 | ac.output = newOutputStateStream() | |
| 110 | 110 | ac.output.attachment = ac | |
| 111 | 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 | 118 | require.True(t, sess.repairAttachmentView(second)) | |
| 119 | 119 | ||
| 120 | 120 | tb := sess.tabs[0] | |
| 121 | - | firstSize := first.size | |
| 121 | + | firstSize := first.geometry.Size | |
| 122 | 122 | secondRevision := second.viewSnapshot().revision | |
| 123 | 123 | secondEpoch := second.output.currentEpoch() | |
| 124 | 124 | ||
| @@ -136,8 +136,8 @@ func TestAttachmentResizeKeepsPeerWindowAndExpandsSharedContent(t *testing.T) { | |||
| 136 | 136 | tb.mu.Lock() | |
| 137 | 137 | require.Equal(t, domain.Size{Cols: 120, Rows: 48}, tb.size) | |
| 138 | 138 | tb.mu.Unlock() | |
| 139 | - | require.Equal(t, firstSize, first.size) | |
| 140 | - | require.Equal(t, domain.Size{Cols: 120, Rows: 50}, second.size) | |
| 139 | + | require.Equal(t, firstSize, first.geometry.Size) | |
| 140 | + | require.Equal(t, domain.Size{Cols: 120, Rows: 50}, second.geometry.Size) | |
| 141 | 141 | require.Equal(t, secondRevision+1, second.viewSnapshot().revision) | |
| 142 | 142 | require.Greater(t, second.output.currentEpoch(), secondEpoch) | |
| 143 | 143 | } | |
| @@ -292,8 +292,8 @@ func TestAttachmentResizeUsesLatestClaimedSessionGeometry(t *testing.T) { | |||
| 292 | 292 | tb.mu.Lock() | |
| 293 | 293 | require.Equal(t, domain.Size{Cols: 120, Rows: 48}, tb.size) | |
| 294 | 294 | tb.mu.Unlock() | |
| 295 | - | require.Equal(t, domain.Size{Cols: 80, Rows: 24}, first.size) | |
| 296 | - | require.Equal(t, domain.Size{Cols: 120, Rows: 50}, second.size) | |
| 295 | + | require.Equal(t, domain.Size{Cols: 80, Rows: 24}, first.geometry.Size) | |
| 296 | + | require.Equal(t, domain.Size{Cols: 120, Rows: 50}, second.geometry.Size) | |
| 297 | 297 | ||
| 298 | 298 | // A same-size resize is still a claim, so the smaller peer can take | |
| 299 | 299 | // authority without changing its local window state first. | |
| @@ -308,8 +308,8 @@ func TestAttachmentResizeUsesLatestClaimedSessionGeometry(t *testing.T) { | |||
| 308 | 308 | tb.mu.Lock() | |
| 309 | 309 | require.Equal(t, domain.Size{Cols: 70, Rows: 18}, tb.size) | |
| 310 | 310 | tb.mu.Unlock() | |
| 311 | - | require.Equal(t, domain.Size{Cols: 70, Rows: 20}, first.size) | |
| 312 | - | require.Equal(t, domain.Size{Cols: 90, Rows: 30}, second.size) | |
| 311 | + | require.Equal(t, domain.Size{Cols: 70, Rows: 20}, first.geometry.Size) | |
| 312 | + | require.Equal(t, domain.Size{Cols: 90, Rows: 30}, second.geometry.Size) | |
| 313 | 313 | ||
| 314 | 314 | // Detaching the latest claimant falls back to the most recent remaining | |
| 315 | 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 | 19 | return | |
| 20 | 20 | } | |
| 21 | 21 | ||
| 22 | - | now := d.clock.Now() | |
| 23 | - | if now.IsZero() { | |
| 24 | - | now = time.Now() | |
| 25 | - | } | |
| 22 | + | now := d.daemonNow() | |
| 26 | 23 | ||
| 27 | 24 | sess.mu.Lock() | |
| 28 | 25 | if !tb.attention { | |
M internal/usecase/daemon/attention_test.go +2 -2
| @@ -12,11 +12,11 @@ import ( | |||
| 12 | 12 | ||
| 13 | 13 | renderer "github.com/bnema/vev-vt" | |
| 14 | 14 | "github.com/bnema/vev/internal/domain" | |
| 15 | + | "github.com/bnema/vev/internal/domain/layout" | |
| 15 | 16 | "github.com/bnema/vev/internal/ports" | |
| 16 | 17 | "github.com/bnema/vev/internal/protocol" | |
| 17 | 18 | "github.com/bnema/vev/internal/protocol/wire" | |
| 18 | 19 | "github.com/bnema/vev/internal/usecase/keys" | |
| 19 | - | "github.com/bnema/vev/internal/usecase/layout" | |
| 20 | 20 | "github.com/bnema/vev/internal/usecase/ui" | |
| 21 | 21 | ) | |
| 22 | 22 | ||
| @@ -172,7 +172,7 @@ func TestNoteAttentionDoesNotBlockOnWedgedOtherClient(t *testing.T) { | |||
| 172 | 172 | return nil | |
| 173 | 173 | }).Maybe() | |
| 174 | 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 | 176 | acW.initOverlays() | |
| 177 | 177 | sctxW, cancelW := context.WithCancel(d.serveCtx) | |
| 178 | 178 | t.Cleanup(cancelW) | |
M internal/usecase/daemon/bar_refresh_test.go +15 -15
| @@ -11,9 +11,9 @@ import ( | |||
| 11 | 11 | "time" | |
| 12 | 12 | ||
| 13 | 13 | "github.com/bnema/vev/internal/domain" | |
| 14 | + | "github.com/bnema/vev/internal/domain/layout" | |
| 14 | 15 | "github.com/bnema/vev/internal/ports" | |
| 15 | 16 | portsmocks "github.com/bnema/vev/internal/ports/mocks" | |
| 16 | - | "github.com/bnema/vev/internal/usecase/layout" | |
| 17 | 17 | "github.com/stretchr/testify/mock" | |
| 18 | 18 | "github.com/stretchr/testify/require" | |
| 19 | 19 | ) | |
| @@ -64,7 +64,7 @@ func TestBarScriptRefreshIntervalClampingTickAndLastGoodOnFailure(t *testing.T) | |||
| 64 | 64 | r := &fakeBarRunner{outs: []string{"top1", "bot1", "top2", "bot2"}} | |
| 65 | 65 | d := newBarRefreshTestDaemon(r, 100*time.Millisecond) | |
| 66 | 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 | 69 | require.True(t, d.refreshBarScriptsIfDue(sess, time.Unix(0, 0), false)) | |
| 70 | 70 | waitBarRefreshIdle(t, d) | |
| @@ -91,7 +91,7 @@ func TestBarScriptContextUsesFirstOrderedPaneAndClientCols(t *testing.T) { | |||
| 91 | 91 | sess := newBarRefreshTestSession() | |
| 92 | 92 | sess.name = "work" | |
| 93 | 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 | 95 | sess.tabs[0].size = domain.Size{Cols: 132, Rows: 38} | |
| 96 | 96 | active := sess.tabs[0].focusedPane() | |
| 97 | 97 | active.stableID = "pane-default" | |
| @@ -130,7 +130,7 @@ func TestBarScriptRefreshSkipsRunnerWhenCommandsDisabled(t *testing.T) { | |||
| 130 | 130 | d.barScripts.cfg.topRight = "" | |
| 131 | 131 | d.barScripts.cfg.bottomRight = "" | |
| 132 | 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 | 134 | d.barScripts.outputs[sess.id] = barScriptOutputs{topRight: "old", bottomRight: "old"} | |
| 135 | 135 | ||
| 136 | 136 | require.False(t, d.refreshBarScriptsIfDue(sess, time.Unix(0, 0), true)) | |
| @@ -145,7 +145,7 @@ func TestDefaultBarConfigDoesNotInvokeRunner(t *testing.T) { | |||
| 145 | 145 | d := newBarRefreshTestDaemon(r, time.Second) | |
| 146 | 146 | d.barScripts.cfg = barConfigFromDomain(domain.Defaults().Bar) | |
| 147 | 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 | 150 | require.False(t, d.refreshBarScriptsIfDue(sess, time.Unix(0, 0), true)) | |
| 151 | 151 | require.Empty(t, r.calls) | |
| @@ -155,7 +155,7 @@ func TestBarScriptForcedRefreshRespectsMinimumInterval(t *testing.T) { | |||
| 155 | 155 | r := &fakeBarRunner{outs: []string{"top1", "bottom1", "top2", "bottom2"}} | |
| 156 | 156 | d := newBarRefreshTestDaemon(r, 5*time.Second) | |
| 157 | 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 | 160 | require.True(t, d.refreshBarScriptsIfDue(sess, time.Unix(10, 0), true)) | |
| 161 | 161 | waitBarRefreshIdle(t, d) | |
| @@ -172,7 +172,7 @@ func TestBarScriptContextChangeDebouncesUntilMinimumInterval(t *testing.T) { | |||
| 172 | 172 | r := &fakeBarRunner{outs: []string{"top1", "bottom1", "top2", "bottom2"}} | |
| 173 | 173 | d := newBarRefreshTestDaemon(r, 5*time.Second) | |
| 174 | 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 | 177 | require.True(t, d.refreshBarScriptsIfDue(sess, time.Now(), false)) | |
| 178 | 178 | waitBarRefreshIdle(t, d) | |
| @@ -190,7 +190,7 @@ func TestBarScriptRefreshForcesWhenContextChanges(t *testing.T) { | |||
| 190 | 190 | r := &fakeBarRunner{outs: []string{"top1", "bottom1", "top2", "bottom2"}} | |
| 191 | 191 | d := newBarRefreshTestDaemon(r, 5*time.Second) | |
| 192 | 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 | 195 | require.True(t, d.refreshBarScriptsIfDue(sess, time.Unix(0, 0), false)) | |
| 196 | 196 | waitBarRefreshIdle(t, d) | |
| @@ -218,7 +218,7 @@ func TestRefreshBarScriptsAllSessionsForcesRun(t *testing.T) { | |||
| 218 | 218 | r := &fakeBarRunner{outs: []string{"top1", "bottom1"}} | |
| 219 | 219 | d := newBarRefreshTestDaemon(r, 60*time.Second) | |
| 220 | 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 | 222 | d.sessions = map[domain.SessionID]*session{sess.id: sess} | |
| 223 | 223 | ||
| 224 | 224 | // Verifies refreshBarScriptsAllSessions iterates every live session and | |
| @@ -251,7 +251,7 @@ func TestBarScriptFailureLogsAndRetainsLastGood(t *testing.T) { | |||
| 251 | 251 | d := newBarRefreshTestDaemon(r, time.Second) | |
| 252 | 252 | d.log = slog.New(slog.NewTextHandler(&logs, nil)) | |
| 253 | 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 | 256 | require.True(t, d.refreshBarScriptsIfDue(sess, time.Unix(10, 0), true)) | |
| 257 | 257 | waitBarRefreshIdle(t, d) | |
| @@ -286,7 +286,7 @@ func TestBarScriptRunConsumesPendingAfterRunningClears(t *testing.T) { | |||
| 286 | 286 | r := newBlockingBarRunner([]string{"top1", "bottom1", "top2", "bottom2"}) | |
| 287 | 287 | d := newBarRefreshTestDaemon(r, time.Second) | |
| 288 | 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 | 291 | require.True(t, d.refreshBarScriptsIfDue(sess, time.Unix(10, 0), true)) | |
| 292 | 292 | select { | |
| @@ -336,7 +336,7 @@ func TestBarScriptFailureLogsOnceUntilSignatureChanges(t *testing.T) { | |||
| 336 | 336 | d.log = logger | |
| 337 | 337 | d.baseEnv = []string{"PATH=/usr/bin"} | |
| 338 | 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 | 341 | readBuf := func() string { | |
| 342 | 342 | mu.Lock() | |
| @@ -419,11 +419,11 @@ func TestBarScriptRefreshIsPerSession(t *testing.T) { | |||
| 419 | 419 | a := newBarRefreshTestSession() | |
| 420 | 420 | a.id = "a" | |
| 421 | 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 | 423 | b := newBarRefreshTestSession() | |
| 424 | 424 | b.id = "b" | |
| 425 | 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 | 428 | require.True(t, d.refreshBarScriptsIfDue(a, time.Unix(0, 0), false)) | |
| 429 | 429 | waitBarRefreshIdle(t, d) | |
| @@ -644,7 +644,7 @@ func TestBarScriptsUseSessionEnvNotDaemonBaseEnv(t *testing.T) { | |||
| 644 | 644 | d.baseEnv = tc.baseEnv | |
| 645 | 645 | sess := newBarRefreshTestSession() | |
| 646 | 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 | 649 | require.True(t, d.refreshBarScriptsIfDue(sess, time.Unix(0, 0), true)) | |
| 650 | 650 | waitBarRefreshIdle(t, d) | |
M internal/usecase/daemon/client.go +2 -6
| @@ -94,7 +94,6 @@ type attachedClient struct { | |||
| 94 | 94 | // sizeMu lets shared-geometry snapshots read attachment windows without | |
| 95 | 95 | // waiting behind a blocked transport send. | |
| 96 | 96 | sizeMu sync.RWMutex | |
| 97 | - | size domain.Size // retained for legacy fixtures; live paths publish geometry | |
| 98 | 97 | geometry domain.Geometry // controlling-terminal geometry for this Attachment | |
| 99 | 98 | keys *keys.Router | |
| 100 | 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 | 181 | ac.sizeMu.RLock() | |
| 183 | 182 | defer ac.sizeMu.RUnlock() | |
| 184 | - | return ac.size | |
| 183 | + | return ac.geometry.Size | |
| 185 | 184 | } | |
| 186 | 185 | ||
| 187 | 186 | func (ac *attachedClient) setSize(size domain.Size) { | |
| @@ -189,7 +188,6 @@ func (ac *attachedClient) setSize(size domain.Size) { | |||
| 189 | 188 | return | |
| 190 | 189 | } | |
| 191 | 190 | ac.sizeMu.Lock() | |
| 192 | - | ac.size = size | |
| 193 | 191 | ac.geometry.Size = size | |
| 194 | 192 | ac.geometry = ac.geometry.NormalizePixels() | |
| 195 | 193 | ac.sizeMu.Unlock() | |
| @@ -204,7 +202,7 @@ func (ac *attachedClient) geometrySnapshot() domain.Geometry { | |||
| 204 | 202 | if ac.geometry.Valid() { | |
| 205 | 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 | 208 | func (ac *attachedClient) setGeometry(geometry domain.Geometry) { | |
| @@ -213,7 +211,6 @@ func (ac *attachedClient) setGeometry(geometry domain.Geometry) { | |||
| 213 | 211 | } | |
| 214 | 212 | geometry = geometry.NormalizePixels() | |
| 215 | 213 | ac.sizeMu.Lock() | |
| 216 | - | ac.size = geometry.Size | |
| 217 | 214 | ac.geometry = geometry | |
| 218 | 215 | ac.sizeMu.Unlock() | |
| 219 | 216 | } | |
| @@ -772,7 +769,6 @@ func (d *Daemon) prepareAttachedClientLocked(sess *session, tr ports.ServerConne | |||
| 772 | 769 | ac := &attachedClient{ | |
| 773 | 770 | tr: tr, | |
| 774 | 771 | output: output, | |
| 775 | - | size: geometry.Size, | |
| 776 | 772 | geometry: geometry, | |
| 777 | 773 | view: attachmentView{windowRows: geometry.Rows, windowSet: true}, | |
| 778 | 774 | clientID: opts.clientID, | |
M internal/usecase/daemon/coderabbit_regression_test.go +1 -1
| @@ -15,9 +15,9 @@ import ( | |||
| 15 | 15 | "github.com/bnema/vev/internal/ports" | |
| 16 | 16 | portsmocks "github.com/bnema/vev/internal/ports/mocks" | |
| 17 | 17 | "github.com/bnema/vev/internal/protocol" | |
| 18 | + | snapcodec "github.com/bnema/vev/internal/snapshotcodec" | |
| 18 | 19 | "github.com/bnema/vev/internal/usecase/picker" | |
| 19 | 20 | recoveryusecase "github.com/bnema/vev/internal/usecase/recovery" | |
| 20 | - | snapcodec "github.com/bnema/vev/internal/usecase/snapshot" | |
| 21 | 21 | ) | |
| 22 | 22 | ||
| 23 | 23 | func TestRestoreAmbiguousBadVersionLoadFailurePreservesCheckpoint(t *testing.T) { | |
M internal/usecase/daemon/control.go +1 -1
| @@ -11,11 +11,11 @@ import ( | |||
| 11 | 11 | "strings" | |
| 12 | 12 | ||
| 13 | 13 | "github.com/bnema/vev/internal/domain" | |
| 14 | + | "github.com/bnema/vev/internal/domain/layout" | |
| 14 | 15 | "github.com/bnema/vev/internal/ports" | |
| 15 | 16 | "github.com/bnema/vev/internal/protocol" | |
| 16 | 17 | "github.com/bnema/vev/internal/protocol/catalogue" | |
| 17 | 18 | "github.com/bnema/vev/internal/usecase/command" | |
| 18 | - | "github.com/bnema/vev/internal/usecase/layout" | |
| 19 | 19 | ) | |
| 20 | 20 | ||
| 21 | 21 | // handleCommand serves one typed one-shot control request. | |
M internal/usecase/daemon/control_test.go +1 -1
| @@ -14,13 +14,13 @@ import ( | |||
| 14 | 14 | "time" | |
| 15 | 15 | ||
| 16 | 16 | "github.com/bnema/vev/internal/domain" | |
| 17 | + | "github.com/bnema/vev/internal/domain/layout" | |
| 17 | 18 | appports "github.com/bnema/vev/internal/ports" | |
| 18 | 19 | portsmocks "github.com/bnema/vev/internal/ports/mocks" | |
| 19 | 20 | "github.com/bnema/vev/internal/protocol" | |
| 20 | 21 | "github.com/bnema/vev/internal/protocol/catalogue" | |
| 21 | 22 | "github.com/bnema/vev/internal/protocol/wire" | |
| 22 | 23 | "github.com/bnema/vev/internal/usecase/command" | |
| 23 | - | "github.com/bnema/vev/internal/usecase/layout" | |
| 24 | 24 | "github.com/stretchr/testify/mock" | |
| 25 | 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 | 4 | "testing" | |
| 5 | 5 | ||
| 6 | 6 | renderer "github.com/bnema/vev-vt" | |
| 7 | + | "github.com/bnema/vev/internal/domain" | |
| 7 | 8 | "github.com/bnema/vev/internal/protocol" | |
| 8 | 9 | scopy "github.com/bnema/vev/internal/usecase/copy" | |
| 9 | 10 | "github.com/stretchr/testify/require" | |
| @@ -94,7 +95,7 @@ func TestCopyExitRefreshesAfterOtherAttachmentConsumesDamage(t *testing.T) { | |||
| 94 | 95 | copyBefore := captureTestFrame(ac.render.cache.frame) | |
| 95 | 96 | ||
| 96 | 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 | 99 | peer.output.attachment = peer | |
| 99 | 100 | peer.initOverlays() | |
| 100 | 101 | peer.setSession(sess) | |
M internal/usecase/daemon/copymode_lifecycle_test.go +1 -1
| @@ -8,9 +8,9 @@ import ( | |||
| 8 | 8 | "github.com/stretchr/testify/require" | |
| 9 | 9 | ||
| 10 | 10 | "github.com/bnema/vev/internal/domain" | |
| 11 | + | "github.com/bnema/vev/internal/domain/layout" | |
| 11 | 12 | portsmocks "github.com/bnema/vev/internal/ports/mocks" | |
| 12 | 13 | scopy "github.com/bnema/vev/internal/usecase/copy" | |
| 13 | - | "github.com/bnema/vev/internal/usecase/layout" | |
| 14 | 14 | ) | |
| 15 | 15 | ||
| 16 | 16 | func TestCopyModeLifecycleResizeExitsBeforeGeometryChange(t *testing.T) { | |
M internal/usecase/daemon/copymode_test.go +1 -1
| @@ -17,12 +17,12 @@ import ( | |||
| 17 | 17 | vt "github.com/bnema/vev-vt" | |
| 18 | 18 | renderer "github.com/bnema/vev-vt/ansi" | |
| 19 | 19 | "github.com/bnema/vev/internal/domain" | |
| 20 | + | "github.com/bnema/vev/internal/domain/layout" | |
| 20 | 21 | "github.com/bnema/vev/internal/ports" | |
| 21 | 22 | portsmocks "github.com/bnema/vev/internal/ports/mocks" | |
| 22 | 23 | "github.com/bnema/vev/internal/protocol" | |
| 23 | 24 | "github.com/bnema/vev/internal/protocol/wire" | |
| 24 | 25 | scopy "github.com/bnema/vev/internal/usecase/copy" | |
| 25 | - | "github.com/bnema/vev/internal/usecase/layout" | |
| 26 | 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 | 27 | // stubClock returns timers whose channel never fires, so a scheduler under it | |
| 28 | 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 | 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 | 34 | func (stubClock) NewTimer(time.Duration) ports.Timer { return stubTimer{} } | |
| 34 | 35 | ||
| 35 | 36 | type stubTimer struct{} | |
| @@ -416,7 +417,7 @@ func newManualSessionWithPTYsClockCleanup(t testing.TB, clock ports.Clock, regis | |||
| 416 | 417 | t.Helper() | |
| 417 | 418 | d := newTestDaemonWithCleanup(t, nil, clock, registerCleanup) | |
| 418 | 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 | 421 | ac.output.attachment = ac | |
| 421 | 422 | ac.initOverlays() | |
| 422 | 423 | sctx, cancel := context.WithCancel(d.serveCtx) | |
M internal/usecase/daemon/durable_recovery_test.go +1 -1
| @@ -21,8 +21,8 @@ import ( | |||
| 21 | 21 | "github.com/bnema/vev/internal/ports" | |
| 22 | 22 | portsmocks "github.com/bnema/vev/internal/ports/mocks" | |
| 23 | 23 | "github.com/bnema/vev/internal/protocol" | |
| 24 | + | snapcodec "github.com/bnema/vev/internal/snapshotcodec" | |
| 24 | 25 | recoveryusecase "github.com/bnema/vev/internal/usecase/recovery" | |
| 25 | - | snapcodec "github.com/bnema/vev/internal/usecase/snapshot" | |
| 26 | 26 | ) | |
| 27 | 27 | ||
| 28 | 28 | type durableRecoveryCatalogue struct { | |
M internal/usecase/daemon/final_acceptance_test.go +10 -10
| @@ -5,8 +5,8 @@ import ( | |||
| 5 | 5 | ||
| 6 | 6 | vt "github.com/bnema/vev-vt" | |
| 7 | 7 | "github.com/bnema/vev/internal/domain" | |
| 8 | + | "github.com/bnema/vev/internal/domain/layout" | |
| 8 | 9 | "github.com/bnema/vev/internal/protocol" | |
| 9 | - | "github.com/bnema/vev/internal/usecase/layout" | |
| 10 | 10 | "github.com/stretchr/testify/require" | |
| 11 | 11 | ) | |
| 12 | 12 | ||
| @@ -19,9 +19,9 @@ func TestAcceptancePaletteNewTabUsesSessionGeometryAfterAttachmentResize(t *test | |||
| 19 | 19 | ||
| 20 | 20 | secondTransport, _ := newCapturingTransport(t) | |
| 21 | 21 | second := &attachedClient{ | |
| 22 | - | tr: secondTransport, | |
| 23 | - | output: newOutputStateStream(), | |
| 24 | - | size: domain.Size{Cols: 80, Rows: 24}, | |
| 22 | + | tr: secondTransport, | |
| 23 | + | output: newOutputStateStream(), | |
| 24 | + | geometry: domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}}, | |
| 25 | 25 | } | |
| 26 | 26 | second.initOverlays() | |
| 27 | 27 | second.clientID[0] = 2 | |
| @@ -85,7 +85,7 @@ func TestAcceptancePaletteNewTabUsesSessionGeometryAfterAttachmentResize(t *test | |||
| 85 | 85 | first.sendMu.Unlock() | |
| 86 | 86 | require.Equal(t, firstEpoch, gotFirstEpoch, "new tab render crossed attachment boundary") | |
| 87 | 87 | second.sendMu.Lock() | |
| 88 | - | gotSecondSize := second.size | |
| 88 | + | gotSecondSize := second.geometry.Size | |
| 89 | 89 | second.sendMu.Unlock() | |
| 90 | 90 | require.Equal(t, domain.Size{Cols: 120, Rows: 40}, gotSecondSize) | |
| 91 | 91 | } | |
| @@ -99,9 +99,9 @@ func TestAcceptanceAttachmentStateIsolationAcrossResetResizeAndDetach(t *testing | |||
| 99 | 99 | ||
| 100 | 100 | secondTransport, _ := newCapturingTransport(t) | |
| 101 | 101 | second := &attachedClient{ | |
| 102 | - | tr: secondTransport, | |
| 103 | - | output: newOutputStateStream(), | |
| 104 | - | size: domain.Size{Cols: 80, Rows: 24}, | |
| 102 | + | tr: secondTransport, | |
| 103 | + | output: newOutputStateStream(), | |
| 104 | + | geometry: domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}}, | |
| 105 | 105 | } | |
| 106 | 106 | second.initOverlays() | |
| 107 | 107 | second.clientID[0] = 2 | |
| @@ -156,8 +156,8 @@ func TestAcceptanceAttachmentStateIsolationAcrossResetResizeAndDetach(t *testing | |||
| 156 | 156 | require.True(t, admitted) | |
| 157 | 157 | require.True(t, d.resizeAttachmentForLease(secondEffect, domain.Size{Cols: 120, Rows: 40})) | |
| 158 | 158 | secondEffect.End() | |
| 159 | - | require.Equal(t, domain.Size{Cols: 120, Rows: 40}, second.size) | |
| 160 | - | require.Equal(t, domain.Size{Cols: 80, Rows: 24}, first.size, "peer resize crossed attachment boundary") | |
| 159 | + | require.Equal(t, domain.Size{Cols: 120, Rows: 40}, second.geometry.Size) | |
| 160 | + | require.Equal(t, domain.Size{Cols: 80, Rows: 24}, first.geometry.Size, "peer resize crossed attachment boundary") | |
| 161 | 161 | sess.mu.Lock() | |
| 162 | 162 | tb := sess.tabs[0] | |
| 163 | 163 | sess.mu.Unlock() | |
M internal/usecase/daemon/floating.go +1 -1
| @@ -6,7 +6,7 @@ import ( | |||
| 6 | 6 | "strings" | |
| 7 | 7 | ||
| 8 | 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 | 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 | 226 | d := newTestDaemon(t, factory, stubClock{}) | |
| 227 | 227 | tb := newFloatingTestTab(t) | |
| 228 | 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 | 230 | ac.initOverlays() | |
| 231 | 231 | sessCtx, cancelSession := context.WithCancel(t.Context()) | |
| 232 | 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 | 12 | renderer "github.com/bnema/vev-vt/ansi" | |
| 13 | 13 | "github.com/bnema/vev/internal/domain" | |
| 14 | - | "github.com/bnema/vev/internal/usecase/layout" | |
| 14 | + | "github.com/bnema/vev/internal/domain/layout" | |
| 15 | 15 | themeui "github.com/bnema/vev/internal/usecase/theme" | |
| 16 | 16 | "github.com/bnema/vev/internal/usecase/ui" | |
| 17 | 17 | ) | |
M internal/usecase/daemon/floating_spare.go +1 -1
| @@ -1,7 +1,7 @@ | |||
| 1 | 1 | package daemon | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | - | "github.com/bnema/vev/internal/usecase/layout" | |
| 4 | + | "github.com/bnema/vev/internal/domain/layout" | |
| 5 | 5 | ) | |
| 6 | 6 | ||
| 7 | 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 | 15 | "github.com/stretchr/testify/require" | |
| 16 | 16 | ||
| 17 | 17 | "github.com/bnema/vev/internal/domain" | |
| 18 | + | "github.com/bnema/vev/internal/domain/layout" | |
| 18 | 19 | "github.com/bnema/vev/internal/ports" | |
| 19 | 20 | portsmocks "github.com/bnema/vev/internal/ports/mocks" | |
| 20 | 21 | "github.com/bnema/vev/internal/protocol" | |
| 21 | 22 | "github.com/bnema/vev/internal/protocol/wire" | |
| 22 | 23 | scopy "github.com/bnema/vev/internal/usecase/copy" | |
| 23 | - | "github.com/bnema/vev/internal/usecase/layout" | |
| 24 | 24 | themeui "github.com/bnema/vev/internal/usecase/theme" | |
| 25 | 25 | ) | |
| 26 | 26 | ||
| @@ -768,7 +768,7 @@ func TestFloatingAsyncSpawnFailureToastsOnlyForUserOpen(t *testing.T) { | |||
| 768 | 768 | d := newTestDaemon(t, factory, stubClock{}) | |
| 769 | 769 | tb := newFloatingTestTab(t) | |
| 770 | 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 | 772 | ac.initOverlays() | |
| 773 | 773 | sess := &session{sessionCore: sessionCore{id: "floating-spawn", name: "work", attachments: map[*attachedClient]struct{}{ac: {}}}, tabs: []*tab{tb}, ctx: t.Context()} | |
| 774 | 774 | ac.setSession(sess) | |
M internal/usecase/daemon/graphics_output.go +1 -1
| @@ -15,8 +15,8 @@ import ( | |||
| 15 | 15 | renderer "github.com/bnema/vev-vt" | |
| 16 | 16 | "github.com/bnema/vev-vt/graphics" | |
| 17 | 17 | "github.com/bnema/vev/internal/domain" | |
| 18 | + | "github.com/bnema/vev/internal/domain/layout" | |
| 18 | 19 | "github.com/bnema/vev/internal/protocol" | |
| 19 | - | "github.com/bnema/vev/internal/usecase/layout" | |
| 20 | 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 | 13 | vt "github.com/bnema/vev-vt" | |
| 14 | 14 | "github.com/bnema/vev-vt/graphics" | |
| 15 | 15 | "github.com/bnema/vev/internal/domain" | |
| 16 | + | "github.com/bnema/vev/internal/domain/layout" | |
| 16 | 17 | "github.com/bnema/vev/internal/domain/terminalcap" | |
| 17 | 18 | "github.com/bnema/vev/internal/protocol" | |
| 18 | 19 | "github.com/bnema/vev/internal/protocol/wire" | |
| 19 | - | "github.com/bnema/vev/internal/usecase/layout" | |
| 20 | 20 | "github.com/stretchr/testify/require" | |
| 21 | 21 | ) | |
| 22 | 22 | ||
M internal/usecase/daemon/input.go +1 -1
| @@ -7,10 +7,10 @@ import ( | |||
| 7 | 7 | "strconv" | |
| 8 | 8 | ||
| 9 | 9 | "github.com/bnema/vev/internal/domain" | |
| 10 | + | "github.com/bnema/vev/internal/domain/layout" | |
| 10 | 11 | "github.com/bnema/vev/internal/usecase/command" | |
| 11 | 12 | "github.com/bnema/vev/internal/usecase/keys" | |
| 12 | 13 | "github.com/bnema/vev/internal/usecase/keys/kittykey" | |
| 13 | - | "github.com/bnema/vev/internal/usecase/layout" | |
| 14 | 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 | 16 | vt "github.com/bnema/vev-vt" | |
| 17 | 17 | "github.com/bnema/vev/internal/domain" | |
| 18 | + | "github.com/bnema/vev/internal/domain/layout" | |
| 18 | 19 | portsmocks "github.com/bnema/vev/internal/ports/mocks" | |
| 19 | 20 | "github.com/bnema/vev/internal/protocol" | |
| 20 | 21 | "github.com/bnema/vev/internal/protocol/wire" | |
| 21 | 22 | scopy "github.com/bnema/vev/internal/usecase/copy" | |
| 22 | 23 | "github.com/bnema/vev/internal/usecase/keys" | |
| 23 | - | "github.com/bnema/vev/internal/usecase/layout" | |
| 24 | 24 | "github.com/bnema/vev/internal/usecase/mouse" | |
| 25 | 25 | "github.com/bnema/vev/internal/usecase/picker" | |
| 26 | 26 | "github.com/bnema/vev/internal/usecase/ui" | |
| @@ -191,7 +191,7 @@ func TestSwitchTabFirstFrameDoesNotReuseSamePaneIDCapture(t *testing.T) { | |||
| 191 | 191 | d.paint(sess, ac, true, nil) | |
| 192 | 192 | first := awaitFrame(t, sends, "Output") | |
| 193 | 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 | 195 | terminal.Write(firstOutput.Data) | |
| 196 | 196 | ||
| 197 | 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 | 4 | "slices" | |
| 5 | 5 | ||
| 6 | 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 | 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 | 6 | "strings" | |
| 7 | 7 | ||
| 8 | 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 | "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 | 7 | "github.com/stretchr/testify/require" | |
| 8 | 8 | ||
| 9 | 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 | 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 | 63 | replacementTransport, _ := newCapturingTransport(t) | |
| 64 | 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 | 67 | replacement.initOverlays() | |
| 68 | 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 | 186 | d, sess, current, _ := newManualSessionWithPTYs(t, newQuietPTY()) | |
| 187 | 187 | d.attachCoordinator(sess, nil, current, true) | |
| 188 | 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 | 190 | other.initOverlays() | |
| 191 | 191 | other.setSession(sess) | |
| 192 | 192 | sess.mu.Lock() | |
M internal/usecase/daemon/move_pane_admission.go +1 -1
| @@ -1,6 +1,6 @@ | |||
| 1 | 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 | 5 | // movePaneTopology is the private pane topology Implementation consumed by the | |
| 6 | 6 | // shared moveTransaction Module. | |
M internal/usecase/daemon/move_pane_candidate.go +1 -1
| @@ -6,7 +6,7 @@ import ( | |||
| 6 | 6 | "math" | |
| 7 | 7 | ||
| 8 | 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 | 12 | // movePaneCandidate contains only unpublished topology and identity changes. | |
M internal/usecase/daemon/move_pane_commit.go +1 -1
| @@ -1,6 +1,6 @@ | |||
| 1 | 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 | 5 | // movePaneCommit owns pane-private validation and publication. The shared | |
| 6 | 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 | 390 | source.tabs = append(source.tabs, newTabWithStableID("remaining", "remaining-pane", pty, movedTab.size)) | |
| 391 | 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 | 394 | peer.setSession(source) | |
| 395 | 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 | 397 | destinationPeer.setSession(destination) | |
| 398 | 398 | destination.registerAttachment(destinationPeer) | |
| 399 | 399 | destination.selectAttachmentTabLocked(destinationPeer, domain.TabStableID(destinationTab.stableID)) | |
M internal/usecase/daemon/move_rejection.go +1 -1
| @@ -4,8 +4,8 @@ import ( | |||
| 4 | 4 | "errors" | |
| 5 | 5 | ||
| 6 | 6 | "github.com/bnema/vev/internal/domain" | |
| 7 | + | "github.com/bnema/vev/internal/domain/layout" | |
| 7 | 8 | "github.com/bnema/vev/internal/protocol" | |
| 8 | - | "github.com/bnema/vev/internal/usecase/layout" | |
| 9 | 9 | ) | |
| 10 | 10 | ||
| 11 | 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 | 7 | "github.com/stretchr/testify/require" | |
| 8 | 8 | ||
| 9 | 9 | "github.com/bnema/vev/internal/domain" | |
| 10 | + | "github.com/bnema/vev/internal/domain/layout" | |
| 10 | 11 | "github.com/bnema/vev/internal/protocol" | |
| 11 | - | "github.com/bnema/vev/internal/usecase/layout" | |
| 12 | 12 | ) | |
| 13 | 13 | ||
| 14 | 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 | 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 | 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 | 4 | "context" | |
| 5 | 5 | ||
| 6 | 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 | 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 | 8 | "github.com/stretchr/testify/require" | |
| 9 | 9 | ||
| 10 | 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 | 14 | func TestMovePaneMutationSameSession(t *testing.T) { | |
| @@ -142,7 +142,7 @@ func TestMovePaneRetiresSourceParkedClients(t *testing.T) { | |||
| 142 | 142 | movedTab.stableID = "source-tab" | |
| 143 | 143 | ||
| 144 | 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 | 146 | parked.initOverlays() | |
| 147 | 147 | require.True(t, d.parkAttachment(source, parked)) | |
| 148 | 148 | destinationPTY := newQuietPTY() | |
M internal/usecase/daemon/multi_attachment_callers_test.go +1 -1
| @@ -5,7 +5,7 @@ import ( | |||
| 5 | 5 | "testing" | |
| 6 | 6 | ||
| 7 | 7 | "github.com/bnema/vev/internal/domain" | |
| 8 | - | "github.com/bnema/vev/internal/usecase/layout" | |
| 8 | + | "github.com/bnema/vev/internal/domain/layout" | |
| 9 | 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 | 6 | "time" | |
| 7 | 7 | ||
| 8 | 8 | "github.com/bnema/vev/internal/domain" | |
| 9 | + | "github.com/bnema/vev/internal/domain/layout" | |
| 9 | 10 | "github.com/bnema/vev/internal/protocol" | |
| 10 | - | "github.com/bnema/vev/internal/usecase/layout" | |
| 11 | 11 | "github.com/bnema/vev/internal/usecase/picker" | |
| 12 | 12 | "github.com/stretchr/testify/require" | |
| 13 | 13 | ) | |
| @@ -172,7 +172,7 @@ func TestStoppedSessionHandoffDoesNotResumeAfterInitiatorReplacement(t *testing. | |||
| 172 | 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 | 176 | next.initOverlays() | |
| 177 | 177 | replacement, err := d.transitionAttachment(attachmentTransitionRequest{ | |
| 178 | 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 | 4 | "sort" | |
| 5 | 5 | ||
| 6 | 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 | "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 | 7 | "github.com/stretchr/testify/require" | |
| 8 | 8 | ||
| 9 | 9 | "github.com/bnema/vev/internal/domain" | |
| 10 | + | "github.com/bnema/vev/internal/domain/layout" | |
| 10 | 11 | "github.com/bnema/vev/internal/usecase/keys" | |
| 11 | - | "github.com/bnema/vev/internal/usecase/layout" | |
| 12 | 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 | 9 | "github.com/stretchr/testify/require" | |
| 10 | 10 | ||
| 11 | 11 | "github.com/bnema/vev/internal/domain" | |
| 12 | + | "github.com/bnema/vev/internal/domain/layout" | |
| 12 | 13 | "github.com/bnema/vev/internal/protocol" | |
| 13 | - | "github.com/bnema/vev/internal/usecase/layout" | |
| 14 | 14 | ) | |
| 15 | 15 | ||
| 16 | 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 | 81 | func TestPaintComposesNoticeToastTopRightAndExpiresOnTTL(t *testing.T) { | |
| 82 | 82 | clk := newNoticeClock() | |
| 83 | 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 | 86 | d.notify(sess, domain.NoticeError, domain.NoticePaneSpawn, "couldn't open pane", nil) | |
| 87 | 87 | awaitToastCount(t, ac, 1) | |
| 88 | 88 | replayOutputFrames(t, screen, drainAllFrames(sends)) | |
| 89 | 89 | ||
| 90 | 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 | 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 | 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 | 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 | 278 | p, _ := newBlockingPTY(t) | |
| 279 | 279 | d := newTestDaemon(t, nil, clk) | |
| 280 | 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 | 282 | ac.initOverlays() | |
| 283 | 283 | sctx, cancel := context.WithCancel(d.serveCtx) | |
| 284 | 284 | wctx, wcancel := context.WithCancel(sctx) | |
| @@ -443,7 +443,7 @@ func TestReportErrorNonBenignIsNeverDropped(t *testing.T) { | |||
| 443 | 443 | func TestNotifyRoutesToSessionClientOnly(t *testing.T) { | |
| 444 | 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 | 447 | other.initOverlays() | |
| 448 | 448 | sess2 := &session{sessionCore: sessionCore{id: "manual-2", name: "other", attachments: map[*attachedClient]struct{}{other: {}}}, ctx: sess.ctx, cancel: func() {}} | |
| 449 | 449 | other.setSession(sess2) | |
| @@ -533,7 +533,7 @@ func TestSessionScopedNoticeQueuedWhileDetached(t *testing.T) { | |||
| 533 | 533 | clearAttachmentsForTestLocked(sessA) | |
| 534 | 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 | 537 | clientB.initOverlays() | |
| 538 | 538 | sessB := &session{sessionCore: sessionCore{id: "manual-2", name: "other"}, ctx: sessA.ctx, cancel: func() {}} | |
| 539 | 539 | clientB.setSession(sessB) | |
| @@ -572,7 +572,7 @@ func TestSessionScopedNoticeQueuedWhileDetached(t *testing.T) { | |||
| 572 | 572 | require.Equal(t, "hello from a script", aToasts[0].Message) | |
| 573 | 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 | 576 | secondA.initOverlays() | |
| 577 | 577 | secondA.setSession(sessA) | |
| 578 | 578 | sessA.mu.Lock() | |
| @@ -586,7 +586,7 @@ func TestSessionScopedNoticeQueuedWhileDetached(t *testing.T) { | |||
| 586 | 586 | func TestNotifyGlobalFansOutToAttachedClients(t *testing.T) { | |
| 587 | 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 | 590 | second.initOverlays() | |
| 591 | 591 | sess2 := &session{sessionCore: sessionCore{id: "manual-2", name: "other", attachments: map[*attachedClient]struct{}{second: {}}}, ctx: sess.ctx, cancel: func() {}} | |
| 592 | 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 | 358 | require.Zero(t, out.Base) | |
| 359 | 359 | require.Contains(t, screenLineText(client, 0), "1") | |
| 360 | 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 | 362 | requireNoOutputFrame(t, sends) | |
| 363 | 363 | } | |
| 364 | 364 | ||
| @@ -392,7 +392,7 @@ func TestResizeBurstFlushesOnlyLatestGeometry(t *testing.T) { | |||
| 392 | 392 | latest.ch <- time.Now() | |
| 393 | 393 | awaitTestCompletion(t, latestDone, "latest resize callback did not complete") | |
| 394 | 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 | 396 | require.Equal(t, 120, testAttachmentTab(sess).focusedPane().screen.Columns()) | |
| 397 | 397 | requireNoOutputFrame(t, sends) | |
| 398 | 398 | } | |
M internal/usecase/daemon/palette.go +1 -1
| @@ -7,9 +7,9 @@ import ( | |||
| 7 | 7 | ||
| 8 | 8 | renderer "github.com/bnema/vev-vt" | |
| 9 | 9 | "github.com/bnema/vev/internal/domain" | |
| 10 | + | "github.com/bnema/vev/internal/domain/layout" | |
| 10 | 11 | "github.com/bnema/vev/internal/protocol" | |
| 11 | 12 | "github.com/bnema/vev/internal/usecase/command" | |
| 12 | - | "github.com/bnema/vev/internal/usecase/layout" | |
| 13 | 13 | "github.com/bnema/vev/internal/usecase/palette" | |
| 14 | 14 | "github.com/bnema/vev/internal/usecase/picker" | |
| 15 | 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 | 8 | vt "github.com/bnema/vev-vt" | |
| 9 | 9 | "github.com/bnema/vev/internal/domain" | |
| 10 | + | "github.com/bnema/vev/internal/domain/layout" | |
| 10 | 11 | "github.com/bnema/vev/internal/ports" | |
| 11 | - | "github.com/bnema/vev/internal/usecase/layout" | |
| 12 | 12 | ) | |
| 13 | 13 | ||
| 14 | 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 | 6 | "slices" | |
| 7 | 7 | ||
| 8 | 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 | 12 | type paneFocusChange struct { | |
M internal/usecase/daemon/pane_actions_test.go +4 -4
| @@ -14,11 +14,11 @@ import ( | |||
| 14 | 14 | ||
| 15 | 15 | renderer "github.com/bnema/vev-vt" | |
| 16 | 16 | "github.com/bnema/vev/internal/domain" | |
| 17 | + | "github.com/bnema/vev/internal/domain/layout" | |
| 17 | 18 | "github.com/bnema/vev/internal/ports" | |
| 18 | 19 | portsmocks "github.com/bnema/vev/internal/ports/mocks" | |
| 19 | 20 | "github.com/bnema/vev/internal/protocol/wire" | |
| 20 | 21 | scopy "github.com/bnema/vev/internal/usecase/copy" | |
| 21 | - | "github.com/bnema/vev/internal/usecase/layout" | |
| 22 | 22 | ) | |
| 23 | 23 | ||
| 24 | 24 | func TestLayoutGenerationMutationBookkeeping(t *testing.T) { | |
| @@ -660,7 +660,7 @@ func TestFocusDirMovesFocusAndExitsCopyMode(t *testing.T) { | |||
| 660 | 660 | tb.panes["pane-1"].rect = domain.Rect{Width: 20, Height: 10} | |
| 661 | 661 | tb.panes["pane-2"].rect = domain.Rect{X: 21, Width: 20, Height: 10} | |
| 662 | 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 | 664 | ac.initOverlays() | |
| 665 | 665 | ac.setSession(sess) | |
| 666 | 666 | require.True(t, sess.registerAttachment(ac)) | |
| @@ -699,7 +699,7 @@ func TestClosePaneRepaintFanoutRespectsAttachmentScope(t *testing.T) { | |||
| 699 | 699 | sends := make([]chan wire.Envelope, 2) | |
| 700 | 700 | for i := range clients { | |
| 701 | 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 | 703 | clients[i].clientID[0] = byte(i + 1) | |
| 704 | 704 | clients[i].initOverlays() | |
| 705 | 705 | clients[i].setSession(sess) | |
| @@ -737,7 +737,7 @@ func TestFinalPaneReapClearsCopyModeForEveryAttachment(t *testing.T) { | |||
| 737 | 737 | ||
| 738 | 738 | for i := range 2 { | |
| 739 | 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 | 741 | ac.clientID[0] = byte(i + 1) | |
| 742 | 742 | ac.initOverlays() | |
| 743 | 743 | ac.setSession(sess) | |
M internal/usecase/daemon/pane_lifetime_test.go +1 -1
| @@ -12,8 +12,8 @@ import ( | |||
| 12 | 12 | "github.com/stretchr/testify/require" | |
| 13 | 13 | ||
| 14 | 14 | "github.com/bnema/vev/internal/domain" | |
| 15 | + | "github.com/bnema/vev/internal/domain/layout" | |
| 15 | 16 | "github.com/bnema/vev/internal/ports" | |
| 16 | - | "github.com/bnema/vev/internal/usecase/layout" | |
| 17 | 17 | ) | |
| 18 | 18 | ||
| 19 | 19 | type lifetimePTY struct { | |
M internal/usecase/daemon/pane_owner_reader_test.go +1 -1
| @@ -6,8 +6,8 @@ import ( | |||
| 6 | 6 | "testing" | |
| 7 | 7 | ||
| 8 | 8 | "github.com/bnema/vev/internal/domain" | |
| 9 | + | "github.com/bnema/vev/internal/domain/layout" | |
| 9 | 10 | "github.com/bnema/vev/internal/ports" | |
| 10 | - | "github.com/bnema/vev/internal/usecase/layout" | |
| 11 | 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 | 4 | "testing" | |
| 5 | 5 | ||
| 6 | 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 | "github.com/stretchr/testify/require" | |
| 9 | 9 | ) | |
| 10 | 10 | ||
M internal/usecase/daemon/pane_rearrange.go +1 -1
| @@ -4,7 +4,7 @@ import ( | |||
| 4 | 4 | "errors" | |
| 5 | 5 | ||
| 6 | 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 | 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 | 11 | "github.com/stretchr/testify/require" | |
| 12 | 12 | ||
| 13 | 13 | "github.com/bnema/vev/internal/domain" | |
| 14 | + | "github.com/bnema/vev/internal/domain/layout" | |
| 14 | 15 | "github.com/bnema/vev/internal/ports" | |
| 15 | 16 | portsmocks "github.com/bnema/vev/internal/ports/mocks" | |
| 16 | 17 | "github.com/bnema/vev/internal/usecase/keys" | |
| 17 | - | "github.com/bnema/vev/internal/usecase/layout" | |
| 18 | 18 | ) | |
| 19 | 19 | ||
| 20 | 20 | func TestMutateTargetLayoutPublishesOnlyChangedCandidates(t *testing.T) { | |
M internal/usecase/daemon/pane_titles.go +1 -1
| @@ -4,7 +4,7 @@ import ( | |||
| 4 | 4 | "strings" | |
| 5 | 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 | 10 | const paneTitleCacheTTL = time.Second | |
M internal/usecase/daemon/performance_benchmark_test.go +7 -7
| @@ -14,11 +14,11 @@ import ( | |||
| 14 | 14 | ||
| 15 | 15 | renderer "github.com/bnema/vev-vt" | |
| 16 | 16 | "github.com/bnema/vev/internal/domain" | |
| 17 | + | "github.com/bnema/vev/internal/domain/layout" | |
| 17 | 18 | "github.com/bnema/vev/internal/ports" | |
| 18 | 19 | "github.com/bnema/vev/internal/protocol" | |
| 19 | 20 | "github.com/bnema/vev/internal/protocol/wire" | |
| 20 | - | "github.com/bnema/vev/internal/usecase/layout" | |
| 21 | - | snapcodec "github.com/bnema/vev/internal/usecase/snapshot" | |
| 21 | + | snapcodec "github.com/bnema/vev/internal/snapshotcodec" | |
| 22 | 22 | ) | |
| 23 | 23 | ||
| 24 | 24 | func TestCountingSnapshotRepositoryPublishCountsAndRetainsManifest(t *testing.T) { | |
| @@ -265,7 +265,7 @@ func TestPerformanceFixtureResize(t *testing.T) { | |||
| 265 | 265 | ||
| 266 | 266 | for _, want := range sequence { | |
| 267 | 267 | fixture.resizeTo(want) | |
| 268 | - | require.Equal(t, want, fixture.ac.size) | |
| 268 | + | require.Equal(t, want, fixture.ac.geometry.Size) | |
| 269 | 269 | require.True(t, fixture.resized()) | |
| 270 | 270 | } | |
| 271 | 271 | metrics := fixture.metrics() | |
| @@ -301,7 +301,7 @@ func TestTransactionalResizeMetrics(t *testing.T) { | |||
| 301 | 301 | require.Equal(t, uint64(3), metrics.outputFrames, "commit, resize-failure notice toast, and successful full-reset retry") | |
| 302 | 302 | require.Zero(t, metrics.frameGapEpochs, "every committed epoch emitted its required frame") | |
| 303 | 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 | 307 | func TestPerformanceFixtureLargeHistoryTopology(t *testing.T) { | |
| @@ -1077,7 +1077,7 @@ func newPerformanceFixtureWithCleanup(t testing.TB, config performanceConfig, re | |||
| 1077 | 1077 | d.clock = clock | |
| 1078 | 1078 | output := &countingOutputTransport{} | |
| 1079 | 1079 | ac.tr = output | |
| 1080 | - | ac.size = config.size | |
| 1080 | + | ac.geometry.Size = config.size | |
| 1081 | 1081 | sess.name = "performance" | |
| 1082 | 1082 | sess.ephemeral = false | |
| 1083 | 1083 | sess.incarnation = domain.IncarnationID{1} | |
| @@ -1300,7 +1300,7 @@ func (f *performanceFixture) retryLatest() { | |||
| 1300 | 1300 | members := make([]resizeMember, 0) | |
| 1301 | 1301 | for _, tb := range tabs { | |
| 1302 | 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 | 1304 | tb.mu.Unlock() | |
| 1305 | 1305 | members = append(members, plan.members...) | |
| 1306 | 1306 | } | |
| @@ -1314,7 +1314,7 @@ func (f *performanceFixture) retryLatest() { | |||
| 1314 | 1314 | } | |
| 1315 | 1315 | ||
| 1316 | 1316 | func (f *performanceFixture) resized() bool { | |
| 1317 | - | if f.ac.size != f.resizedSize { | |
| 1317 | + | if f.ac.geometry.Size != f.resizedSize { | |
| 1318 | 1318 | return false | |
| 1319 | 1319 | } | |
| 1320 | 1320 | for _, tb := range f.sess.tabs { | |
M internal/usecase/daemon/process_restore.go +1 -1
| @@ -5,7 +5,7 @@ import ( | |||
| 5 | 5 | "strings" | |
| 6 | 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 | 11 | const ( | |
M internal/usecase/daemon/process_restore_test.go +1 -1
| @@ -3,7 +3,7 @@ package daemon | |||
| 3 | 3 | import ( | |
| 4 | 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 | 9 | func TestProcessRestorePlan(t *testing.T) { | |
M internal/usecase/daemon/purge_test.go +1 -1
| @@ -20,8 +20,8 @@ import ( | |||
| 20 | 20 | "github.com/bnema/vev/internal/ports" | |
| 21 | 21 | portsmocks "github.com/bnema/vev/internal/ports/mocks" | |
| 22 | 22 | "github.com/bnema/vev/internal/protocol" | |
| 23 | + | snapcodec "github.com/bnema/vev/internal/snapshotcodec" | |
| 23 | 24 | recoveryusecase "github.com/bnema/vev/internal/usecase/recovery" | |
| 24 | - | snapcodec "github.com/bnema/vev/internal/usecase/snapshot" | |
| 25 | 25 | ) | |
| 26 | 26 | ||
| 27 | 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 | 11 | renderer "github.com/bnema/vev-vt" | |
| 12 | 12 | ansirenderer "github.com/bnema/vev-vt/ansi" | |
| 13 | 13 | "github.com/bnema/vev/internal/domain" | |
| 14 | + | "github.com/bnema/vev/internal/domain/layout" | |
| 14 | 15 | "github.com/bnema/vev/internal/domain/terminalcap" | |
| 15 | 16 | "github.com/bnema/vev/internal/protocol" | |
| 16 | 17 | "github.com/bnema/vev/internal/usecase/colorprofile" | |
| 17 | - | "github.com/bnema/vev/internal/usecase/layout" | |
| 18 | 18 | themeui "github.com/bnema/vev/internal/usecase/theme" | |
| 19 | 19 | "github.com/bnema/vev/internal/usecase/ui" | |
| 20 | 20 | ) | |
| @@ -219,7 +219,7 @@ func TestAppliedThemeIsPerAttachment(t *testing.T) { | |||
| 219 | 219 | d, sess, ac16, _ := newManualSessionWithPTYs(t, p) | |
| 220 | 220 | ac16.terminalCapabilities = terminalcap.Capabilities{Color: terminalcap.ColorCapabilities{Mode: terminalcap.ANSI16}} | |
| 221 | 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 | 223 | acTrue.output.attachment = acTrue | |
| 224 | 224 | acTrue.initOverlays() | |
| 225 | 225 | sess.mu.Lock() | |
M internal/usecase/daemon/render.go +1 -1
| @@ -6,9 +6,9 @@ import ( | |||
| 6 | 6 | ||
| 7 | 7 | renderer "github.com/bnema/vev-vt/ansi" | |
| 8 | 8 | "github.com/bnema/vev/internal/domain" | |
| 9 | + | "github.com/bnema/vev/internal/domain/layout" | |
| 9 | 10 | "github.com/bnema/vev/internal/domain/terminalcap" | |
| 10 | 11 | "github.com/bnema/vev/internal/ports" | |
| 11 | - | "github.com/bnema/vev/internal/usecase/layout" | |
| 12 | 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 | 4 | renderer "github.com/bnema/vev-vt" | |
| 5 | 5 | vevgraphics "github.com/bnema/vev-vt/graphics" | |
| 6 | 6 | "github.com/bnema/vev/internal/domain" | |
| 7 | + | "github.com/bnema/vev/internal/domain/layout" | |
| 7 | 8 | "github.com/bnema/vev/internal/protocol" | |
| 8 | 9 | scopy "github.com/bnema/vev/internal/usecase/copy" | |
| 9 | - | "github.com/bnema/vev/internal/usecase/layout" | |
| 10 | 10 | themeui "github.com/bnema/vev/internal/usecase/theme" | |
| 11 | 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 | 991 | if c.opts.ackReadyFor != nil { | |
| 992 | 992 | return c.opts.ackReadyFor(ac) | |
| 993 | 993 | } | |
| 994 | - | if c.opts.ackReady != nil { | |
| 995 | - | return c.opts.ackReady() | |
| 996 | - | } | |
| 997 | 994 | return true | |
| 998 | 995 | } | |
| 999 | 996 | ||
| @@ -1014,25 +1011,15 @@ func (c *renderCoordinator) fireWithTimerTokenAndLease(token *timerToken, gen ui | |||
| 1014 | 1011 | leases[ac] = lease | |
| 1015 | 1012 | } | |
| 1016 | 1013 | } | |
| 1017 | - | previewOnly := len(leases) == 0 && len(c.previewWakes) != 0 | |
| 1018 | - | legacyAckReady := c.opts.ackReady | |
| 1019 | 1014 | c.mu.Unlock() | |
| 1020 | 1015 | ||
| 1021 | 1016 | ready := make(map[*attachmentLease]bool, len(leases)) | |
| 1022 | - | if len(leases) != 0 { | |
| 1023 | - | for ac, lease := range leases { | |
| 1024 | - | // Welcome holds attachment.sendMu while its transport Send is in | |
| 1025 | - | // flight. Do not probe an incarnation before it is ready. | |
| 1026 | - | if lease.ready { | |
| 1027 | - | ready[lease] = c.attachmentAckReady(ac) | |
| 1028 | - | } | |
| 1017 | + | for ac, lease := range leases { | |
| 1018 | + | // Welcome holds attachment.sendMu while its transport Send is in | |
| 1019 | + | // flight. Do not probe an incarnation before it is ready. | |
| 1020 | + | if lease.ready { | |
| 1021 | + | ready[lease] = c.attachmentAckReady(ac) | |
| 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 | 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 | 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) | |
| 1106 | - | outputReady := len(attachmentLeases) != 0 || legacyOutput | |
| 1089 | + | // Without any attachment lease (headless sessions and coordinator-only | |
| 1090 | + | // callers) nothing gates output, so a shared invalidation renders directly. | |
| 1091 | + | leaselessOutput := len(leases) == 0 && (c.pendingShared || len(c.pendingTargets) == 0) | |
| 1092 | + | outputReady := len(attachmentLeases) != 0 || leaselessOutput | |
| 1107 | 1093 | if len(leases) != 0 { | |
| 1108 | 1094 | for ac, lease := range leases { | |
| 1109 | 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 | 1108 | allDelivered = true | |
| 1123 | 1109 | } | |
| 1124 | 1110 | wakeGeneration := uint64(0) | |
| @@ -1143,7 +1129,7 @@ func (c *renderCoordinator) fireWithTimerTokenAndLease(token *timerToken, gen ui | |||
| 1143 | 1129 | ackStart = newACKBlockedSpan(c.opts.observer) | |
| 1144 | 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 | 1133 | if !outputReady && !allDelivered { | |
| 1148 | 1134 | c.mu.Unlock() | |
| 1149 | 1135 | stopDetachedTimer(worker) | |
M internal/usecase/daemon/render_coordinator_handshake_test.go +3 -3
| @@ -83,9 +83,9 @@ func TestRenderCoordinatorFanoutDoesNotWaitForSlowTransport(t *testing.T) { | |||
| 83 | 83 | d, sess, healthy, healthySends, _ := newManualTabSession(t, 1) | |
| 84 | 84 | slowTransport := newFanoutBlockingTransport() | |
| 85 | 85 | slow := &attachedClient{ | |
| 86 | - | tr: slowTransport, | |
| 87 | - | output: newOutputStateStream(), | |
| 88 | - | size: domain.Size{Cols: 80, Rows: 24}, | |
| 86 | + | tr: slowTransport, | |
| 87 | + | output: newOutputStateStream(), | |
| 88 | + | geometry: domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}}, | |
| 89 | 89 | } | |
| 90 | 90 | slow.initOverlays() | |
| 91 | 91 | slow.setSession(sess) | |
M internal/usecase/daemon/render_coordinator_test.go +13 -10
| @@ -12,12 +12,12 @@ import ( | |||
| 12 | 12 | "github.com/stretchr/testify/require" | |
| 13 | 13 | ||
| 14 | 14 | "github.com/bnema/vev/internal/domain" | |
| 15 | + | "github.com/bnema/vev/internal/domain/layout" | |
| 15 | 16 | "github.com/bnema/vev/internal/ports" | |
| 16 | 17 | portsmocks "github.com/bnema/vev/internal/ports/mocks" | |
| 17 | 18 | "github.com/bnema/vev/internal/protocol" | |
| 18 | 19 | "github.com/bnema/vev/internal/protocol/wire" | |
| 19 | 20 | "github.com/bnema/vev/internal/usecase/keys" | |
| 20 | - | "github.com/bnema/vev/internal/usecase/layout" | |
| 21 | 21 | ) | |
| 22 | 22 | ||
| 23 | 23 | // --- coordinator harness ------------------------------------------------------ | |
| @@ -97,7 +97,7 @@ func TestRenderCoordinatorDetachDoesNotJoinSelectedDeadlineWorker(t *testing.T) | |||
| 97 | 97 | release := make(chan struct{}) | |
| 98 | 98 | rc := newRenderCoordinator(renderCoordinatorOptions{ | |
| 99 | 99 | clock: clock, | |
| 100 | - | ackReady: func() bool { | |
| 100 | + | ackReadyFor: func(*attachedClient) bool { | |
| 101 | 101 | close(entered) | |
| 102 | 102 | <-release | |
| 103 | 103 | return true | |
| @@ -166,10 +166,10 @@ func newCoordinatorHarness(t *testing.T) *coordinatorHarness { | |||
| 166 | 166 | } | |
| 167 | 167 | h.ackReady.Store(true) | |
| 168 | 168 | h.rc = newRenderCoordinator(renderCoordinatorOptions{ | |
| 169 | - | clock: h.clk.clock, | |
| 170 | - | wake: func(w renderWake) { h.wakes <- w }, | |
| 171 | - | ackReady: func() bool { return h.ackReady.Load() }, | |
| 172 | - | syncActive: func() bool { return h.syncActive.Load() }, | |
| 169 | + | clock: h.clk.clock, | |
| 170 | + | wake: func(w renderWake) { h.wakes <- w }, | |
| 171 | + | ackReadyFor: func(*attachedClient) bool { return h.ackReady.Load() }, | |
| 172 | + | syncActive: func() bool { return h.syncActive.Load() }, | |
| 173 | 173 | }) | |
| 174 | 174 | return h | |
| 175 | 175 | } | |
| @@ -449,7 +449,7 @@ func TestRenderCoordinatorAckReadinessReentersWithoutBlockingResize(t *testing.T | |||
| 449 | 449 | ||
| 450 | 450 | var rc *renderCoordinator | |
| 451 | 451 | rc = newRenderCoordinator(renderCoordinatorOptions{ | |
| 452 | - | ackReady: func() bool { | |
| 452 | + | ackReadyFor: func(*attachedClient) bool { | |
| 453 | 453 | close(ackEntered) | |
| 454 | 454 | // The readiness probe models the output send path reading coordinator | |
| 455 | 455 | // metadata after sendMu is held. It must never run under c.mu. | |
| @@ -496,6 +496,7 @@ func TestRenderCoordinatorAckDoesNotBypassAnUnexpiredDeadline(t *testing.T) { | |||
| 496 | 496 | func TestRenderCoordinatorAckFlushesOnlyExpiredAckDeferredWork(t *testing.T) { | |
| 497 | 497 | t.Run("expired deadline flushes exactly once after readiness", func(t *testing.T) { | |
| 498 | 498 | h := newCoordinatorHarness(t) | |
| 499 | + | h.rc.attach(&attachedClient{}) | |
| 499 | 500 | h.ackReady.Store(false) | |
| 500 | 501 | h.rc.invalidate(renderInvalidation{class: invalidateOutput, reset: true, producer: "render.go"}) | |
| 501 | 502 | timer := awaitCoordinatorScheduledTimer(t, h.clk) | |
| @@ -516,6 +517,7 @@ func TestRenderCoordinatorAckFlushesOnlyExpiredAckDeferredWork(t *testing.T) { | |||
| 516 | 517 | ||
| 517 | 518 | t.Run("lifecycle clears deferred work and urgent explicit fires stay immediate", func(t *testing.T) { | |
| 518 | 519 | h := newCoordinatorHarness(t) | |
| 520 | + | h.rc.attach(&attachedClient{}) | |
| 519 | 521 | h.ackReady.Store(false) | |
| 520 | 522 | h.rc.invalidate(renderInvalidation{class: invalidateOutput}) | |
| 521 | 523 | timer := awaitCoordinatorScheduledTimer(t, h.clk) | |
| @@ -536,6 +538,7 @@ func TestRenderCoordinatorAckFlushesOnlyExpiredAckDeferredWork(t *testing.T) { | |||
| 536 | 538 | ||
| 537 | 539 | func TestRenderCoordinatorAckGateBlocksCompositionUntilAck(t *testing.T) { | |
| 538 | 540 | h := newCoordinatorHarness(t) | |
| 541 | + | h.rc.attach(&attachedClient{}) | |
| 539 | 542 | h.ackReady.Store(false) | |
| 540 | 543 | ||
| 541 | 544 | h.rc.invalidate(renderInvalidation{class: invalidateOutput}) | |
| @@ -1451,9 +1454,9 @@ func TestRenderCoordinatorInertTimerFiresSynchronouslyWithoutWorker(t *testing.T | |||
| 1451 | 1454 | ||
| 1452 | 1455 | wakes := make(chan renderWake, 1) | |
| 1453 | 1456 | rc := newRenderCoordinator(renderCoordinatorOptions{ | |
| 1454 | - | clock: clock, | |
| 1455 | - | ackReady: func() bool { return true }, | |
| 1456 | - | wake: func(w renderWake) { wakes <- w }, | |
| 1457 | + | clock: clock, | |
| 1458 | + | ackReadyFor: func(*attachedClient) bool { return true }, | |
| 1459 | + | wake: func(w renderWake) { wakes <- w }, | |
| 1457 | 1460 | }) | |
| 1458 | 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 | 105 | observer ports.RuntimeObserver | |
| 106 | 106 | // wake composes a coalesced render request. | |
| 107 | 107 | wake func(renderWake) | |
| 108 | - | // ackReady reports whether the attachment may compose another output | |
| 109 | - | // state (the attachmentOutput window has capacity). It is retained for | |
| 110 | - | // coordinator-only tests and headless callers; production uses ackReadyFor | |
| 111 | - | // so one attachment cannot gate another. | |
| 112 | - | ackReady func() bool | |
| 108 | + | // ackReadyFor reports whether one attachment may compose another output | |
| 109 | + | // state (its attachmentOutput window has capacity), so one attachment | |
| 110 | + | // cannot gate another. Nil means every attachment is ready. | |
| 113 | 111 | ackReadyFor func(*attachedClient) bool | |
| 114 | 112 | // syncRenderable reports whether a pane's synchronized batch currently | |
| 115 | 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 | 6 | renderer "github.com/bnema/vev-vt" | |
| 7 | 7 | "github.com/bnema/vev/internal/domain" | |
| 8 | + | "github.com/bnema/vev/internal/domain/layout" | |
| 8 | 9 | "github.com/bnema/vev/internal/ports" | |
| 9 | - | "github.com/bnema/vev/internal/usecase/layout" | |
| 10 | 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 | 6 | renderer "github.com/bnema/vev-vt" | |
| 7 | 7 | "github.com/bnema/vev/internal/domain" | |
| 8 | - | "github.com/bnema/vev/internal/usecase/layout" | |
| 8 | + | "github.com/bnema/vev/internal/domain/layout" | |
| 9 | 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 | 7 | renderer "github.com/bnema/vev-vt" | |
| 8 | 8 | "github.com/bnema/vev/internal/domain" | |
| 9 | + | "github.com/bnema/vev/internal/domain/layout" | |
| 9 | 10 | "github.com/bnema/vev/internal/ports" | |
| 10 | 11 | "github.com/bnema/vev/internal/protocol" | |
| 11 | - | "github.com/bnema/vev/internal/usecase/layout" | |
| 12 | 12 | "github.com/bnema/vev/internal/usecase/notices" | |
| 13 | 13 | "github.com/bnema/vev/internal/usecase/palette" | |
| 14 | 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 | 9 | renderer "github.com/bnema/vev-vt" | |
| 10 | 10 | "github.com/bnema/vev/internal/domain" | |
| 11 | + | "github.com/bnema/vev/internal/domain/layout" | |
| 11 | 12 | "github.com/bnema/vev/internal/protocol" | |
| 12 | 13 | "github.com/bnema/vev/internal/protocol/wire" | |
| 13 | - | "github.com/bnema/vev/internal/usecase/layout" | |
| 14 | 14 | themeui "github.com/bnema/vev/internal/usecase/theme" | |
| 15 | 15 | "github.com/bnema/vev/internal/usecase/ui" | |
| 16 | 16 | "github.com/stretchr/testify/require" | |
M internal/usecase/daemon/render_pipeline_test.go +1 -1
| @@ -14,11 +14,11 @@ import ( | |||
| 14 | 14 | vt "github.com/bnema/vev-vt" | |
| 15 | 15 | renderer "github.com/bnema/vev-vt/ansi" | |
| 16 | 16 | "github.com/bnema/vev/internal/domain" | |
| 17 | + | "github.com/bnema/vev/internal/domain/layout" | |
| 17 | 18 | "github.com/bnema/vev/internal/protocol" | |
| 18 | 19 | "github.com/bnema/vev/internal/protocol/wire" | |
| 19 | 20 | "github.com/bnema/vev/internal/testutil/replaytest" | |
| 20 | 21 | scopy "github.com/bnema/vev/internal/usecase/copy" | |
| 21 | - | "github.com/bnema/vev/internal/usecase/layout" | |
| 22 | 22 | "github.com/bnema/vev/internal/usecase/notices" | |
| 23 | 23 | "github.com/bnema/vev/internal/usecase/palette" | |
| 24 | 24 | promptui "github.com/bnema/vev/internal/usecase/prompt" | |
M internal/usecase/daemon/render_test.go +3 -3
| @@ -19,11 +19,11 @@ import ( | |||
| 19 | 19 | vt "github.com/bnema/vev-vt" | |
| 20 | 20 | renderer "github.com/bnema/vev-vt/ansi" | |
| 21 | 21 | "github.com/bnema/vev/internal/domain" | |
| 22 | + | "github.com/bnema/vev/internal/domain/layout" | |
| 22 | 23 | portsmocks "github.com/bnema/vev/internal/ports/mocks" | |
| 23 | 24 | "github.com/bnema/vev/internal/protocol" | |
| 24 | 25 | "github.com/bnema/vev/internal/protocol/wire" | |
| 25 | 26 | scopy "github.com/bnema/vev/internal/usecase/copy" | |
| 26 | - | "github.com/bnema/vev/internal/usecase/layout" | |
| 27 | 27 | themeui "github.com/bnema/vev/internal/usecase/theme" | |
| 28 | 28 | ) | |
| 29 | 29 | ||
| @@ -73,7 +73,7 @@ func TestFirstPaintRetainedFloatingPaneEmitsOneReset(t *testing.T) { | |||
| 73 | 73 | defer releasePTY() | |
| 74 | 74 | d, sess, ac, sends := newManualSessionWithPTYs(t, pty) | |
| 75 | 75 | ac.sendMu.Lock() | |
| 76 | - | ac.size = tc.clientSize | |
| 76 | + | ac.geometry.Size = tc.clientSize | |
| 77 | 77 | ac.sendMu.Unlock() | |
| 78 | 78 | ||
| 79 | 79 | // A retained visible popup still needs activation warmup. When the | |
| @@ -404,7 +404,7 @@ func TestPTYReaderRepublishesSynchronizedCompletionAfterAttachmentLifecycle(t *t | |||
| 404 | 404 | rc := d.attachCoordinator(target, nil, detached, true) | |
| 405 | 405 | ||
| 406 | 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 | 408 | remaining.initOverlays() | |
| 409 | 409 | remaining.setSession(target) | |
| 410 | 410 | target.mu.Lock() | |
M internal/usecase/daemon/resize_mode.go +1 -1
| @@ -4,9 +4,9 @@ import ( | |||
| 4 | 4 | "errors" | |
| 5 | 5 | ||
| 6 | 6 | "github.com/bnema/vev/internal/domain" | |
| 7 | + | "github.com/bnema/vev/internal/domain/layout" | |
| 7 | 8 | "github.com/bnema/vev/internal/ports" | |
| 8 | 9 | "github.com/bnema/vev/internal/usecase/keys" | |
| 9 | - | "github.com/bnema/vev/internal/usecase/layout" | |
| 10 | 10 | ) | |
| 11 | 11 | ||
| 12 | 12 | const ( | |
M internal/usecase/daemon/resize_mode_test.go +1 -1
| @@ -5,8 +5,8 @@ import ( | |||
| 5 | 5 | "testing" | |
| 6 | 6 | ||
| 7 | 7 | "github.com/bnema/vev/internal/domain" | |
| 8 | + | "github.com/bnema/vev/internal/domain/layout" | |
| 8 | 9 | "github.com/bnema/vev/internal/protocol" | |
| 9 | - | "github.com/bnema/vev/internal/usecase/layout" | |
| 10 | 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 | 15 | vt "github.com/bnema/vev-vt" | |
| 16 | 16 | "github.com/bnema/vev/internal/domain" | |
| 17 | + | "github.com/bnema/vev/internal/domain/layout" | |
| 17 | 18 | "github.com/bnema/vev/internal/domain/terminalcap" | |
| 18 | 19 | "github.com/bnema/vev/internal/ports" | |
| 19 | 20 | portsmocks "github.com/bnema/vev/internal/ports/mocks" | |
| 20 | 21 | "github.com/bnema/vev/internal/protocol" | |
| 21 | 22 | "github.com/bnema/vev/internal/protocol/wire" | |
| 22 | - | "github.com/bnema/vev/internal/usecase/layout" | |
| 23 | 23 | ) | |
| 24 | 24 | ||
| 25 | 25 | type closeTrackingTransport struct { | |
| @@ -1247,7 +1247,7 @@ func TestParkingReleasesPaneCapturesBeforeHeadlessCloseAndResume(t *testing.T) { | |||
| 1247 | 1247 | require.Len(t, sends, 1) | |
| 1248 | 1248 | output := unmarshalTestOutput(t, sends[0].Payload) | |
| 1249 | 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 | 1251 | terminal.Write(output.Data) | |
| 1252 | 1252 | contents := strings.Join(frameRows(terminal), "\n") | |
| 1253 | 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 | 18 | vt "github.com/bnema/vev-vt" | |
| 19 | 19 | "github.com/bnema/vev/internal/domain" | |
| 20 | + | "github.com/bnema/vev/internal/domain/layout" | |
| 20 | 21 | "github.com/bnema/vev/internal/ports" | |
| 21 | 22 | "github.com/bnema/vev/internal/protocol" | |
| 22 | - | "github.com/bnema/vev/internal/usecase/layout" | |
| 23 | 23 | ) | |
| 24 | 24 | ||
| 25 | 25 | var ( | |
| @@ -2169,12 +2169,6 @@ func (d *Daemon) refreshSessionCwd(sess *session) { | |||
| 2169 | 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 | 2172 | func copyEnvironment(env []string) []string { | |
| 2179 | 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 | 6 | "github.com/bnema/vev/internal/ports" | |
| 7 | 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 | 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 | 1255 | d.ptys = newFactorySeq(t, firstPTY, secondPTY) | |
| 1256 | 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 | 1259 | require.NoError(t, err) | |
| 1260 | 1260 | require.NoError(t, d.renameSession(first, "named")) | |
| 1261 | 1261 | staleCreatedAt := first.createdAt | |
| @@ -1263,7 +1263,7 @@ func TestEphemeralPromotionLifecyclePreventsStaleSameNamePaletteTarget(t *testin | |||
| 1263 | 1263 | require.NoError(t, d.killSession(first, protocol.ReasonSessionKilled, true)) | |
| 1264 | 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 | 1267 | require.NoError(t, err) | |
| 1268 | 1268 | require.NoError(t, d.renameSession(second, "named")) | |
| 1269 | 1269 | require.NotEqual(t, staleCreatedAt, second.createdAt) | |
| @@ -1414,7 +1414,7 @@ func TestPickerStoppedTargetKillPurges(t *testing.T) { | |||
| 1414 | 1414 | func TestChildEnvEscapesLegacySessionName(t *testing.T) { | |
| 1415 | 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 | 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 | 1580 | d := newTestDaemon(t, nil, stubClock{}) | |
| 1581 | 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 | 1585 | for _, want := range tt.wantContain { | |
| 1586 | 1586 | require.Contains(t, got, want) | |
| @@ -1629,7 +1629,7 @@ func TestAttachUpdatesFutureChildEnvTrueColor(t *testing.T) { | |||
| 1629 | 1629 | require.Contains(t, opens[0], "TERM=xterm-256color") | |
| 1630 | 1630 | require.Contains(t, opens[0], "COLORTERM=truecolor") | |
| 1631 | 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 | 1633 | require.Contains(t, opens[1], "TERM=xterm-256color") | |
| 1634 | 1634 | require.Contains(t, opens[1], "COLORTERM=truecolor") | |
| 1635 | 1635 | require.Contains(t, opens[1], "TERM_PROGRAM=vev") | |
| @@ -1675,7 +1675,7 @@ func TestLiveAttachUpdatesFutureChildEnvTrueColor(t *testing.T) { | |||
| 1675 | 1675 | ||
| 1676 | 1676 | require.Contains(t, opens[0], "TERM=xterm-256color") | |
| 1677 | 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 | 1679 | require.Contains(t, opens[1], "TERM=xterm-256color") | |
| 1680 | 1680 | require.Contains(t, opens[1], "COLORTERM=truecolor") | |
| 1681 | 1681 | require.Contains(t, opens[1], "TERM_PROGRAM=vev") | |
| @@ -1792,7 +1792,7 @@ func TestAttachEnvironmentRefreshesOnlySessionBoundFuturePTYInputs(t *testing.T) | |||
| 1792 | 1792 | require.Equal(t, "/usr/bin/fish", commands[0]) | |
| 1793 | 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 | 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 | 1796 | // Only session-bound variables follow the attaching client: the X11-only | |
| 1797 | 1797 | // display group replaces the Wayland one, the absent agent socket is | |
| 1798 | 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 | 10 | vt "github.com/bnema/vev-vt" | |
| 11 | 11 | "github.com/bnema/vev/internal/domain" | |
| 12 | - | "github.com/bnema/vev/internal/usecase/layout" | |
| 13 | - | snapcodec "github.com/bnema/vev/internal/usecase/snapshot" | |
| 12 | + | "github.com/bnema/vev/internal/domain/layout" | |
| 13 | + | snapcodec "github.com/bnema/vev/internal/snapshotcodec" | |
| 14 | 14 | ) | |
| 15 | 15 | ||
| 16 | 16 | // snapshotQueueCapacity bounds retained immutable captures. A full queue never | |
M internal/usecase/daemon/snapshot_capture.go +1 -1
| @@ -1,6 +1,6 @@ | |||
| 1 | 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 | 5 | // captureSnapshotState copies history tails and recovery transcripts while holding | |
| 6 | 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 | 6 | vt "github.com/bnema/vev-vt" | |
| 7 | 7 | "github.com/bnema/vev/internal/domain" | |
| 8 | 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 | 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 | 10 | vt "github.com/bnema/vev-vt" | |
| 11 | 11 | renderer "github.com/bnema/vev-vt/ansi" | |
| 12 | 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 | 16 | func TestMarshalSnapshotTailSelectsCanonicalEncoder(t *testing.T) { | |
M internal/usecase/daemon/snapshot_restore.go +2 -2
| @@ -9,9 +9,9 @@ import ( | |||
| 9 | 9 | ||
| 10 | 10 | vt "github.com/bnema/vev-vt" | |
| 11 | 11 | "github.com/bnema/vev/internal/domain" | |
| 12 | + | "github.com/bnema/vev/internal/domain/layout" | |
| 12 | 13 | "github.com/bnema/vev/internal/protocol" | |
| 13 | - | "github.com/bnema/vev/internal/usecase/layout" | |
| 14 | - | snapcodec "github.com/bnema/vev/internal/usecase/snapshot" | |
| 14 | + | snapcodec "github.com/bnema/vev/internal/snapshotcodec" | |
| 15 | 15 | ) | |
| 16 | 16 | ||
| 17 | 17 | func (d *Daemon) closeRestoreDone() { | |
M internal/usecase/daemon/snapshot_restore_fixture_test.go +1 -1
| @@ -5,7 +5,7 @@ import ( | |||
| 5 | 5 | ||
| 6 | 6 | "github.com/bnema/vev/internal/domain" | |
| 7 | 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 | 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 | 12 | "github.com/bnema/vev/internal/domain" | |
| 13 | 13 | "github.com/bnema/vev/internal/ports" | |
| 14 | 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 | 18 | const catalogueRestoreConcurrency = 8 | |
M internal/usecase/daemon/snapshot_restore_test.go +2 -2
| @@ -14,11 +14,11 @@ import ( | |||
| 14 | 14 | vt "github.com/bnema/vev-vt" | |
| 15 | 15 | renderer "github.com/bnema/vev-vt/ansi" | |
| 16 | 16 | "github.com/bnema/vev/internal/domain" | |
| 17 | + | "github.com/bnema/vev/internal/domain/layout" | |
| 17 | 18 | "github.com/bnema/vev/internal/ports" | |
| 18 | 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 | 21 | recoveryusecase "github.com/bnema/vev/internal/usecase/recovery" | |
| 21 | - | snapcodec "github.com/bnema/vev/internal/usecase/snapshot" | |
| 22 | 22 | ) | |
| 23 | 23 | ||
| 24 | 24 | type snapshotAcceptanceRepository struct { | |
M internal/usecase/daemon/status_test.go +1 -1
| @@ -13,10 +13,10 @@ import ( | |||
| 13 | 13 | ||
| 14 | 14 | renderer "github.com/bnema/vev-vt/ansi" | |
| 15 | 15 | "github.com/bnema/vev/internal/domain" | |
| 16 | + | "github.com/bnema/vev/internal/domain/layout" | |
| 16 | 17 | "github.com/bnema/vev/internal/protocol" | |
| 17 | 18 | "github.com/bnema/vev/internal/protocol/wire" | |
| 18 | 19 | "github.com/bnema/vev/internal/usecase/command" | |
| 19 | - | "github.com/bnema/vev/internal/usecase/layout" | |
| 20 | 20 | "github.com/bnema/vev/internal/usecase/palette" | |
| 21 | 21 | themeui "github.com/bnema/vev/internal/usecase/theme" | |
| 22 | 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 | 8 | renderer "github.com/bnema/vev-vt" | |
| 9 | 9 | "github.com/bnema/vev/internal/domain" | |
| 10 | + | "github.com/bnema/vev/internal/domain/layout" | |
| 10 | 11 | "github.com/bnema/vev/internal/protocol" | |
| 11 | - | "github.com/bnema/vev/internal/usecase/layout" | |
| 12 | 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 | 7 | "github.com/stretchr/testify/require" | |
| 8 | 8 | ||
| 9 | 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 | 13 | func TestComposeFramePrunesTitleGenerationsAfterLayoutChurn(t *testing.T) { | |
M internal/usecase/daemon/toast_damage_test.go +1 -1
| @@ -6,7 +6,7 @@ import ( | |||
| 6 | 6 | vt "github.com/bnema/vev-vt" | |
| 7 | 7 | renderer "github.com/bnema/vev-vt/ansi" | |
| 8 | 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 | "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 | 6 | vt "github.com/bnema/vev-vt" | |
| 7 | 7 | "github.com/bnema/vev/internal/domain" | |
| 8 | + | "github.com/bnema/vev/internal/domain/layout" | |
| 8 | 9 | "github.com/bnema/vev/internal/ports" | |
| 9 | - | "github.com/bnema/vev/internal/usecase/layout" | |
| 10 | 10 | ) | |
| 11 | 11 | ||
| 12 | 12 | type resizeOwnerPostEffect uint8 | |
M internal/usecase/daemon/transactional_resize_test.go +3 -3
| @@ -11,8 +11,8 @@ import ( | |||
| 11 | 11 | "github.com/stretchr/testify/require" | |
| 12 | 12 | ||
| 13 | 13 | "github.com/bnema/vev/internal/domain" | |
| 14 | + | "github.com/bnema/vev/internal/domain/layout" | |
| 14 | 15 | "github.com/bnema/vev/internal/ports" | |
| 15 | - | "github.com/bnema/vev/internal/usecase/layout" | |
| 16 | 16 | "github.com/bnema/vev/internal/usecase/ui" | |
| 17 | 17 | "github.com/bnema/vev/internal/usecase/visualsearch" | |
| 18 | 18 | ) | |
| @@ -474,7 +474,7 @@ func TestTransactionalResizeRechecksLeaseAtAttachmentPublication(t *testing.T) { | |||
| 474 | 474 | lease := rc.attachmentLease(ac) | |
| 475 | 475 | epoch := rc.recordResizeRequestForLease(domain.Size{Cols: 100, Rows: 30}, ac, lease) | |
| 476 | 476 | require.NotZero(t, epoch) | |
| 477 | - | initialSize := ac.size | |
| 477 | + | initialSize := ac.geometry.Size | |
| 478 | 478 | ||
| 479 | 479 | entered := make(chan struct{}) | |
| 480 | 480 | release := make(chan struct{}) | |
| @@ -492,7 +492,7 @@ func TestTransactionalResizeRechecksLeaseAtAttachmentPublication(t *testing.T) { | |||
| 492 | 492 | require.NotZero(t, newer) | |
| 493 | 493 | close(release) | |
| 494 | 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 | 496 | if tc.effect == resizeOwnerPostSnapshotDirty { | |
| 497 | 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 | 188 | observer := &daemonRuntimeObserver{} | |
| 189 | 189 | ready := false | |
| 190 | 190 | rc := newRenderCoordinator(renderCoordinatorOptions{ | |
| 191 | - | observer: observer, | |
| 192 | - | ackReady: func() bool { return ready }, | |
| 191 | + | observer: observer, | |
| 192 | + | ackReadyFor: func(*attachedClient) bool { return ready }, | |
| 193 | 193 | }) | |
| 194 | 194 | rc.attach(&attachedClient{}) | |
| 195 | 195 | rc.invalidate(renderInvalidation{class: invalidateOutput}) | |
| @@ -233,9 +233,9 @@ func TestTransportObservabilityBlockedACKEndDoesNotDelayNotifyAck(t *testing.T) | |||
| 233 | 233 | wakes := make(chan renderWake, 1) | |
| 234 | 234 | var ready atomic.Bool | |
| 235 | 235 | rc := newRenderCoordinator(renderCoordinatorOptions{ | |
| 236 | - | observer: d.runtimeObserver, | |
| 237 | - | ackReady: ready.Load, | |
| 238 | - | wake: func(w renderWake) { wakes <- w }, | |
| 236 | + | observer: d.runtimeObserver, | |
| 237 | + | ackReadyFor: func(*attachedClient) bool { return ready.Load() }, | |
| 238 | + | wake: func(w renderWake) { wakes <- w }, | |
| 239 | 239 | }) | |
| 240 | 240 | rc.attach(&attachedClient{}) | |
| 241 | 241 | rc.invalidate(renderInvalidation{class: invalidateOutput}) | |
| @@ -284,8 +284,8 @@ func (o *blockingACKEndObserver) ObserveRuntime(mark ports.RuntimeMark) { | |||
| 284 | 284 | func TestTransportObservabilityBlockedCoordinatorObserverReleasesLock(t *testing.T) { | |
| 285 | 285 | observer := newBlockingDaemonRuntimeObserver() | |
| 286 | 286 | rc := newRenderCoordinator(renderCoordinatorOptions{ | |
| 287 | - | observer: observer, | |
| 288 | - | ackReady: func() bool { return false }, | |
| 287 | + | observer: observer, | |
| 288 | + | ackReadyFor: func(*attachedClient) bool { return false }, | |
| 289 | 289 | }) | |
| 290 | 290 | rc.attach(&attachedClient{}) | |
| 291 | 291 | rc.mu.Lock() | |
| @@ -347,9 +347,10 @@ func TestTransportObservabilityACKBlockedStartPublishesBeforeEnd(t *testing.T) { | |||
| 347 | 347 | observer := newACKSpanInterleavingObserver() | |
| 348 | 348 | ready := false | |
| 349 | 349 | rc := newRenderCoordinator(renderCoordinatorOptions{ | |
| 350 | - | observer: observer, | |
| 351 | - | ackReady: func() bool { return ready }, | |
| 350 | + | observer: observer, | |
| 351 | + | ackReadyFor: func(*attachedClient) bool { return ready }, | |
| 352 | 352 | }) | |
| 353 | + | rc.attach(&attachedClient{}) | |
| 353 | 354 | rc.mu.Lock() | |
| 354 | 355 | rc.pending = true | |
| 355 | 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 | 3 | import ( | |
| 4 | 4 | "sort" | |
| 5 | 5 | "strings" | |
| 6 | - | "unicode/utf8" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/vev/internal/usecase/fuzzy" | |
| 7 | 8 | ) | |
| 8 | 9 | ||
| 9 | 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 | 86 | return match, true | |
| 86 | 87 | } | |
| 87 | 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 | 90 | match.rank, match.span, match.first = 5, positions[len(positions)-1]-positions[0]+1, positions[0] | |
| 90 | 91 | return match, true | |
| 91 | 92 | } | |
| @@ -101,25 +102,19 @@ type fieldScore struct { | |||
| 101 | 102 | } | |
| 102 | 103 | ||
| 103 | 104 | func scoreField(text, needle string, needleRunes []rune, displayOffset, exactRank, prefixRank, subsequenceRank int) (fieldScore, bool) { | |
| 104 | - | text = strings.ToLower(text) | |
| 105 | - | var scored fieldScore | |
| 106 | - | switch { | |
| 107 | - | case text == needle: | |
| 108 | - | scored.positions = rangePositions(utf8.RuneCountInString(text)) | |
| 105 | + | matched, ok := fuzzy.Match(strings.ToLower(text), needle, needleRunes) | |
| 106 | + | if !ok { | |
| 107 | + | return fieldScore{}, false | |
| 108 | + | } | |
| 109 | + | scored := fieldScore{positions: matched.Positions, span: matched.Span, first: matched.First} | |
| 110 | + | switch matched.Kind { | |
| 111 | + | case fuzzy.Exact: | |
| 109 | 112 | scored.rank = exactRank | |
| 110 | - | case strings.HasPrefix(text, needle): | |
| 111 | - | scored.positions = rangePositions(utf8.RuneCountInString(needle)) | |
| 113 | + | case fuzzy.Prefix: | |
| 112 | 114 | scored.rank = prefixRank | |
| 113 | 115 | default: | |
| 114 | - | positions, ok := subsequencePositions([]rune(text), needleRunes) | |
| 115 | - | if !ok { | |
| 116 | - | return fieldScore{}, false | |
| 117 | - | } | |
| 118 | - | scored.positions = positions | |
| 119 | 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 | 118 | for i := range scored.positions { | |
| 124 | 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 | 124 | func (s fieldScore) betterThan(other fieldScore) bool { | |
| 130 | - | if s.rank != other.rank { | |
| 131 | - | return s.rank < other.rank | |
| 132 | - | } | |
| 133 | - | if s.span != other.span { | |
| 134 | - | return s.span < other.span | |
| 135 | - | } | |
| 136 | - | return s.first < other.first | |
| 125 | + | return fuzzy.Less(s.rank, fuzzy.Score{Span: s.span, First: s.first}, other.rank, fuzzy.Score{Span: other.span, First: other.first}) | |
| 137 | 126 | } | |
| 138 | 127 | ||
| 139 | 128 | func (s fieldScore) apply(match *Match) { | |
| @@ -146,29 +135,3 @@ func (s fieldScore) apply(match *Match) { | |||
| 146 | 135 | func newMatch(result Result, order int) Match { | |
| 147 | 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 | 6 | "unicode/utf8" | |
| 7 | 7 | ||
| 8 | 8 | renderer "github.com/bnema/vev-vt" | |
| 9 | + | "github.com/bnema/vev/internal/usecase/fuzzy" | |
| 9 | 10 | ) | |
| 10 | 11 | ||
| 11 | 12 | const MaxSearchQueryRunes = 256 | |
| @@ -282,60 +283,16 @@ func matchRow(row row, query string, needleRunes []rune) (searchMatch, bool) { | |||
| 282 | 283 | } | |
| 283 | 284 | ||
| 284 | 285 | func scoreField(field matchField, text, query string, needle []rune) (fieldMatch, bool) { | |
| 285 | - | if len(needle) == 0 { | |
| 286 | - | return fieldMatch{field: field}, true | |
| 287 | - | } | |
| 288 | - | if text == query { | |
| 289 | - | positions := rangeRunePositions(len(needle)) | |
| 290 | - | return fieldMatch{field: field, positions: positions, rank: 0, span: len(positions)}, true | |
| 291 | - | } | |
| 292 | - | if strings.HasPrefix(text, query) { | |
| 293 | - | positions := rangeRunePositions(len(needle)) | |
| 294 | - | return fieldMatch{field: field, positions: positions, rank: 1, span: len(positions)}, true | |
| 295 | - | } | |
| 296 | - | positions, ok := subsequenceRunePositions([]rune(text), needle) | |
| 286 | + | matched, ok := fuzzy.Match(text, query, needle) | |
| 297 | 287 | if !ok { | |
| 298 | 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 | 293 | func lessFieldMatch(left, right fieldMatch) bool { | |
| 304 | - | if left.rank != right.rank { | |
| 305 | - | return left.rank < right.rank | |
| 306 | - | } | |
| 307 | - | if left.span != right.span { | |
| 308 | - | return left.span < right.span | |
| 309 | - | } | |
| 310 | - | if left.first != right.first { | |
| 311 | - | return left.first < right.first | |
| 294 | + | if left.rank != right.rank || left.span != right.span || left.first != right.first { | |
| 295 | + | return fuzzy.Less(left.rank, fuzzy.Score{Span: left.span, First: left.first}, right.rank, fuzzy.Score{Span: right.span, First: right.first}) | |
| 312 | 296 | } | |
| 313 | 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 | - | } | |