1package client
2
3import (
4 "context"
5 "errors"
6 "sync"
7 "testing"
8
9 "github.com/bnema/vev/internal/domain"
10 "github.com/bnema/vev/internal/ports"
11 "github.com/stretchr/testify/require"
12)
13
14func routeTestTarget(index byte) ports.ExactSessionTarget {
15 return ports.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{index + 1}, SessionName: "session-" + string(rune('a'+index%26)) + string(rune('0'+index/26))}
16}
17
18func routeTestCandidate(index byte, origin ports.RouteOrigin) routeCandidate {
19 target := routeTestTarget(index)
20 return routeCandidate{
21 origin: origin,
22 target: target,
23 presentation: routePresentation{
24 name: target.SessionName,
25 kind: ports.RouteKindLocal,
26 reachability: ports.RouteReachabilityReachable,
27 },
28 request: AttachRequest{
29 Intent: ports.IntentAttach,
30 SessionName: target.SessionName,
31 Origin: origin,
32 },
33 }
34}
35
36func TestRouteCandidateRetainsRemoteOriginWithoutDiscoveryTarget(t *testing.T) {
37 target := routeTestTarget(1)
38 candidate := routeCandidateForAttach(AttachRequest{
39 Intent: ports.IntentAttach, SessionName: target.SessionName, Remote: true,
40 Origin: ports.RouteOriginRemote, OriginKey: "remote",
41 }, ports.CommittedRouteIdentity{Target: target}, nil, 0)
42
43 require.Nil(t, candidate.request.RemoteTarget)
44 require.Equal(t, "remote", candidate.request.RemoteOrigin)
45 require.Equal(t, "remote", candidate.presentation.hostLabel)
46}
47
48func TestRouteLedgerBoundsAndImmutableSnapshot(t *testing.T) {
49 ledger := newRouteLedger()
50 var keys []routeKey
51 for i := byte(0); i < maxRouteLedgerEntries+8; i++ {
52 candidate := routeTestCandidate(i, ports.RouteOriginLocal)
53 candidate.home = i == 0
54 identity, err := ledger.commit(candidate)
55 require.NoError(t, err)
56 keys = append(keys, identity.key)
57 }
58
59 snapshot := ledger.snapshot()
60 require.Len(t, ledger.entries, maxRouteLedgerEntries)
61 require.Len(t, snapshot.Entries, maxRouteLedgerEntries-1)
62 require.Equal(t, uint64(keys[len(keys)-1]), snapshot.Active.Key)
63 for _, entry := range snapshot.Entries {
64 require.NotEqual(t, snapshot.Active, ports.RouteRef{Key: entry.Key, Generation: entry.Generation})
65 }
66 require.Equal(t, uint64(keys[len(keys)-2]), snapshot.Previous.Key)
67 require.Equal(t, uint64(keys[0]), snapshot.Home.Key, "home is retained when older entries are evicted")
68
69 for i := 1; i < len(snapshot.Entries); i++ {
70 require.Greater(t, snapshot.Entries[i-1].Generation, snapshot.Entries[i].Generation)
71 }
72 originalName := snapshot.Entries[0].Name
73 snapshot.Entries[0].Name = "mutated"
74 fresh := ledger.snapshot()
75 require.Equal(t, originalName, fresh.Entries[0].Name)
76
77 old := ledger.activeRef()
78 _, err := ledger.commit(routeTestCandidate(maxRouteLedgerEntries+9, ports.RouteOriginLocal))
79 require.NoError(t, err)
80 require.NotEqual(t, old, ledger.activeRef())
81 require.Equal(t, old, ledger.previousRef(), "the old active route becomes the previous route")
82}
83
84func TestRouteLedgerRemembersTabsIndependentlyPerExactRoute(t *testing.T) {
85 ledger := newRouteLedger()
86 first := routeTestCandidate(1, ports.RouteOriginLocal)
87 firstIdentity, err := ledger.commit(first)
88 require.NoError(t, err)
89 firstRef := ledger.activeRef()
90 firstSnapshotGeneration := ledger.snapshot().Generation
91 require.NoError(t, ledger.updateRoutePosition(ports.RoutePosition{Target: first.target, ActiveTabID: "tab-first"}))
92 require.Equal(t, firstRef, ledger.activeRef(), "tab memory must not rotate route identity")
93 require.Equal(t, firstSnapshotGeneration, ledger.snapshot().Generation, "tab memory must not stale navigation snapshots")
94
95 second := routeTestCandidate(2, ports.RouteOriginRemote)
96 secondIdentity, err := ledger.commit(second)
97 require.NoError(t, err)
98 secondRef := ledger.activeRef()
99 secondSnapshotGeneration := ledger.snapshot().Generation
100 require.NoError(t, ledger.updateRoutePosition(ports.RoutePosition{Target: second.target, ActiveTabID: "tab-second"}))
101 require.Equal(t, secondRef, ledger.activeRef(), "tab memory must not rotate route identity")
102 require.Equal(t, secondSnapshotGeneration, ledger.snapshot().Generation, "tab memory must not stale navigation snapshots")
103
104 firstRecord, ok := ledger.lookup(firstIdentity.wire())
105 require.True(t, ok)
106 require.Equal(t, domain.TabStableID("tab-first"), firstRecord.request.PreferredTabID)
107 secondRecord, ok := ledger.lookup(secondIdentity.wire())
108 require.True(t, ok)
109 require.Equal(t, domain.TabStableID("tab-second"), secondRecord.request.PreferredTabID)
110}
111
112func TestRouteLedgerSamePeerHandoffRestoresRouteTab(t *testing.T) {
113 ledger := newRouteLedger()
114 first := routeTestCandidate(1, ports.RouteOriginLocal)
115 first.originKey = "local"
116 _, err := ledger.commit(first)
117 require.NoError(t, err)
118 require.NoError(t, ledger.updateRoutePosition(ports.RoutePosition{Target: first.target, ActiveTabID: "tab-first"}))
119
120 second := routeTestCandidate(2, ports.RouteOriginLocal)
121 second.originKey = "local"
122 _, err = ledger.commit(second)
123 require.NoError(t, err)
124
125 recreated := first.target
126 recreated.LifecycleID[0]++
127 for _, tt := range []struct {
128 name string
129 target ports.ExactSessionTarget
130 wantExactTarget *ports.ExactSessionTarget
131 wantTab domain.TabStableID
132 }{
133 {name: "matching lifecycle restores remembered tab", target: first.target, wantExactTarget: &first.target, wantTab: "tab-first"},
134 {name: "recreated session does not reuse remembered tab", target: recreated, wantExactTarget: &recreated},
135 } {
136 t.Run(tt.name, func(t *testing.T) {
137 request := ledger.samePeerHandoff(second.request, ports.AttachTarget{
138 Session: first.target.SessionName,
139 Intent: ports.IntentAttach,
140 ExactTarget: &tt.target,
141 EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned,
142 })
143 require.Equal(t, first.target.SessionName, request.SessionName)
144 require.Equal(t, tt.wantExactTarget, request.ExactTarget)
145 require.Equal(t, tt.wantTab, request.PreferredTabID)
146 require.Equal(t, ports.EnvironmentPolicyClientOwned, request.EnvironmentPolicy)
147 })
148 }
149}
150
151func TestRouteLedgerSamePeerHandoffClearsDaemonOwnedNavigationForLocalRoutes(t *testing.T) {
152 active := routeTestCandidate(2, ports.RouteOriginLocal)
153 active.originKey = "local"
154 active.request.EnvironmentPolicy = ports.EnvironmentPolicyDaemonOwned
155 active.request.NavigationCapabilities = ports.NavigationCapabilityHomePicker
156 active.request.StartupOverlay = ports.StartupOverlaySessionPicker
157
158 for _, tt := range []struct {
159 name string
160 matched bool
161 }{
162 {name: "matched route", matched: true},
163 {name: "fallback route"},
164 } {
165 t.Run(tt.name, func(t *testing.T) {
166 ledger := newRouteLedger()
167 target := routeTestTarget(1)
168 if tt.matched {
169 stored := routeTestCandidate(1, ports.RouteOriginLocal)
170 stored.originKey = "local"
171 stored.request.EnvironmentPolicy = ports.EnvironmentPolicyDaemonOwned
172 stored.request.NavigationCapabilities = ports.NavigationCapabilityHomePicker
173 stored.request.StartupOverlay = ports.StartupOverlaySessionPicker
174 _, err := ledger.commit(stored)
175 require.NoError(t, err)
176 }
177
178 request := ledger.samePeerHandoff(active.request, ports.AttachTarget{
179 Session: target.SessionName, Intent: ports.IntentAttach, ExactTarget: &target,
180 EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned,
181 })
182
183 require.Equal(t, ports.EnvironmentPolicyClientOwned, request.EnvironmentPolicy)
184 require.Zero(t, request.NavigationCapabilities)
185 require.Equal(t, ports.StartupOverlayNone, request.StartupOverlay)
186 require.NoError(t, validateAttachRequest(request))
187 })
188 }
189}
190
191func TestRouteLedgerSamePeerHandoffDropsOriginalRemoteTarget(t *testing.T) {
192 ledger := newRouteLedger()
193 work := routeTestCandidate(1, ports.RouteOriginRemote)
194 work.originKey = "remote"
195 work.request.Remote = true
196 work.request.OriginKey = "remote"
197 work.request.EnvironmentPolicy = ports.EnvironmentPolicyDaemonOwned
198 work.request.RemoteTarget = &domain.RemoteSessionTarget{
199 Endpoint: "remote", DisplayOrigin: "remote", LifecycleID: work.target.LifecycleID,
200 SessionName: work.target.SessionName, LiveTabID: "tab-work",
201 }
202 _, err := ledger.commit(work)
203 require.NoError(t, err)
204
205 agents := routeTestCandidate(2, ports.RouteOriginRemote)
206 agents.originKey = "remote"
207 agents.request.Remote = true
208 agents.request.OriginKey = "remote"
209 agents.request.EnvironmentPolicy = ports.EnvironmentPolicyDaemonOwned
210
211 request := ledger.samePeerHandoff(agents.request, ports.AttachTarget{
212 Session: work.target.SessionName,
213 Intent: ports.IntentAttach,
214 ExactTarget: &work.target,
215 EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned,
216 })
217
218 require.True(t, request.Remote)
219 require.Equal(t, ports.RouteOriginRemote, request.Origin)
220 require.Equal(t, "remote", request.OriginKey)
221 require.Nil(t, request.RemoteTarget)
222}
223
224func TestRouteAttentionSubscriptionIncludesOnlyActiveOriginRoutes(t *testing.T) {
225 ledger := newRouteLedger()
226 first := routeTestCandidate(0, ports.RouteOriginRemote)
227 first.originKey = "host-a"
228 firstIdentity, err := ledger.commit(first)
229 require.NoError(t, err)
230 other := routeTestCandidate(1, ports.RouteOriginLocal)
231 _, err = ledger.commit(other)
232 require.NoError(t, err)
233 otherRemote := routeTestCandidate(2, ports.RouteOriginRemote)
234 otherRemote.originKey = "host-b"
235 _, err = ledger.commit(otherRemote)
236 require.NoError(t, err)
237 active := routeTestCandidate(3, ports.RouteOriginRemote)
238 active.originKey = "host-a"
239 _, err = ledger.commit(active)
240 require.NoError(t, err)
241
242 subscription := ledger.attentionSubscription()
243
244 require.Equal(t, []ports.RouteAttentionTarget{{
245 Ref: firstIdentity.wire(),
246 Target: first.target,
247 }}, subscription.Targets)
248}
249
250func TestCommittedIdentityRenamesActiveRouteInPlace(t *testing.T) {
251 ledger := newRouteLedger()
252 original := routeTestCandidate(0, ports.RouteOriginLocal)
253 original.target = ports.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{1}, SessionName: "0"}
254 original.presentation.name = "0"
255 original.request.SessionName = "0"
256 identity, err := ledger.commit(original)
257 require.NoError(t, err)
258 before := ledger.snapshot()
259
260 committed, err := ledger.commitCommittedIdentity(ports.CommittedRouteIdentity{
261 Target: ports.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{1}, SessionName: "vps-infra"},
262 Ephemeral: false,
263 })
264 require.NoError(t, err)
265
266 snapshot := ledger.snapshot()
267 require.Equal(t, identity, committed, "a rename must retain the route's process-local identity")
268 require.Greater(t, snapshot.Generation, before.Generation, "a rename invalidates prior route snapshots")
269 require.Empty(t, snapshot.Entries, "the old ephemeral label must not remain in route history")
270 active, ok := ledger.lookup(snapshot.Active)
271 require.True(t, ok)
272 require.Equal(t, "vps-infra", active.presentation.name)
273 require.Equal(t, "vps-infra", active.target.SessionName)
274 require.False(t, active.presentation.ephemeral)
275}
276
277func TestCommittedIdentityDoesNotReassignHomeRoute(t *testing.T) {
278 ledger := newRouteLedger()
279 home := routeTestCandidate(1, ports.RouteOriginLocal)
280 home.home = true
281 homeIdentity, err := ledger.commit(home)
282 require.NoError(t, err)
283 committed, err := ledger.commitCommittedIdentity(ports.CommittedRouteIdentity{
284 Target: routeTestTarget(2),
285 Ephemeral: true,
286 })
287 require.NoError(t, err)
288 require.Equal(t, uint64(homeIdentity.key), ledger.homeRef().Key)
289 require.NotEqual(t, ledger.homeRef(), committed.wire())
290 active, ok := ledger.lookup(ledger.activeRef())
291 require.True(t, ok)
292 require.Equal(t, committed, active.identity)
293
294 _, err = ledger.commit(routeTestCandidate(1, ports.RouteOriginLocal))
295 require.NoError(t, err)
296 require.True(t, mustRouteRecord(t, ledger, ledger.activeRef()).home, "upserting the home route must retain its marker")
297}
298
299func TestRouteLedgerConcurrentInitialAttachmentsKeepOneHome(t *testing.T) {
300 ledger := newRouteLedger()
301 const attachments = 16
302 errs := make(chan error, attachments)
303 var wg sync.WaitGroup
304 for i := byte(0); i < attachments; i++ {
305 wg.Add(1)
306 go func() {
307 defer wg.Done()
308 _, err := ledger.commitAttach(routeTestCandidate(i, ports.RouteOriginLocal))
309 errs <- err
310 }()
311 }
312 wg.Wait()
313 close(errs)
314 for err := range errs {
315 require.NoError(t, err)
316 }
317
318 ledger.mu.RLock()
319 defer ledger.mu.RUnlock()
320 homeCount := 0
321 for _, entry := range ledger.entries {
322 if entry.home {
323 homeCount++
324 require.Equal(t, entry.identity, ledger.home)
325 }
326 }
327 require.Equal(t, 1, homeCount)
328}
329
330func TestRouteLedgerTransitionRejectsConcurrentCommit(t *testing.T) {
331 ledger := newRouteLedger()
332 selected, err := ledger.commit(routeTestCandidate(1, ports.RouteOriginLocal))
333 require.NoError(t, err)
334 _, err = ledger.commit(routeTestCandidate(2, ports.RouteOriginLocal))
335 require.NoError(t, err)
336 selection, ok := ledger.navigationSelection(selected.wireNavigationAction(routeGeneration(ledger.snapshot().Generation)))
337 require.True(t, ok)
338
339 _, err = ledger.commit(routeTestCandidate(3, ports.RouteOriginLocal))
340 require.NoError(t, err)
341 candidate := routeTestCandidate(4, ports.RouteOriginLocal)
342 err = ledger.commitTransition(selected, selection.snapshotGeneration, selection.active, candidate)
343 require.ErrorIs(t, err, errRouteStaleSelection)
344 require.Equal(t, routeTestTarget(3), mustRouteRecord(t, ledger, ledger.activeRef()).target)
345}
346
347func mustRouteRecord(t *testing.T, ledger *routeLedger, ref ports.RouteRef) routeRecord {
348 t.Helper()
349 record, ok := ledger.lookup(ref)
350 require.True(t, ok)
351 return record
352}
353
354func TestRouteLedgerUpsertChangesGenerationWithoutChangingOpaqueKey(t *testing.T) {
355 ledger := newRouteLedger()
356 candidate := routeTestCandidate(1, ports.RouteOriginRemote)
357 first, err := ledger.commit(candidate)
358 require.NoError(t, err)
359
360 candidate.presentation.attention = true
361 second, err := ledger.commit(candidate)
362 require.NoError(t, err)
363 require.Equal(t, first.key, second.key)
364 require.Greater(t, second.generation, first.generation)
365 _, ok := ledger.lookup(first.wire())
366 require.False(t, ok, "old generation must not resolve")
367}
368
369func TestRouteLedgerDiscoveryCyclesAreIndependent(t *testing.T) {
370 ledger := newRouteLedger()
371 first := ledger.beginDiscoveryCycle()
372 second := ledger.beginDiscoveryCycle()
373
374 require.NotEqual(t, first, second)
375 require.False(t, ledger.discoveryCycleCurrent(first))
376 require.True(t, ledger.discoveryCycleCurrent(second))
377}
378
379type routeTestConnector struct {
380 resumeErr error
381 attachErr error
382 resumeCalls int
383 attachCalls int
384 restoreCalls int
385 restoreErr error
386 restoreTarget ports.ExactSessionTarget
387}
388
389func (c *routeTestConnector) Resume(context.Context, routeRecord) (routeTransitionCommit, error) {
390 c.resumeCalls++
391 if c.resumeErr != nil {
392 return routeTransitionCommit{}, c.resumeErr
393 }
394 return routeTransitionCommit{}, errors.New("unexpected resume success")
395}
396
397func (c *routeTestConnector) Restore(_ context.Context, record routeRecord) error {
398 c.restoreCalls++
399 c.restoreTarget = record.target
400 return c.restoreErr
401}
402
403func (c *routeTestConnector) Attach(_ context.Context, record routeRecord) (routeTransitionCommit, error) {
404 c.attachCalls++
405 if c.attachErr != nil {
406 return routeTransitionCommit{}, c.attachErr
407 }
408 return routeTransitionCommit{
409 identity: ports.CommittedRouteIdentity{Target: record.target},
410 presentation: routePresentation{
411 name: record.presentation.name,
412 kind: record.presentation.kind,
413 reachability: ports.RouteReachabilityReachable,
414 },
415 resumeToken: 123,
416 }, nil
417}
418
419func TestRouteLedgerNavigateResumesThenFallsBackAndRestoresOrigin(t *testing.T) {
420 ledger := newRouteLedger()
421 candidate := routeTestCandidate(2, ports.RouteOriginDiscovery)
422 candidate.originKey = "daemon-discovered"
423 candidate.resumeToken = 77
424 identity, err := ledger.commit(candidate)
425 require.NoError(t, err)
426 _, err = ledger.commit(routeTestCandidate(4, ports.RouteOriginLocal))
427 require.NoError(t, err)
428
429 connector := &routeTestConnector{resumeErr: errRouteResumeUnavailable}
430 action := identity.wireNavigationAction(routeGeneration(ledger.snapshot().Generation))
431 err = ledger.navigate(context.Background(), action, connector)
432 require.NoError(t, err)
433 require.Equal(t, 1, connector.resumeCalls)
434 require.Equal(t, 1, connector.attachCalls)
435 require.Zero(t, connector.restoreCalls)
436
437 active, ok := ledger.lookup(ledger.activeRef())
438 require.True(t, ok)
439 require.Equal(t, ports.RouteOriginDiscovery, active.origin)
440 require.Equal(t, "daemon-discovered", active.originKey)
441 require.Equal(t, ports.RouteOriginDiscovery, active.request.Origin)
442 require.Equal(t, candidate.target, active.target)
443 require.Greater(t, active.identity.generation, identity.generation)
444}
445
446func TestRouteLedgerNavigateIsTransactionalOnFailureOrTargetChange(t *testing.T) {
447 ledger := newRouteLedger()
448 identity, err := ledger.commit(routeTestCandidate(3, ports.RouteOriginLocal))
449 require.NoError(t, err)
450 _, err = ledger.commit(routeTestCandidate(5, ports.RouteOriginLocal))
451 require.NoError(t, err)
452 before := ledger.snapshot()
453 action := identity.wireNavigationAction(routeGeneration(before.Generation))
454
455 connector := &routeTestConnector{attachErr: errors.New("offline")}
456 err = ledger.navigate(context.Background(), action, connector)
457 require.Error(t, err)
458 require.Equal(t, before, ledger.snapshot())
459 require.Equal(t, routeTestTarget(5), connector.restoreTarget)
460 require.Equal(t, 1, connector.restoreCalls)
461
462 connector = &routeTestConnector{}
463 // Force a mismatched committed target through a small wrapper.
464 mismatch := mismatchingRouteConnector{routeTestConnector: connector}
465 err = ledger.navigate(context.Background(), action, mismatch)
466 require.ErrorIs(t, err, errRouteTargetChanged)
467 require.Equal(t, before, ledger.snapshot())
468 require.Equal(t, routeTestTarget(5), connector.restoreTarget)
469 require.Equal(t, 1, connector.restoreCalls)
470}
471
472func TestRouteLedgerNavigationRejectsStaleActionsAndActiveNoOp(t *testing.T) {
473 ledger := newRouteLedger()
474 active, err := ledger.commit(routeTestCandidate(6, ports.RouteOriginLocal))
475 require.NoError(t, err)
476 firstSnapshot := ledger.snapshot()
477 before := ledger.snapshot()
478
479 require.NoError(t, ledger.navigate(context.Background(), active.wireNavigationAction(routeGeneration(firstSnapshot.Generation)), nil))
480 require.Equal(t, before, ledger.snapshot())
481
482 _, err = ledger.commit(routeTestCandidate(7, ports.RouteOriginLocal))
483 require.NoError(t, err)
484 stale := active.wireNavigationAction(routeGeneration(firstSnapshot.Generation))
485 connector := &routeTestConnector{}
486 require.ErrorIs(t, ledger.navigate(context.Background(), stale, connector), errRouteStaleSelection)
487 require.Zero(t, connector.resumeCalls)
488 require.Zero(t, connector.attachCalls)
489}
490
491type mismatchingRouteConnector struct{ *routeTestConnector }
492
493func (c mismatchingRouteConnector) Attach(_ context.Context, record routeRecord) (routeTransitionCommit, error) {
494 return routeTransitionCommit{
495 identity: ports.CommittedRouteIdentity{Target: routeTestTarget(99)},
496 presentation: routePresentation{name: record.presentation.name, kind: record.presentation.kind},
497 }, nil
498}
499
500func (i routeIdentity) wireNavigationAction(snapshotGeneration routeGeneration) ports.RouteNavigationAction {
501 return ports.RouteNavigationAction{SnapshotGeneration: uint64(snapshotGeneration), Key: uint64(i.key), Generation: uint64(i.generation)}
502}
503
504func TestRouteLedgerConcurrentSnapshotsAndCommits(t *testing.T) {
505 ledger := newRouteLedger()
506 var wg sync.WaitGroup
507 for worker := byte(0); worker < 4; worker++ {
508 wg.Add(1)
509 go func() {
510 defer wg.Done()
511 for i := byte(0); i < 20; i++ {
512 _, _ = ledger.commit(routeTestCandidate(worker*20+i, ports.RouteOriginLocal))
513 _ = ledger.snapshot()
514 }
515 }()
516 }
517 wg.Wait()
518 require.LessOrEqual(t, len(ledger.snapshot().Entries), maxRouteLedgerEntries-1)
519}
520
521func TestRouteLedgerSeparatesRemoteDaemonOrigins(t *testing.T) {
522 ledger := newRouteLedger()
523 firstCandidate := routeTestCandidate(40, ports.RouteOriginRemote)
524 firstCandidate.originKey = "daemon-a"
525 secondCandidate := firstCandidate
526 secondCandidate.originKey = "daemon-b"
527
528 first, err := ledger.commit(firstCandidate)
529 require.NoError(t, err)
530 second, err := ledger.commit(secondCandidate)
531 require.NoError(t, err)
532
533 require.NotEqual(t, first.key, second.key)
534 require.Len(t, ledger.snapshot().Entries, 1)
535}