detached 9b767e7

fix(client,app): fall back to creation when the remote stays unobserved

A remote host whose broker observation never completes can still serve a
stream (the P8 sandbox hosts do), so a bounded wait that refused left the
driver in the picker. The pending resolution now carries the creation as
its fallback; the supervisor takes it when the budget expires, and the
remote daemon's live-name check stays the final fence.
commit
9b767e77acd4313f45c7264b6f8142c4e186eb67
author
brice · · 2026-09-23 03:10 UTC
parent
b3155ec
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
4 files · +41 -10
M internal/app/broker_client.go +9 -2
@@ -455,7 +455,8 @@ func remoteNamedCreateResolver(endpoint, name string) client.InitialNavigationRe
455455 // attach when the endpoint's committed observation carries it, and to a named
456456 // creation fenced to the same registration once an inventory observed at or
457457 // after requestedAt proves it absent. Before such an observation it asks the
458-// supervisor to request one and resolve again. An endpoint the broker does not
458+// supervisor to request one and resolve again, with the creation as the
459+// fallback should the host stay unobserved. An endpoint the broker does not
459460 // carry is refused rather than dialed.
460461 func remoteAttachOrCreateResolver(endpoint, name string, requestedAt time.Time) client.InitialNavigationResolver {
461462 attach := remoteExactAttachResolver(endpoint, name)
@@ -469,7 +470,13 @@ func remoteAttachOrCreateResolver(endpoint, name string, requestedAt time.Time)
469470 return attach(snapshot)
470471 }
471472 if !observation.InventoryKnown || observation.LastSuccess.Before(requestedAt) {
472- return client.InitialNavigation{}, client.InitialNavigationNotObserved{Endpoint: endpoint}
473+ // Should the host stay unobserved, creating is still the only
474+ // resolvable intent; the remote daemon refuses a live name.
475+ fallback, err := create(snapshot)
476+ if err != nil {
477+ fallback = client.InitialNavigation{}
478+ }
479+ return client.InitialNavigation{}, client.InitialNavigationNotObserved{Endpoint: endpoint, Fallback: fallback}
473480 }
474481 return create(snapshot)
475482 }
@@ -455,7 +455,8 @@ func remoteNamedCreateResolver(endpoint, name string) client.InitialNavigationRe
455 // attach when the endpoint's committed observation carries it, and to a named455 // attach when the endpoint's committed observation carries it, and to a named
456 // creation fenced to the same registration once an inventory observed at or456 // creation fenced to the same registration once an inventory observed at or
457 // after requestedAt proves it absent. Before such an observation it asks the457 // after requestedAt proves it absent. Before such an observation it asks the
458-// supervisor to request one and resolve again. An endpoint the broker does not458+// supervisor to request one and resolve again, with the creation as the
459+// fallback should the host stay unobserved. An endpoint the broker does not
459 // carry is refused rather than dialed.460 // carry is refused rather than dialed.
460 func remoteAttachOrCreateResolver(endpoint, name string, requestedAt time.Time) client.InitialNavigationResolver {461 func remoteAttachOrCreateResolver(endpoint, name string, requestedAt time.Time) client.InitialNavigationResolver {
461 attach := remoteExactAttachResolver(endpoint, name)462 attach := remoteExactAttachResolver(endpoint, name)
@@ -469,7 +470,13 @@ func remoteAttachOrCreateResolver(endpoint, name string, requestedAt time.Time)
469 return attach(snapshot)470 return attach(snapshot)
470 }471 }
471 if !observation.InventoryKnown || observation.LastSuccess.Before(requestedAt) {472 if !observation.InventoryKnown || observation.LastSuccess.Before(requestedAt) {
472- return client.InitialNavigation{}, client.InitialNavigationNotObserved{Endpoint: endpoint}473+ // Should the host stay unobserved, creating is still the only
474+ // resolvable intent; the remote daemon refuses a live name.
475+ fallback, err := create(snapshot)
476+ if err != nil {
477+ fallback = client.InitialNavigation{}
478+ }
479+ return client.InitialNavigation{}, client.InitialNavigationNotObserved{Endpoint: endpoint, Fallback: fallback}
473 }480 }
474 return create(snapshot)481 return create(snapshot)
475 }482 }
M internal/app/ui_driver_acceptance_test.go +3 -0
@@ -268,6 +268,9 @@ func TestUIDriverNavigationReusesTheSharedTerminalTranslation(t *testing.T) {
268268 var pending client.InitialNavigationNotObserved
269269 require.ErrorAs(t, resolveErr, &pending)
270270 require.Equal(t, "user@example.com", pending.Endpoint, "the wait names the endpoint to reconcile")
271+ require.Equal(t, client.InitialNavigationCreateNamed, pending.Fallback.Kind, "an unobservable host still falls back to creation")
272+ require.Equal(t, tt.options.session, pending.Fallback.Name)
273+ require.NoError(t, pending.Fallback.Validate())
271274 return
272275 }
273276 require.NoError(t, resolveErr)
@@ -268,6 +268,9 @@ func TestUIDriverNavigationReusesTheSharedTerminalTranslation(t *testing.T) {
268 var pending client.InitialNavigationNotObserved268 var pending client.InitialNavigationNotObserved
269 require.ErrorAs(t, resolveErr, &pending)269 require.ErrorAs(t, resolveErr, &pending)
270 require.Equal(t, "user@example.com", pending.Endpoint, "the wait names the endpoint to reconcile")270 require.Equal(t, "user@example.com", pending.Endpoint, "the wait names the endpoint to reconcile")
271+ require.Equal(t, client.InitialNavigationCreateNamed, pending.Fallback.Kind, "an unobservable host still falls back to creation")
272+ require.Equal(t, tt.options.session, pending.Fallback.Name)
273+ require.NoError(t, pending.Fallback.Validate())
271 return274 return
272 }275 }
273 require.NoError(t, resolveErr)276 require.NoError(t, resolveErr)
M internal/usecase/client/initial_navigation_test.go +15 -3
@@ -512,10 +512,13 @@ func TestInitialNavigationWaitsForTargetObservation(t *testing.T) {
512512 // observedAt is the first revision the resolver can decide on; zero
513513 // means the target is never observed.
514514 observedAt ports.BrokerRevision
515- wantOpen bool
515+ // fallback is offered with the pending resolution.
516+ fallback bool
517+ wantOpen bool
516518 }{
517519 {name: "a later publication resolves the armed intent", observedAt: 2, wantOpen: true},
518520 {name: "the observation budget refuses once", observedAt: 0},
521+ {name: "the observation budget takes the resolver's fallback", observedAt: 0, fallback: true, wantOpen: true},
519522 }
520523 for _, tt := range tests {
521524 t.Run(tt.name, func(t *testing.T) {
@@ -541,7 +544,11 @@ func TestInitialNavigationWaitsForTargetObservation(t *testing.T) {
541544 ResolveInitialNavigation: func(snapshot ports.BrokerSnapshot) (InitialNavigation, error) {
542545 calls.Add(1)
543546 if tt.observedAt == 0 || snapshot.Revision < tt.observedAt {
544- return InitialNavigation{}, InitialNavigationNotObserved{Endpoint: "user@host"}
547+ pending := InitialNavigationNotObserved{Endpoint: "user@host"}
548+ if tt.fallback {
549+ pending.Fallback = InitialNavigation{Kind: InitialNavigationCreateEphemeral, Destination: ports.BrokerEndpointFence{Local: true}}
550+ }
551+ return InitialNavigation{}, pending
545552 }
546553 // The target exists only in the later publication: the stream
547554 // request must resolve against the snapshot that decided.
@@ -570,7 +577,12 @@ func TestInitialNavigationWaitsForTargetObservation(t *testing.T) {
570577 service.mu.Unlock()
571578 require.Equal(t, []string{"user@host"}, reconciles, "the wait asks the broker for one fresh observation")
572579
573- if tt.wantOpen {
580+ if tt.fallback {
581+ budget.fire()
582+ require.Eventually(t, func() bool { return opened.Load() == 1 }, 5*time.Second, time.Millisecond)
583+ require.Equal(t, protocol.ExactSessionTarget{}, service.openedRequests()[0].Target, "the fallback creation opens, not an exact attach")
584+ require.Equal(t, int64(1), calls.Load())
585+ } else if tt.wantOpen {
574586 service.publishSnapshot(ports.BrokerSnapshot{Epoch: 3, Revision: 2, Daemons: []ports.BrokerDaemonObservation{
575587 pickerTestLocalObservation(time.Unix(1000, 0), pickerTestSession("late", 5, catalogue_Up)),
576588 }})
@@ -512,10 +512,13 @@ func TestInitialNavigationWaitsForTargetObservation(t *testing.T) {
512 // observedAt is the first revision the resolver can decide on; zero512 // observedAt is the first revision the resolver can decide on; zero
513 // means the target is never observed.513 // means the target is never observed.
514 observedAt ports.BrokerRevision514 observedAt ports.BrokerRevision
515- wantOpen bool515+ // fallback is offered with the pending resolution.
516+ fallback bool
517+ wantOpen bool
516 }{518 }{
517 {name: "a later publication resolves the armed intent", observedAt: 2, wantOpen: true},519 {name: "a later publication resolves the armed intent", observedAt: 2, wantOpen: true},
518 {name: "the observation budget refuses once", observedAt: 0},520 {name: "the observation budget refuses once", observedAt: 0},
521+ {name: "the observation budget takes the resolver's fallback", observedAt: 0, fallback: true, wantOpen: true},
519 }522 }
520 for _, tt := range tests {523 for _, tt := range tests {
521 t.Run(tt.name, func(t *testing.T) {524 t.Run(tt.name, func(t *testing.T) {
@@ -541,7 +544,11 @@ func TestInitialNavigationWaitsForTargetObservation(t *testing.T) {
541 ResolveInitialNavigation: func(snapshot ports.BrokerSnapshot) (InitialNavigation, error) {544 ResolveInitialNavigation: func(snapshot ports.BrokerSnapshot) (InitialNavigation, error) {
542 calls.Add(1)545 calls.Add(1)
543 if tt.observedAt == 0 || snapshot.Revision < tt.observedAt {546 if tt.observedAt == 0 || snapshot.Revision < tt.observedAt {
544- return InitialNavigation{}, InitialNavigationNotObserved{Endpoint: "user@host"}547+ pending := InitialNavigationNotObserved{Endpoint: "user@host"}
548+ if tt.fallback {
549+ pending.Fallback = InitialNavigation{Kind: InitialNavigationCreateEphemeral, Destination: ports.BrokerEndpointFence{Local: true}}
550+ }
551+ return InitialNavigation{}, pending
545 }552 }
546 // The target exists only in the later publication: the stream553 // The target exists only in the later publication: the stream
547 // request must resolve against the snapshot that decided.554 // request must resolve against the snapshot that decided.
@@ -570,7 +577,12 @@ func TestInitialNavigationWaitsForTargetObservation(t *testing.T) {
570 service.mu.Unlock()577 service.mu.Unlock()
571 require.Equal(t, []string{"user@host"}, reconciles, "the wait asks the broker for one fresh observation")578 require.Equal(t, []string{"user@host"}, reconciles, "the wait asks the broker for one fresh observation")
572 579
573- if tt.wantOpen {580+ if tt.fallback {
581+ budget.fire()
582+ require.Eventually(t, func() bool { return opened.Load() == 1 }, 5*time.Second, time.Millisecond)
583+ require.Equal(t, protocol.ExactSessionTarget{}, service.openedRequests()[0].Target, "the fallback creation opens, not an exact attach")
584+ require.Equal(t, int64(1), calls.Load())
585+ } else if tt.wantOpen {
574 service.publishSnapshot(ports.BrokerSnapshot{Epoch: 3, Revision: 2, Daemons: []ports.BrokerDaemonObservation{586 service.publishSnapshot(ports.BrokerSnapshot{Epoch: 3, Revision: 2, Daemons: []ports.BrokerDaemonObservation{
575 pickerTestLocalObservation(time.Unix(1000, 0), pickerTestSession("late", 5, catalogue_Up)),587 pickerTestLocalObservation(time.Unix(1000, 0), pickerTestSession("late", 5, catalogue_Up)),
576 }})588 }})
M internal/usecase/client/supervisor_attach.go +14 -5
@@ -396,8 +396,13 @@ var ErrInitialNavigationNotObserved = errors.New("vev: initial navigation target
396396 // InitialNavigationNotObserved is the resolver's ErrInitialNavigationNotObserved
397397 // naming the configured endpoint whose fresh observation it needs; the
398398 // supervisor asks the broker to reconcile that endpoint once per wait.
399+// Fallback, when it is not the picker, is the navigation to take if the budget
400+// expires before the endpoint is observed (a remote host whose observation
401+// fails can still serve a stream); the destination daemon stays the final
402+// authority over it.
399403 type InitialNavigationNotObserved struct {
400404 Endpoint string
405+ Fallback InitialNavigation
401406 }
402407
403408 func (e InitialNavigationNotObserved) Error() string {
@@ -426,10 +431,10 @@ type initialNavigationTake struct {
426431
427432 // awaitInitialNavigationObservation re-resolves the armed initial navigation
428433 // on each committed publication until the resolver can decide, the budget
429-// expires (a bounded refusal), or the run ends. The returned snapshot is the
430-// publication the decision was made on, so the stream request resolves against
431-// the same authority.
432-func (s *Supervisor) awaitInitialNavigationObservation(ctx context.Context, input *terminalInputLifetime, service ports.BrokerService) initialNavigationTake {
434+// expires (the resolver's fallback, or a bounded refusal without one), or the
435+// run ends. The returned snapshot is the publication the decision was made on,
436+// so the stream request resolves against the same authority.
437+func (s *Supervisor) awaitInitialNavigationObservation(ctx context.Context, input *terminalInputLifetime, service ports.BrokerService, pending InitialNavigationNotObserved) initialNavigationTake {
433438 timer := s.cfg.Clock.NewTimer(initialNavigationObservationBudget)
434439 defer timer.Stop()
435440 for {
@@ -438,6 +443,7 @@ func (s *Supervisor) awaitInitialNavigationObservation(ctx context.Context, inpu
438443 snapshot := service.Snapshot()
439444 navigation, consumed, err := s.takeInitialNavigation(snapshot)
440445 if errors.Is(err, ErrInitialNavigationNotObserved) {
446+ _ = errors.As(err, &pending)
441447 continue
442448 }
443449 return initialNavigationTake{snapshot: snapshot, navigation: navigation, consumed: consumed, err: err}
@@ -445,6 +451,9 @@ func (s *Supervisor) awaitInitialNavigationObservation(ctx context.Context, inpu
445451 s.mu.Lock()
446452 s.navigationConsumed = true
447453 s.mu.Unlock()
454+ if pending.Fallback.Kind != InitialNavigationPicker {
455+ return initialNavigationTake{snapshot: service.Snapshot(), navigation: pending.Fallback, consumed: true}
456+ }
448457 return initialNavigationTake{consumed: true, err: ErrInitialNavigationNotObserved}
449458 case <-service.Done():
450459 // The ready loop observes the loss; the intent stays armed for the
@@ -475,7 +484,7 @@ func (s *Supervisor) runInitialNavigation(ctx context.Context, input *terminalIn
475484 if errors.As(err, &pending) && pending.Endpoint != "" {
476485 service.RequestReconcile(pending.Endpoint)
477486 }
478- take := s.awaitInitialNavigationObservation(ctx, input, service)
487+ take := s.awaitInitialNavigationObservation(ctx, input, service, pending)
479488 if take.terminated {
480489 return true, take.termErr
481490 }
@@ -396,8 +396,13 @@ var ErrInitialNavigationNotObserved = errors.New("vev: initial navigation target
396 // InitialNavigationNotObserved is the resolver's ErrInitialNavigationNotObserved396 // InitialNavigationNotObserved is the resolver's ErrInitialNavigationNotObserved
397 // naming the configured endpoint whose fresh observation it needs; the397 // naming the configured endpoint whose fresh observation it needs; the
398 // supervisor asks the broker to reconcile that endpoint once per wait.398 // supervisor asks the broker to reconcile that endpoint once per wait.
399+// Fallback, when it is not the picker, is the navigation to take if the budget
400+// expires before the endpoint is observed (a remote host whose observation
401+// fails can still serve a stream); the destination daemon stays the final
402+// authority over it.
399 type InitialNavigationNotObserved struct {403 type InitialNavigationNotObserved struct {
400 Endpoint string404 Endpoint string
405+ Fallback InitialNavigation
401 }406 }
402 407
403 func (e InitialNavigationNotObserved) Error() string {408 func (e InitialNavigationNotObserved) Error() string {
@@ -426,10 +431,10 @@ type initialNavigationTake struct {
426 431
427 // awaitInitialNavigationObservation re-resolves the armed initial navigation432 // awaitInitialNavigationObservation re-resolves the armed initial navigation
428 // on each committed publication until the resolver can decide, the budget433 // on each committed publication until the resolver can decide, the budget
429-// expires (a bounded refusal), or the run ends. The returned snapshot is the434+// expires (the resolver's fallback, or a bounded refusal without one), or the
430-// publication the decision was made on, so the stream request resolves against435+// run ends. The returned snapshot is the publication the decision was made on,
431-// the same authority.436+// so the stream request resolves against the same authority.
432-func (s *Supervisor) awaitInitialNavigationObservation(ctx context.Context, input *terminalInputLifetime, service ports.BrokerService) initialNavigationTake {437+func (s *Supervisor) awaitInitialNavigationObservation(ctx context.Context, input *terminalInputLifetime, service ports.BrokerService, pending InitialNavigationNotObserved) initialNavigationTake {
433 timer := s.cfg.Clock.NewTimer(initialNavigationObservationBudget)438 timer := s.cfg.Clock.NewTimer(initialNavigationObservationBudget)
434 defer timer.Stop()439 defer timer.Stop()
435 for {440 for {
@@ -438,6 +443,7 @@ func (s *Supervisor) awaitInitialNavigationObservation(ctx context.Context, inpu
438 snapshot := service.Snapshot()443 snapshot := service.Snapshot()
439 navigation, consumed, err := s.takeInitialNavigation(snapshot)444 navigation, consumed, err := s.takeInitialNavigation(snapshot)
440 if errors.Is(err, ErrInitialNavigationNotObserved) {445 if errors.Is(err, ErrInitialNavigationNotObserved) {
446+ _ = errors.As(err, &pending)
441 continue447 continue
442 }448 }
443 return initialNavigationTake{snapshot: snapshot, navigation: navigation, consumed: consumed, err: err}449 return initialNavigationTake{snapshot: snapshot, navigation: navigation, consumed: consumed, err: err}
@@ -445,6 +451,9 @@ func (s *Supervisor) awaitInitialNavigationObservation(ctx context.Context, inpu
445 s.mu.Lock()451 s.mu.Lock()
446 s.navigationConsumed = true452 s.navigationConsumed = true
447 s.mu.Unlock()453 s.mu.Unlock()
454+ if pending.Fallback.Kind != InitialNavigationPicker {
455+ return initialNavigationTake{snapshot: service.Snapshot(), navigation: pending.Fallback, consumed: true}
456+ }
448 return initialNavigationTake{consumed: true, err: ErrInitialNavigationNotObserved}457 return initialNavigationTake{consumed: true, err: ErrInitialNavigationNotObserved}
449 case <-service.Done():458 case <-service.Done():
450 // The ready loop observes the loss; the intent stays armed for the459 // The ready loop observes the loss; the intent stays armed for the
@@ -475,7 +484,7 @@ func (s *Supervisor) runInitialNavigation(ctx context.Context, input *terminalIn
475 if errors.As(err, &pending) && pending.Endpoint != "" {484 if errors.As(err, &pending) && pending.Endpoint != "" {
476 service.RequestReconcile(pending.Endpoint)485 service.RequestReconcile(pending.Endpoint)
477 }486 }
478- take := s.awaitInitialNavigationObservation(ctx, input, service)487+ take := s.awaitInitialNavigationObservation(ctx, input, service, pending)
479 if take.terminated {488 if take.terminated {
480 return true, take.termErr489 return true, take.termErr
481 }490 }

Keyboard shortcuts

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

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

Branches 12

Tags 11

↑↓ moveenter openesc close