detached 5c34fff

feat(broker): distinguish reachable hosts without a daemon

commit
5c34fffa2882d4f06166167fc8f24e2716f05e62
author
brice · · 2026-09-23 08:36 UTC
parent
4165329
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
19 files · +240 -21
M internal/adapters/brokerwire/codec_test.go +12 -0
@@ -561,6 +561,18 @@ func TestBrokerSnapshotDaemonByteForByte(t *testing.T) {
561561 message SnapshotPart
562562 }{
563563 {"observed remote daemon", SnapshotPart{Epoch: 7, Connection: connection, Generation: 2, Revision: 5, Index: 1, Part: SnapshotDaemonPart{HostIndex: 1, Daemon: testDaemonObservation(), SessionCount: 1}}},
564+ {"reachable remote without daemon", SnapshotPart{Epoch: 7, Connection: connection, Generation: 2, Revision: 5, Index: 2, Part: SnapshotDaemonPart{HostIndex: 1, Daemon: func() ports.BrokerDaemonObservation {
565+ daemon := testDaemonObservation()
566+ daemon.Identity = ""
567+ daemon.Incarnation = ports.BrokerDaemonIncarnation{}
568+ daemon.ProtocolVersion = 0
569+ daemon.Capabilities = 0
570+ daemon.Availability = domain.RemoteAvailabilityNoDaemon
571+ daemon.LastFailure = domain.RemoteFailure{}
572+ daemon.LastSuccess = time.Time{}
573+ daemon.InventoryKnown = false
574+ return daemon
575+ }(), SessionCount: 0}}},
564576 {"local daemon", SnapshotPart{Epoch: 7, Connection: connection, Generation: 2, Revision: 5, Index: 0, Part: SnapshotDaemonPart{HostIndex: 0, Daemon: testLocalDaemonObservation(), SessionCount: 1}}},
565577 {"local session", SnapshotPart{Epoch: 7, Connection: connection, Generation: 2, Revision: 5, Index: 2, Part: SnapshotSessionPart{HostIndex: 0, SessionIndex: 0, Local: true, Session: testCatalogSession()}}},
566578 }
@@ -561,6 +561,18 @@ func TestBrokerSnapshotDaemonByteForByte(t *testing.T) {
561 message SnapshotPart561 message SnapshotPart
562 }{562 }{
563 {"observed remote daemon", SnapshotPart{Epoch: 7, Connection: connection, Generation: 2, Revision: 5, Index: 1, Part: SnapshotDaemonPart{HostIndex: 1, Daemon: testDaemonObservation(), SessionCount: 1}}},563 {"observed remote daemon", SnapshotPart{Epoch: 7, Connection: connection, Generation: 2, Revision: 5, Index: 1, Part: SnapshotDaemonPart{HostIndex: 1, Daemon: testDaemonObservation(), SessionCount: 1}}},
564+ {"reachable remote without daemon", SnapshotPart{Epoch: 7, Connection: connection, Generation: 2, Revision: 5, Index: 2, Part: SnapshotDaemonPart{HostIndex: 1, Daemon: func() ports.BrokerDaemonObservation {
565+ daemon := testDaemonObservation()
566+ daemon.Identity = ""
567+ daemon.Incarnation = ports.BrokerDaemonIncarnation{}
568+ daemon.ProtocolVersion = 0
569+ daemon.Capabilities = 0
570+ daemon.Availability = domain.RemoteAvailabilityNoDaemon
571+ daemon.LastFailure = domain.RemoteFailure{}
572+ daemon.LastSuccess = time.Time{}
573+ daemon.InventoryKnown = false
574+ return daemon
575+ }(), SessionCount: 0}}},
564 {"local daemon", SnapshotPart{Epoch: 7, Connection: connection, Generation: 2, Revision: 5, Index: 0, Part: SnapshotDaemonPart{HostIndex: 0, Daemon: testLocalDaemonObservation(), SessionCount: 1}}},576 {"local daemon", SnapshotPart{Epoch: 7, Connection: connection, Generation: 2, Revision: 5, Index: 0, Part: SnapshotDaemonPart{HostIndex: 0, Daemon: testLocalDaemonObservation(), SessionCount: 1}}},
565 {"local session", SnapshotPart{Epoch: 7, Connection: connection, Generation: 2, Revision: 5, Index: 2, Part: SnapshotSessionPart{HostIndex: 0, SessionIndex: 0, Local: true, Session: testCatalogSession()}}},577 {"local session", SnapshotPart{Epoch: 7, Connection: connection, Generation: 2, Revision: 5, Index: 2, Part: SnapshotSessionPart{HostIndex: 0, SessionIndex: 0, Local: true, Session: testCatalogSession()}}},
566 }578 }
M internal/adapters/brokerwire/convert.go +1 -1
@@ -456,7 +456,7 @@ func validateSnapshotDaemonFields(daemon ports.BrokerDaemonObservation) error {
456456 return errConvertRange
457457 }
458458 if daemon.Availability < domain.RemoteAvailabilityUnknown ||
459- daemon.Availability > domain.RemoteAvailabilityInvalidResponse {
459+ daemon.Availability > domain.RemoteAvailabilityNoDaemon {
460460 return ErrInvalidMessage
461461 }
462462 if daemon.LastFailure.Kind > domain.RemoteFailureInvalidResponse {
@@ -456,7 +456,7 @@ func validateSnapshotDaemonFields(daemon ports.BrokerDaemonObservation) error {
456 return errConvertRange456 return errConvertRange
457 }457 }
458 if daemon.Availability < domain.RemoteAvailabilityUnknown ||458 if daemon.Availability < domain.RemoteAvailabilityUnknown ||
459- daemon.Availability > domain.RemoteAvailabilityInvalidResponse {459+ daemon.Availability > domain.RemoteAvailabilityNoDaemon {
460 return ErrInvalidMessage460 return ErrInvalidMessage
461 }461 }
462 if daemon.LastFailure.Kind > domain.RemoteFailureInvalidResponse {462 if daemon.LastFailure.Kind > domain.RemoteFailureInvalidResponse {
M internal/adapters/sshstdio/mux.go +3 -0
@@ -57,6 +57,9 @@ const (
5757 // The capture is a diagnostic aid, so an unbounded or hostile stream can
5858 // never grow the buffer without limit.
5959 muxStderrLimit = 4 << 10
60+ // MuxExitNoDaemon is reserved for the hidden mux helper when its local
61+ // daemon carriage is absent. OpenSSH uses 255 for transport/auth failures.
62+ MuxExitNoDaemon = 73
6063 // muxDiagnosticLimit bounds the sanitized diagnostic echoed to a logger.
6164 muxDiagnosticLimit = 512
6265 )
@@ -57,6 +57,9 @@ const (
57 // The capture is a diagnostic aid, so an unbounded or hostile stream can57 // The capture is a diagnostic aid, so an unbounded or hostile stream can
58 // never grow the buffer without limit.58 // never grow the buffer without limit.
59 muxStderrLimit = 4 << 1059 muxStderrLimit = 4 << 10
60+ // MuxExitNoDaemon is reserved for the hidden mux helper when its local
61+ // daemon carriage is absent. OpenSSH uses 255 for transport/auth failures.
62+ MuxExitNoDaemon = 73
60 // muxDiagnosticLimit bounds the sanitized diagnostic echoed to a logger.63 // muxDiagnosticLimit bounds the sanitized diagnostic echoed to a logger.
61 muxDiagnosticLimit = 51264 muxDiagnosticLimit = 512
62 )65 )
M internal/app/broker_offline_mux.go +6 -0
@@ -512,6 +512,9 @@ func brokerQUICUnavailable(action string, err error) error {
512512 func runBrokerMuxStdioCommand(ctx context.Context, options brokerMuxOptions) error {
513513 raw, err := dialBrokerMuxHelper(ctx, options)
514514 if err != nil {
515+ if daemonCarriageAbsence(err) {
516+ return &exitCoded{code: sshstdio.MuxExitNoDaemon, err: err}
517+ }
515518 return fmt.Errorf("vev: broker mux stdio: dial daemonmux carriage: %w", err)
516519 }
517520 bounded, ok := raw.(wire.BoundedTransport)
@@ -633,6 +636,9 @@ func runBrokerMuxQUICProxyCommand(ctx context.Context, options brokerMuxOptions)
633636 }()
634637 raw, err := dialBrokerMuxHelper(ctx, options)
635638 if err != nil {
639+ if daemonCarriageAbsence(err) {
640+ return &exitCoded{code: sshstdio.MuxExitNoDaemon, err: err}
641+ }
636642 return fmt.Errorf("vev: broker mux quic proxy: dial daemonmux carriage: %w", err)
637643 }
638644 bounded, ok := raw.(wire.BoundedTransport)
@@ -512,6 +512,9 @@ func brokerQUICUnavailable(action string, err error) error {
512 func runBrokerMuxStdioCommand(ctx context.Context, options brokerMuxOptions) error {512 func runBrokerMuxStdioCommand(ctx context.Context, options brokerMuxOptions) error {
513 raw, err := dialBrokerMuxHelper(ctx, options)513 raw, err := dialBrokerMuxHelper(ctx, options)
514 if err != nil {514 if err != nil {
515+ if daemonCarriageAbsence(err) {
516+ return &exitCoded{code: sshstdio.MuxExitNoDaemon, err: err}
517+ }
515 return fmt.Errorf("vev: broker mux stdio: dial daemonmux carriage: %w", err)518 return fmt.Errorf("vev: broker mux stdio: dial daemonmux carriage: %w", err)
516 }519 }
517 bounded, ok := raw.(wire.BoundedTransport)520 bounded, ok := raw.(wire.BoundedTransport)
@@ -633,6 +636,9 @@ func runBrokerMuxQUICProxyCommand(ctx context.Context, options brokerMuxOptions)
633 }()636 }()
634 raw, err := dialBrokerMuxHelper(ctx, options)637 raw, err := dialBrokerMuxHelper(ctx, options)
635 if err != nil {638 if err != nil {
639+ if daemonCarriageAbsence(err) {
640+ return &exitCoded{code: sshstdio.MuxExitNoDaemon, err: err}
641+ }
636 return fmt.Errorf("vev: broker mux quic proxy: dial daemonmux carriage: %w", err)642 return fmt.Errorf("vev: broker mux quic proxy: dial daemonmux carriage: %w", err)
637 }643 }
638 bounded, ok := raw.(wire.BoundedTransport)644 bounded, ok := raw.(wire.BoundedTransport)
M internal/app/broker_remote_probe.go +11 -0
@@ -105,6 +105,13 @@ func (p *brokerRemoteProbe) Probe(ctx context.Context, registration domain.Remot
105105 }
106106 physical, err := p.connector.Connect(ctx, endpoint)
107107 if err != nil {
108+ var brokerErr ports.BrokerError
109+ if errors.As(err, &brokerErr) && brokerErr.Code == ports.BrokerErrorNoDaemon {
110+ // This typed dial-time result means the SSH helper ran on the remote
111+ // machine but its local daemon carriage was absent. Do not infer this
112+ // from a later stream loss on an already authenticated connection.
113+ return ports.BrokerDaemonObservation{Availability: domain.RemoteAvailabilityNoDaemon}, nil
114+ }
108115 return ports.BrokerDaemonObservation{}, err
109116 }
110117 adopted := false
@@ -146,6 +153,10 @@ func observeDaemonCatalogue(ctx context.Context, physical ports.BrokerPhysicalCo
146153 }
147154 logical, err := physical.OpenStream(ctx, request)
148155 if err != nil {
156+ // A connection that completed the authenticated physical handshake had
157+ // a daemon. Losing its logical stream is a transport failure, not proof
158+ // that the daemon is absent; preserve the failure so pooled callers can
159+ // discard a dead connection and retry with a fresh dial.
149160 return ports.BrokerDaemonObservation{}, err
150161 }
151162 if logical == nil {
@@ -105,6 +105,13 @@ func (p *brokerRemoteProbe) Probe(ctx context.Context, registration domain.Remot
105 }105 }
106 physical, err := p.connector.Connect(ctx, endpoint)106 physical, err := p.connector.Connect(ctx, endpoint)
107 if err != nil {107 if err != nil {
108+ var brokerErr ports.BrokerError
109+ if errors.As(err, &brokerErr) && brokerErr.Code == ports.BrokerErrorNoDaemon {
110+ // This typed dial-time result means the SSH helper ran on the remote
111+ // machine but its local daemon carriage was absent. Do not infer this
112+ // from a later stream loss on an already authenticated connection.
113+ return ports.BrokerDaemonObservation{Availability: domain.RemoteAvailabilityNoDaemon}, nil
114+ }
108 return ports.BrokerDaemonObservation{}, err115 return ports.BrokerDaemonObservation{}, err
109 }116 }
110 adopted := false117 adopted := false
@@ -146,6 +153,10 @@ func observeDaemonCatalogue(ctx context.Context, physical ports.BrokerPhysicalCo
146 }153 }
147 logical, err := physical.OpenStream(ctx, request)154 logical, err := physical.OpenStream(ctx, request)
148 if err != nil {155 if err != nil {
156+ // A connection that completed the authenticated physical handshake had
157+ // a daemon. Losing its logical stream is a transport failure, not proof
158+ // that the daemon is absent; preserve the failure so pooled callers can
159+ // discard a dead connection and retry with a fresh dial.
149 return ports.BrokerDaemonObservation{}, err160 return ports.BrokerDaemonObservation{}, err
150 }161 }
151 if logical == nil {162 if logical == nil {
M internal/domain/remote_state.go +4 -0
@@ -16,6 +16,7 @@ const (
1616 RemoteAvailabilityIncompatible
1717 RemoteAvailabilityAuthFailed
1818 RemoteAvailabilityInvalidResponse
19+ RemoteAvailabilityNoDaemon
1920 )
2021
2122 // String returns the stable lowercase wire/display token for an availability.
@@ -33,6 +34,8 @@ func (a RemoteAvailability) String() string {
3334 return "authentication_failed"
3435 case RemoteAvailabilityInvalidResponse:
3536 return "invalid_response"
37+ case RemoteAvailabilityNoDaemon:
38+ return "no_daemon"
3639 default:
3740 return "unknown"
3841 }
@@ -155,4 +158,5 @@ const (
155158 RemoteReasonTimeout = "timeout"
156159 RemoteReasonMalformed = "malformed"
157160 RemoteReasonAuthFailure = "auth_failure"
161+ RemoteReasonNoDaemon = "no_daemon"
158162 )
@@ -16,6 +16,7 @@ const (
16 RemoteAvailabilityIncompatible16 RemoteAvailabilityIncompatible
17 RemoteAvailabilityAuthFailed17 RemoteAvailabilityAuthFailed
18 RemoteAvailabilityInvalidResponse18 RemoteAvailabilityInvalidResponse
19+ RemoteAvailabilityNoDaemon
19 )20 )
20 21
21 // String returns the stable lowercase wire/display token for an availability.22 // String returns the stable lowercase wire/display token for an availability.
@@ -33,6 +34,8 @@ func (a RemoteAvailability) String() string {
33 return "authentication_failed"34 return "authentication_failed"
34 case RemoteAvailabilityInvalidResponse:35 case RemoteAvailabilityInvalidResponse:
35 return "invalid_response"36 return "invalid_response"
37+ case RemoteAvailabilityNoDaemon:
38+ return "no_daemon"
36 default:39 default:
37 return "unknown"40 return "unknown"
38 }41 }
@@ -155,4 +158,5 @@ const (
155 RemoteReasonTimeout = "timeout"158 RemoteReasonTimeout = "timeout"
156 RemoteReasonMalformed = "malformed"159 RemoteReasonMalformed = "malformed"
157 RemoteReasonAuthFailure = "auth_failure"160 RemoteReasonAuthFailure = "auth_failure"
161+ RemoteReasonNoDaemon = "no_daemon"
158 )162 )
M internal/ports/broker.go +4 -1
@@ -570,6 +570,7 @@ const (
570570 BrokerErrorFatalTerminal
571571 BrokerErrorHostConflict
572572 BrokerErrorMembershipImmutable
573+ BrokerErrorNoDaemon
573574 )
574575
575576 func (c BrokerErrorCode) String() string {
@@ -598,6 +599,8 @@ func (c BrokerErrorCode) String() string {
598599 return "host_conflict"
599600 case BrokerErrorMembershipImmutable:
600601 return "membership_immutable"
602+ case BrokerErrorNoDaemon:
603+ return "no_daemon"
601604 default:
602605 return "unknown"
603606 }
@@ -609,7 +612,7 @@ func (c BrokerErrorCode) Validate() error {
609612 BrokerErrorCancelled, BrokerErrorOutcomeUnknown, BrokerErrorAttachmentLost,
610613 BrokerErrorStaleEpoch, BrokerErrorConflictingPolicy,
611614 BrokerErrorExplicitExit, BrokerErrorFatalTerminal,
612- BrokerErrorHostConflict, BrokerErrorMembershipImmutable:
615+ BrokerErrorHostConflict, BrokerErrorMembershipImmutable, BrokerErrorNoDaemon:
613616 return nil
614617 default:
615618 return errors.New("ports: invalid broker error code")
@@ -570,6 +570,7 @@ const (
570 BrokerErrorFatalTerminal570 BrokerErrorFatalTerminal
571 BrokerErrorHostConflict571 BrokerErrorHostConflict
572 BrokerErrorMembershipImmutable572 BrokerErrorMembershipImmutable
573+ BrokerErrorNoDaemon
573 )574 )
574 575
575 func (c BrokerErrorCode) String() string {576 func (c BrokerErrorCode) String() string {
@@ -598,6 +599,8 @@ func (c BrokerErrorCode) String() string {
598 return "host_conflict"599 return "host_conflict"
599 case BrokerErrorMembershipImmutable:600 case BrokerErrorMembershipImmutable:
600 return "membership_immutable"601 return "membership_immutable"
602+ case BrokerErrorNoDaemon:
603+ return "no_daemon"
601 default:604 default:
602 return "unknown"605 return "unknown"
603 }606 }
@@ -609,7 +612,7 @@ func (c BrokerErrorCode) Validate() error {
609 BrokerErrorCancelled, BrokerErrorOutcomeUnknown, BrokerErrorAttachmentLost,612 BrokerErrorCancelled, BrokerErrorOutcomeUnknown, BrokerErrorAttachmentLost,
610 BrokerErrorStaleEpoch, BrokerErrorConflictingPolicy,613 BrokerErrorStaleEpoch, BrokerErrorConflictingPolicy,
611 BrokerErrorExplicitExit, BrokerErrorFatalTerminal,614 BrokerErrorExplicitExit, BrokerErrorFatalTerminal,
612- BrokerErrorHostConflict, BrokerErrorMembershipImmutable:615+ BrokerErrorHostConflict, BrokerErrorMembershipImmutable, BrokerErrorNoDaemon:
613 return nil616 return nil
614 default:617 default:
615 return errors.New("ports: invalid broker error code")618 return errors.New("ports: invalid broker error code")
M internal/ports/broker_observation.go +1 -1
@@ -107,7 +107,7 @@ func (o BrokerDaemonObservation) Validate() error {
107107 if err := validateObservedDaemonState(o); err != nil {
108108 return err
109109 }
110- if o.Availability < domain.RemoteAvailabilityUnknown || o.Availability > domain.RemoteAvailabilityInvalidResponse {
110+ if o.Availability < domain.RemoteAvailabilityUnknown || o.Availability > domain.RemoteAvailabilityNoDaemon {
111111 return errors.New("ports: broker daemon observation availability is out of range")
112112 }
113113 if o.LastFailure.Kind > domain.RemoteFailureInvalidResponse {
@@ -107,7 +107,7 @@ func (o BrokerDaemonObservation) Validate() error {
107 if err := validateObservedDaemonState(o); err != nil {107 if err := validateObservedDaemonState(o); err != nil {
108 return err108 return err
109 }109 }
110- if o.Availability < domain.RemoteAvailabilityUnknown || o.Availability > domain.RemoteAvailabilityInvalidResponse {110+ if o.Availability < domain.RemoteAvailabilityUnknown || o.Availability > domain.RemoteAvailabilityNoDaemon {
111 return errors.New("ports: broker daemon observation availability is out of range")111 return errors.New("ports: broker daemon observation availability is out of range")
112 }112 }
113 if o.LastFailure.Kind > domain.RemoteFailureInvalidResponse {113 if o.LastFailure.Kind > domain.RemoteFailureInvalidResponse {
M internal/ports/broker_store.go +1 -1
@@ -228,7 +228,7 @@ func ValidateDurableHostProjection(obs BrokerDaemonObservation) error {
228228 if obs.Local {
229229 return errors.New("ports: local daemon observation is never durable")
230230 }
231- if obs.Availability < domain.RemoteAvailabilityUnknown || obs.Availability > domain.RemoteAvailabilityInvalidResponse {
231+ if obs.Availability < domain.RemoteAvailabilityUnknown || obs.Availability > domain.RemoteAvailabilityNoDaemon {
232232 return errors.New("ports: broker host availability is out of range")
233233 }
234234 if obs.LastFailure.Kind > domain.RemoteFailureInvalidResponse {
@@ -228,7 +228,7 @@ func ValidateDurableHostProjection(obs BrokerDaemonObservation) error {
228 if obs.Local {228 if obs.Local {
229 return errors.New("ports: local daemon observation is never durable")229 return errors.New("ports: local daemon observation is never durable")
230 }230 }
231- if obs.Availability < domain.RemoteAvailabilityUnknown || obs.Availability > domain.RemoteAvailabilityInvalidResponse {231+ if obs.Availability < domain.RemoteAvailabilityUnknown || obs.Availability > domain.RemoteAvailabilityNoDaemon {
232 return errors.New("ports: broker host availability is out of range")232 return errors.New("ports: broker host availability is out of range")
233 }233 }
234 if obs.LastFailure.Kind > domain.RemoteFailureInvalidResponse {234 if obs.LastFailure.Kind > domain.RemoteFailureInvalidResponse {
M internal/ports/broker_test.go +1 -1
@@ -101,7 +101,7 @@ func TestValidateDurableHostProjection(t *testing.T) {
101101 }, wantErr: true},
102102 {name: "zero availability", mutate: func(o *BrokerDaemonObservation) { o.Availability = 0 }, wantErr: true},
103103 {name: "unknown availability", mutate: func(o *BrokerDaemonObservation) { o.Availability = domain.RemoteAvailabilityUnknown }},
104- {name: "availability past the closed range", mutate: func(o *BrokerDaemonObservation) { o.Availability = domain.RemoteAvailabilityInvalidResponse + 1 }, wantErr: true},
104+ {name: "availability past the closed range", mutate: func(o *BrokerDaemonObservation) { o.Availability = domain.RemoteAvailabilityNoDaemon + 1 }, wantErr: true},
105105 {name: "failure kind past the closed range", mutate: func(o *BrokerDaemonObservation) { o.LastFailure.Kind = domain.RemoteFailureInvalidResponse + 1 }, wantErr: true},
106106 {name: "missing lifecycle identity", mutate: func(o *BrokerDaemonObservation) {
107107 o.InventoryKnown = true
@@ -101,7 +101,7 @@ func TestValidateDurableHostProjection(t *testing.T) {
101 }, wantErr: true},101 }, wantErr: true},
102 {name: "zero availability", mutate: func(o *BrokerDaemonObservation) { o.Availability = 0 }, wantErr: true},102 {name: "zero availability", mutate: func(o *BrokerDaemonObservation) { o.Availability = 0 }, wantErr: true},
103 {name: "unknown availability", mutate: func(o *BrokerDaemonObservation) { o.Availability = domain.RemoteAvailabilityUnknown }},103 {name: "unknown availability", mutate: func(o *BrokerDaemonObservation) { o.Availability = domain.RemoteAvailabilityUnknown }},
104- {name: "availability past the closed range", mutate: func(o *BrokerDaemonObservation) { o.Availability = domain.RemoteAvailabilityInvalidResponse + 1 }, wantErr: true},104+ {name: "availability past the closed range", mutate: func(o *BrokerDaemonObservation) { o.Availability = domain.RemoteAvailabilityNoDaemon + 1 }, wantErr: true},
105 {name: "failure kind past the closed range", mutate: func(o *BrokerDaemonObservation) { o.LastFailure.Kind = domain.RemoteFailureInvalidResponse + 1 }, wantErr: true},105 {name: "failure kind past the closed range", mutate: func(o *BrokerDaemonObservation) { o.LastFailure.Kind = domain.RemoteFailureInvalidResponse + 1 }, wantErr: true},
106 {name: "missing lifecycle identity", mutate: func(o *BrokerDaemonObservation) {106 {name: "missing lifecycle identity", mutate: func(o *BrokerDaemonObservation) {
107 o.InventoryKnown = true107 o.InventoryKnown = true
M internal/protocol/catalogue/catalogue.go +1 -1
@@ -265,7 +265,7 @@ func validText(value string) bool {
265265
266266 func validReason(reason string) bool {
267267 switch reason {
268- case "", domain.RemoteReasonRefreshing, domain.RemoteReasonCatalogStale, domain.RemoteReasonHostUnreachable, domain.RemoteReasonVersionMismatch, domain.RemoteReasonSessionDown, domain.RemoteReasonSessionBroken, domain.RemoteReasonIdentityChanged, domain.RemoteReasonNotFound, domain.RemoteReasonTimeout, domain.RemoteReasonMalformed:
268+ case "", domain.RemoteReasonRefreshing, domain.RemoteReasonCatalogStale, domain.RemoteReasonHostUnreachable, domain.RemoteReasonVersionMismatch, domain.RemoteReasonSessionDown, domain.RemoteReasonSessionBroken, domain.RemoteReasonIdentityChanged, domain.RemoteReasonNotFound, domain.RemoteReasonTimeout, domain.RemoteReasonMalformed, domain.RemoteReasonNoDaemon:
269269 return true
270270 default:
271271 return false
@@ -265,7 +265,7 @@ func validText(value string) bool {
265 265
266 func validReason(reason string) bool {266 func validReason(reason string) bool {
267 switch reason {267 switch reason {
268- case "", domain.RemoteReasonRefreshing, domain.RemoteReasonCatalogStale, domain.RemoteReasonHostUnreachable, domain.RemoteReasonVersionMismatch, domain.RemoteReasonSessionDown, domain.RemoteReasonSessionBroken, domain.RemoteReasonIdentityChanged, domain.RemoteReasonNotFound, domain.RemoteReasonTimeout, domain.RemoteReasonMalformed:268+ case "", domain.RemoteReasonRefreshing, domain.RemoteReasonCatalogStale, domain.RemoteReasonHostUnreachable, domain.RemoteReasonVersionMismatch, domain.RemoteReasonSessionDown, domain.RemoteReasonSessionBroken, domain.RemoteReasonIdentityChanged, domain.RemoteReasonNotFound, domain.RemoteReasonTimeout, domain.RemoteReasonMalformed, domain.RemoteReasonNoDaemon:
269 return true269 return true
270 default:270 default:
271 return false271 return false
M internal/protocol/picker_interaction.go +2 -1
@@ -64,6 +64,7 @@ const (
6464 PickerLineStatusStale
6565 PickerLineStatusVersion
6666 PickerLineStatusError
67+ PickerLineStatusNoDaemon
6768 )
6869
6970 // PickerLineActions is the set of actions a row admits. The presenting client
@@ -255,7 +256,7 @@ func validPickerLineKind(kind PickerLineKind) bool {
255256 }
256257
257258 func validPickerLineStatus(status PickerLineStatus) bool {
258- return status <= PickerLineStatusError
259+ return status <= PickerLineStatusNoDaemon
259260 }
260261
261262 func validPickerLineActions(actions PickerLineActions) bool {
@@ -64,6 +64,7 @@ const (
64 PickerLineStatusStale64 PickerLineStatusStale
65 PickerLineStatusVersion65 PickerLineStatusVersion
66 PickerLineStatusError66 PickerLineStatusError
67+ PickerLineStatusNoDaemon
67 )68 )
68 69
69 // PickerLineActions is the set of actions a row admits. The presenting client70 // PickerLineActions is the set of actions a row admits. The presenting client
@@ -255,7 +256,7 @@ func validPickerLineKind(kind PickerLineKind) bool {
255 }256 }
256 257
257 func validPickerLineStatus(status PickerLineStatus) bool {258 func validPickerLineStatus(status PickerLineStatus) bool {
258- return status <= PickerLineStatusError259+ return status <= PickerLineStatusNoDaemon
259 }260 }
260 261
261 func validPickerLineActions(actions PickerLineActions) bool {262 func validPickerLineActions(actions PickerLineActions) bool {
M internal/usecase/broker/pool_warm_test.go +45 -0
@@ -241,6 +241,51 @@ func TestPoolCloseRetiresWarmTransports(t *testing.T) {
241241 require.False(t, ok)
242242 }
243243
244+func TestPoolLocalOpenDoesNotWaitForProbeAdoption(t *testing.T) {
245+ h := newWarmHarness(t, 2, time.Minute)
246+ h.visit(t, "local-daemon")
247+
248+ probe := &fakePhysical{
249+ endpoint: ports.BrokerDialTarget{Policy: poolPolicy()},
250+ identity: "remote-daemon",
251+ done: make(chan struct{}),
252+ }
253+ start := make(chan struct{})
254+ adopted := make(chan bool, 1)
255+ opened := make(chan error, 1)
256+ go func() {
257+ <-start
258+ adopted <- h.pool.AdoptPhysical(probe, poolPolicy())
259+ }()
260+ go func() {
261+ <-start
262+ h.mu.Lock()
263+ h.target = "local-daemon"
264+ h.stream++
265+ stream := h.stream
266+ h.mu.Unlock()
267+ logical, err := h.pool.OpenStream(context.Background(), poolRequest(h.client, stream))
268+ if err == nil {
269+ err = logical.Close()
270+ }
271+ opened <- err
272+ }()
273+ close(start)
274+
275+ select {
276+ case err := <-opened:
277+ require.NoError(t, err, "local OpenStream must progress while another goroutine adopts a remote probe transport")
278+ case <-time.After(3 * time.Second):
279+ t.Fatal("local OpenStream blocked while probe adoption was in progress")
280+ }
281+ select {
282+ case ok := <-adopted:
283+ require.True(t, ok, "the authenticated remote physical should be adopted")
284+ case <-time.After(3 * time.Second):
285+ t.Fatal("probe transport adoption did not complete")
286+ }
287+}
288+
244289 func TestPoolSharedPhysical(t *testing.T) {
245290 tests := []struct {
246291 name string
@@ -241,6 +241,51 @@ func TestPoolCloseRetiresWarmTransports(t *testing.T) {
241 require.False(t, ok)241 require.False(t, ok)
242 }242 }
243 243
244+func TestPoolLocalOpenDoesNotWaitForProbeAdoption(t *testing.T) {
245+ h := newWarmHarness(t, 2, time.Minute)
246+ h.visit(t, "local-daemon")
247+
248+ probe := &fakePhysical{
249+ endpoint: ports.BrokerDialTarget{Policy: poolPolicy()},
250+ identity: "remote-daemon",
251+ done: make(chan struct{}),
252+ }
253+ start := make(chan struct{})
254+ adopted := make(chan bool, 1)
255+ opened := make(chan error, 1)
256+ go func() {
257+ <-start
258+ adopted <- h.pool.AdoptPhysical(probe, poolPolicy())
259+ }()
260+ go func() {
261+ <-start
262+ h.mu.Lock()
263+ h.target = "local-daemon"
264+ h.stream++
265+ stream := h.stream
266+ h.mu.Unlock()
267+ logical, err := h.pool.OpenStream(context.Background(), poolRequest(h.client, stream))
268+ if err == nil {
269+ err = logical.Close()
270+ }
271+ opened <- err
272+ }()
273+ close(start)
274+
275+ select {
276+ case err := <-opened:
277+ require.NoError(t, err, "local OpenStream must progress while another goroutine adopts a remote probe transport")
278+ case <-time.After(3 * time.Second):
279+ t.Fatal("local OpenStream blocked while probe adoption was in progress")
280+ }
281+ select {
282+ case ok := <-adopted:
283+ require.True(t, ok, "the authenticated remote physical should be adopted")
284+ case <-time.After(3 * time.Second):
285+ t.Fatal("probe transport adoption did not complete")
286+ }
287+}
288+
244 func TestPoolSharedPhysical(t *testing.T) {289 func TestPoolSharedPhysical(t *testing.T) {
245 tests := []struct {290 tests := []struct {
246 name string291 name string
M internal/usecase/broker/registry.go +24 -2
@@ -1107,6 +1107,22 @@ func (r *Registry) apply(result probeResult) {
11071107 current.LastAttempt = result.at
11081108
11091109 kind, availability, failed := observationOutcome(result)
1110+ if !failed && availability == domain.RemoteAvailabilityNoDaemon {
1111+ current.Availability = availability
1112+ current.LastSuccess = result.at
1113+ current.NextDue = result.at.Add(r.jitter(r.freshForLocked(false), result.endpoint, result.attempt))
1114+ current.ConsecutiveFailures = 0
1115+ current.LastFailure = domain.RemoteFailure{}
1116+ current.Identity = ""
1117+ current.Incarnation = ports.BrokerDaemonIncarnation{}
1118+ current.ProtocolVersion = 0
1119+ current.Capabilities = 0
1120+ current.InventoryKnown = false
1121+ current.Sessions = nil
1122+ r.hosts[result.endpoint] = current
1123+ r.publishLocked(true)
1124+ return
1125+ }
11101126 if !failed {
11111127 // Configured authority comes only from the registry's own projection:
11121128 // a probe result supplies observed state, never endpoint, registration,
@@ -1146,8 +1162,12 @@ func (r *Registry) apply(result probeResult) {
11461162 }
11471163 current.ConsecutiveFailures++
11481164 current.LastFailure = domain.RemoteFailure{Kind: kind, Err: result.err}
1149- current.NextDue = result.at.Add(r.jitter(r.retryDelay(current.ConsecutiveFailures), result.endpoint, result.attempt))
1150- r.log.Debug("broker_probe_failed", "endpoint", result.endpoint, "kind", kind, "failures", current.ConsecutiveFailures, "err", result.err)
1165+ delay := r.retryDelay(current.ConsecutiveFailures)
1166+ if r.demand > 0 && delay > r.demandFreshForRemote {
1167+ delay = r.demandFreshForRemote
1168+ }
1169+ current.NextDue = result.at.Add(r.jitter(delay, result.endpoint, result.attempt))
1170+ r.log.Debug("broker_probe_failed", "endpoint", result.endpoint, "kind", kind, "failures", current.ConsecutiveFailures, "err", result.err, "cause", errors.Unwrap(result.err))
11511171 r.hosts[result.endpoint] = current
11521172 r.publishLocked(true)
11531173 }
@@ -1169,6 +1189,8 @@ func observationOutcome(result probeResult) (domain.RemoteFailureKind, domain.Re
11691189 switch result.snapshot.Availability {
11701190 case domain.RemoteAvailabilityReachable:
11711191 return domain.RemoteFailureNone, domain.RemoteAvailabilityReachable, false
1192+ case domain.RemoteAvailabilityNoDaemon:
1193+ return domain.RemoteFailureNone, domain.RemoteAvailabilityNoDaemon, false
11721194 case domain.RemoteAvailabilityIncompatible:
11731195 return domain.RemoteFailureIncompatible, domain.RemoteAvailabilityIncompatible, true
11741196 case domain.RemoteAvailabilityAuthFailed:
@@ -1107,6 +1107,22 @@ func (r *Registry) apply(result probeResult) {
1107 current.LastAttempt = result.at1107 current.LastAttempt = result.at
1108 1108
1109 kind, availability, failed := observationOutcome(result)1109 kind, availability, failed := observationOutcome(result)
1110+ if !failed && availability == domain.RemoteAvailabilityNoDaemon {
1111+ current.Availability = availability
1112+ current.LastSuccess = result.at
1113+ current.NextDue = result.at.Add(r.jitter(r.freshForLocked(false), result.endpoint, result.attempt))
1114+ current.ConsecutiveFailures = 0
1115+ current.LastFailure = domain.RemoteFailure{}
1116+ current.Identity = ""
1117+ current.Incarnation = ports.BrokerDaemonIncarnation{}
1118+ current.ProtocolVersion = 0
1119+ current.Capabilities = 0
1120+ current.InventoryKnown = false
1121+ current.Sessions = nil
1122+ r.hosts[result.endpoint] = current
1123+ r.publishLocked(true)
1124+ return
1125+ }
1110 if !failed {1126 if !failed {
1111 // Configured authority comes only from the registry's own projection:1127 // Configured authority comes only from the registry's own projection:
1112 // a probe result supplies observed state, never endpoint, registration,1128 // a probe result supplies observed state, never endpoint, registration,
@@ -1146,8 +1162,12 @@ func (r *Registry) apply(result probeResult) {
1146 }1162 }
1147 current.ConsecutiveFailures++1163 current.ConsecutiveFailures++
1148 current.LastFailure = domain.RemoteFailure{Kind: kind, Err: result.err}1164 current.LastFailure = domain.RemoteFailure{Kind: kind, Err: result.err}
1149- current.NextDue = result.at.Add(r.jitter(r.retryDelay(current.ConsecutiveFailures), result.endpoint, result.attempt))1165+ delay := r.retryDelay(current.ConsecutiveFailures)
1150- r.log.Debug("broker_probe_failed", "endpoint", result.endpoint, "kind", kind, "failures", current.ConsecutiveFailures, "err", result.err)1166+ if r.demand > 0 && delay > r.demandFreshForRemote {
1167+ delay = r.demandFreshForRemote
1168+ }
1169+ current.NextDue = result.at.Add(r.jitter(delay, result.endpoint, result.attempt))
1170+ r.log.Debug("broker_probe_failed", "endpoint", result.endpoint, "kind", kind, "failures", current.ConsecutiveFailures, "err", result.err, "cause", errors.Unwrap(result.err))
1151 r.hosts[result.endpoint] = current1171 r.hosts[result.endpoint] = current
1152 r.publishLocked(true)1172 r.publishLocked(true)
1153 }1173 }
@@ -1169,6 +1189,8 @@ func observationOutcome(result probeResult) (domain.RemoteFailureKind, domain.Re
1169 switch result.snapshot.Availability {1189 switch result.snapshot.Availability {
1170 case domain.RemoteAvailabilityReachable:1190 case domain.RemoteAvailabilityReachable:
1171 return domain.RemoteFailureNone, domain.RemoteAvailabilityReachable, false1191 return domain.RemoteFailureNone, domain.RemoteAvailabilityReachable, false
1192+ case domain.RemoteAvailabilityNoDaemon:
1193+ return domain.RemoteFailureNone, domain.RemoteAvailabilityNoDaemon, false
1172 case domain.RemoteAvailabilityIncompatible:1194 case domain.RemoteAvailabilityIncompatible:
1173 return domain.RemoteFailureIncompatible, domain.RemoteAvailabilityIncompatible, true1195 return domain.RemoteFailureIncompatible, domain.RemoteAvailabilityIncompatible, true
1174 case domain.RemoteAvailabilityAuthFailed:1196 case domain.RemoteAvailabilityAuthFailed:
M internal/usecase/broker/registry_test.go +78 -7
@@ -1320,6 +1320,12 @@ func TestRegistryNonReachableOutcomesUseTypedFailureBackoff(t *testing.T) {
13201320 wantAvailability: domain.RemoteAvailabilityInvalidResponse,
13211321 wantKind: domain.RemoteFailureInvalidResponse,
13221322 },
1323+ {
1324+ name: "no daemon after a prior success",
1325+ answer: probeAnswer{snapshot: ports.BrokerDaemonObservation{Availability: domain.RemoteAvailabilityNoDaemon}},
1326+ wantAvailability: domain.RemoteAvailabilityNoDaemon,
1327+ wantKind: domain.RemoteFailureNone,
1328+ },
13231329 {
13241330 name: "transport error",
13251331 answer: probeAnswer{err: dialErr},
@@ -1344,20 +1350,55 @@ func TestRegistryNonReachableOutcomesUseTypedFailureBackoff(t *testing.T) {
13441350 reg := registration(t, "example.test", 1)
13451351 require.NoError(t, registry.setHosts(hostRecords(reg)))
13461352 startRegistry(t, registry)
1353+ if tt.wantAvailability == domain.RemoteAvailabilityNoDaemon {
1354+ reachable := probeAnswer{snapshot: ports.BrokerDaemonObservation{
1355+ Availability: domain.RemoteAvailabilityReachable, Identity: ports.BrokerDaemonIdentity("remote-daemon"),
1356+ Incarnation: ports.BrokerDaemonIncarnation{1}, ProtocolVersion: protocol.Version,
1357+ InventoryKnown: true, Sessions: []catalogue.RemoteCatalogSession{hostSession("old")},
1358+ }}
1359+ call := receiveCall(t, probe)
1360+ call.result <- reachable
1361+ waitHost(t, registry, reg.Endpoint, func(host ports.BrokerDaemonObservation) bool {
1362+ return !host.Checking && host.Availability == domain.RemoteAvailabilityReachable && host.Identity == ports.BrokerDaemonIdentity("remote-daemon")
1363+ })
1364+ clock.Advance(5 * time.Second)
1365+ registry.RequestProbe(reg.Endpoint)
1366+ call = receiveCall(t, probe)
1367+ call.result <- reachable
1368+ waitHost(t, registry, reg.Endpoint, func(host ports.BrokerDaemonObservation) bool {
1369+ return !host.Checking && host.Availability == domain.RemoteAvailabilityReachable
1370+ })
1371+ clock.Advance(20 * time.Second)
1372+ registry.RequestProbe(reg.Endpoint)
1373+ }
13471374
13481375 call := receiveCall(t, probe)
13491376 call.result <- tt.answer
13501377 host := waitHost(t, registry, reg.Endpoint, func(host ports.BrokerDaemonObservation) bool {
1351- return !host.Checking && host.ConsecutiveFailures == 1
1378+ return !host.Checking && (host.ConsecutiveFailures == 1 || tt.wantAvailability == domain.RemoteAvailabilityNoDaemon && host.Availability == domain.RemoteAvailabilityNoDaemon)
13521379 })
13531380 require.Equal(t, tt.wantAvailability, host.Availability)
13541381 require.Equal(t, tt.wantKind, host.LastFailure.Kind)
1355- require.Equal(t, uint(1), host.ConsecutiveFailures)
1356- require.Equal(t, uint64(1), host.FailureEpisode)
1357- // No observation succeeded yet, so there is no last success.
1358- require.True(t, host.LastSuccess.IsZero())
1359- // Every non-reachable outcome retries on the capped cadence.
1360- require.Equal(t, start.Add(defaultRetryBase), host.NextDue)
1382+ if tt.wantAvailability == domain.RemoteAvailabilityNoDaemon {
1383+ require.Zero(t, host.ConsecutiveFailures)
1384+ require.Zero(t, host.FailureEpisode)
1385+ require.Equal(t, start.Add(25*time.Second), host.LastSuccess)
1386+ require.Equal(t, start.Add(40*time.Second), host.NextDue)
1387+ require.Empty(t, host.Identity)
1388+ require.True(t, host.Incarnation.IsZero())
1389+ require.Zero(t, host.ProtocolVersion)
1390+ require.Zero(t, host.Capabilities)
1391+ require.False(t, host.InventoryKnown)
1392+ require.Empty(t, host.Sessions)
1393+ require.Equal(t, domain.RemoteFailureNone, host.LastFailure.Kind)
1394+ } else {
1395+ require.Equal(t, uint(1), host.ConsecutiveFailures)
1396+ require.Equal(t, uint64(1), host.FailureEpisode)
1397+ // No observation succeeded yet, so there is no last success.
1398+ require.True(t, host.LastSuccess.IsZero())
1399+ // Every non-reachable outcome retries on the capped cadence.
1400+ require.Equal(t, start.Add(defaultRetryBase), host.NextDue)
1401+ }
13611402 if tt.wantErr != nil {
13621403 require.ErrorIs(t, host.LastFailure, tt.wantErr)
13631404 } else {
@@ -1605,6 +1646,36 @@ func TestRegistryPreservesLastSuccessAndOwnsFailureEpisode(t *testing.T) {
16051646 require.Nil(t, host.LastFailure.Err)
16061647 }
16071648
1649+func TestRegistryDemandCapsFailureBackoff(t *testing.T) {
1650+ start := time.Unix(100, 0)
1651+ clock := newManualClock(start)
1652+ probe := newTestProbe(1)
1653+ reg := registration(t, "demand.test", 1)
1654+ store := newTestStore()
1655+ require.NoError(t, store.ReplaceHosts(1, hostRecords(reg)))
1656+ registry := newTestRegistry(t, 1, store, probe, clock)
1657+ startRegistry(t, registry)
1658+ registry.SetDemand(true)
1659+ t.Cleanup(func() { registry.SetDemand(false) })
1660+
1661+ for failure := uint(1); failure <= 9; failure++ {
1662+ call := receiveCall(t, probe)
1663+ call.result <- probeAnswer{err: errors.New("daemon not available")}
1664+ host := waitHost(t, registry, reg.Endpoint, func(host ports.BrokerDaemonObservation) bool {
1665+ return !host.Checking && host.ConsecutiveFailures == failure
1666+ })
1667+ require.LessOrEqual(t, host.NextDue.Sub(clock.Now()), defaultDemandFreshForRemote, "demanded retry gap must never exceed the freshness interval")
1668+ clock.Advance(host.NextDue.Sub(clock.Now()))
1669+ registry.RequestProbe(reg.Endpoint)
1670+ }
1671+ call := receiveCall(t, probe)
1672+ call.result <- probeAnswer{snapshot: ports.BrokerDaemonObservation{Availability: domain.RemoteAvailabilityReachable}}
1673+ host := waitHost(t, registry, reg.Endpoint, func(host ports.BrokerDaemonObservation) bool {
1674+ return !host.Checking && host.Availability == domain.RemoteAvailabilityReachable
1675+ })
1676+ require.Zero(t, host.ConsecutiveFailures)
1677+}
1678+
16081679 func TestRegistryBackoffIsExponentialAndCapped(t *testing.T) {
16091680 start := time.Unix(100, 0)
16101681 clock := newManualClock(start)
@@ -1320,6 +1320,12 @@ func TestRegistryNonReachableOutcomesUseTypedFailureBackoff(t *testing.T) {
1320 wantAvailability: domain.RemoteAvailabilityInvalidResponse,1320 wantAvailability: domain.RemoteAvailabilityInvalidResponse,
1321 wantKind: domain.RemoteFailureInvalidResponse,1321 wantKind: domain.RemoteFailureInvalidResponse,
1322 },1322 },
1323+ {
1324+ name: "no daemon after a prior success",
1325+ answer: probeAnswer{snapshot: ports.BrokerDaemonObservation{Availability: domain.RemoteAvailabilityNoDaemon}},
1326+ wantAvailability: domain.RemoteAvailabilityNoDaemon,
1327+ wantKind: domain.RemoteFailureNone,
1328+ },
1323 {1329 {
1324 name: "transport error",1330 name: "transport error",
1325 answer: probeAnswer{err: dialErr},1331 answer: probeAnswer{err: dialErr},
@@ -1344,20 +1350,55 @@ func TestRegistryNonReachableOutcomesUseTypedFailureBackoff(t *testing.T) {
1344 reg := registration(t, "example.test", 1)1350 reg := registration(t, "example.test", 1)
1345 require.NoError(t, registry.setHosts(hostRecords(reg)))1351 require.NoError(t, registry.setHosts(hostRecords(reg)))
1346 startRegistry(t, registry)1352 startRegistry(t, registry)
1353+ if tt.wantAvailability == domain.RemoteAvailabilityNoDaemon {
1354+ reachable := probeAnswer{snapshot: ports.BrokerDaemonObservation{
1355+ Availability: domain.RemoteAvailabilityReachable, Identity: ports.BrokerDaemonIdentity("remote-daemon"),
1356+ Incarnation: ports.BrokerDaemonIncarnation{1}, ProtocolVersion: protocol.Version,
1357+ InventoryKnown: true, Sessions: []catalogue.RemoteCatalogSession{hostSession("old")},
1358+ }}
1359+ call := receiveCall(t, probe)
1360+ call.result <- reachable
1361+ waitHost(t, registry, reg.Endpoint, func(host ports.BrokerDaemonObservation) bool {
1362+ return !host.Checking && host.Availability == domain.RemoteAvailabilityReachable && host.Identity == ports.BrokerDaemonIdentity("remote-daemon")
1363+ })
1364+ clock.Advance(5 * time.Second)
1365+ registry.RequestProbe(reg.Endpoint)
1366+ call = receiveCall(t, probe)
1367+ call.result <- reachable
1368+ waitHost(t, registry, reg.Endpoint, func(host ports.BrokerDaemonObservation) bool {
1369+ return !host.Checking && host.Availability == domain.RemoteAvailabilityReachable
1370+ })
1371+ clock.Advance(20 * time.Second)
1372+ registry.RequestProbe(reg.Endpoint)
1373+ }
1347 1374
1348 call := receiveCall(t, probe)1375 call := receiveCall(t, probe)
1349 call.result <- tt.answer1376 call.result <- tt.answer
1350 host := waitHost(t, registry, reg.Endpoint, func(host ports.BrokerDaemonObservation) bool {1377 host := waitHost(t, registry, reg.Endpoint, func(host ports.BrokerDaemonObservation) bool {
1351- return !host.Checking && host.ConsecutiveFailures == 11378+ return !host.Checking && (host.ConsecutiveFailures == 1 || tt.wantAvailability == domain.RemoteAvailabilityNoDaemon && host.Availability == domain.RemoteAvailabilityNoDaemon)
1352 })1379 })
1353 require.Equal(t, tt.wantAvailability, host.Availability)1380 require.Equal(t, tt.wantAvailability, host.Availability)
1354 require.Equal(t, tt.wantKind, host.LastFailure.Kind)1381 require.Equal(t, tt.wantKind, host.LastFailure.Kind)
1355- require.Equal(t, uint(1), host.ConsecutiveFailures)1382+ if tt.wantAvailability == domain.RemoteAvailabilityNoDaemon {
1356- require.Equal(t, uint64(1), host.FailureEpisode)1383+ require.Zero(t, host.ConsecutiveFailures)
1357- // No observation succeeded yet, so there is no last success.1384+ require.Zero(t, host.FailureEpisode)
1358- require.True(t, host.LastSuccess.IsZero())1385+ require.Equal(t, start.Add(25*time.Second), host.LastSuccess)
1359- // Every non-reachable outcome retries on the capped cadence.1386+ require.Equal(t, start.Add(40*time.Second), host.NextDue)
1360- require.Equal(t, start.Add(defaultRetryBase), host.NextDue)1387+ require.Empty(t, host.Identity)
1388+ require.True(t, host.Incarnation.IsZero())
1389+ require.Zero(t, host.ProtocolVersion)
1390+ require.Zero(t, host.Capabilities)
1391+ require.False(t, host.InventoryKnown)
1392+ require.Empty(t, host.Sessions)
1393+ require.Equal(t, domain.RemoteFailureNone, host.LastFailure.Kind)
1394+ } else {
1395+ require.Equal(t, uint(1), host.ConsecutiveFailures)
1396+ require.Equal(t, uint64(1), host.FailureEpisode)
1397+ // No observation succeeded yet, so there is no last success.
1398+ require.True(t, host.LastSuccess.IsZero())
1399+ // Every non-reachable outcome retries on the capped cadence.
1400+ require.Equal(t, start.Add(defaultRetryBase), host.NextDue)
1401+ }
1361 if tt.wantErr != nil {1402 if tt.wantErr != nil {
1362 require.ErrorIs(t, host.LastFailure, tt.wantErr)1403 require.ErrorIs(t, host.LastFailure, tt.wantErr)
1363 } else {1404 } else {
@@ -1605,6 +1646,36 @@ func TestRegistryPreservesLastSuccessAndOwnsFailureEpisode(t *testing.T) {
1605 require.Nil(t, host.LastFailure.Err)1646 require.Nil(t, host.LastFailure.Err)
1606 }1647 }
1607 1648
1649+func TestRegistryDemandCapsFailureBackoff(t *testing.T) {
1650+ start := time.Unix(100, 0)
1651+ clock := newManualClock(start)
1652+ probe := newTestProbe(1)
1653+ reg := registration(t, "demand.test", 1)
1654+ store := newTestStore()
1655+ require.NoError(t, store.ReplaceHosts(1, hostRecords(reg)))
1656+ registry := newTestRegistry(t, 1, store, probe, clock)
1657+ startRegistry(t, registry)
1658+ registry.SetDemand(true)
1659+ t.Cleanup(func() { registry.SetDemand(false) })
1660+
1661+ for failure := uint(1); failure <= 9; failure++ {
1662+ call := receiveCall(t, probe)
1663+ call.result <- probeAnswer{err: errors.New("daemon not available")}
1664+ host := waitHost(t, registry, reg.Endpoint, func(host ports.BrokerDaemonObservation) bool {
1665+ return !host.Checking && host.ConsecutiveFailures == failure
1666+ })
1667+ require.LessOrEqual(t, host.NextDue.Sub(clock.Now()), defaultDemandFreshForRemote, "demanded retry gap must never exceed the freshness interval")
1668+ clock.Advance(host.NextDue.Sub(clock.Now()))
1669+ registry.RequestProbe(reg.Endpoint)
1670+ }
1671+ call := receiveCall(t, probe)
1672+ call.result <- probeAnswer{snapshot: ports.BrokerDaemonObservation{Availability: domain.RemoteAvailabilityReachable}}
1673+ host := waitHost(t, registry, reg.Endpoint, func(host ports.BrokerDaemonObservation) bool {
1674+ return !host.Checking && host.Availability == domain.RemoteAvailabilityReachable
1675+ })
1676+ require.Zero(t, host.ConsecutiveFailures)
1677+}
1678+
1608 func TestRegistryBackoffIsExponentialAndCapped(t *testing.T) {1679 func TestRegistryBackoffIsExponentialAndCapped(t *testing.T) {
1609 start := time.Unix(100, 0)1680 start := time.Unix(100, 0)
1610 clock := newManualClock(start)1681 clock := newManualClock(start)
M internal/usecase/client/picker_catalogue_projection.go +26 -4
@@ -27,7 +27,8 @@ import (
2727 // A session with tabs is a non-focusable header; its tab rows are the
2828 // destinations, each resolving to its exact tab. A session without tab
2929 // metadata is its own destination. A host status row is published only when
30-// it adds information: the host lists no session and is not reachable.
30+// it adds information: the host lists no session and is either not reachable
31+// or known to have no daemon.
3132 //
3233 // The cursor starts on the attachment the picker is presented over (its tab,
3334 // else its session), otherwise on the most recent session's active tab.
@@ -100,6 +101,10 @@ func (c *pickerCatalogue) projectLocked() {
100101 }
101102 }
102103 if len(observation.Sessions) == 0 && observation.Availability != domain.RemoteAvailabilityReachable {
104+ hostRef := pickerSelectionRef{
105+ kind: pickerSelectionCreateEphemeral, epoch: c.snapshot.Epoch,
106+ local: observation.Local, endpoint: observation.Endpoint, registration: observation.Registration,
107+ }
103108 hostRows = append(hostRows, pickerRow{line: protocol.PickerLine{
104109 Key: pickerHostRowKey(observation),
105110 Kind: protocol.PickerLineHost,
@@ -108,9 +113,16 @@ func (c *pickerCatalogue) projectLocked() {
108113 Status: pickerObservationStatus(observation, fresh),
109114 StatusDetail: pickerObservationReason(observation, fresh),
110115 Dim: true,
111- // A host row stays reachable for inspection and never commits.
116+ // A host row is focusable and offers creation only when no daemon
117+ // exists. The endpoint registration is revalidated at commit.
112118 Focusable: true,
113- }})
119+ Actions: func() protocol.PickerLineActions {
120+ if observation.Availability == domain.RemoteAvailabilityNoDaemon {
121+ return protocol.PickerCanNavigate
122+ }
123+ return 0
124+ }(),
125+ }, ref: hostRef, hasRef: observation.Availability == domain.RemoteAvailabilityNoDaemon})
114126 }
115127 if observation.Local {
116128 hasLocal = true
@@ -131,9 +143,17 @@ func (c *pickerCatalogue) projectLocked() {
131143 localRows = append(localRows, block.rows...)
132144 }
133145 localRows = append(localRows, localHost...)
146+ for _, row := range localHost {
147+ if row.hasRef {
148+ refs[row.line.Key] = row.ref
149+ }
150+ }
134151 recent = append(recent, localRows...)
135152 for _, remote := range remotes {
136153 for _, row := range remote.rows {
154+ if row.line.Kind == protocol.PickerLineHost && row.hasRef {
155+ refs[row.line.Key] = row.ref
156+ }
137157 if row.line.Kind == protocol.PickerLineSession && row.line.Detail == "" {
138158 row.line.Detail = "@" + remote.origin
139159 } else if row.line.Kind == protocol.PickerLineSession {
@@ -430,7 +450,7 @@ func pickerResolveTab(ref pickerSelectionRef, session catalogue.RemoteCatalogSes
430450 // last probe could not reach it, authenticate, or read its catalogue.
431451 func pickerObservationFailing(observation ports.BrokerDaemonObservation) bool {
432452 switch observation.Availability {
433- case domain.RemoteAvailabilityUnreachable, domain.RemoteAvailabilityAuthFailed, domain.RemoteAvailabilityInvalidResponse:
453+ case domain.RemoteAvailabilityUnreachable, domain.RemoteAvailabilityAuthFailed, domain.RemoteAvailabilityInvalidResponse, domain.RemoteAvailabilityNoDaemon:
434454 return true
435455 default:
436456 return false
@@ -468,6 +488,8 @@ func pickerReasonText(reason string) string {
468488 return "catalog malformed"
469489 case domain.RemoteReasonAuthFailure:
470490 return "authentication failed"
491+ case domain.RemoteReasonNoDaemon:
492+ return "no daemon — Enter to create a session"
471493 case domain.RemoteReasonRefreshing:
472494 return "refreshing"
473495 case domain.RemoteReasonIdentityChanged:
@@ -27,7 +27,8 @@ import (
27 // A session with tabs is a non-focusable header; its tab rows are the27 // A session with tabs is a non-focusable header; its tab rows are the
28 // destinations, each resolving to its exact tab. A session without tab28 // destinations, each resolving to its exact tab. A session without tab
29 // metadata is its own destination. A host status row is published only when29 // metadata is its own destination. A host status row is published only when
30-// it adds information: the host lists no session and is not reachable.30+// it adds information: the host lists no session and is either not reachable
31+// or known to have no daemon.
31 //32 //
32 // The cursor starts on the attachment the picker is presented over (its tab,33 // The cursor starts on the attachment the picker is presented over (its tab,
33 // else its session), otherwise on the most recent session's active tab.34 // else its session), otherwise on the most recent session's active tab.
@@ -100,6 +101,10 @@ func (c *pickerCatalogue) projectLocked() {
100 }101 }
101 }102 }
102 if len(observation.Sessions) == 0 && observation.Availability != domain.RemoteAvailabilityReachable {103 if len(observation.Sessions) == 0 && observation.Availability != domain.RemoteAvailabilityReachable {
104+ hostRef := pickerSelectionRef{
105+ kind: pickerSelectionCreateEphemeral, epoch: c.snapshot.Epoch,
106+ local: observation.Local, endpoint: observation.Endpoint, registration: observation.Registration,
107+ }
103 hostRows = append(hostRows, pickerRow{line: protocol.PickerLine{108 hostRows = append(hostRows, pickerRow{line: protocol.PickerLine{
104 Key: pickerHostRowKey(observation),109 Key: pickerHostRowKey(observation),
105 Kind: protocol.PickerLineHost,110 Kind: protocol.PickerLineHost,
@@ -108,9 +113,16 @@ func (c *pickerCatalogue) projectLocked() {
108 Status: pickerObservationStatus(observation, fresh),113 Status: pickerObservationStatus(observation, fresh),
109 StatusDetail: pickerObservationReason(observation, fresh),114 StatusDetail: pickerObservationReason(observation, fresh),
110 Dim: true,115 Dim: true,
111- // A host row stays reachable for inspection and never commits.116+ // A host row is focusable and offers creation only when no daemon
117+ // exists. The endpoint registration is revalidated at commit.
112 Focusable: true,118 Focusable: true,
113- }})119+ Actions: func() protocol.PickerLineActions {
120+ if observation.Availability == domain.RemoteAvailabilityNoDaemon {
121+ return protocol.PickerCanNavigate
122+ }
123+ return 0
124+ }(),
125+ }, ref: hostRef, hasRef: observation.Availability == domain.RemoteAvailabilityNoDaemon})
114 }126 }
115 if observation.Local {127 if observation.Local {
116 hasLocal = true128 hasLocal = true
@@ -131,9 +143,17 @@ func (c *pickerCatalogue) projectLocked() {
131 localRows = append(localRows, block.rows...)143 localRows = append(localRows, block.rows...)
132 }144 }
133 localRows = append(localRows, localHost...)145 localRows = append(localRows, localHost...)
146+ for _, row := range localHost {
147+ if row.hasRef {
148+ refs[row.line.Key] = row.ref
149+ }
150+ }
134 recent = append(recent, localRows...)151 recent = append(recent, localRows...)
135 for _, remote := range remotes {152 for _, remote := range remotes {
136 for _, row := range remote.rows {153 for _, row := range remote.rows {
154+ if row.line.Kind == protocol.PickerLineHost && row.hasRef {
155+ refs[row.line.Key] = row.ref
156+ }
137 if row.line.Kind == protocol.PickerLineSession && row.line.Detail == "" {157 if row.line.Kind == protocol.PickerLineSession && row.line.Detail == "" {
138 row.line.Detail = "@" + remote.origin158 row.line.Detail = "@" + remote.origin
139 } else if row.line.Kind == protocol.PickerLineSession {159 } else if row.line.Kind == protocol.PickerLineSession {
@@ -430,7 +450,7 @@ func pickerResolveTab(ref pickerSelectionRef, session catalogue.RemoteCatalogSes
430 // last probe could not reach it, authenticate, or read its catalogue.450 // last probe could not reach it, authenticate, or read its catalogue.
431 func pickerObservationFailing(observation ports.BrokerDaemonObservation) bool {451 func pickerObservationFailing(observation ports.BrokerDaemonObservation) bool {
432 switch observation.Availability {452 switch observation.Availability {
433- case domain.RemoteAvailabilityUnreachable, domain.RemoteAvailabilityAuthFailed, domain.RemoteAvailabilityInvalidResponse:453+ case domain.RemoteAvailabilityUnreachable, domain.RemoteAvailabilityAuthFailed, domain.RemoteAvailabilityInvalidResponse, domain.RemoteAvailabilityNoDaemon:
434 return true454 return true
435 default:455 default:
436 return false456 return false
@@ -468,6 +488,8 @@ func pickerReasonText(reason string) string {
468 return "catalog malformed"488 return "catalog malformed"
469 case domain.RemoteReasonAuthFailure:489 case domain.RemoteReasonAuthFailure:
470 return "authentication failed"490 return "authentication failed"
491+ case domain.RemoteReasonNoDaemon:
492+ return "no daemon — Enter to create a session"
471 case domain.RemoteReasonRefreshing:493 case domain.RemoteReasonRefreshing:
472 return "refreshing"494 return "refreshing"
473 case domain.RemoteReasonIdentityChanged:495 case domain.RemoteReasonIdentityChanged:
M internal/usecase/client/picker_controller.go +16 -1
@@ -562,7 +562,22 @@ func (p *pickerController) ResolveKeyTarget(key string, base pickerResolveBase)
562562 }
563563 return ports.BrokerOpenStreamRequest{}, attachmentTab{}, err
564564 }
565- request, tab, err := p.catalogue.ResolveTarget(key, base)
565+ ref, ok := p.catalogue.Ref(key)
566+ if !ok {
567+ err := pickerCatalogueError{Code: pickerCatalogueUnknown, Text: "picker row is not in the catalogue"}
568+ p.offerNotice("picker-refusal", pickerRefusalNotice(err))
569+ return ports.BrokerOpenStreamRequest{}, attachmentTab{}, err
570+ }
571+ var request ports.BrokerOpenStreamRequest
572+ var tab attachmentTab
573+ var err error
574+ if ref.kind == pickerSelectionCreateNamed || ref.kind == pickerSelectionCreateEphemeral {
575+ request, err = p.catalogue.Resolve(key, base)
576+ } else if ref.kind == pickerSelectionExact {
577+ request, tab, err = p.catalogue.ResolveTarget(key, base)
578+ } else {
579+ err = pickerCatalogueError{Code: pickerCatalogueUnavailable, Text: "this picker row is not a session destination"}
580+ }
566581 if err != nil {
567582 p.offerNotice("picker-refusal", pickerRefusalNotice(err))
568583 return ports.BrokerOpenStreamRequest{}, attachmentTab{}, err
@@ -562,7 +562,22 @@ func (p *pickerController) ResolveKeyTarget(key string, base pickerResolveBase)
562 }562 }
563 return ports.BrokerOpenStreamRequest{}, attachmentTab{}, err563 return ports.BrokerOpenStreamRequest{}, attachmentTab{}, err
564 }564 }
565- request, tab, err := p.catalogue.ResolveTarget(key, base)565+ ref, ok := p.catalogue.Ref(key)
566+ if !ok {
567+ err := pickerCatalogueError{Code: pickerCatalogueUnknown, Text: "picker row is not in the catalogue"}
568+ p.offerNotice("picker-refusal", pickerRefusalNotice(err))
569+ return ports.BrokerOpenStreamRequest{}, attachmentTab{}, err
570+ }
571+ var request ports.BrokerOpenStreamRequest
572+ var tab attachmentTab
573+ var err error
574+ if ref.kind == pickerSelectionCreateNamed || ref.kind == pickerSelectionCreateEphemeral {
575+ request, err = p.catalogue.Resolve(key, base)
576+ } else if ref.kind == pickerSelectionExact {
577+ request, tab, err = p.catalogue.ResolveTarget(key, base)
578+ } else {
579+ err = pickerCatalogueError{Code: pickerCatalogueUnavailable, Text: "this picker row is not a session destination"}
580+ }
566 if err != nil {581 if err != nil {
567 p.offerNotice("picker-refusal", pickerRefusalNotice(err))582 p.offerNotice("picker-refusal", pickerRefusalNotice(err))
568 return ports.BrokerOpenStreamRequest{}, attachmentTab{}, err583 return ports.BrokerOpenStreamRequest{}, attachmentTab{}, err
M internal/usecase/daemon/picker_lines.go +2 -0
@@ -404,6 +404,8 @@ func pickerStatusFor(view pickerSessionView, stopped bool) protocol.PickerLineSt
404404 remote := pickerViewIsRemote(view)
405405 if remote {
406406 switch view.RemoteReason {
407+ case domain.RemoteReasonNoDaemon:
408+ return protocol.PickerLineStatusNoDaemon
407409 case domain.RemoteReasonHostUnreachable:
408410 return protocol.PickerLineStatusDown
409411 case domain.RemoteReasonVersionMismatch:
@@ -404,6 +404,8 @@ func pickerStatusFor(view pickerSessionView, stopped bool) protocol.PickerLineSt
404 remote := pickerViewIsRemote(view)404 remote := pickerViewIsRemote(view)
405 if remote {405 if remote {
406 switch view.RemoteReason {406 switch view.RemoteReason {
407+ case domain.RemoteReasonNoDaemon:
408+ return protocol.PickerLineStatusNoDaemon
407 case domain.RemoteReasonHostUnreachable:409 case domain.RemoteReasonHostUnreachable:
408 return protocol.PickerLineStatusDown410 return protocol.PickerLineStatusDown
409 case domain.RemoteReasonVersionMismatch:411 case domain.RemoteReasonVersionMismatch:
M internal/usecase/picker/model.go +2 -0
@@ -477,6 +477,8 @@ func lineStatusBadge(status protocol.PickerLineStatus) string {
477477 return "[version]"
478478 case protocol.PickerLineStatusError:
479479 return "[error]"
480+ case protocol.PickerLineStatusNoDaemon:
481+ return "[no daemon]"
480482 default:
481483 return ""
482484 }
@@ -477,6 +477,8 @@ func lineStatusBadge(status protocol.PickerLineStatus) string {
477 return "[version]"477 return "[version]"
478 case protocol.PickerLineStatusError:478 case protocol.PickerLineStatusError:
479 return "[error]"479 return "[error]"
480+ case protocol.PickerLineStatusNoDaemon:
481+ return "[no daemon]"
480 default:482 default:
481 return ""483 return ""
482 }484 }

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