feat(client): add autonomous broker supervisor
- commit
- 21cbe0bb8b93e008d9079d27463775394ca6d024
- author
- brice · · 2026-09-17 08:59 UTC
- parent
- c16729e
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
13 files · +2805 -1
M internal/adapters/brokeripc/client.go +42 -0
| @@ -35,6 +35,34 @@ func Dial(ctx context.Context, path string, cfg Config) (ports.BrokerService, er | |||
| 35 | 35 | return dial(ctx, path, cfg, ipc.SameUserPeerVerifier()) | |
| 36 | 36 | } | |
| 37 | 37 | ||
| 38 | + | // Connector implements ports.BrokerConnector over the private broker IPC | |
| 39 | + | // carriage. It holds only the endpoint and its bounds, so every Connect is an | |
| 40 | + | // independent attempt and the caller owns Close on the returned service. The | |
| 41 | + | // client process reaches the broker use case exclusively through this seam. | |
| 42 | + | type Connector struct { | |
| 43 | + | path string | |
| 44 | + | cfg Config | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | var _ ports.BrokerConnector = (*Connector)(nil) | |
| 48 | + | ||
| 49 | + | // NewConnector returns a connector for one broker endpoint. An empty path is | |
| 50 | + | // refused by Connect, exactly as Dial refuses it, so a misconfigured connector | |
| 51 | + | // fails closed instead of dialing an unspecified socket. | |
| 52 | + | func NewConnector(path string, cfg Config) *Connector { | |
| 53 | + | return &Connector{path: path, cfg: cfg} | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | // Connect dials the configured endpoint and returns the connection-scoped | |
| 57 | + | // service. Cancellation is honored by the underlying Dial, which bounds the | |
| 58 | + | // whole setup and closes the carriage when its context expires. | |
| 59 | + | func (c *Connector) Connect(ctx context.Context) (ports.BrokerService, error) { | |
| 60 | + | if c == nil { | |
| 61 | + | return nil, ErrConfig | |
| 62 | + | } | |
| 63 | + | return Dial(ctx, c.path, c.cfg) | |
| 64 | + | } | |
| 65 | + | ||
| 38 | 66 | // dial is Dial with an injected peer verifier, so a test can observe a | |
| 39 | 67 | // deterministic same-user refusal without root. | |
| 40 | 68 | func dial(ctx context.Context, path string, cfg Config, verify ipc.PeerVerifier) (ports.BrokerService, error) { | |
| @@ -351,6 +379,20 @@ func (c *client) completeOperation(operation ports.BrokerOperationID, result ope | |||
| 351 | 379 | // ConnectionID returns the identity assigned to this connection at accept. | |
| 352 | 380 | func (c *client) ConnectionID() ports.BrokerConnectionID { return c.scope.Connection } | |
| 353 | 381 | ||
| 382 | + | // Done closes exactly once when this connection is terminal: the carriage | |
| 383 | + | // failed, the broker retired it, or this side closed it. Err is stable | |
| 384 | + | // afterwards. | |
| 385 | + | func (c *client) Done() <-chan struct{} { return c.done } | |
| 386 | + | ||
| 387 | + | // Err returns this connection's terminal cause: nil for an orderly local Close, | |
| 388 | + | // otherwise the failure that settled it. The cause is recorded exactly once and | |
| 389 | + | // never overwritten, so it is stable once Done is closed. | |
| 390 | + | func (c *client) Err() error { | |
| 391 | + | c.mu.Lock() | |
| 392 | + | defer c.mu.Unlock() | |
| 393 | + | return c.err | |
| 394 | + | } | |
| 395 | + | ||
| 354 | 396 | // Snapshot returns the newest fully committed publication. | |
| 355 | 397 | func (c *client) Snapshot() ports.BrokerSnapshot { | |
| 356 | 398 | c.mu.Lock() | |
| @@ -35,6 +35,34 @@ func Dial(ctx context.Context, path string, cfg Config) (ports.BrokerService, er | |||||
| 35 | return dial(ctx, path, cfg, ipc.SameUserPeerVerifier()) | 35 | return dial(ctx, path, cfg, ipc.SameUserPeerVerifier()) | ||
| 36 | } | 36 | } | ||
| 37 | 37 | ||||
| 38 | + | // Connector implements ports.BrokerConnector over the private broker IPC | |||
| 39 | + | // carriage. It holds only the endpoint and its bounds, so every Connect is an | |||
| 40 | + | // independent attempt and the caller owns Close on the returned service. The | |||
| 41 | + | // client process reaches the broker use case exclusively through this seam. | |||
| 42 | + | type Connector struct { | |||
| 43 | + | path string | |||
| 44 | + | cfg Config | |||
| 45 | + | } | |||
| 46 | + | ||||
| 47 | + | var _ ports.BrokerConnector = (*Connector)(nil) | |||
| 48 | + | ||||
| 49 | + | // NewConnector returns a connector for one broker endpoint. An empty path is | |||
| 50 | + | // refused by Connect, exactly as Dial refuses it, so a misconfigured connector | |||
| 51 | + | // fails closed instead of dialing an unspecified socket. | |||
| 52 | + | func NewConnector(path string, cfg Config) *Connector { | |||
| 53 | + | return &Connector{path: path, cfg: cfg} | |||
| 54 | + | } | |||
| 55 | + | ||||
| 56 | + | // Connect dials the configured endpoint and returns the connection-scoped | |||
| 57 | + | // service. Cancellation is honored by the underlying Dial, which bounds the | |||
| 58 | + | // whole setup and closes the carriage when its context expires. | |||
| 59 | + | func (c *Connector) Connect(ctx context.Context) (ports.BrokerService, error) { | |||
| 60 | + | if c == nil { | |||
| 61 | + | return nil, ErrConfig | |||
| 62 | + | } | |||
| 63 | + | return Dial(ctx, c.path, c.cfg) | |||
| 64 | + | } | |||
| 65 | + | ||||
| 38 | // dial is Dial with an injected peer verifier, so a test can observe a | 66 | // dial is Dial with an injected peer verifier, so a test can observe a | ||
| 39 | // deterministic same-user refusal without root. | 67 | // deterministic same-user refusal without root. | ||
| 40 | func dial(ctx context.Context, path string, cfg Config, verify ipc.PeerVerifier) (ports.BrokerService, error) { | 68 | func dial(ctx context.Context, path string, cfg Config, verify ipc.PeerVerifier) (ports.BrokerService, error) { | ||
| @@ -351,6 +379,20 @@ func (c *client) completeOperation(operation ports.BrokerOperationID, result ope | |||||
| 351 | // ConnectionID returns the identity assigned to this connection at accept. | 379 | // ConnectionID returns the identity assigned to this connection at accept. | ||
| 352 | func (c *client) ConnectionID() ports.BrokerConnectionID { return c.scope.Connection } | 380 | func (c *client) ConnectionID() ports.BrokerConnectionID { return c.scope.Connection } | ||
| 353 | 381 | ||||
| 382 | + | // Done closes exactly once when this connection is terminal: the carriage | |||
| 383 | + | // failed, the broker retired it, or this side closed it. Err is stable | |||
| 384 | + | // afterwards. | |||
| 385 | + | func (c *client) Done() <-chan struct{} { return c.done } | |||
| 386 | + | ||||
| 387 | + | // Err returns this connection's terminal cause: nil for an orderly local Close, | |||
| 388 | + | // otherwise the failure that settled it. The cause is recorded exactly once and | |||
| 389 | + | // never overwritten, so it is stable once Done is closed. | |||
| 390 | + | func (c *client) Err() error { | |||
| 391 | + | c.mu.Lock() | |||
| 392 | + | defer c.mu.Unlock() | |||
| 393 | + | return c.err | |||
| 394 | + | } | |||
| 395 | + | ||||
| 354 | // Snapshot returns the newest fully committed publication. | 396 | // Snapshot returns the newest fully committed publication. | ||
| 355 | func (c *client) Snapshot() ports.BrokerSnapshot { | 397 | func (c *client) Snapshot() ports.BrokerSnapshot { | ||
| 356 | c.mu.Lock() | 398 | c.mu.Lock() | ||
A internal/adapters/brokeripc/connector_test.go +140 -0
| @@ -0,0 +1,140 @@ | |||
| 1 | + | package brokeripc | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "testing" | |
| 6 | + | "time" | |
| 7 | + | ||
| 8 | + | "github.com/stretchr/testify/require" | |
| 9 | + | ||
| 10 | + | "github.com/bnema/vev/internal/ports" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | // awaitDone waits for one broker service to become terminal. | |
| 14 | + | func awaitDone(t *testing.T, service ports.BrokerService) { | |
| 15 | + | t.Helper() | |
| 16 | + | select { | |
| 17 | + | case <-service.Done(): | |
| 18 | + | case <-time.After(5 * time.Second): | |
| 19 | + | t.Fatal("broker service did not become terminal") | |
| 20 | + | } | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | // TestConnectorConnectReturnsIndependentService proves the ports.BrokerConnector | |
| 24 | + | // implementation dials one connection per Connect and hands ownership to the | |
| 25 | + | // caller. | |
| 26 | + | func TestConnectorConnectReturnsIndependentService(t *testing.T) { | |
| 27 | + | e := startEndpoint(t, Config{}) | |
| 28 | + | connector := NewConnector(e.path, Config{}) | |
| 29 | + | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) | |
| 30 | + | defer cancel() | |
| 31 | + | ||
| 32 | + | first, err := connector.Connect(ctx) | |
| 33 | + | require.NoError(t, err) | |
| 34 | + | require.NotNil(t, first) | |
| 35 | + | ||
| 36 | + | second, err := connector.Connect(ctx) | |
| 37 | + | require.NoError(t, err) | |
| 38 | + | require.NotNil(t, second) | |
| 39 | + | require.NotEqual(t, first.ConnectionID(), second.ConnectionID(), "each Connect admits an independent connection") | |
| 40 | + | ||
| 41 | + | // An orderly local Close reports no terminal cause and is stable. | |
| 42 | + | require.NoError(t, first.Close()) | |
| 43 | + | awaitDone(t, first) | |
| 44 | + | require.NoError(t, first.Err()) | |
| 45 | + | require.NoError(t, first.Err(), "Err is stable after Done") | |
| 46 | + | require.False(t, func() bool { | |
| 47 | + | select { | |
| 48 | + | case <-second.Done(): | |
| 49 | + | return true | |
| 50 | + | default: | |
| 51 | + | return false | |
| 52 | + | } | |
| 53 | + | }(), "closing one connection must not close another") | |
| 54 | + | ||
| 55 | + | require.NoError(t, second.Close()) | |
| 56 | + | awaitDone(t, second) | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | // TestConnectorReportsCarriageLoss proves the client service surfaces a broker | |
| 60 | + | // loss through Done and Err while it holds no logical stream. | |
| 61 | + | func TestConnectorReportsCarriageLoss(t *testing.T) { | |
| 62 | + | e := startEndpoint(t, Config{}) | |
| 63 | + | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) | |
| 64 | + | defer cancel() | |
| 65 | + | ||
| 66 | + | service, err := NewConnector(e.path, Config{}).Connect(ctx) | |
| 67 | + | require.NoError(t, err) | |
| 68 | + | ||
| 69 | + | // The broker side is torn down: the client observes a non-nil terminal | |
| 70 | + | // cause instead of a silent close. | |
| 71 | + | require.NoError(t, e.accept().Close()) | |
| 72 | + | awaitDone(t, service) | |
| 73 | + | require.Error(t, service.Err()) | |
| 74 | + | ||
| 75 | + | require.NoError(t, service.Close()) | |
| 76 | + | require.Error(t, service.Err(), "the first terminal cause is never overwritten by a local Close") | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | func TestConnectorRefusesInvalidConfiguration(t *testing.T) { | |
| 80 | + | var absent *Connector | |
| 81 | + | _, err := absent.Connect(context.Background()) | |
| 82 | + | require.ErrorIs(t, err, ErrConfig) | |
| 83 | + | ||
| 84 | + | _, err = NewConnector("", Config{}).Connect(context.Background()) | |
| 85 | + | require.ErrorIs(t, err, ErrConfig) | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | // TestConnectorCancellationInterruptsStalledRegistration proves the | |
| 89 | + | // ports.BrokerConnector seam honors cancellation while a peer stalls in the | |
| 90 | + | // registration exchange: Connect returns the caller's cancellation instead of | |
| 91 | + | // waiting out the handshake budget. The supervisor's cancellation lifetime | |
| 92 | + | // depends on exactly this contract. | |
| 93 | + | func TestConnectorCancellationInterruptsStalledRegistration(t *testing.T) { | |
| 94 | + | path := testSocketPath(t) | |
| 95 | + | peer := startSilentBrokerPeer(t, path) | |
| 96 | + | ||
| 97 | + | connector := NewConnector(path, Config{HandshakeTimeout: 30 * time.Second}) | |
| 98 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 99 | + | defer cancel() | |
| 100 | + | result := make(chan error, 1) | |
| 101 | + | go func() { | |
| 102 | + | _, err := connector.Connect(ctx) | |
| 103 | + | result <- err | |
| 104 | + | }() | |
| 105 | + | peer.awaitPreamble(t) | |
| 106 | + | cancel() | |
| 107 | + | ||
| 108 | + | select { | |
| 109 | + | case err := <-result: | |
| 110 | + | require.ErrorIs(t, err, context.Canceled, "Connect must return the caller's cancellation, not wait out the handshake budget") | |
| 111 | + | case <-time.After(5 * time.Second): | |
| 112 | + | t.Fatal("Connect must return on cancellation instead of waiting out HandshakeTimeout") | |
| 113 | + | } | |
| 114 | + | peer.stop() | |
| 115 | + | } | |
| 116 | + | ||
| 117 | + | // TestConnectorServiceOutlivesSetupContext pins the ports.BrokerConnector | |
| 118 | + | // contract that a successful service is independent of the setup context: | |
| 119 | + | // cancelling that context after Connect returns must not close or disturb the | |
| 120 | + | // established connection. | |
| 121 | + | func TestConnectorServiceOutlivesSetupContext(t *testing.T) { | |
| 122 | + | e := startEndpoint(t, Config{}) | |
| 123 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 124 | + | service, err := NewConnector(e.path, Config{}).Connect(ctx) | |
| 125 | + | require.NoError(t, err) | |
| 126 | + | cancel() | |
| 127 | + | ||
| 128 | + | select { | |
| 129 | + | case <-service.Done(): | |
| 130 | + | t.Fatal("cancelling the setup context must not close the established service") | |
| 131 | + | case <-time.After(100 * time.Millisecond): | |
| 132 | + | } | |
| 133 | + | ||
| 134 | + | // The detached connection still serves its own scope and can be closed by | |
| 135 | + | // its owner, which reports an orderly end. | |
| 136 | + | require.NotEqual(t, ports.BrokerConnectionID{}, service.ConnectionID()) | |
| 137 | + | require.NoError(t, service.Close()) | |
| 138 | + | awaitDone(t, service) | |
| 139 | + | require.NoError(t, service.Err()) | |
| 140 | + | } | |
| @@ -0,0 +1,140 @@ | |||||
| 1 | + | package brokeripc | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "testing" | |||
| 6 | + | "time" | |||
| 7 | + | ||||
| 8 | + | "github.com/stretchr/testify/require" | |||
| 9 | + | ||||
| 10 | + | "github.com/bnema/vev/internal/ports" | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | // awaitDone waits for one broker service to become terminal. | |||
| 14 | + | func awaitDone(t *testing.T, service ports.BrokerService) { | |||
| 15 | + | t.Helper() | |||
| 16 | + | select { | |||
| 17 | + | case <-service.Done(): | |||
| 18 | + | case <-time.After(5 * time.Second): | |||
| 19 | + | t.Fatal("broker service did not become terminal") | |||
| 20 | + | } | |||
| 21 | + | } | |||
| 22 | + | ||||
| 23 | + | // TestConnectorConnectReturnsIndependentService proves the ports.BrokerConnector | |||
| 24 | + | // implementation dials one connection per Connect and hands ownership to the | |||
| 25 | + | // caller. | |||
| 26 | + | func TestConnectorConnectReturnsIndependentService(t *testing.T) { | |||
| 27 | + | e := startEndpoint(t, Config{}) | |||
| 28 | + | connector := NewConnector(e.path, Config{}) | |||
| 29 | + | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) | |||
| 30 | + | defer cancel() | |||
| 31 | + | ||||
| 32 | + | first, err := connector.Connect(ctx) | |||
| 33 | + | require.NoError(t, err) | |||
| 34 | + | require.NotNil(t, first) | |||
| 35 | + | ||||
| 36 | + | second, err := connector.Connect(ctx) | |||
| 37 | + | require.NoError(t, err) | |||
| 38 | + | require.NotNil(t, second) | |||
| 39 | + | require.NotEqual(t, first.ConnectionID(), second.ConnectionID(), "each Connect admits an independent connection") | |||
| 40 | + | ||||
| 41 | + | // An orderly local Close reports no terminal cause and is stable. | |||
| 42 | + | require.NoError(t, first.Close()) | |||
| 43 | + | awaitDone(t, first) | |||
| 44 | + | require.NoError(t, first.Err()) | |||
| 45 | + | require.NoError(t, first.Err(), "Err is stable after Done") | |||
| 46 | + | require.False(t, func() bool { | |||
| 47 | + | select { | |||
| 48 | + | case <-second.Done(): | |||
| 49 | + | return true | |||
| 50 | + | default: | |||
| 51 | + | return false | |||
| 52 | + | } | |||
| 53 | + | }(), "closing one connection must not close another") | |||
| 54 | + | ||||
| 55 | + | require.NoError(t, second.Close()) | |||
| 56 | + | awaitDone(t, second) | |||
| 57 | + | } | |||
| 58 | + | ||||
| 59 | + | // TestConnectorReportsCarriageLoss proves the client service surfaces a broker | |||
| 60 | + | // loss through Done and Err while it holds no logical stream. | |||
| 61 | + | func TestConnectorReportsCarriageLoss(t *testing.T) { | |||
| 62 | + | e := startEndpoint(t, Config{}) | |||
| 63 | + | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) | |||
| 64 | + | defer cancel() | |||
| 65 | + | ||||
| 66 | + | service, err := NewConnector(e.path, Config{}).Connect(ctx) | |||
| 67 | + | require.NoError(t, err) | |||
| 68 | + | ||||
| 69 | + | // The broker side is torn down: the client observes a non-nil terminal | |||
| 70 | + | // cause instead of a silent close. | |||
| 71 | + | require.NoError(t, e.accept().Close()) | |||
| 72 | + | awaitDone(t, service) | |||
| 73 | + | require.Error(t, service.Err()) | |||
| 74 | + | ||||
| 75 | + | require.NoError(t, service.Close()) | |||
| 76 | + | require.Error(t, service.Err(), "the first terminal cause is never overwritten by a local Close") | |||
| 77 | + | } | |||
| 78 | + | ||||
| 79 | + | func TestConnectorRefusesInvalidConfiguration(t *testing.T) { | |||
| 80 | + | var absent *Connector | |||
| 81 | + | _, err := absent.Connect(context.Background()) | |||
| 82 | + | require.ErrorIs(t, err, ErrConfig) | |||
| 83 | + | ||||
| 84 | + | _, err = NewConnector("", Config{}).Connect(context.Background()) | |||
| 85 | + | require.ErrorIs(t, err, ErrConfig) | |||
| 86 | + | } | |||
| 87 | + | ||||
| 88 | + | // TestConnectorCancellationInterruptsStalledRegistration proves the | |||
| 89 | + | // ports.BrokerConnector seam honors cancellation while a peer stalls in the | |||
| 90 | + | // registration exchange: Connect returns the caller's cancellation instead of | |||
| 91 | + | // waiting out the handshake budget. The supervisor's cancellation lifetime | |||
| 92 | + | // depends on exactly this contract. | |||
| 93 | + | func TestConnectorCancellationInterruptsStalledRegistration(t *testing.T) { | |||
| 94 | + | path := testSocketPath(t) | |||
| 95 | + | peer := startSilentBrokerPeer(t, path) | |||
| 96 | + | ||||
| 97 | + | connector := NewConnector(path, Config{HandshakeTimeout: 30 * time.Second}) | |||
| 98 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 99 | + | defer cancel() | |||
| 100 | + | result := make(chan error, 1) | |||
| 101 | + | go func() { | |||
| 102 | + | _, err := connector.Connect(ctx) | |||
| 103 | + | result <- err | |||
| 104 | + | }() | |||
| 105 | + | peer.awaitPreamble(t) | |||
| 106 | + | cancel() | |||
| 107 | + | ||||
| 108 | + | select { | |||
| 109 | + | case err := <-result: | |||
| 110 | + | require.ErrorIs(t, err, context.Canceled, "Connect must return the caller's cancellation, not wait out the handshake budget") | |||
| 111 | + | case <-time.After(5 * time.Second): | |||
| 112 | + | t.Fatal("Connect must return on cancellation instead of waiting out HandshakeTimeout") | |||
| 113 | + | } | |||
| 114 | + | peer.stop() | |||
| 115 | + | } | |||
| 116 | + | ||||
| 117 | + | // TestConnectorServiceOutlivesSetupContext pins the ports.BrokerConnector | |||
| 118 | + | // contract that a successful service is independent of the setup context: | |||
| 119 | + | // cancelling that context after Connect returns must not close or disturb the | |||
| 120 | + | // established connection. | |||
| 121 | + | func TestConnectorServiceOutlivesSetupContext(t *testing.T) { | |||
| 122 | + | e := startEndpoint(t, Config{}) | |||
| 123 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 124 | + | service, err := NewConnector(e.path, Config{}).Connect(ctx) | |||
| 125 | + | require.NoError(t, err) | |||
| 126 | + | cancel() | |||
| 127 | + | ||||
| 128 | + | select { | |||
| 129 | + | case <-service.Done(): | |||
| 130 | + | t.Fatal("cancelling the setup context must not close the established service") | |||
| 131 | + | case <-time.After(100 * time.Millisecond): | |||
| 132 | + | } | |||
| 133 | + | ||||
| 134 | + | // The detached connection still serves its own scope and can be closed by | |||
| 135 | + | // its owner, which reports an orderly end. | |||
| 136 | + | require.NotEqual(t, ports.BrokerConnectionID{}, service.ConnectionID()) | |||
| 137 | + | require.NoError(t, service.Close()) | |||
| 138 | + | awaitDone(t, service) | |||
| 139 | + | require.NoError(t, service.Err()) | |||
| 140 | + | } | |||
M internal/adapters/brokeripc/helpers_test.go +4 -0
| @@ -241,6 +241,10 @@ type fakeCore struct { | |||
| 241 | 241 | ||
| 242 | 242 | func (c *fakeCore) ConnectionID() ports.BrokerConnectionID { return c.id } | |
| 243 | 243 | ||
| 244 | + | func (c *fakeCore) Done() <-chan struct{} { return c.done } | |
| 245 | + | ||
| 246 | + | func (c *fakeCore) Err() error { return nil } | |
| 247 | + | ||
| 244 | 248 | func (c *fakeCore) Snapshot() ports.BrokerSnapshot { return c.hub.current() } | |
| 245 | 249 | ||
| 246 | 250 | func (c *fakeCore) Subscribe() (ports.BrokerSubscription, error) { return c.hub.subscribe(), nil } | |
| @@ -241,6 +241,10 @@ type fakeCore struct { | |||||
| 241 | 241 | ||||
| 242 | func (c *fakeCore) ConnectionID() ports.BrokerConnectionID { return c.id } | 242 | func (c *fakeCore) ConnectionID() ports.BrokerConnectionID { return c.id } | ||
| 243 | 243 | ||||
| 244 | + | func (c *fakeCore) Done() <-chan struct{} { return c.done } | |||
| 245 | + | ||||
| 246 | + | func (c *fakeCore) Err() error { return nil } | |||
| 247 | + | ||||
| 244 | func (c *fakeCore) Snapshot() ports.BrokerSnapshot { return c.hub.current() } | 248 | func (c *fakeCore) Snapshot() ports.BrokerSnapshot { return c.hub.current() } | ||
| 245 | 249 | ||||
| 246 | func (c *fakeCore) Subscribe() (ports.BrokerSubscription, error) { return c.hub.subscribe(), nil } | 250 | func (c *fakeCore) Subscribe() (ports.BrokerSubscription, error) { return c.hub.subscribe(), nil } | ||
M internal/adapters/brokeripc/server.go +18 -0
| @@ -889,6 +889,24 @@ func orderlyDisconnect(err error) bool { | |||
| 889 | 889 | // ConnectionID returns the identity assigned to this connection at accept. | |
| 890 | 890 | func (s *serverSession) ConnectionID() ports.BrokerConnectionID { return s.scope.Connection } | |
| 891 | 891 | ||
| 892 | + | // Done closes exactly once when this session is terminal: the carriage failed, | |
| 893 | + | // the peer disconnected, or the owner closed it. Err is stable afterwards. | |
| 894 | + | func (s *serverSession) Done() <-chan struct{} { return s.done } | |
| 895 | + | ||
| 896 | + | // Err returns the cause that settled this session, or nil for an orderly end | |
| 897 | + | // (a peer disconnect, a registration timeout, or a local close). The underlying | |
| 898 | + | // terminal cause is never overwritten, so the result is stable once Done is | |
| 899 | + | // closed. A cause Close already reports as orderly is reported as nil here too, | |
| 900 | + | // so a client that observes Done/Err never mistakes a peer's ordinary departure | |
| 901 | + | // for a sandbox failure. | |
| 902 | + | func (s *serverSession) Err() error { | |
| 903 | + | err := s.terminalErr() | |
| 904 | + | if orderlyDisconnect(err) { | |
| 905 | + | return nil | |
| 906 | + | } | |
| 907 | + | return err | |
| 908 | + | } | |
| 909 | + | ||
| 892 | 910 | // Snapshot returns the admitted core service's current publication. | |
| 893 | 911 | func (s *serverSession) Snapshot() ports.BrokerSnapshot { return s.core.Snapshot() } | |
| 894 | 912 | ||
| @@ -889,6 +889,24 @@ func orderlyDisconnect(err error) bool { | |||||
| 889 | // ConnectionID returns the identity assigned to this connection at accept. | 889 | // ConnectionID returns the identity assigned to this connection at accept. | ||
| 890 | func (s *serverSession) ConnectionID() ports.BrokerConnectionID { return s.scope.Connection } | 890 | func (s *serverSession) ConnectionID() ports.BrokerConnectionID { return s.scope.Connection } | ||
| 891 | 891 | ||||
| 892 | + | // Done closes exactly once when this session is terminal: the carriage failed, | |||
| 893 | + | // the peer disconnected, or the owner closed it. Err is stable afterwards. | |||
| 894 | + | func (s *serverSession) Done() <-chan struct{} { return s.done } | |||
| 895 | + | ||||
| 896 | + | // Err returns the cause that settled this session, or nil for an orderly end | |||
| 897 | + | // (a peer disconnect, a registration timeout, or a local close). The underlying | |||
| 898 | + | // terminal cause is never overwritten, so the result is stable once Done is | |||
| 899 | + | // closed. A cause Close already reports as orderly is reported as nil here too, | |||
| 900 | + | // so a client that observes Done/Err never mistakes a peer's ordinary departure | |||
| 901 | + | // for a sandbox failure. | |||
| 902 | + | func (s *serverSession) Err() error { | |||
| 903 | + | err := s.terminalErr() | |||
| 904 | + | if orderlyDisconnect(err) { | |||
| 905 | + | return nil | |||
| 906 | + | } | |||
| 907 | + | return err | |||
| 908 | + | } | |||
| 909 | + | ||||
| 892 | // Snapshot returns the admitted core service's current publication. | 910 | // Snapshot returns the admitted core service's current publication. | ||
| 893 | func (s *serverSession) Snapshot() ports.BrokerSnapshot { return s.core.Snapshot() } | 911 | func (s *serverSession) Snapshot() ports.BrokerSnapshot { return s.core.Snapshot() } | ||
| 894 | 912 | ||||
A internal/adapters/brokeripc/session_terminal_test.go +79 -0
| @@ -0,0 +1,79 @@ | |||
| 1 | + | package brokeripc | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "net" | |
| 5 | + | "testing" | |
| 6 | + | "time" | |
| 7 | + | ||
| 8 | + | "github.com/stretchr/testify/require" | |
| 9 | + | ||
| 10 | + | "github.com/bnema/vev/internal/adapters/brokerwire" | |
| 11 | + | "github.com/bnema/vev/internal/adapters/ipc" | |
| 12 | + | "github.com/bnema/vev/internal/ports" | |
| 13 | + | "github.com/bnema/vev/internal/protocol/wire" | |
| 14 | + | ) | |
| 15 | + | ||
| 16 | + | // newRunningSession starts one registered server session over a real net.Pipe | |
| 17 | + | // carriage so a test can drive its terminal outcome from the peer side. | |
| 18 | + | func newRunningSession(t *testing.T, epoch ports.BrokerEpoch) (*serverSession, net.Conn) { | |
| 19 | + | t.Helper() | |
| 20 | + | serverConn, peerConn := net.Pipe() | |
| 21 | + | t.Cleanup(func() { _ = peerConn.Close() }) | |
| 22 | + | transport, ok := ipc.NewTransport(serverConn).(wire.BoundedTransport) | |
| 23 | + | require.True(t, ok, "the IPC carriage must be a bounded transport") | |
| 24 | + | session, err := newServerSession(epoch, transport, brokerwire.DefaultCeilings(), newTestCore(epoch), Config{}, func() {}, time.Time{}) | |
| 25 | + | require.NoError(t, err) | |
| 26 | + | require.NoError(t, session.conn.Register()) | |
| 27 | + | go session.run() | |
| 28 | + | return session, peerConn | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | // awaitSessionDone waits for one session to reach its terminal state. | |
| 32 | + | func awaitSessionDone(t *testing.T, session *serverSession) { | |
| 33 | + | t.Helper() | |
| 34 | + | select { | |
| 35 | + | case <-session.Done(): | |
| 36 | + | case <-time.After(5 * time.Second): | |
| 37 | + | t.Fatal("session did not become terminal") | |
| 38 | + | } | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | // TestSessionErrReportsOrderlyEndAsNil pins the server session Err contract: a | |
| 42 | + | // peer disconnect and a local Close are orderly ends and report nil, while a | |
| 43 | + | // protocol failure reports the typed cause that settled the session. Done is | |
| 44 | + | // closed and Err stable in every case. | |
| 45 | + | func TestSessionErrReportsOrderlyEndAsNil(t *testing.T) { | |
| 46 | + | t.Run("peer EOF is orderly", func(t *testing.T) { | |
| 47 | + | session, peer := newRunningSession(t, 0x61) | |
| 48 | + | require.NoError(t, peer.Close()) | |
| 49 | + | awaitSessionDone(t, session) | |
| 50 | + | ||
| 51 | + | // The underlying cause is the peer's EOF, but Err presents it as an | |
| 52 | + | // orderly end so a watcher never mistakes a departure for a failure. | |
| 53 | + | require.Error(t, session.terminalErr(), "the peer EOF remains the recorded internal cause") | |
| 54 | + | require.True(t, orderlyDisconnect(session.terminalErr())) | |
| 55 | + | require.NoError(t, session.Err()) | |
| 56 | + | require.NoError(t, session.Err(), "Err is stable after Done") | |
| 57 | + | require.NoError(t, session.Close()) | |
| 58 | + | }) | |
| 59 | + | ||
| 60 | + | t.Run("local Close is orderly", func(t *testing.T) { | |
| 61 | + | session, _ := newRunningSession(t, 0x62) | |
| 62 | + | require.NoError(t, session.Close()) | |
| 63 | + | awaitSessionDone(t, session) | |
| 64 | + | require.NoError(t, session.Err()) | |
| 65 | + | }) | |
| 66 | + | ||
| 67 | + | t.Run("protocol failure is typed", func(t *testing.T) { | |
| 68 | + | session, peer := newRunningSession(t, 0x63) | |
| 69 | + | raw := &rawPeer{conn: peer, ceilings: brokerwire.DefaultCeilings()} | |
| 70 | + | // A second Register is a protocol-order violation and settles the | |
| 71 | + | // connection. | |
| 72 | + | raw.send(t, brokerwire.Register{}) | |
| 73 | + | awaitSessionDone(t, session) | |
| 74 | + | ||
| 75 | + | require.Error(t, session.Err()) | |
| 76 | + | require.ErrorIs(t, session.Err(), ErrProtocol) | |
| 77 | + | require.Error(t, session.Close(), "a protocol failure is not an orderly end") | |
| 78 | + | }) | |
| 79 | + | } | |
| @@ -0,0 +1,79 @@ | |||||
| 1 | + | package brokeripc | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "net" | |||
| 5 | + | "testing" | |||
| 6 | + | "time" | |||
| 7 | + | ||||
| 8 | + | "github.com/stretchr/testify/require" | |||
| 9 | + | ||||
| 10 | + | "github.com/bnema/vev/internal/adapters/brokerwire" | |||
| 11 | + | "github.com/bnema/vev/internal/adapters/ipc" | |||
| 12 | + | "github.com/bnema/vev/internal/ports" | |||
| 13 | + | "github.com/bnema/vev/internal/protocol/wire" | |||
| 14 | + | ) | |||
| 15 | + | ||||
| 16 | + | // newRunningSession starts one registered server session over a real net.Pipe | |||
| 17 | + | // carriage so a test can drive its terminal outcome from the peer side. | |||
| 18 | + | func newRunningSession(t *testing.T, epoch ports.BrokerEpoch) (*serverSession, net.Conn) { | |||
| 19 | + | t.Helper() | |||
| 20 | + | serverConn, peerConn := net.Pipe() | |||
| 21 | + | t.Cleanup(func() { _ = peerConn.Close() }) | |||
| 22 | + | transport, ok := ipc.NewTransport(serverConn).(wire.BoundedTransport) | |||
| 23 | + | require.True(t, ok, "the IPC carriage must be a bounded transport") | |||
| 24 | + | session, err := newServerSession(epoch, transport, brokerwire.DefaultCeilings(), newTestCore(epoch), Config{}, func() {}, time.Time{}) | |||
| 25 | + | require.NoError(t, err) | |||
| 26 | + | require.NoError(t, session.conn.Register()) | |||
| 27 | + | go session.run() | |||
| 28 | + | return session, peerConn | |||
| 29 | + | } | |||
| 30 | + | ||||
| 31 | + | // awaitSessionDone waits for one session to reach its terminal state. | |||
| 32 | + | func awaitSessionDone(t *testing.T, session *serverSession) { | |||
| 33 | + | t.Helper() | |||
| 34 | + | select { | |||
| 35 | + | case <-session.Done(): | |||
| 36 | + | case <-time.After(5 * time.Second): | |||
| 37 | + | t.Fatal("session did not become terminal") | |||
| 38 | + | } | |||
| 39 | + | } | |||
| 40 | + | ||||
| 41 | + | // TestSessionErrReportsOrderlyEndAsNil pins the server session Err contract: a | |||
| 42 | + | // peer disconnect and a local Close are orderly ends and report nil, while a | |||
| 43 | + | // protocol failure reports the typed cause that settled the session. Done is | |||
| 44 | + | // closed and Err stable in every case. | |||
| 45 | + | func TestSessionErrReportsOrderlyEndAsNil(t *testing.T) { | |||
| 46 | + | t.Run("peer EOF is orderly", func(t *testing.T) { | |||
| 47 | + | session, peer := newRunningSession(t, 0x61) | |||
| 48 | + | require.NoError(t, peer.Close()) | |||
| 49 | + | awaitSessionDone(t, session) | |||
| 50 | + | ||||
| 51 | + | // The underlying cause is the peer's EOF, but Err presents it as an | |||
| 52 | + | // orderly end so a watcher never mistakes a departure for a failure. | |||
| 53 | + | require.Error(t, session.terminalErr(), "the peer EOF remains the recorded internal cause") | |||
| 54 | + | require.True(t, orderlyDisconnect(session.terminalErr())) | |||
| 55 | + | require.NoError(t, session.Err()) | |||
| 56 | + | require.NoError(t, session.Err(), "Err is stable after Done") | |||
| 57 | + | require.NoError(t, session.Close()) | |||
| 58 | + | }) | |||
| 59 | + | ||||
| 60 | + | t.Run("local Close is orderly", func(t *testing.T) { | |||
| 61 | + | session, _ := newRunningSession(t, 0x62) | |||
| 62 | + | require.NoError(t, session.Close()) | |||
| 63 | + | awaitSessionDone(t, session) | |||
| 64 | + | require.NoError(t, session.Err()) | |||
| 65 | + | }) | |||
| 66 | + | ||||
| 67 | + | t.Run("protocol failure is typed", func(t *testing.T) { | |||
| 68 | + | session, peer := newRunningSession(t, 0x63) | |||
| 69 | + | raw := &rawPeer{conn: peer, ceilings: brokerwire.DefaultCeilings()} | |||
| 70 | + | // A second Register is a protocol-order violation and settles the | |||
| 71 | + | // connection. | |||
| 72 | + | raw.send(t, brokerwire.Register{}) | |||
| 73 | + | awaitSessionDone(t, session) | |||
| 74 | + | ||||
| 75 | + | require.Error(t, session.Err()) | |||
| 76 | + | require.ErrorIs(t, session.Err(), ErrProtocol) | |||
| 77 | + | require.Error(t, session.Close(), "a protocol failure is not an orderly end") | |||
| 78 | + | }) | |||
| 79 | + | } | |||
M internal/ports/broker.go +33 -0
| @@ -611,6 +611,14 @@ type BrokerService interface { | |||
| 611 | 611 | // ConnectionID returns the ID assigned when this client connection was | |
| 612 | 612 | // accepted. Clients carry it on stream operations to fence stale requests. | |
| 613 | 613 | ConnectionID() BrokerConnectionID | |
| 614 | + | // Done closes exactly once when this connection is terminal: the broker | |
| 615 | + | // retired it, the carriage failed, or the client closed it locally. Err is | |
| 616 | + | // stable afterwards. A client observes Done/Err to attribute a broker loss | |
| 617 | + | // to the connection even while it holds no logical stream. | |
| 618 | + | Done() <-chan struct{} | |
| 619 | + | // Err returns the terminal cause, stable once Done is closed. It is nil for | |
| 620 | + | // an orderly local Close and non-nil for a broker-side loss or failure. | |
| 621 | + | Err() error | |
| 614 | 622 | Snapshot() BrokerSnapshot | |
| 615 | 623 | Subscribe() (BrokerSubscription, error) | |
| 616 | 624 | OpenStream(ctx context.Context, request BrokerOpenStreamRequest) (BrokerLogicalConnection, error) | |
| @@ -680,6 +688,31 @@ type BrokerEndpointConnector interface { | |||
| 680 | 688 | Connect(ctx context.Context, endpoint BrokerResolvedEndpoint) (BrokerPhysicalConnection, error) | |
| 681 | 689 | } | |
| 682 | 690 | ||
| 691 | + | // BrokerConnector establishes the client-facing broker connection: it connects | |
| 692 | + | // to the per-user broker endpoint and returns the connection-scoped | |
| 693 | + | // BrokerService for exactly that accepted connection. It is the client | |
| 694 | + | // process's admission seam, distinct from BrokerEndpointConnector (which opens | |
| 695 | + | // a pooled physical transport for one endpoint inside the broker) and from | |
| 696 | + | // BrokerAuthority (which admits a listener-side accepted connection). | |
| 697 | + | // | |
| 698 | + | // Connect must honor cancellation: a cancelled connect returns promptly and | |
| 699 | + | // leaves no service behind, so a client that abandons an attempt never leaks a | |
| 700 | + | // connection. Each successful Connect is independent and the caller owns Close | |
| 701 | + | // on the returned service; a connector retains no connection state of its own. | |
| 702 | + | // Semantic rejection is reported as a typed BrokerError and cancellation as a | |
| 703 | + | // context error. | |
| 704 | + | // | |
| 705 | + | // A successful service is independent of the setup context: Connect must be | |
| 706 | + | // given a context that bounds only the setup, and once Connect returns a | |
| 707 | + | // service the caller may cancel or let that context expire without closing, | |
| 708 | + | // interrupting, or otherwise disturbing the established connection. The | |
| 709 | + | // returned service's lifetime is bounded solely by its own Close and by the | |
| 710 | + | // broker, so a caller that abandons the setup context (a bounded attempt) still | |
| 711 | + | // owns a live connection it must Close. | |
| 712 | + | type BrokerConnector interface { | |
| 713 | + | Connect(ctx context.Context) (BrokerService, error) | |
| 714 | + | } | |
| 715 | + | ||
| 683 | 716 | // BrokerAuthority admits one accepted client connection to the broker core | |
| 684 | 717 | // and returns the BrokerService scoped to exactly that connection. It is the | |
| 685 | 718 | // admission seam between the broker listener adapter and the broker use case: | |
| @@ -611,6 +611,14 @@ type BrokerService interface { | |||||
| 611 | // ConnectionID returns the ID assigned when this client connection was | 611 | // ConnectionID returns the ID assigned when this client connection was | ||
| 612 | // accepted. Clients carry it on stream operations to fence stale requests. | 612 | // accepted. Clients carry it on stream operations to fence stale requests. | ||
| 613 | ConnectionID() BrokerConnectionID | 613 | ConnectionID() BrokerConnectionID | ||
| 614 | + | // Done closes exactly once when this connection is terminal: the broker | |||
| 615 | + | // retired it, the carriage failed, or the client closed it locally. Err is | |||
| 616 | + | // stable afterwards. A client observes Done/Err to attribute a broker loss | |||
| 617 | + | // to the connection even while it holds no logical stream. | |||
| 618 | + | Done() <-chan struct{} | |||
| 619 | + | // Err returns the terminal cause, stable once Done is closed. It is nil for | |||
| 620 | + | // an orderly local Close and non-nil for a broker-side loss or failure. | |||
| 621 | + | Err() error | |||
| 614 | Snapshot() BrokerSnapshot | 622 | Snapshot() BrokerSnapshot | ||
| 615 | Subscribe() (BrokerSubscription, error) | 623 | Subscribe() (BrokerSubscription, error) | ||
| 616 | OpenStream(ctx context.Context, request BrokerOpenStreamRequest) (BrokerLogicalConnection, error) | 624 | OpenStream(ctx context.Context, request BrokerOpenStreamRequest) (BrokerLogicalConnection, error) | ||
| @@ -680,6 +688,31 @@ type BrokerEndpointConnector interface { | |||||
| 680 | Connect(ctx context.Context, endpoint BrokerResolvedEndpoint) (BrokerPhysicalConnection, error) | 688 | Connect(ctx context.Context, endpoint BrokerResolvedEndpoint) (BrokerPhysicalConnection, error) | ||
| 681 | } | 689 | } | ||
| 682 | 690 | ||||
| 691 | + | // BrokerConnector establishes the client-facing broker connection: it connects | |||
| 692 | + | // to the per-user broker endpoint and returns the connection-scoped | |||
| 693 | + | // BrokerService for exactly that accepted connection. It is the client | |||
| 694 | + | // process's admission seam, distinct from BrokerEndpointConnector (which opens | |||
| 695 | + | // a pooled physical transport for one endpoint inside the broker) and from | |||
| 696 | + | // BrokerAuthority (which admits a listener-side accepted connection). | |||
| 697 | + | // | |||
| 698 | + | // Connect must honor cancellation: a cancelled connect returns promptly and | |||
| 699 | + | // leaves no service behind, so a client that abandons an attempt never leaks a | |||
| 700 | + | // connection. Each successful Connect is independent and the caller owns Close | |||
| 701 | + | // on the returned service; a connector retains no connection state of its own. | |||
| 702 | + | // Semantic rejection is reported as a typed BrokerError and cancellation as a | |||
| 703 | + | // context error. | |||
| 704 | + | // | |||
| 705 | + | // A successful service is independent of the setup context: Connect must be | |||
| 706 | + | // given a context that bounds only the setup, and once Connect returns a | |||
| 707 | + | // service the caller may cancel or let that context expire without closing, | |||
| 708 | + | // interrupting, or otherwise disturbing the established connection. The | |||
| 709 | + | // returned service's lifetime is bounded solely by its own Close and by the | |||
| 710 | + | // broker, so a caller that abandons the setup context (a bounded attempt) still | |||
| 711 | + | // owns a live connection it must Close. | |||
| 712 | + | type BrokerConnector interface { | |||
| 713 | + | Connect(ctx context.Context) (BrokerService, error) | |||
| 714 | + | } | |||
| 715 | + | ||||
| 683 | // BrokerAuthority admits one accepted client connection to the broker core | 716 | // BrokerAuthority admits one accepted client connection to the broker core | ||
| 684 | // and returns the BrokerService scoped to exactly that connection. It is the | 717 | // and returns the BrokerService scoped to exactly that connection. It is the | ||
| 685 | // admission seam between the broker listener adapter and the broker use case: | 718 | // admission seam between the broker listener adapter and the broker use case: | ||
M internal/ports/broker_test.go +1 -0
| @@ -535,6 +535,7 @@ func TestBrokerInterfacesArePorts(t *testing.T) { | |||
| 535 | 535 | "BrokerHostProbe": reflect.TypeOf((*BrokerHostProbe)(nil)).Elem(), | |
| 536 | 536 | "BrokerPhysicalConnection": reflect.TypeOf((*BrokerPhysicalConnection)(nil)).Elem(), | |
| 537 | 537 | "BrokerEndpointConnector": reflect.TypeOf((*BrokerEndpointConnector)(nil)).Elem(), | |
| 538 | + | "BrokerConnector": reflect.TypeOf((*BrokerConnector)(nil)).Elem(), | |
| 538 | 539 | "BrokerAuthority": reflect.TypeOf((*BrokerAuthority)(nil)).Elem(), | |
| 539 | 540 | "BrokerListener": reflect.TypeOf((*BrokerListener)(nil)).Elem(), | |
| 540 | 541 | "BrokerSubscription": reflect.TypeOf((*BrokerSubscription)(nil)).Elem(), | |
| @@ -535,6 +535,7 @@ func TestBrokerInterfacesArePorts(t *testing.T) { | |||||
| 535 | "BrokerHostProbe": reflect.TypeOf((*BrokerHostProbe)(nil)).Elem(), | 535 | "BrokerHostProbe": reflect.TypeOf((*BrokerHostProbe)(nil)).Elem(), | ||
| 536 | "BrokerPhysicalConnection": reflect.TypeOf((*BrokerPhysicalConnection)(nil)).Elem(), | 536 | "BrokerPhysicalConnection": reflect.TypeOf((*BrokerPhysicalConnection)(nil)).Elem(), | ||
| 537 | "BrokerEndpointConnector": reflect.TypeOf((*BrokerEndpointConnector)(nil)).Elem(), | 537 | "BrokerEndpointConnector": reflect.TypeOf((*BrokerEndpointConnector)(nil)).Elem(), | ||
| 538 | + | "BrokerConnector": reflect.TypeOf((*BrokerConnector)(nil)).Elem(), | |||
| 538 | "BrokerAuthority": reflect.TypeOf((*BrokerAuthority)(nil)).Elem(), | 539 | "BrokerAuthority": reflect.TypeOf((*BrokerAuthority)(nil)).Elem(), | ||
| 539 | "BrokerListener": reflect.TypeOf((*BrokerListener)(nil)).Elem(), | 540 | "BrokerListener": reflect.TypeOf((*BrokerListener)(nil)).Elem(), | ||
| 540 | "BrokerSubscription": reflect.TypeOf((*BrokerSubscription)(nil)).Elem(), | 541 | "BrokerSubscription": reflect.TypeOf((*BrokerSubscription)(nil)).Elem(), | ||
M internal/ports/mocks/broker_mock.go +179 -0
| @@ -349,6 +349,96 @@ func (_c *MockBrokerService_ConnectionID_Call) RunAndReturn(run func() ports.Bro | |||
| 349 | 349 | return _c | |
| 350 | 350 | } | |
| 351 | 351 | ||
| 352 | + | // Done provides a mock function for the type MockBrokerService | |
| 353 | + | func (_mock *MockBrokerService) Done() <-chan struct{} { | |
| 354 | + | ret := _mock.Called() | |
| 355 | + | ||
| 356 | + | if len(ret) == 0 { | |
| 357 | + | panic("no return value specified for Done") | |
| 358 | + | } | |
| 359 | + | ||
| 360 | + | var r0 <-chan struct{} | |
| 361 | + | if returnFunc, ok := ret.Get(0).(func() <-chan struct{}); ok { | |
| 362 | + | r0 = returnFunc() | |
| 363 | + | } else { | |
| 364 | + | if ret.Get(0) != nil { | |
| 365 | + | r0 = ret.Get(0).(<-chan struct{}) | |
| 366 | + | } | |
| 367 | + | } | |
| 368 | + | return r0 | |
| 369 | + | } | |
| 370 | + | ||
| 371 | + | // MockBrokerService_Done_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Done' | |
| 372 | + | type MockBrokerService_Done_Call struct { | |
| 373 | + | *mock.Call | |
| 374 | + | } | |
| 375 | + | ||
| 376 | + | // Done is a helper method to define mock.On call | |
| 377 | + | func (_e *MockBrokerService_Expecter) Done() *MockBrokerService_Done_Call { | |
| 378 | + | return &MockBrokerService_Done_Call{Call: _e.mock.On("Done")} | |
| 379 | + | } | |
| 380 | + | ||
| 381 | + | func (_c *MockBrokerService_Done_Call) Run(run func()) *MockBrokerService_Done_Call { | |
| 382 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 383 | + | run() | |
| 384 | + | }) | |
| 385 | + | return _c | |
| 386 | + | } | |
| 387 | + | ||
| 388 | + | func (_c *MockBrokerService_Done_Call) Return(valCh <-chan struct{}) *MockBrokerService_Done_Call { | |
| 389 | + | _c.Call.Return(valCh) | |
| 390 | + | return _c | |
| 391 | + | } | |
| 392 | + | ||
| 393 | + | func (_c *MockBrokerService_Done_Call) RunAndReturn(run func() <-chan struct{}) *MockBrokerService_Done_Call { | |
| 394 | + | _c.Call.Return(run) | |
| 395 | + | return _c | |
| 396 | + | } | |
| 397 | + | ||
| 398 | + | // Err provides a mock function for the type MockBrokerService | |
| 399 | + | func (_mock *MockBrokerService) Err() error { | |
| 400 | + | ret := _mock.Called() | |
| 401 | + | ||
| 402 | + | if len(ret) == 0 { | |
| 403 | + | panic("no return value specified for Err") | |
| 404 | + | } | |
| 405 | + | ||
| 406 | + | var r0 error | |
| 407 | + | if returnFunc, ok := ret.Get(0).(func() error); ok { | |
| 408 | + | r0 = returnFunc() | |
| 409 | + | } else { | |
| 410 | + | r0 = ret.Error(0) | |
| 411 | + | } | |
| 412 | + | return r0 | |
| 413 | + | } | |
| 414 | + | ||
| 415 | + | // MockBrokerService_Err_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Err' | |
| 416 | + | type MockBrokerService_Err_Call struct { | |
| 417 | + | *mock.Call | |
| 418 | + | } | |
| 419 | + | ||
| 420 | + | // Err is a helper method to define mock.On call | |
| 421 | + | func (_e *MockBrokerService_Expecter) Err() *MockBrokerService_Err_Call { | |
| 422 | + | return &MockBrokerService_Err_Call{Call: _e.mock.On("Err")} | |
| 423 | + | } | |
| 424 | + | ||
| 425 | + | func (_c *MockBrokerService_Err_Call) Run(run func()) *MockBrokerService_Err_Call { | |
| 426 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 427 | + | run() | |
| 428 | + | }) | |
| 429 | + | return _c | |
| 430 | + | } | |
| 431 | + | ||
| 432 | + | func (_c *MockBrokerService_Err_Call) Return(err error) *MockBrokerService_Err_Call { | |
| 433 | + | _c.Call.Return(err) | |
| 434 | + | return _c | |
| 435 | + | } | |
| 436 | + | ||
| 437 | + | func (_c *MockBrokerService_Err_Call) RunAndReturn(run func() error) *MockBrokerService_Err_Call { | |
| 438 | + | _c.Call.Return(run) | |
| 439 | + | return _c | |
| 440 | + | } | |
| 441 | + | ||
| 352 | 442 | // OpenStream provides a mock function for the type MockBrokerService | |
| 353 | 443 | func (_mock *MockBrokerService) OpenStream(ctx context.Context, request ports.BrokerOpenStreamRequest) (ports.BrokerLogicalConnection, error) { | |
| 354 | 444 | ret := _mock.Called(ctx, request) | |
| @@ -1348,6 +1438,95 @@ func (_c *MockBrokerEndpointConnector_Connect_Call) RunAndReturn(run func(ctx co | |||
| 1348 | 1438 | return _c | |
| 1349 | 1439 | } | |
| 1350 | 1440 | ||
| 1441 | + | // NewMockBrokerConnector creates a new instance of MockBrokerConnector. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |
| 1442 | + | // The first argument is typically a *testing.T value. | |
| 1443 | + | func NewMockBrokerConnector(t interface { | |
| 1444 | + | mock.TestingT | |
| 1445 | + | Cleanup(func()) | |
| 1446 | + | }) *MockBrokerConnector { | |
| 1447 | + | mock := &MockBrokerConnector{} | |
| 1448 | + | mock.Mock.Test(t) | |
| 1449 | + | ||
| 1450 | + | t.Cleanup(func() { mock.AssertExpectations(t) }) | |
| 1451 | + | ||
| 1452 | + | return mock | |
| 1453 | + | } | |
| 1454 | + | ||
| 1455 | + | // MockBrokerConnector is an autogenerated mock type for the BrokerConnector type | |
| 1456 | + | type MockBrokerConnector struct { | |
| 1457 | + | mock.Mock | |
| 1458 | + | } | |
| 1459 | + | ||
| 1460 | + | type MockBrokerConnector_Expecter struct { | |
| 1461 | + | mock *mock.Mock | |
| 1462 | + | } | |
| 1463 | + | ||
| 1464 | + | func (_m *MockBrokerConnector) EXPECT() *MockBrokerConnector_Expecter { | |
| 1465 | + | return &MockBrokerConnector_Expecter{mock: &_m.Mock} | |
| 1466 | + | } | |
| 1467 | + | ||
| 1468 | + | // Connect provides a mock function for the type MockBrokerConnector | |
| 1469 | + | func (_mock *MockBrokerConnector) Connect(ctx context.Context) (ports.BrokerService, error) { | |
| 1470 | + | ret := _mock.Called(ctx) | |
| 1471 | + | ||
| 1472 | + | if len(ret) == 0 { | |
| 1473 | + | panic("no return value specified for Connect") | |
| 1474 | + | } | |
| 1475 | + | ||
| 1476 | + | var r0 ports.BrokerService | |
| 1477 | + | var r1 error | |
| 1478 | + | if returnFunc, ok := ret.Get(0).(func(context.Context) (ports.BrokerService, error)); ok { | |
| 1479 | + | return returnFunc(ctx) | |
| 1480 | + | } | |
| 1481 | + | if returnFunc, ok := ret.Get(0).(func(context.Context) ports.BrokerService); ok { | |
| 1482 | + | r0 = returnFunc(ctx) | |
| 1483 | + | } else { | |
| 1484 | + | if ret.Get(0) != nil { | |
| 1485 | + | r0 = ret.Get(0).(ports.BrokerService) | |
| 1486 | + | } | |
| 1487 | + | } | |
| 1488 | + | if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok { | |
| 1489 | + | r1 = returnFunc(ctx) | |
| 1490 | + | } else { | |
| 1491 | + | r1 = ret.Error(1) | |
| 1492 | + | } | |
| 1493 | + | return r0, r1 | |
| 1494 | + | } | |
| 1495 | + | ||
| 1496 | + | // MockBrokerConnector_Connect_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Connect' | |
| 1497 | + | type MockBrokerConnector_Connect_Call struct { | |
| 1498 | + | *mock.Call | |
| 1499 | + | } | |
| 1500 | + | ||
| 1501 | + | // Connect is a helper method to define mock.On call | |
| 1502 | + | // - ctx context.Context | |
| 1503 | + | func (_e *MockBrokerConnector_Expecter) Connect(ctx any) *MockBrokerConnector_Connect_Call { | |
| 1504 | + | return &MockBrokerConnector_Connect_Call{Call: _e.mock.On("Connect", ctx)} | |
| 1505 | + | } | |
| 1506 | + | ||
| 1507 | + | func (_c *MockBrokerConnector_Connect_Call) Run(run func(ctx context.Context)) *MockBrokerConnector_Connect_Call { | |
| 1508 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 1509 | + | var arg0 context.Context | |
| 1510 | + | if args[0] != nil { | |
| 1511 | + | arg0 = args[0].(context.Context) | |
| 1512 | + | } | |
| 1513 | + | run( | |
| 1514 | + | arg0, | |
| 1515 | + | ) | |
| 1516 | + | }) | |
| 1517 | + | return _c | |
| 1518 | + | } | |
| 1519 | + | ||
| 1520 | + | func (_c *MockBrokerConnector_Connect_Call) Return(brokerService ports.BrokerService, err error) *MockBrokerConnector_Connect_Call { | |
| 1521 | + | _c.Call.Return(brokerService, err) | |
| 1522 | + | return _c | |
| 1523 | + | } | |
| 1524 | + | ||
| 1525 | + | func (_c *MockBrokerConnector_Connect_Call) RunAndReturn(run func(ctx context.Context) (ports.BrokerService, error)) *MockBrokerConnector_Connect_Call { | |
| 1526 | + | _c.Call.Return(run) | |
| 1527 | + | return _c | |
| 1528 | + | } | |
| 1529 | + | ||
| 1351 | 1530 | // NewMockBrokerAuthority creates a new instance of MockBrokerAuthority. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |
| 1352 | 1531 | // The first argument is typically a *testing.T value. | |
| 1353 | 1532 | func NewMockBrokerAuthority(t interface { | |
| @@ -349,6 +349,96 @@ func (_c *MockBrokerService_ConnectionID_Call) RunAndReturn(run func() ports.Bro | |||||
| 349 | return _c | 349 | return _c | ||
| 350 | } | 350 | } | ||
| 351 | 351 | ||||
| 352 | + | // Done provides a mock function for the type MockBrokerService | |||
| 353 | + | func (_mock *MockBrokerService) Done() <-chan struct{} { | |||
| 354 | + | ret := _mock.Called() | |||
| 355 | + | ||||
| 356 | + | if len(ret) == 0 { | |||
| 357 | + | panic("no return value specified for Done") | |||
| 358 | + | } | |||
| 359 | + | ||||
| 360 | + | var r0 <-chan struct{} | |||
| 361 | + | if returnFunc, ok := ret.Get(0).(func() <-chan struct{}); ok { | |||
| 362 | + | r0 = returnFunc() | |||
| 363 | + | } else { | |||
| 364 | + | if ret.Get(0) != nil { | |||
| 365 | + | r0 = ret.Get(0).(<-chan struct{}) | |||
| 366 | + | } | |||
| 367 | + | } | |||
| 368 | + | return r0 | |||
| 369 | + | } | |||
| 370 | + | ||||
| 371 | + | // MockBrokerService_Done_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Done' | |||
| 372 | + | type MockBrokerService_Done_Call struct { | |||
| 373 | + | *mock.Call | |||
| 374 | + | } | |||
| 375 | + | ||||
| 376 | + | // Done is a helper method to define mock.On call | |||
| 377 | + | func (_e *MockBrokerService_Expecter) Done() *MockBrokerService_Done_Call { | |||
| 378 | + | return &MockBrokerService_Done_Call{Call: _e.mock.On("Done")} | |||
| 379 | + | } | |||
| 380 | + | ||||
| 381 | + | func (_c *MockBrokerService_Done_Call) Run(run func()) *MockBrokerService_Done_Call { | |||
| 382 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 383 | + | run() | |||
| 384 | + | }) | |||
| 385 | + | return _c | |||
| 386 | + | } | |||
| 387 | + | ||||
| 388 | + | func (_c *MockBrokerService_Done_Call) Return(valCh <-chan struct{}) *MockBrokerService_Done_Call { | |||
| 389 | + | _c.Call.Return(valCh) | |||
| 390 | + | return _c | |||
| 391 | + | } | |||
| 392 | + | ||||
| 393 | + | func (_c *MockBrokerService_Done_Call) RunAndReturn(run func() <-chan struct{}) *MockBrokerService_Done_Call { | |||
| 394 | + | _c.Call.Return(run) | |||
| 395 | + | return _c | |||
| 396 | + | } | |||
| 397 | + | ||||
| 398 | + | // Err provides a mock function for the type MockBrokerService | |||
| 399 | + | func (_mock *MockBrokerService) Err() error { | |||
| 400 | + | ret := _mock.Called() | |||
| 401 | + | ||||
| 402 | + | if len(ret) == 0 { | |||
| 403 | + | panic("no return value specified for Err") | |||
| 404 | + | } | |||
| 405 | + | ||||
| 406 | + | var r0 error | |||
| 407 | + | if returnFunc, ok := ret.Get(0).(func() error); ok { | |||
| 408 | + | r0 = returnFunc() | |||
| 409 | + | } else { | |||
| 410 | + | r0 = ret.Error(0) | |||
| 411 | + | } | |||
| 412 | + | return r0 | |||
| 413 | + | } | |||
| 414 | + | ||||
| 415 | + | // MockBrokerService_Err_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Err' | |||
| 416 | + | type MockBrokerService_Err_Call struct { | |||
| 417 | + | *mock.Call | |||
| 418 | + | } | |||
| 419 | + | ||||
| 420 | + | // Err is a helper method to define mock.On call | |||
| 421 | + | func (_e *MockBrokerService_Expecter) Err() *MockBrokerService_Err_Call { | |||
| 422 | + | return &MockBrokerService_Err_Call{Call: _e.mock.On("Err")} | |||
| 423 | + | } | |||
| 424 | + | ||||
| 425 | + | func (_c *MockBrokerService_Err_Call) Run(run func()) *MockBrokerService_Err_Call { | |||
| 426 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 427 | + | run() | |||
| 428 | + | }) | |||
| 429 | + | return _c | |||
| 430 | + | } | |||
| 431 | + | ||||
| 432 | + | func (_c *MockBrokerService_Err_Call) Return(err error) *MockBrokerService_Err_Call { | |||
| 433 | + | _c.Call.Return(err) | |||
| 434 | + | return _c | |||
| 435 | + | } | |||
| 436 | + | ||||
| 437 | + | func (_c *MockBrokerService_Err_Call) RunAndReturn(run func() error) *MockBrokerService_Err_Call { | |||
| 438 | + | _c.Call.Return(run) | |||
| 439 | + | return _c | |||
| 440 | + | } | |||
| 441 | + | ||||
| 352 | // OpenStream provides a mock function for the type MockBrokerService | 442 | // OpenStream provides a mock function for the type MockBrokerService | ||
| 353 | func (_mock *MockBrokerService) OpenStream(ctx context.Context, request ports.BrokerOpenStreamRequest) (ports.BrokerLogicalConnection, error) { | 443 | func (_mock *MockBrokerService) OpenStream(ctx context.Context, request ports.BrokerOpenStreamRequest) (ports.BrokerLogicalConnection, error) { | ||
| 354 | ret := _mock.Called(ctx, request) | 444 | ret := _mock.Called(ctx, request) | ||
| @@ -1348,6 +1438,95 @@ func (_c *MockBrokerEndpointConnector_Connect_Call) RunAndReturn(run func(ctx co | |||||
| 1348 | return _c | 1438 | return _c | ||
| 1349 | } | 1439 | } | ||
| 1350 | 1440 | ||||
| 1441 | + | // NewMockBrokerConnector creates a new instance of MockBrokerConnector. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |||
| 1442 | + | // The first argument is typically a *testing.T value. | |||
| 1443 | + | func NewMockBrokerConnector(t interface { | |||
| 1444 | + | mock.TestingT | |||
| 1445 | + | Cleanup(func()) | |||
| 1446 | + | }) *MockBrokerConnector { | |||
| 1447 | + | mock := &MockBrokerConnector{} | |||
| 1448 | + | mock.Mock.Test(t) | |||
| 1449 | + | ||||
| 1450 | + | t.Cleanup(func() { mock.AssertExpectations(t) }) | |||
| 1451 | + | ||||
| 1452 | + | return mock | |||
| 1453 | + | } | |||
| 1454 | + | ||||
| 1455 | + | // MockBrokerConnector is an autogenerated mock type for the BrokerConnector type | |||
| 1456 | + | type MockBrokerConnector struct { | |||
| 1457 | + | mock.Mock | |||
| 1458 | + | } | |||
| 1459 | + | ||||
| 1460 | + | type MockBrokerConnector_Expecter struct { | |||
| 1461 | + | mock *mock.Mock | |||
| 1462 | + | } | |||
| 1463 | + | ||||
| 1464 | + | func (_m *MockBrokerConnector) EXPECT() *MockBrokerConnector_Expecter { | |||
| 1465 | + | return &MockBrokerConnector_Expecter{mock: &_m.Mock} | |||
| 1466 | + | } | |||
| 1467 | + | ||||
| 1468 | + | // Connect provides a mock function for the type MockBrokerConnector | |||
| 1469 | + | func (_mock *MockBrokerConnector) Connect(ctx context.Context) (ports.BrokerService, error) { | |||
| 1470 | + | ret := _mock.Called(ctx) | |||
| 1471 | + | ||||
| 1472 | + | if len(ret) == 0 { | |||
| 1473 | + | panic("no return value specified for Connect") | |||
| 1474 | + | } | |||
| 1475 | + | ||||
| 1476 | + | var r0 ports.BrokerService | |||
| 1477 | + | var r1 error | |||
| 1478 | + | if returnFunc, ok := ret.Get(0).(func(context.Context) (ports.BrokerService, error)); ok { | |||
| 1479 | + | return returnFunc(ctx) | |||
| 1480 | + | } | |||
| 1481 | + | if returnFunc, ok := ret.Get(0).(func(context.Context) ports.BrokerService); ok { | |||
| 1482 | + | r0 = returnFunc(ctx) | |||
| 1483 | + | } else { | |||
| 1484 | + | if ret.Get(0) != nil { | |||
| 1485 | + | r0 = ret.Get(0).(ports.BrokerService) | |||
| 1486 | + | } | |||
| 1487 | + | } | |||
| 1488 | + | if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok { | |||
| 1489 | + | r1 = returnFunc(ctx) | |||
| 1490 | + | } else { | |||
| 1491 | + | r1 = ret.Error(1) | |||
| 1492 | + | } | |||
| 1493 | + | return r0, r1 | |||
| 1494 | + | } | |||
| 1495 | + | ||||
| 1496 | + | // MockBrokerConnector_Connect_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Connect' | |||
| 1497 | + | type MockBrokerConnector_Connect_Call struct { | |||
| 1498 | + | *mock.Call | |||
| 1499 | + | } | |||
| 1500 | + | ||||
| 1501 | + | // Connect is a helper method to define mock.On call | |||
| 1502 | + | // - ctx context.Context | |||
| 1503 | + | func (_e *MockBrokerConnector_Expecter) Connect(ctx any) *MockBrokerConnector_Connect_Call { | |||
| 1504 | + | return &MockBrokerConnector_Connect_Call{Call: _e.mock.On("Connect", ctx)} | |||
| 1505 | + | } | |||
| 1506 | + | ||||
| 1507 | + | func (_c *MockBrokerConnector_Connect_Call) Run(run func(ctx context.Context)) *MockBrokerConnector_Connect_Call { | |||
| 1508 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 1509 | + | var arg0 context.Context | |||
| 1510 | + | if args[0] != nil { | |||
| 1511 | + | arg0 = args[0].(context.Context) | |||
| 1512 | + | } | |||
| 1513 | + | run( | |||
| 1514 | + | arg0, | |||
| 1515 | + | ) | |||
| 1516 | + | }) | |||
| 1517 | + | return _c | |||
| 1518 | + | } | |||
| 1519 | + | ||||
| 1520 | + | func (_c *MockBrokerConnector_Connect_Call) Return(brokerService ports.BrokerService, err error) *MockBrokerConnector_Connect_Call { | |||
| 1521 | + | _c.Call.Return(brokerService, err) | |||
| 1522 | + | return _c | |||
| 1523 | + | } | |||
| 1524 | + | ||||
| 1525 | + | func (_c *MockBrokerConnector_Connect_Call) RunAndReturn(run func(ctx context.Context) (ports.BrokerService, error)) *MockBrokerConnector_Connect_Call { | |||
| 1526 | + | _c.Call.Return(run) | |||
| 1527 | + | return _c | |||
| 1528 | + | } | |||
| 1529 | + | ||||
| 1351 | // NewMockBrokerAuthority creates a new instance of MockBrokerAuthority. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | 1530 | // NewMockBrokerAuthority creates a new instance of MockBrokerAuthority. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | ||
| 1352 | // The first argument is typically a *testing.T value. | 1531 | // The first argument is typically a *testing.T value. | ||
| 1353 | func NewMockBrokerAuthority(t interface { | 1532 | func NewMockBrokerAuthority(t interface { | ||
M internal/usecase/broker/service.go +52 -1
| @@ -133,22 +133,66 @@ type Service struct { | |||
| 133 | 133 | wg sync.WaitGroup | |
| 134 | 134 | ||
| 135 | 135 | closeOnce sync.Once | |
| 136 | + | ||
| 137 | + | // done closes exactly once when this connection is terminal, either because | |
| 138 | + | // the broker root shut down or because Close settled it locally. Err is | |
| 139 | + | // stable afterwards (nil for an orderly local Close). stopRoot detaches the | |
| 140 | + | // root watcher once Close has committed so a closed connection never leaves | |
| 141 | + | // a root callback behind. | |
| 142 | + | done chan struct{} | |
| 143 | + | stopRoot func() bool | |
| 144 | + | termOnce sync.Once | |
| 145 | + | termErr error | |
| 136 | 146 | } | |
| 137 | 147 | ||
| 138 | 148 | var _ ports.BrokerService = (*Service)(nil) | |
| 139 | 149 | ||
| 140 | 150 | func newService(epoch ports.BrokerEpoch, id ports.BrokerConnectionID, registry *Registry, pool *Pool, supervisor *Supervisor, lease *Lease) *Service { | |
| 141 | 151 | ctx, cancel := context.WithCancel(supervisor.RootContext()) | |
| 142 | - | return &Service{ | |
| 152 | + | s := &Service{ | |
| 143 | 153 | epoch: epoch, id: id, registry: registry, pool: pool, supervisor: supervisor, lease: lease, | |
| 144 | 154 | ctx: ctx, cancel: cancel, subs: make(map[*serviceSubscription]struct{}), | |
| 155 | + | done: make(chan struct{}), | |
| 145 | 156 | } | |
| 157 | + | // Broker shutdown ends every admitted connection. The watcher records the | |
| 158 | + | // loss as the terminal cause unless a local Close has already settled the | |
| 159 | + | // connection, so a connection closed by its owner reports nil and one lost | |
| 160 | + | // to a broker shutdown reports the typed loss. | |
| 161 | + | s.stopRoot = context.AfterFunc(supervisor.RootContext(), func() { | |
| 162 | + | s.terminalize(ports.BrokerError{Code: ports.BrokerErrorUnavailable, Text: "broker shutdown", Cause: context.Canceled}) | |
| 163 | + | }) | |
| 164 | + | return s | |
| 146 | 165 | } | |
| 147 | 166 | ||
| 148 | 167 | // ConnectionID returns the pool identity assigned at admission. Clients carry | |
| 149 | 168 | // it on stream operations so a stale request can be fenced. | |
| 150 | 169 | func (s *Service) ConnectionID() ports.BrokerConnectionID { return s.id } | |
| 151 | 170 | ||
| 171 | + | // Done closes exactly once when this connection is terminal: the broker root | |
| 172 | + | // shut down or Close settled it locally. Err is stable afterwards. | |
| 173 | + | func (s *Service) Done() <-chan struct{} { return s.done } | |
| 174 | + | ||
| 175 | + | // Err returns this connection's terminal cause: nil for an orderly local Close, | |
| 176 | + | // otherwise the broker-side loss that settled it. The cause is recorded exactly | |
| 177 | + | // once and never overwritten, so it is stable once Done is closed. | |
| 178 | + | func (s *Service) Err() error { | |
| 179 | + | s.mu.Lock() | |
| 180 | + | defer s.mu.Unlock() | |
| 181 | + | return s.termErr | |
| 182 | + | } | |
| 183 | + | ||
| 184 | + | // terminalize records the terminal cause and closes Done exactly once. The | |
| 185 | + | // first cause wins, so a race between a broker-root loss and a local Close | |
| 186 | + | // yields one stable, observable outcome. | |
| 187 | + | func (s *Service) terminalize(err error) { | |
| 188 | + | s.termOnce.Do(func() { | |
| 189 | + | s.mu.Lock() | |
| 190 | + | s.termErr = err | |
| 191 | + | s.mu.Unlock() | |
| 192 | + | close(s.done) | |
| 193 | + | }) | |
| 194 | + | } | |
| 195 | + | ||
| 152 | 196 | // Snapshot delegates to the registry's current immutable publication. | |
| 153 | 197 | func (s *Service) Snapshot() ports.BrokerSnapshot { return s.registry.Snapshot() } | |
| 154 | 198 | ||
| @@ -252,6 +296,13 @@ func (s *Service) Close() error { | |||
| 252 | 296 | } | |
| 253 | 297 | s.pool.CloseClient(s.id) | |
| 254 | 298 | s.lease.Release() | |
| 299 | + | // The connection is locally and orderly closed: detach the broker-root | |
| 300 | + | // watcher so it can neither fire later nor overwrite the clean outcome, | |
| 301 | + | // then publish the terminal state. | |
| 302 | + | if s.stopRoot != nil { | |
| 303 | + | s.stopRoot() | |
| 304 | + | } | |
| 305 | + | s.terminalize(nil) | |
| 255 | 306 | }) | |
| 256 | 307 | return nil | |
| 257 | 308 | } | |
| @@ -133,22 +133,66 @@ type Service struct { | |||||
| 133 | wg sync.WaitGroup | 133 | wg sync.WaitGroup | ||
| 134 | 134 | ||||
| 135 | closeOnce sync.Once | 135 | closeOnce sync.Once | ||
| 136 | + | ||||
| 137 | + | // done closes exactly once when this connection is terminal, either because | |||
| 138 | + | // the broker root shut down or because Close settled it locally. Err is | |||
| 139 | + | // stable afterwards (nil for an orderly local Close). stopRoot detaches the | |||
| 140 | + | // root watcher once Close has committed so a closed connection never leaves | |||
| 141 | + | // a root callback behind. | |||
| 142 | + | done chan struct{} | |||
| 143 | + | stopRoot func() bool | |||
| 144 | + | termOnce sync.Once | |||
| 145 | + | termErr error | |||
| 136 | } | 146 | } | ||
| 137 | 147 | ||||
| 138 | var _ ports.BrokerService = (*Service)(nil) | 148 | var _ ports.BrokerService = (*Service)(nil) | ||
| 139 | 149 | ||||
| 140 | func newService(epoch ports.BrokerEpoch, id ports.BrokerConnectionID, registry *Registry, pool *Pool, supervisor *Supervisor, lease *Lease) *Service { | 150 | func newService(epoch ports.BrokerEpoch, id ports.BrokerConnectionID, registry *Registry, pool *Pool, supervisor *Supervisor, lease *Lease) *Service { | ||
| 141 | ctx, cancel := context.WithCancel(supervisor.RootContext()) | 151 | ctx, cancel := context.WithCancel(supervisor.RootContext()) | ||
| 142 | - | return &Service{ | 152 | + | s := &Service{ |
| 143 | epoch: epoch, id: id, registry: registry, pool: pool, supervisor: supervisor, lease: lease, | 153 | epoch: epoch, id: id, registry: registry, pool: pool, supervisor: supervisor, lease: lease, | ||
| 144 | ctx: ctx, cancel: cancel, subs: make(map[*serviceSubscription]struct{}), | 154 | ctx: ctx, cancel: cancel, subs: make(map[*serviceSubscription]struct{}), | ||
| 155 | + | done: make(chan struct{}), | |||
| 145 | } | 156 | } | ||
| 157 | + | // Broker shutdown ends every admitted connection. The watcher records the | |||
| 158 | + | // loss as the terminal cause unless a local Close has already settled the | |||
| 159 | + | // connection, so a connection closed by its owner reports nil and one lost | |||
| 160 | + | // to a broker shutdown reports the typed loss. | |||
| 161 | + | s.stopRoot = context.AfterFunc(supervisor.RootContext(), func() { | |||
| 162 | + | s.terminalize(ports.BrokerError{Code: ports.BrokerErrorUnavailable, Text: "broker shutdown", Cause: context.Canceled}) | |||
| 163 | + | }) | |||
| 164 | + | return s | |||
| 146 | } | 165 | } | ||
| 147 | 166 | ||||
| 148 | // ConnectionID returns the pool identity assigned at admission. Clients carry | 167 | // ConnectionID returns the pool identity assigned at admission. Clients carry | ||
| 149 | // it on stream operations so a stale request can be fenced. | 168 | // it on stream operations so a stale request can be fenced. | ||
| 150 | func (s *Service) ConnectionID() ports.BrokerConnectionID { return s.id } | 169 | func (s *Service) ConnectionID() ports.BrokerConnectionID { return s.id } | ||
| 151 | 170 | ||||
| 171 | + | // Done closes exactly once when this connection is terminal: the broker root | |||
| 172 | + | // shut down or Close settled it locally. Err is stable afterwards. | |||
| 173 | + | func (s *Service) Done() <-chan struct{} { return s.done } | |||
| 174 | + | ||||
| 175 | + | // Err returns this connection's terminal cause: nil for an orderly local Close, | |||
| 176 | + | // otherwise the broker-side loss that settled it. The cause is recorded exactly | |||
| 177 | + | // once and never overwritten, so it is stable once Done is closed. | |||
| 178 | + | func (s *Service) Err() error { | |||
| 179 | + | s.mu.Lock() | |||
| 180 | + | defer s.mu.Unlock() | |||
| 181 | + | return s.termErr | |||
| 182 | + | } | |||
| 183 | + | ||||
| 184 | + | // terminalize records the terminal cause and closes Done exactly once. The | |||
| 185 | + | // first cause wins, so a race between a broker-root loss and a local Close | |||
| 186 | + | // yields one stable, observable outcome. | |||
| 187 | + | func (s *Service) terminalize(err error) { | |||
| 188 | + | s.termOnce.Do(func() { | |||
| 189 | + | s.mu.Lock() | |||
| 190 | + | s.termErr = err | |||
| 191 | + | s.mu.Unlock() | |||
| 192 | + | close(s.done) | |||
| 193 | + | }) | |||
| 194 | + | } | |||
| 195 | + | ||||
| 152 | // Snapshot delegates to the registry's current immutable publication. | 196 | // Snapshot delegates to the registry's current immutable publication. | ||
| 153 | func (s *Service) Snapshot() ports.BrokerSnapshot { return s.registry.Snapshot() } | 197 | func (s *Service) Snapshot() ports.BrokerSnapshot { return s.registry.Snapshot() } | ||
| 154 | 198 | ||||
| @@ -252,6 +296,13 @@ func (s *Service) Close() error { | |||||
| 252 | } | 296 | } | ||
| 253 | s.pool.CloseClient(s.id) | 297 | s.pool.CloseClient(s.id) | ||
| 254 | s.lease.Release() | 298 | s.lease.Release() | ||
| 299 | + | // The connection is locally and orderly closed: detach the broker-root | |||
| 300 | + | // watcher so it can neither fire later nor overwrite the clean outcome, | |||
| 301 | + | // then publish the terminal state. | |||
| 302 | + | if s.stopRoot != nil { | |||
| 303 | + | s.stopRoot() | |||
| 304 | + | } | |||
| 305 | + | s.terminalize(nil) | |||
| 255 | }) | 306 | }) | ||
| 256 | return nil | 307 | return nil | ||
| 257 | } | 308 | } | ||
M internal/usecase/broker/service_test.go +30 -0
| @@ -380,6 +380,36 @@ func TestServiceCloseIsIdempotentAndConcurrent(t *testing.T) { | |||
| 380 | 380 | requireBrokerClosed(t, err) | |
| 381 | 381 | } | |
| 382 | 382 | ||
| 383 | + | func TestServiceDoneErrContract(t *testing.T) { | |
| 384 | + | t.Run("local close is orderly", func(t *testing.T) { | |
| 385 | + | authority, _, _, _, _, _ := newTestAuthority(t, 1, nil, immediateConnector) | |
| 386 | + | service, err := authority.AdmitClient(context.Background()) | |
| 387 | + | require.NoError(t, err) | |
| 388 | + | ||
| 389 | + | select { | |
| 390 | + | case <-service.Done(): | |
| 391 | + | t.Fatal("Done closed before any terminal event") | |
| 392 | + | default: | |
| 393 | + | } | |
| 394 | + | require.NoError(t, service.Close()) | |
| 395 | + | await(t, service.Done()) | |
| 396 | + | require.NoError(t, service.Err(), "an orderly local Close reports no terminal cause") | |
| 397 | + | require.NoError(t, service.Err(), "Err is stable after Done") | |
| 398 | + | }) | |
| 399 | + | ||
| 400 | + | t.Run("broker shutdown is a typed loss", func(t *testing.T) { | |
| 401 | + | authority, _, _, supervisor, _, _ := newTestAuthority(t, 1, nil, immediateConnector) | |
| 402 | + | service, err := authority.AdmitClient(context.Background()) | |
| 403 | + | require.NoError(t, err) | |
| 404 | + | ||
| 405 | + | require.NoError(t, supervisor.Close()) | |
| 406 | + | await(t, service.Done()) | |
| 407 | + | var typed ports.BrokerError | |
| 408 | + | require.ErrorAs(t, service.Err(), &typed) | |
| 409 | + | require.Equal(t, ports.BrokerErrorUnavailable, typed.Code) | |
| 410 | + | }) | |
| 411 | + | } | |
| 412 | + | ||
| 383 | 413 | func TestServiceShutdownTerminatesOwnedWork(t *testing.T) { | |
| 384 | 414 | authority, _, _, supervisor, _, _ := newTestAuthority(t, 1, nil, immediateConnector) | |
| 385 | 415 | service, err := authority.AdmitClient(context.Background()) | |
| @@ -380,6 +380,36 @@ func TestServiceCloseIsIdempotentAndConcurrent(t *testing.T) { | |||||
| 380 | requireBrokerClosed(t, err) | 380 | requireBrokerClosed(t, err) | ||
| 381 | } | 381 | } | ||
| 382 | 382 | ||||
| 383 | + | func TestServiceDoneErrContract(t *testing.T) { | |||
| 384 | + | t.Run("local close is orderly", func(t *testing.T) { | |||
| 385 | + | authority, _, _, _, _, _ := newTestAuthority(t, 1, nil, immediateConnector) | |||
| 386 | + | service, err := authority.AdmitClient(context.Background()) | |||
| 387 | + | require.NoError(t, err) | |||
| 388 | + | ||||
| 389 | + | select { | |||
| 390 | + | case <-service.Done(): | |||
| 391 | + | t.Fatal("Done closed before any terminal event") | |||
| 392 | + | default: | |||
| 393 | + | } | |||
| 394 | + | require.NoError(t, service.Close()) | |||
| 395 | + | await(t, service.Done()) | |||
| 396 | + | require.NoError(t, service.Err(), "an orderly local Close reports no terminal cause") | |||
| 397 | + | require.NoError(t, service.Err(), "Err is stable after Done") | |||
| 398 | + | }) | |||
| 399 | + | ||||
| 400 | + | t.Run("broker shutdown is a typed loss", func(t *testing.T) { | |||
| 401 | + | authority, _, _, supervisor, _, _ := newTestAuthority(t, 1, nil, immediateConnector) | |||
| 402 | + | service, err := authority.AdmitClient(context.Background()) | |||
| 403 | + | require.NoError(t, err) | |||
| 404 | + | ||||
| 405 | + | require.NoError(t, supervisor.Close()) | |||
| 406 | + | await(t, service.Done()) | |||
| 407 | + | var typed ports.BrokerError | |||
| 408 | + | require.ErrorAs(t, service.Err(), &typed) | |||
| 409 | + | require.Equal(t, ports.BrokerErrorUnavailable, typed.Code) | |||
| 410 | + | }) | |||
| 411 | + | } | |||
| 412 | + | ||||
| 383 | func TestServiceShutdownTerminatesOwnedWork(t *testing.T) { | 413 | func TestServiceShutdownTerminatesOwnedWork(t *testing.T) { | ||
| 384 | authority, _, _, supervisor, _, _ := newTestAuthority(t, 1, nil, immediateConnector) | 414 | authority, _, _, supervisor, _, _ := newTestAuthority(t, 1, nil, immediateConnector) | ||
| 385 | service, err := authority.AdmitClient(context.Background()) | 415 | service, err := authority.AdmitClient(context.Background()) | ||
A internal/usecase/client/supervisor.go +730 -0
| @@ -0,0 +1,730 @@ | |||
| 1 | + | package client | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "errors" | |
| 6 | + | "fmt" | |
| 7 | + | "io" | |
| 8 | + | "math/rand/v2" | |
| 9 | + | "reflect" | |
| 10 | + | "sync" | |
| 11 | + | "time" | |
| 12 | + | ||
| 13 | + | "github.com/bnema/vev/internal/ports" | |
| 14 | + | ) | |
| 15 | + | ||
| 16 | + | // Autonomous client supervisor (Plan 001 P5.1a, offline and unactivated). | |
| 17 | + | // | |
| 18 | + | // The supervisor is the attachment-free, terminal-owning owner of one | |
| 19 | + | // autonomous client process. It enters raw mode once, before it ever reaches | |
| 20 | + | // for the broker, and owns a single terminal input lifetime for the whole run. | |
| 21 | + | // While the broker connection is down it renders the picker and keeps | |
| 22 | + | // reconnecting; it never attaches a session and never interprets terminal | |
| 23 | + | // input. Only the picker presentation and the broker-connectivity lifecycle are | |
| 24 | + | // exercised by this slice: PresentConnecting and PresentAttached are defined so | |
| 25 | + | // the attach slices can drive them later, but nothing here enters them. | |
| 26 | + | // | |
| 27 | + | // Connectivity is one attempt at a time. An attempt connects, subscribes, and | |
| 28 | + | // waits for the first committed publication before it is Ready; readiness | |
| 29 | + | // resets the equal-jitter exponential retry cadence (100ms doubling to a 2s | |
| 30 | + | // cap). A transient connect, subscribe, or snapshot failure, and the loss of an | |
| 31 | + | // established connection observed through BrokerService.Done/Err while no | |
| 32 | + | // logical stream is held, both retire the old subscription and service, wait | |
| 33 | + | // the cadence, and reconnect under a fresh generation. A failure that can never | |
| 34 | + | // succeed by retrying (an incompatible carriage or a conflicting policy) is | |
| 35 | + | // surfaced once and stops automatic reconnection; only the domain's terminal | |
| 36 | + | // codes end the process. | |
| 37 | + | // | |
| 38 | + | // A connection attempt is joined before Run returns. Since the connector | |
| 39 | + | // contract requires Connect to honor cancellation, a completion that arrives | |
| 40 | + | // after the supervisor has begun terminating is still drained and its service | |
| 41 | + | // and subscription are closed, so a late superseded success never leaks. Raw | |
| 42 | + | // mode is restored exactly once on every exit path, terminal EOF and process | |
| 43 | + | // cancellation included. | |
| 44 | + | // | |
| 45 | + | // Parent cancellation settles the run: an attempt that settles at the same | |
| 46 | + | // instant as cancellation is retired instead of adopted, and the run ends with | |
| 47 | + | // ctx.Err without publishing a retry cadence, rendering ConnectivityRetryWait, | |
| 48 | + | // or notifying a connectivity failure. | |
| 49 | + | ||
| 50 | + | // Supervisor retry cadence bounds. The exponential cap doubles from the initial | |
| 51 | + | // interval to the maximum; the equal-jitter interval is then half the cap plus | |
| 52 | + | // a uniform fraction of the other half. | |
| 53 | + | const ( | |
| 54 | + | supervisorRetryInitial = 100 * time.Millisecond | |
| 55 | + | supervisorRetryMax = 2 * time.Second | |
| 56 | + | ) | |
| 57 | + | ||
| 58 | + | // Presentation is what the autonomous client is presenting on the terminal. | |
| 59 | + | type Presentation uint8 | |
| 60 | + | ||
| 61 | + | const ( | |
| 62 | + | // PresentPicker shows the session picker. P5.1a exercises only this state. | |
| 63 | + | PresentPicker Presentation = iota + 1 | |
| 64 | + | // PresentConnecting shows the attach transition. Reserved for the attach | |
| 65 | + | // slice; the P5.1a driver never enters it. | |
| 66 | + | PresentConnecting | |
| 67 | + | // PresentAttached shows a live attachment. Reserved for the attach slice. | |
| 68 | + | PresentAttached | |
| 69 | + | // PresentTerminating is the terminal state: raw mode is being restored and | |
| 70 | + | // the process is leaving. | |
| 71 | + | PresentTerminating | |
| 72 | + | ) | |
| 73 | + | ||
| 74 | + | func (p Presentation) String() string { | |
| 75 | + | switch p { | |
| 76 | + | case PresentPicker: | |
| 77 | + | return "picker" | |
| 78 | + | case PresentConnecting: | |
| 79 | + | return "connecting" | |
| 80 | + | case PresentAttached: | |
| 81 | + | return "attached" | |
| 82 | + | case PresentTerminating: | |
| 83 | + | return "terminating" | |
| 84 | + | default: | |
| 85 | + | return "unknown" | |
| 86 | + | } | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | // Connectivity is the broker-connection lifecycle. | |
| 90 | + | type Connectivity uint8 | |
| 91 | + | ||
| 92 | + | const ( | |
| 93 | + | // ConnectivityDisconnected holds no broker connection: either before the | |
| 94 | + | // first attempt, after a non-retryable failure, or while terminating. | |
| 95 | + | ConnectivityDisconnected Connectivity = iota + 1 | |
| 96 | + | // ConnectivityConnectingBroker is one in-flight connect/subscribe/first | |
| 97 | + | // publication attempt. | |
| 98 | + | ConnectivityConnectingBroker | |
| 99 | + | // ConnectivityReady holds an established connection with its first | |
| 100 | + | // publication committed. | |
| 101 | + | ConnectivityReady | |
| 102 | + | // ConnectivityRetryWait waits the retry cadence before the next attempt. | |
| 103 | + | ConnectivityRetryWait | |
| 104 | + | ) | |
| 105 | + | ||
| 106 | + | func (c Connectivity) String() string { | |
| 107 | + | switch c { | |
| 108 | + | case ConnectivityDisconnected: | |
| 109 | + | return "disconnected" | |
| 110 | + | case ConnectivityConnectingBroker: | |
| 111 | + | return "connecting_broker" | |
| 112 | + | case ConnectivityReady: | |
| 113 | + | return "ready" | |
| 114 | + | case ConnectivityRetryWait: | |
| 115 | + | return "retry_wait" | |
| 116 | + | default: | |
| 117 | + | return "unknown" | |
| 118 | + | } | |
| 119 | + | } | |
| 120 | + | ||
| 121 | + | // State is the immutable projection of the supervisor. | |
| 122 | + | type State struct { | |
| 123 | + | Presentation Presentation | |
| 124 | + | Connectivity Connectivity | |
| 125 | + | // Attempt counts consecutive broker-connection failures since the last | |
| 126 | + | // Ready. It is zero before the first attempt and after Ready, and drives | |
| 127 | + | // the equal-jitter exponential backoff. | |
| 128 | + | Attempt uint64 | |
| 129 | + | // Generation identifies the current connection attempt. It increases before | |
| 130 | + | // every attempt, so a settled attempt carries the generation that produced | |
| 131 | + | // it. Run joins each attempt before it advances, so every completion today | |
| 132 | + | // carries the current generation: the comparison in adoptAttempt is a | |
| 133 | + | // defensive invariant for a future concurrent driver, not a path this | |
| 134 | + | // driver can currently reach. | |
| 135 | + | Generation uint64 | |
| 136 | + | // Err is the most recent visible connectivity failure, nil while healthy | |
| 137 | + | // and before any failure. | |
| 138 | + | Err error | |
| 139 | + | } | |
| 140 | + | ||
| 141 | + | // supervisorEventKind is one input to the pure supervisor reducer. | |
| 142 | + | type supervisorEventKind uint8 | |
| 143 | + | ||
| 144 | + | const ( | |
| 145 | + | // supervisorBeginAttempt starts one connect/subscribe attempt. | |
| 146 | + | supervisorBeginAttempt supervisorEventKind = iota + 1 | |
| 147 | + | // supervisorReady marks the first committed publication of the current | |
| 148 | + | // attempt. | |
| 149 | + | supervisorReady | |
| 150 | + | // supervisorTransientFailure is a connect, subscribe, or first-publication | |
| 151 | + | // failure that is worth retrying. | |
| 152 | + | supervisorTransientFailure | |
| 153 | + | // supervisorBrokerLoss is the loss of an established connection observed | |
| 154 | + | // through BrokerService.Done/Err while no logical stream is held. | |
| 155 | + | supervisorBrokerLoss | |
| 156 | + | // supervisorNonRetryable is a failure that retrying can never fix. | |
| 157 | + | supervisorNonRetryable | |
| 158 | + | // supervisorTerminal ends the process: terminal EOF, process cancellation, | |
| 159 | + | // or a terminal broker failure. | |
| 160 | + | supervisorTerminal | |
| 161 | + | ) | |
| 162 | + | ||
| 163 | + | type supervisorEvent struct { | |
| 164 | + | kind supervisorEventKind | |
| 165 | + | err error | |
| 166 | + | } | |
| 167 | + | ||
| 168 | + | // reduceSupervisor is the pure connectivity/presentation reducer. It is | |
| 169 | + | // side-effect free so the state machine can be exhaustively table-tested; the | |
| 170 | + | // driver applies it under a mutex and renders the result. | |
| 171 | + | func reduceSupervisor(state State, event supervisorEvent) State { | |
| 172 | + | switch event.kind { | |
| 173 | + | case supervisorBeginAttempt: | |
| 174 | + | state.Presentation = PresentPicker | |
| 175 | + | state.Connectivity = ConnectivityConnectingBroker | |
| 176 | + | state.Generation++ | |
| 177 | + | state.Err = nil | |
| 178 | + | case supervisorReady: | |
| 179 | + | state.Connectivity = ConnectivityReady | |
| 180 | + | state.Attempt = 0 | |
| 181 | + | state.Err = nil | |
| 182 | + | case supervisorTransientFailure, supervisorBrokerLoss: | |
| 183 | + | state.Connectivity = ConnectivityRetryWait | |
| 184 | + | state.Attempt++ | |
| 185 | + | state.Err = event.err | |
| 186 | + | case supervisorNonRetryable: | |
| 187 | + | state.Connectivity = ConnectivityDisconnected | |
| 188 | + | state.Err = event.err | |
| 189 | + | case supervisorTerminal: | |
| 190 | + | state.Presentation = PresentTerminating | |
| 191 | + | state.Connectivity = ConnectivityDisconnected | |
| 192 | + | state.Err = event.err | |
| 193 | + | } | |
| 194 | + | return state | |
| 195 | + | } | |
| 196 | + | ||
| 197 | + | // SupervisorConfig supplies the autonomous client's dependencies. The | |
| 198 | + | // connector, terminal, and clock are required; render, notify, and jitter are | |
| 199 | + | // injectable seams with safe defaults. Render, notify, and jitter must not | |
| 200 | + | // block: the supervisor drives them from its single run goroutine. | |
| 201 | + | type SupervisorConfig struct { | |
| 202 | + | // Connector establishes the broker connection. Required. | |
| 203 | + | Connector ports.BrokerConnector | |
| 204 | + | // Terminal is the controlling terminal the supervisor owns. Required. | |
| 205 | + | Terminal ports.Terminal | |
| 206 | + | // Clock supplies the retry timers. Required; use ports.Clock, not the wall | |
| 207 | + | // clock. | |
| 208 | + | Clock ports.Clock | |
| 209 | + | // Render paints the current state. It is called once per visible | |
| 210 | + | // transition. Optional; the default renders nothing. | |
| 211 | + | Render func(State) | |
| 212 | + | // Notify surfaces a connectivity failure without leaving the picker. | |
| 213 | + | // Optional; the default notifies nothing. | |
| 214 | + | Notify func(State, error) | |
| 215 | + | // Jitter returns an equal-jitter fraction in [0,1). It is the only | |
| 216 | + | // randomness seam so tests can pin the retry cadence exactly. Optional; the | |
| 217 | + | // default is a process-local random source. | |
| 218 | + | Jitter func() float64 | |
| 219 | + | } | |
| 220 | + | ||
| 221 | + | // Supervisor owns one autonomous client process: raw mode, one terminal input | |
| 222 | + | // lifetime, and the broker-connectivity lifecycle. | |
| 223 | + | type Supervisor struct { | |
| 224 | + | cfg SupervisorConfig | |
| 225 | + | ||
| 226 | + | mu sync.Mutex | |
| 227 | + | state State | |
| 228 | + | } | |
| 229 | + | ||
| 230 | + | // NewSupervisor validates the required dependencies and returns a supervisor | |
| 231 | + | // parked on the picker with no connection. It does not touch the terminal; Run | |
| 232 | + | // enters raw mode. | |
| 233 | + | func NewSupervisor(cfg SupervisorConfig) (*Supervisor, error) { | |
| 234 | + | if supervisorNil(cfg.Connector) { | |
| 235 | + | return nil, errors.New("vev: supervisor requires a broker connector") | |
| 236 | + | } | |
| 237 | + | if supervisorNil(cfg.Terminal) { | |
| 238 | + | return nil, errors.New("vev: supervisor requires a terminal") | |
| 239 | + | } | |
| 240 | + | if supervisorNil(cfg.Clock) { | |
| 241 | + | return nil, errors.New("vev: supervisor requires a clock") | |
| 242 | + | } | |
| 243 | + | if cfg.Render == nil { | |
| 244 | + | cfg.Render = func(State) {} | |
| 245 | + | } | |
| 246 | + | if cfg.Jitter == nil { | |
| 247 | + | cfg.Jitter = rand.Float64 | |
| 248 | + | } | |
| 249 | + | return &Supervisor{ | |
| 250 | + | cfg: cfg, | |
| 251 | + | state: State{Presentation: PresentPicker, Connectivity: ConnectivityDisconnected}, | |
| 252 | + | }, nil | |
| 253 | + | } | |
| 254 | + | ||
| 255 | + | // State returns the current immutable projection. | |
| 256 | + | func (s *Supervisor) State() State { | |
| 257 | + | s.mu.Lock() | |
| 258 | + | defer s.mu.Unlock() | |
| 259 | + | return s.state | |
| 260 | + | } | |
| 261 | + | ||
| 262 | + | // Run owns the terminal for exactly its lifetime. It enters raw mode before it | |
| 263 | + | // connects to the broker, starts the single terminal input reader, and then | |
| 264 | + | // drives connect/subscribe/retry until the terminal reaches EOF, the process | |
| 265 | + | // context is cancelled, or a terminal broker failure settles the process. Raw | |
| 266 | + | // mode is restored exactly once on every exit path. | |
| 267 | + | func (s *Supervisor) Run(ctx context.Context) (retErr error) { | |
| 268 | + | restore, err := s.cfg.Terminal.EnterRaw() | |
| 269 | + | if err != nil { | |
| 270 | + | return fmt.Errorf("vev: entering raw mode: %w", err) | |
| 271 | + | } | |
| 272 | + | if restore == nil { | |
| 273 | + | restore = func() error { return nil } | |
| 274 | + | } | |
| 275 | + | restoreTerminal := sync.OnceValue(restore) | |
| 276 | + | defer func() { | |
| 277 | + | if rerr := restoreTerminal(); rerr != nil && retErr == nil { | |
| 278 | + | retErr = fmt.Errorf("vev: restoring terminal: %w", rerr) | |
| 279 | + | } | |
| 280 | + | }() | |
| 281 | + | ||
| 282 | + | input := startTerminalInputLifetime(s.cfg.Terminal.In()) | |
| 283 | + | ||
| 284 | + | // The active connection is owned across loop iterations. retire is | |
| 285 | + | // idempotent so every exit path closes exactly the connection it adopted. | |
| 286 | + | var ( | |
| 287 | + | service ports.BrokerService | |
| 288 | + | sub ports.BrokerSubscription | |
| 289 | + | ) | |
| 290 | + | retire := func() { | |
| 291 | + | if sub != nil { | |
| 292 | + | sub.Close() | |
| 293 | + | sub = nil | |
| 294 | + | } | |
| 295 | + | if service != nil { | |
| 296 | + | _ = service.Close() | |
| 297 | + | service = nil | |
| 298 | + | } | |
| 299 | + | } | |
| 300 | + | defer retire() | |
| 301 | + | ||
| 302 | + | for { | |
| 303 | + | // The generation is claimed before the connector runs, so the attempt is | |
| 304 | + | // always attributable to the state that started it and a completion can be | |
| 305 | + | // matched against the generation that produced it. | |
| 306 | + | s.transition(supervisorEvent{kind: supervisorBeginAttempt}) | |
| 307 | + | generation := s.State().Generation | |
| 308 | + | attemptCtx, cancelAttempt := context.WithCancel(ctx) | |
| 309 | + | outcome := make(chan supervisorAttempt, 1) | |
| 310 | + | go func() { outcome <- s.connectAttempt(attemptCtx, generation) }() | |
| 311 | + | ||
| 312 | + | result, terminated, termErr := s.awaitAttempt(ctx, input, outcome) | |
| 313 | + | if terminated { | |
| 314 | + | // Connect must honor cancellation, so joining the attempt here both | |
| 315 | + | // retires a late superseded success and leaves no goroutine behind. | |
| 316 | + | cancelAttempt() | |
| 317 | + | late := <-outcome | |
| 318 | + | late.retire() | |
| 319 | + | s.transition(supervisorEvent{kind: supervisorTerminal, err: termErr}) | |
| 320 | + | return termErr | |
| 321 | + | } | |
| 322 | + | cancelAttempt() | |
| 323 | + | adopted, cause := s.adoptAttempt(ctx, result) | |
| 324 | + | if !adopted { | |
| 325 | + | // The cancelled or superseded attempt is retired rather than adopted, so | |
| 326 | + | // it cannot drive the state of a newer attempt and a cancelled run never | |
| 327 | + | // publishes a retry cadence, renders ConnectivityRetryWait, or notifies a | |
| 328 | + | // connectivity failure for it. | |
| 329 | + | result.retire() | |
| 330 | + | if cause != nil { | |
| 331 | + | s.transition(supervisorEvent{kind: supervisorTerminal, err: cause}) | |
| 332 | + | return cause | |
| 333 | + | } | |
| 334 | + | continue | |
| 335 | + | } | |
| 336 | + | ||
| 337 | + | if result.err != nil { | |
| 338 | + | if supervisorTerminalFailure(result.err) { | |
| 339 | + | s.transition(supervisorEvent{kind: supervisorTerminal, err: result.err}) | |
| 340 | + | return result.err | |
| 341 | + | } | |
| 342 | + | if !supervisorRetryable(result.err) { | |
| 343 | + | // Retrying can never fix this. Return to the picker with the | |
| 344 | + | // visible error and stop reconnecting; the process still owns | |
| 345 | + | // the terminal until the user exits or it is cancelled. | |
| 346 | + | s.transition(supervisorEvent{kind: supervisorNonRetryable, err: result.err}) | |
| 347 | + | s.notify(result.err) | |
| 348 | + | terminalErr := s.awaitTermination(ctx, input) | |
| 349 | + | s.transition(supervisorEvent{kind: supervisorTerminal, err: terminalErr}) | |
| 350 | + | return terminalErr | |
| 351 | + | } | |
| 352 | + | if terminated, termErr := s.retryFailure(ctx, input, supervisorTransientFailure, result.err); terminated { | |
| 353 | + | return termErr | |
| 354 | + | } | |
| 355 | + | continue | |
| 356 | + | } | |
| 357 | + | ||
| 358 | + | service, sub = result.service, result.sub | |
| 359 | + | s.transition(supervisorEvent{kind: supervisorReady}) | |
| 360 | + | ||
| 361 | + | lossErr, terminated, termErr := s.awaitLoss(ctx, input, service) | |
| 362 | + | retire() | |
| 363 | + | if terminated { | |
| 364 | + | s.transition(supervisorEvent{kind: supervisorTerminal, err: termErr}) | |
| 365 | + | return termErr | |
| 366 | + | } | |
| 367 | + | if cause := ctx.Err(); cause != nil { | |
| 368 | + | // Parent cancellation wins over a loss that settled at the same instant, | |
| 369 | + | // exactly as it does at the attempt settle. The connection was already | |
| 370 | + | // released above, so the loss is simply never turned into a retry | |
| 371 | + | // cadence, a ConnectivityRetryWait render, or a notification for a run | |
| 372 | + | // that was already cancelled. | |
| 373 | + | s.transition(supervisorEvent{kind: supervisorTerminal, err: cause}) | |
| 374 | + | return cause | |
| 375 | + | } | |
| 376 | + | if terminated, termErr := s.retryFailure(ctx, input, supervisorBrokerLoss, normalizeUnavailable(lossErr)); terminated { | |
| 377 | + | return termErr | |
| 378 | + | } | |
| 379 | + | } | |
| 380 | + | } | |
| 381 | + | ||
| 382 | + | // supervisorAttempt is the settled outcome of one connect/subscribe/first | |
| 383 | + | // publication attempt. generation is the attempt's generation, claimed before | |
| 384 | + | // the connector ran, so the completion is always attributable to the attempt | |
| 385 | + | // that produced it (see adoptAttempt's generation fence). | |
| 386 | + | type supervisorAttempt struct { | |
| 387 | + | generation uint64 | |
| 388 | + | service ports.BrokerService | |
| 389 | + | sub ports.BrokerSubscription | |
| 390 | + | err error | |
| 391 | + | } | |
| 392 | + | ||
| 393 | + | // retire closes whatever the attempt produced. It is safe on a partially built | |
| 394 | + | // or empty attempt, and it treats a typed-nil service or subscription as absent | |
| 395 | + | // rather than calling into it. | |
| 396 | + | func (a supervisorAttempt) retire() { | |
| 397 | + | if !supervisorNil(a.sub) { | |
| 398 | + | a.sub.Close() | |
| 399 | + | } | |
| 400 | + | if !supervisorNil(a.service) { | |
| 401 | + | _ = a.service.Close() | |
| 402 | + | } | |
| 403 | + | } | |
| 404 | + | ||
| 405 | + | // adoptAttempt decides whether one settled attempt may be adopted, and reports the | |
| 406 | + | // parent cancellation that must settle the run instead. The decision is made under | |
| 407 | + | // the state lock, atomically with respect to the state it consults, so an attempt | |
| 408 | + | // that settled after the parent cancelled the run is never adopted even though | |
| 409 | + | // awaitAttempt's select may have taken its outcome over an already-ready | |
| 410 | + | // ctx.Done. The generation fence is enforced at the same decision point: a | |
| 411 | + | // superseded attempt is rejected too. It is a defensive invariant, not a | |
| 412 | + | // currently reachable production path, because Run joins each attempt before it | |
| 413 | + | // advances; it is kept so a future driver that admits more than one attempt at a | |
| 414 | + | // time can never let a late completion drive the state of a newer attempt. | |
| 415 | + | func (s *Supervisor) adoptAttempt(ctx context.Context, result supervisorAttempt) (adopted bool, cause error) { | |
| 416 | + | s.mu.Lock() | |
| 417 | + | defer s.mu.Unlock() | |
| 418 | + | if cause := ctx.Err(); cause != nil { | |
| 419 | + | return false, cause | |
| 420 | + | } | |
| 421 | + | if result.generation != s.state.Generation { | |
| 422 | + | return false, nil | |
| 423 | + | } | |
| 424 | + | return true, nil | |
| 425 | + | } | |
| 426 | + | ||
| 427 | + | // retryFailure records one retryable connectivity failure, surfaces it, and | |
| 428 | + | // waits the cadence for the next attempt. It is the shared tail of the transient | |
| 429 | + | // connect/subscribe failure and the established-connection loss paths, so both | |
| 430 | + | // select the same observable transition, notification, and backoff. | |
| 431 | + | func (s *Supervisor) retryFailure(ctx context.Context, input *terminalInputLifetime, kind supervisorEventKind, err error) (bool, error) { | |
| 432 | + | if cause := ctx.Err(); cause != nil { | |
| 433 | + | s.transition(supervisorEvent{kind: supervisorTerminal, err: cause}) | |
| 434 | + | return true, cause | |
| 435 | + | } | |
| 436 | + | s.transition(supervisorEvent{kind: kind, err: err}) | |
| 437 | + | s.notify(err) | |
| 438 | + | if terminated, termErr := s.waitBackoff(ctx, input); terminated { | |
| 439 | + | s.transition(supervisorEvent{kind: supervisorTerminal, err: termErr}) | |
| 440 | + | return true, termErr | |
| 441 | + | } | |
| 442 | + | return false, nil | |
| 443 | + | } | |
| 444 | + | ||
| 445 | + | // connectAttempt connects once, subscribes, and waits for the first committed | |
| 446 | + | // publication. It returns a live service and subscription only when the | |
| 447 | + | // connection is Ready; every failure closes whatever it opened before | |
| 448 | + | // returning, so a caller has nothing to clean up on the error path. Every | |
| 449 | + | // connect, subscribe, or first-publication failure is stamped with generation | |
| 450 | + | // and normalized to a typed unavailable failure so the run loop never observes | |
| 451 | + | // an untyped cause. | |
| 452 | + | func (s *Supervisor) connectAttempt(ctx context.Context, generation uint64) supervisorAttempt { | |
| 453 | + | service, err := s.cfg.Connector.Connect(ctx) | |
| 454 | + | if err != nil { | |
| 455 | + | return supervisorAttempt{generation: generation, err: normalizeUnavailable(err)} | |
| 456 | + | } | |
| 457 | + | if supervisorNil(service) { | |
| 458 | + | return supervisorAttempt{generation: generation, err: ports.BrokerError{ | |
| 459 | + | Code: ports.BrokerErrorUnavailable, | |
| 460 | + | Text: "broker connector returned no service", | |
| 461 | + | }} | |
| 462 | + | } | |
| 463 | + | sub, err := service.Subscribe() | |
| 464 | + | if err != nil { | |
| 465 | + | _ = service.Close() | |
| 466 | + | return supervisorAttempt{generation: generation, err: normalizeUnavailable(err)} | |
| 467 | + | } | |
| 468 | + | if supervisorNil(sub) { | |
| 469 | + | // A service that reports no subscription cannot ever become Ready, so it | |
| 470 | + | // is a typed unavailable failure rather than a nil dereference on the | |
| 471 | + | // first Changed() call. | |
| 472 | + | _ = service.Close() | |
| 473 | + | return supervisorAttempt{generation: generation, err: ports.BrokerError{ | |
| 474 | + | Code: ports.BrokerErrorUnavailable, | |
| 475 | + | Text: "broker service returned no subscription", | |
| 476 | + | }} | |
| 477 | + | } | |
| 478 | + | for { | |
| 479 | + | if service.Snapshot().Epoch != 0 { | |
| 480 | + | return supervisorAttempt{generation: generation, service: service, sub: sub} | |
| 481 | + | } | |
| 482 | + | select { | |
| 483 | + | case <-sub.Changed(): | |
| 484 | + | continue | |
| 485 | + | case <-service.Done(): | |
| 486 | + | cause := normalizeUnavailable(service.Err()) | |
| 487 | + | sub.Close() | |
| 488 | + | _ = service.Close() | |
| 489 | + | return supervisorAttempt{generation: generation, err: cause} | |
| 490 | + | case <-ctx.Done(): | |
| 491 | + | sub.Close() | |
| 492 | + | _ = service.Close() | |
| 493 | + | return supervisorAttempt{generation: generation, err: ctx.Err()} | |
| 494 | + | } | |
| 495 | + | } | |
| 496 | + | } | |
| 497 | + | ||
| 498 | + | // awaitAttempt waits for the in-flight attempt to settle, or for the run to be | |
| 499 | + | // terminated first. | |
| 500 | + | func (s *Supervisor) awaitAttempt(ctx context.Context, input *terminalInputLifetime, outcome <-chan supervisorAttempt) (supervisorAttempt, bool, error) { | |
| 501 | + | select { | |
| 502 | + | case result := <-outcome: | |
| 503 | + | return result, false, nil | |
| 504 | + | case <-ctx.Done(): | |
| 505 | + | return supervisorAttempt{}, true, ctx.Err() | |
| 506 | + | case err := <-input.EOF(): | |
| 507 | + | return supervisorAttempt{}, true, terminalReadCause(err) | |
| 508 | + | } | |
| 509 | + | } | |
| 510 | + | ||
| 511 | + | // awaitLoss waits for an established connection to be lost, or for the run to | |
| 512 | + | // be terminated first. A loss is reported with the service's stable terminal | |
| 513 | + | // cause. | |
| 514 | + | func (s *Supervisor) awaitLoss(ctx context.Context, input *terminalInputLifetime, service ports.BrokerService) (error, bool, error) { | |
| 515 | + | select { | |
| 516 | + | case <-service.Done(): | |
| 517 | + | return service.Err(), false, nil | |
| 518 | + | case <-ctx.Done(): | |
| 519 | + | return nil, true, ctx.Err() | |
| 520 | + | case err := <-input.EOF(): | |
| 521 | + | return nil, true, terminalReadCause(err) | |
| 522 | + | } | |
| 523 | + | } | |
| 524 | + | ||
| 525 | + | // awaitTermination parks the supervisor on the picker after a non-retryable | |
| 526 | + | // failure until the terminal ends or the process is cancelled. A clean terminal | |
| 527 | + | // EOF returns nil; every other cause is reported. | |
| 528 | + | func (s *Supervisor) awaitTermination(ctx context.Context, input *terminalInputLifetime) error { | |
| 529 | + | select { | |
| 530 | + | case <-ctx.Done(): | |
| 531 | + | return ctx.Err() | |
| 532 | + | case err := <-input.EOF(): | |
| 533 | + | return terminalReadCause(err) | |
| 534 | + | } | |
| 535 | + | } | |
| 536 | + | ||
| 537 | + | // waitBackoff waits one equal-jitter retry interval for the current failure | |
| 538 | + | // count, or settles on cancellation or terminal EOF. The timer is always | |
| 539 | + | // stopped and drained, so no timer outlives the attempt transition. | |
| 540 | + | func (s *Supervisor) waitBackoff(ctx context.Context, input *terminalInputLifetime) (bool, error) { | |
| 541 | + | delay := supervisorBackoffDelay(s.State().Attempt, s.cfg.Jitter) | |
| 542 | + | if delay <= 0 { | |
| 543 | + | return false, nil | |
| 544 | + | } | |
| 545 | + | timer := s.cfg.Clock.NewTimer(delay) | |
| 546 | + | defer stopSupervisorTimer(timer) | |
| 547 | + | select { | |
| 548 | + | case <-timer.C(): | |
| 549 | + | return false, nil | |
| 550 | + | case <-ctx.Done(): | |
| 551 | + | return true, ctx.Err() | |
| 552 | + | case err := <-input.EOF(): | |
| 553 | + | return true, terminalReadCause(err) | |
| 554 | + | } | |
| 555 | + | } | |
| 556 | + | ||
| 557 | + | // supervisorBackoffDelay is the equal-jitter exponential interval for one | |
| 558 | + | // failure count: the cap doubles from supervisorRetryInitial to | |
| 559 | + | // supervisorRetryMax, and the interval is half the cap plus a uniform fraction | |
| 560 | + | // of the other half, so attempts spread across [cap/2, cap). It is pure so the | |
| 561 | + | // cadence can be tested exactly. | |
| 562 | + | func supervisorBackoffDelay(attempt uint64, jitter func() float64) time.Duration { | |
| 563 | + | if attempt == 0 { | |
| 564 | + | return 0 | |
| 565 | + | } | |
| 566 | + | limit := supervisorRetryInitial | |
| 567 | + | for i := uint64(1); i < attempt && limit < supervisorRetryMax; i++ { | |
| 568 | + | limit *= 2 | |
| 569 | + | } | |
| 570 | + | if limit > supervisorRetryMax { | |
| 571 | + | limit = supervisorRetryMax | |
| 572 | + | } | |
| 573 | + | half := limit / 2 | |
| 574 | + | fraction := jitter() | |
| 575 | + | if !(fraction >= 0) { | |
| 576 | + | // NaN is unordered: both fraction < 0 and fraction > 1 are false for | |
| 577 | + | // it, so an explicit non-negative test maps NaN and every negative value | |
| 578 | + | // to the minimum fraction. Without this, a NaN would reach the duration | |
| 579 | + | // conversion and yield an undefined interval instead of the documented | |
| 580 | + | // half-cap minimum. | |
| 581 | + | fraction = 0 | |
| 582 | + | } | |
| 583 | + | if fraction > 1 { | |
| 584 | + | fraction = 1 | |
| 585 | + | } | |
| 586 | + | return half + time.Duration(fraction*float64(half)) | |
| 587 | + | } | |
| 588 | + | ||
| 589 | + | // terminalReadCause maps a terminal read end to the Run result: a clean EOF is | |
| 590 | + | // an orderly exit (nil), any other read failure is reported. | |
| 591 | + | func terminalReadCause(err error) error { | |
| 592 | + | if err == nil || errors.Is(err, io.EOF) { | |
| 593 | + | return nil | |
| 594 | + | } | |
| 595 | + | return err | |
| 596 | + | } | |
| 597 | + | ||
| 598 | + | // stopSupervisorTimer stops and drains a timer, supporting buffered timer | |
| 599 | + | // adapters with pre-Go-1.23 reset semantics. | |
| 600 | + | func stopSupervisorTimer(timer ports.Timer) { | |
| 601 | + | if timer == nil { | |
| 602 | + | return | |
| 603 | + | } | |
| 604 | + | if !timer.Stop() { | |
| 605 | + | select { | |
| 606 | + | case <-timer.C(): | |
| 607 | + | default: | |
| 608 | + | } | |
| 609 | + | } | |
| 610 | + | } | |
| 611 | + | ||
| 612 | + | // supervisorTerminalFailure reports whether a broker failure must end the | |
| 613 | + | // process. Only the domain's terminal codes do; every other failure returns the | |
| 614 | + | // client to the picker. | |
| 615 | + | func supervisorTerminalFailure(err error) bool { | |
| 616 | + | var typed ports.BrokerError | |
| 617 | + | return errors.As(err, &typed) && typed.Terminal() | |
| 618 | + | } | |
| 619 | + | ||
| 620 | + | // supervisorRetryable reports whether a failure is worth another attempt after | |
| 621 | + | // backoff. An incompatible carriage or a conflicting policy can never succeed | |
| 622 | + | // by retrying, so it is surfaced once and automatic reconnection stops; every | |
| 623 | + | // other failure is treated as transient. | |
| 624 | + | func supervisorRetryable(err error) bool { | |
| 625 | + | var typed ports.BrokerError | |
| 626 | + | if errors.As(err, &typed) { | |
| 627 | + | switch typed.Code { | |
| 628 | + | case ports.BrokerErrorIncompatible, ports.BrokerErrorConflictingPolicy: | |
| 629 | + | return false | |
| 630 | + | } | |
| 631 | + | } | |
| 632 | + | return true | |
| 633 | + | } | |
| 634 | + | ||
| 635 | + | // normalizeUnavailable presents one connect, subscribe, or connection-loss | |
| 636 | + | // failure as a typed ports.BrokerError. A cause that is already a BrokerError is | |
| 637 | + | // returned unchanged, so its own code (for example incompatible or conflicting | |
| 638 | + | // policy) still selects the non-retryable path; every untyped failure, including | |
| 639 | + | // a nil cause, becomes an unavailable BrokerError that preserves the original | |
| 640 | + | // error as its local-only Cause. | |
| 641 | + | func normalizeUnavailable(err error) error { | |
| 642 | + | if err == nil { | |
| 643 | + | return ports.BrokerError{Code: ports.BrokerErrorUnavailable, Text: "broker connection lost"} | |
| 644 | + | } | |
| 645 | + | var typed ports.BrokerError | |
| 646 | + | if errors.As(err, &typed) { | |
| 647 | + | return err | |
| 648 | + | } | |
| 649 | + | return ports.BrokerError{Code: ports.BrokerErrorUnavailable, Text: "broker connection lost", Cause: err} | |
| 650 | + | } | |
| 651 | + | ||
| 652 | + | // notify surfaces a connectivity failure through the optional notifier. | |
| 653 | + | func (s *Supervisor) notify(err error) { | |
| 654 | + | if s.cfg.Notify == nil || err == nil { | |
| 655 | + | return | |
| 656 | + | } | |
| 657 | + | s.cfg.Notify(s.State(), err) | |
| 658 | + | } | |
| 659 | + | ||
| 660 | + | // transition applies one reducer event under the supervisor mutex and renders | |
| 661 | + | // the resulting state outside it, so a renderer that reads State back never | |
| 662 | + | // deadlocks. | |
| 663 | + | func (s *Supervisor) transition(event supervisorEvent) { | |
| 664 | + | s.mu.Lock() | |
| 665 | + | s.state = reduceSupervisor(s.state, event) | |
| 666 | + | state := s.state | |
| 667 | + | s.mu.Unlock() | |
| 668 | + | s.cfg.Render(state) | |
| 669 | + | } | |
| 670 | + | ||
| 671 | + | // terminalInputLifetime owns the single read of the controlling terminal. It is | |
| 672 | + | // started once and never replaced: a bare io.Reader cannot be interrupted, so | |
| 673 | + | // the goroutine may outlive Run when stdin never reaches EOF, exactly like the | |
| 674 | + | // attach client's input pump. It never closes caller-owned input, and P5.1a | |
| 675 | + | // discards read bytes because picker interaction is a later slice. | |
| 676 | + | type terminalInputLifetime struct { | |
| 677 | + | eof chan error | |
| 678 | + | once sync.Once | |
| 679 | + | } | |
| 680 | + | ||
| 681 | + | // startTerminalInputLifetime starts the single terminal read. A nil reader is | |
| 682 | + | // treated as an immediate orderly EOF. | |
| 683 | + | func startTerminalInputLifetime(in io.Reader) *terminalInputLifetime { | |
| 684 | + | lifetime := &terminalInputLifetime{eof: make(chan error, 1)} | |
| 685 | + | if supervisorNil(in) { | |
| 686 | + | lifetime.finish(io.EOF) | |
| 687 | + | return lifetime | |
| 688 | + | } | |
| 689 | + | go func() { | |
| 690 | + | buffer := make([]byte, 4096) | |
| 691 | + | for { | |
| 692 | + | n, err := in.Read(buffer) | |
| 693 | + | if err != nil { | |
| 694 | + | lifetime.finish(err) | |
| 695 | + | return | |
| 696 | + | } | |
| 697 | + | if n == 0 { | |
| 698 | + | // A zero-byte, nil-error read is legal but cannot make | |
| 699 | + | // progress; keep reading so a well-behaved reader is never | |
| 700 | + | // mistaken for EOF. | |
| 701 | + | continue | |
| 702 | + | } | |
| 703 | + | } | |
| 704 | + | }() | |
| 705 | + | return lifetime | |
| 706 | + | } | |
| 707 | + | ||
| 708 | + | // finish publishes the terminal cause exactly once. | |
| 709 | + | func (l *terminalInputLifetime) finish(err error) { | |
| 710 | + | l.once.Do(func() { l.eof <- err }) | |
| 711 | + | } | |
| 712 | + | ||
| 713 | + | // EOF is closed-result channel delivering the terminal read cause exactly once. | |
| 714 | + | func (l *terminalInputLifetime) EOF() <-chan error { return l.eof } | |
| 715 | + | ||
| 716 | + | // supervisorNil reports whether a dependency is nil in any shape. A typed nil | |
| 717 | + | // satisfies a plain != nil comparison, so the guard would defer the failure to | |
| 718 | + | // the first method call, which panics; the reflection check keeps it total. | |
| 719 | + | func supervisorNil(dependency any) bool { | |
| 720 | + | if dependency == nil { | |
| 721 | + | return true | |
| 722 | + | } | |
| 723 | + | value := reflect.ValueOf(dependency) | |
| 724 | + | switch value.Kind() { | |
| 725 | + | case reflect.Chan, reflect.Func, reflect.Interface, reflect.Map, reflect.Ptr, reflect.Slice: | |
| 726 | + | return value.IsNil() | |
| 727 | + | default: | |
| 728 | + | return false | |
| 729 | + | } | |
| 730 | + | } | |
| @@ -0,0 +1,730 @@ | |||||
| 1 | + | package client | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "errors" | |||
| 6 | + | "fmt" | |||
| 7 | + | "io" | |||
| 8 | + | "math/rand/v2" | |||
| 9 | + | "reflect" | |||
| 10 | + | "sync" | |||
| 11 | + | "time" | |||
| 12 | + | ||||
| 13 | + | "github.com/bnema/vev/internal/ports" | |||
| 14 | + | ) | |||
| 15 | + | ||||
| 16 | + | // Autonomous client supervisor (Plan 001 P5.1a, offline and unactivated). | |||
| 17 | + | // | |||
| 18 | + | // The supervisor is the attachment-free, terminal-owning owner of one | |||
| 19 | + | // autonomous client process. It enters raw mode once, before it ever reaches | |||
| 20 | + | // for the broker, and owns a single terminal input lifetime for the whole run. | |||
| 21 | + | // While the broker connection is down it renders the picker and keeps | |||
| 22 | + | // reconnecting; it never attaches a session and never interprets terminal | |||
| 23 | + | // input. Only the picker presentation and the broker-connectivity lifecycle are | |||
| 24 | + | // exercised by this slice: PresentConnecting and PresentAttached are defined so | |||
| 25 | + | // the attach slices can drive them later, but nothing here enters them. | |||
| 26 | + | // | |||
| 27 | + | // Connectivity is one attempt at a time. An attempt connects, subscribes, and | |||
| 28 | + | // waits for the first committed publication before it is Ready; readiness | |||
| 29 | + | // resets the equal-jitter exponential retry cadence (100ms doubling to a 2s | |||
| 30 | + | // cap). A transient connect, subscribe, or snapshot failure, and the loss of an | |||
| 31 | + | // established connection observed through BrokerService.Done/Err while no | |||
| 32 | + | // logical stream is held, both retire the old subscription and service, wait | |||
| 33 | + | // the cadence, and reconnect under a fresh generation. A failure that can never | |||
| 34 | + | // succeed by retrying (an incompatible carriage or a conflicting policy) is | |||
| 35 | + | // surfaced once and stops automatic reconnection; only the domain's terminal | |||
| 36 | + | // codes end the process. | |||
| 37 | + | // | |||
| 38 | + | // A connection attempt is joined before Run returns. Since the connector | |||
| 39 | + | // contract requires Connect to honor cancellation, a completion that arrives | |||
| 40 | + | // after the supervisor has begun terminating is still drained and its service | |||
| 41 | + | // and subscription are closed, so a late superseded success never leaks. Raw | |||
| 42 | + | // mode is restored exactly once on every exit path, terminal EOF and process | |||
| 43 | + | // cancellation included. | |||
| 44 | + | // | |||
| 45 | + | // Parent cancellation settles the run: an attempt that settles at the same | |||
| 46 | + | // instant as cancellation is retired instead of adopted, and the run ends with | |||
| 47 | + | // ctx.Err without publishing a retry cadence, rendering ConnectivityRetryWait, | |||
| 48 | + | // or notifying a connectivity failure. | |||
| 49 | + | ||||
| 50 | + | // Supervisor retry cadence bounds. The exponential cap doubles from the initial | |||
| 51 | + | // interval to the maximum; the equal-jitter interval is then half the cap plus | |||
| 52 | + | // a uniform fraction of the other half. | |||
| 53 | + | const ( | |||
| 54 | + | supervisorRetryInitial = 100 * time.Millisecond | |||
| 55 | + | supervisorRetryMax = 2 * time.Second | |||
| 56 | + | ) | |||
| 57 | + | ||||
| 58 | + | // Presentation is what the autonomous client is presenting on the terminal. | |||
| 59 | + | type Presentation uint8 | |||
| 60 | + | ||||
| 61 | + | const ( | |||
| 62 | + | // PresentPicker shows the session picker. P5.1a exercises only this state. | |||
| 63 | + | PresentPicker Presentation = iota + 1 | |||
| 64 | + | // PresentConnecting shows the attach transition. Reserved for the attach | |||
| 65 | + | // slice; the P5.1a driver never enters it. | |||
| 66 | + | PresentConnecting | |||
| 67 | + | // PresentAttached shows a live attachment. Reserved for the attach slice. | |||
| 68 | + | PresentAttached | |||
| 69 | + | // PresentTerminating is the terminal state: raw mode is being restored and | |||
| 70 | + | // the process is leaving. | |||
| 71 | + | PresentTerminating | |||
| 72 | + | ) | |||
| 73 | + | ||||
| 74 | + | func (p Presentation) String() string { | |||
| 75 | + | switch p { | |||
| 76 | + | case PresentPicker: | |||
| 77 | + | return "picker" | |||
| 78 | + | case PresentConnecting: | |||
| 79 | + | return "connecting" | |||
| 80 | + | case PresentAttached: | |||
| 81 | + | return "attached" | |||
| 82 | + | case PresentTerminating: | |||
| 83 | + | return "terminating" | |||
| 84 | + | default: | |||
| 85 | + | return "unknown" | |||
| 86 | + | } | |||
| 87 | + | } | |||
| 88 | + | ||||
| 89 | + | // Connectivity is the broker-connection lifecycle. | |||
| 90 | + | type Connectivity uint8 | |||
| 91 | + | ||||
| 92 | + | const ( | |||
| 93 | + | // ConnectivityDisconnected holds no broker connection: either before the | |||
| 94 | + | // first attempt, after a non-retryable failure, or while terminating. | |||
| 95 | + | ConnectivityDisconnected Connectivity = iota + 1 | |||
| 96 | + | // ConnectivityConnectingBroker is one in-flight connect/subscribe/first | |||
| 97 | + | // publication attempt. | |||
| 98 | + | ConnectivityConnectingBroker | |||
| 99 | + | // ConnectivityReady holds an established connection with its first | |||
| 100 | + | // publication committed. | |||
| 101 | + | ConnectivityReady | |||
| 102 | + | // ConnectivityRetryWait waits the retry cadence before the next attempt. | |||
| 103 | + | ConnectivityRetryWait | |||
| 104 | + | ) | |||
| 105 | + | ||||
| 106 | + | func (c Connectivity) String() string { | |||
| 107 | + | switch c { | |||
| 108 | + | case ConnectivityDisconnected: | |||
| 109 | + | return "disconnected" | |||
| 110 | + | case ConnectivityConnectingBroker: | |||
| 111 | + | return "connecting_broker" | |||
| 112 | + | case ConnectivityReady: | |||
| 113 | + | return "ready" | |||
| 114 | + | case ConnectivityRetryWait: | |||
| 115 | + | return "retry_wait" | |||
| 116 | + | default: | |||
| 117 | + | return "unknown" | |||
| 118 | + | } | |||
| 119 | + | } | |||
| 120 | + | ||||
| 121 | + | // State is the immutable projection of the supervisor. | |||
| 122 | + | type State struct { | |||
| 123 | + | Presentation Presentation | |||
| 124 | + | Connectivity Connectivity | |||
| 125 | + | // Attempt counts consecutive broker-connection failures since the last | |||
| 126 | + | // Ready. It is zero before the first attempt and after Ready, and drives | |||
| 127 | + | // the equal-jitter exponential backoff. | |||
| 128 | + | Attempt uint64 | |||
| 129 | + | // Generation identifies the current connection attempt. It increases before | |||
| 130 | + | // every attempt, so a settled attempt carries the generation that produced | |||
| 131 | + | // it. Run joins each attempt before it advances, so every completion today | |||
| 132 | + | // carries the current generation: the comparison in adoptAttempt is a | |||
| 133 | + | // defensive invariant for a future concurrent driver, not a path this | |||
| 134 | + | // driver can currently reach. | |||
| 135 | + | Generation uint64 | |||
| 136 | + | // Err is the most recent visible connectivity failure, nil while healthy | |||
| 137 | + | // and before any failure. | |||
| 138 | + | Err error | |||
| 139 | + | } | |||
| 140 | + | ||||
| 141 | + | // supervisorEventKind is one input to the pure supervisor reducer. | |||
| 142 | + | type supervisorEventKind uint8 | |||
| 143 | + | ||||
| 144 | + | const ( | |||
| 145 | + | // supervisorBeginAttempt starts one connect/subscribe attempt. | |||
| 146 | + | supervisorBeginAttempt supervisorEventKind = iota + 1 | |||
| 147 | + | // supervisorReady marks the first committed publication of the current | |||
| 148 | + | // attempt. | |||
| 149 | + | supervisorReady | |||
| 150 | + | // supervisorTransientFailure is a connect, subscribe, or first-publication | |||
| 151 | + | // failure that is worth retrying. | |||
| 152 | + | supervisorTransientFailure | |||
| 153 | + | // supervisorBrokerLoss is the loss of an established connection observed | |||
| 154 | + | // through BrokerService.Done/Err while no logical stream is held. | |||
| 155 | + | supervisorBrokerLoss | |||
| 156 | + | // supervisorNonRetryable is a failure that retrying can never fix. | |||
| 157 | + | supervisorNonRetryable | |||
| 158 | + | // supervisorTerminal ends the process: terminal EOF, process cancellation, | |||
| 159 | + | // or a terminal broker failure. | |||
| 160 | + | supervisorTerminal | |||
| 161 | + | ) | |||
| 162 | + | ||||
| 163 | + | type supervisorEvent struct { | |||
| 164 | + | kind supervisorEventKind | |||
| 165 | + | err error | |||
| 166 | + | } | |||
| 167 | + | ||||
| 168 | + | // reduceSupervisor is the pure connectivity/presentation reducer. It is | |||
| 169 | + | // side-effect free so the state machine can be exhaustively table-tested; the | |||
| 170 | + | // driver applies it under a mutex and renders the result. | |||
| 171 | + | func reduceSupervisor(state State, event supervisorEvent) State { | |||
| 172 | + | switch event.kind { | |||
| 173 | + | case supervisorBeginAttempt: | |||
| 174 | + | state.Presentation = PresentPicker | |||
| 175 | + | state.Connectivity = ConnectivityConnectingBroker | |||
| 176 | + | state.Generation++ | |||
| 177 | + | state.Err = nil | |||
| 178 | + | case supervisorReady: | |||
| 179 | + | state.Connectivity = ConnectivityReady | |||
| 180 | + | state.Attempt = 0 | |||
| 181 | + | state.Err = nil | |||
| 182 | + | case supervisorTransientFailure, supervisorBrokerLoss: | |||
| 183 | + | state.Connectivity = ConnectivityRetryWait | |||
| 184 | + | state.Attempt++ | |||
| 185 | + | state.Err = event.err | |||
| 186 | + | case supervisorNonRetryable: | |||
| 187 | + | state.Connectivity = ConnectivityDisconnected | |||
| 188 | + | state.Err = event.err | |||
| 189 | + | case supervisorTerminal: | |||
| 190 | + | state.Presentation = PresentTerminating | |||
| 191 | + | state.Connectivity = ConnectivityDisconnected | |||
| 192 | + | state.Err = event.err | |||
| 193 | + | } | |||
| 194 | + | return state | |||
| 195 | + | } | |||
| 196 | + | ||||
| 197 | + | // SupervisorConfig supplies the autonomous client's dependencies. The | |||
| 198 | + | // connector, terminal, and clock are required; render, notify, and jitter are | |||
| 199 | + | // injectable seams with safe defaults. Render, notify, and jitter must not | |||
| 200 | + | // block: the supervisor drives them from its single run goroutine. | |||
| 201 | + | type SupervisorConfig struct { | |||
| 202 | + | // Connector establishes the broker connection. Required. | |||
| 203 | + | Connector ports.BrokerConnector | |||
| 204 | + | // Terminal is the controlling terminal the supervisor owns. Required. | |||
| 205 | + | Terminal ports.Terminal | |||
| 206 | + | // Clock supplies the retry timers. Required; use ports.Clock, not the wall | |||
| 207 | + | // clock. | |||
| 208 | + | Clock ports.Clock | |||
| 209 | + | // Render paints the current state. It is called once per visible | |||
| 210 | + | // transition. Optional; the default renders nothing. | |||
| 211 | + | Render func(State) | |||
| 212 | + | // Notify surfaces a connectivity failure without leaving the picker. | |||
| 213 | + | // Optional; the default notifies nothing. | |||
| 214 | + | Notify func(State, error) | |||
| 215 | + | // Jitter returns an equal-jitter fraction in [0,1). It is the only | |||
| 216 | + | // randomness seam so tests can pin the retry cadence exactly. Optional; the | |||
| 217 | + | // default is a process-local random source. | |||
| 218 | + | Jitter func() float64 | |||
| 219 | + | } | |||
| 220 | + | ||||
| 221 | + | // Supervisor owns one autonomous client process: raw mode, one terminal input | |||
| 222 | + | // lifetime, and the broker-connectivity lifecycle. | |||
| 223 | + | type Supervisor struct { | |||
| 224 | + | cfg SupervisorConfig | |||
| 225 | + | ||||
| 226 | + | mu sync.Mutex | |||
| 227 | + | state State | |||
| 228 | + | } | |||
| 229 | + | ||||
| 230 | + | // NewSupervisor validates the required dependencies and returns a supervisor | |||
| 231 | + | // parked on the picker with no connection. It does not touch the terminal; Run | |||
| 232 | + | // enters raw mode. | |||
| 233 | + | func NewSupervisor(cfg SupervisorConfig) (*Supervisor, error) { | |||
| 234 | + | if supervisorNil(cfg.Connector) { | |||
| 235 | + | return nil, errors.New("vev: supervisor requires a broker connector") | |||
| 236 | + | } | |||
| 237 | + | if supervisorNil(cfg.Terminal) { | |||
| 238 | + | return nil, errors.New("vev: supervisor requires a terminal") | |||
| 239 | + | } | |||
| 240 | + | if supervisorNil(cfg.Clock) { | |||
| 241 | + | return nil, errors.New("vev: supervisor requires a clock") | |||
| 242 | + | } | |||
| 243 | + | if cfg.Render == nil { | |||
| 244 | + | cfg.Render = func(State) {} | |||
| 245 | + | } | |||
| 246 | + | if cfg.Jitter == nil { | |||
| 247 | + | cfg.Jitter = rand.Float64 | |||
| 248 | + | } | |||
| 249 | + | return &Supervisor{ | |||
| 250 | + | cfg: cfg, | |||
| 251 | + | state: State{Presentation: PresentPicker, Connectivity: ConnectivityDisconnected}, | |||
| 252 | + | }, nil | |||
| 253 | + | } | |||
| 254 | + | ||||
| 255 | + | // State returns the current immutable projection. | |||
| 256 | + | func (s *Supervisor) State() State { | |||
| 257 | + | s.mu.Lock() | |||
| 258 | + | defer s.mu.Unlock() | |||
| 259 | + | return s.state | |||
| 260 | + | } | |||
| 261 | + | ||||
| 262 | + | // Run owns the terminal for exactly its lifetime. It enters raw mode before it | |||
| 263 | + | // connects to the broker, starts the single terminal input reader, and then | |||
| 264 | + | // drives connect/subscribe/retry until the terminal reaches EOF, the process | |||
| 265 | + | // context is cancelled, or a terminal broker failure settles the process. Raw | |||
| 266 | + | // mode is restored exactly once on every exit path. | |||
| 267 | + | func (s *Supervisor) Run(ctx context.Context) (retErr error) { | |||
| 268 | + | restore, err := s.cfg.Terminal.EnterRaw() | |||
| 269 | + | if err != nil { | |||
| 270 | + | return fmt.Errorf("vev: entering raw mode: %w", err) | |||
| 271 | + | } | |||
| 272 | + | if restore == nil { | |||
| 273 | + | restore = func() error { return nil } | |||
| 274 | + | } | |||
| 275 | + | restoreTerminal := sync.OnceValue(restore) | |||
| 276 | + | defer func() { | |||
| 277 | + | if rerr := restoreTerminal(); rerr != nil && retErr == nil { | |||
| 278 | + | retErr = fmt.Errorf("vev: restoring terminal: %w", rerr) | |||
| 279 | + | } | |||
| 280 | + | }() | |||
| 281 | + | ||||
| 282 | + | input := startTerminalInputLifetime(s.cfg.Terminal.In()) | |||
| 283 | + | ||||
| 284 | + | // The active connection is owned across loop iterations. retire is | |||
| 285 | + | // idempotent so every exit path closes exactly the connection it adopted. | |||
| 286 | + | var ( | |||
| 287 | + | service ports.BrokerService | |||
| 288 | + | sub ports.BrokerSubscription | |||
| 289 | + | ) | |||
| 290 | + | retire := func() { | |||
| 291 | + | if sub != nil { | |||
| 292 | + | sub.Close() | |||
| 293 | + | sub = nil | |||
| 294 | + | } | |||
| 295 | + | if service != nil { | |||
| 296 | + | _ = service.Close() | |||
| 297 | + | service = nil | |||
| 298 | + | } | |||
| 299 | + | } | |||
| 300 | + | defer retire() | |||
| 301 | + | ||||
| 302 | + | for { | |||
| 303 | + | // The generation is claimed before the connector runs, so the attempt is | |||
| 304 | + | // always attributable to the state that started it and a completion can be | |||
| 305 | + | // matched against the generation that produced it. | |||
| 306 | + | s.transition(supervisorEvent{kind: supervisorBeginAttempt}) | |||
| 307 | + | generation := s.State().Generation | |||
| 308 | + | attemptCtx, cancelAttempt := context.WithCancel(ctx) | |||
| 309 | + | outcome := make(chan supervisorAttempt, 1) | |||
| 310 | + | go func() { outcome <- s.connectAttempt(attemptCtx, generation) }() | |||
| 311 | + | ||||
| 312 | + | result, terminated, termErr := s.awaitAttempt(ctx, input, outcome) | |||
| 313 | + | if terminated { | |||
| 314 | + | // Connect must honor cancellation, so joining the attempt here both | |||
| 315 | + | // retires a late superseded success and leaves no goroutine behind. | |||
| 316 | + | cancelAttempt() | |||
| 317 | + | late := <-outcome | |||
| 318 | + | late.retire() | |||
| 319 | + | s.transition(supervisorEvent{kind: supervisorTerminal, err: termErr}) | |||
| 320 | + | return termErr | |||
| 321 | + | } | |||
| 322 | + | cancelAttempt() | |||
| 323 | + | adopted, cause := s.adoptAttempt(ctx, result) | |||
| 324 | + | if !adopted { | |||
| 325 | + | // The cancelled or superseded attempt is retired rather than adopted, so | |||
| 326 | + | // it cannot drive the state of a newer attempt and a cancelled run never | |||
| 327 | + | // publishes a retry cadence, renders ConnectivityRetryWait, or notifies a | |||
| 328 | + | // connectivity failure for it. | |||
| 329 | + | result.retire() | |||
| 330 | + | if cause != nil { | |||
| 331 | + | s.transition(supervisorEvent{kind: supervisorTerminal, err: cause}) | |||
| 332 | + | return cause | |||
| 333 | + | } | |||
| 334 | + | continue | |||
| 335 | + | } | |||
| 336 | + | ||||
| 337 | + | if result.err != nil { | |||
| 338 | + | if supervisorTerminalFailure(result.err) { | |||
| 339 | + | s.transition(supervisorEvent{kind: supervisorTerminal, err: result.err}) | |||
| 340 | + | return result.err | |||
| 341 | + | } | |||
| 342 | + | if !supervisorRetryable(result.err) { | |||
| 343 | + | // Retrying can never fix this. Return to the picker with the | |||
| 344 | + | // visible error and stop reconnecting; the process still owns | |||
| 345 | + | // the terminal until the user exits or it is cancelled. | |||
| 346 | + | s.transition(supervisorEvent{kind: supervisorNonRetryable, err: result.err}) | |||
| 347 | + | s.notify(result.err) | |||
| 348 | + | terminalErr := s.awaitTermination(ctx, input) | |||
| 349 | + | s.transition(supervisorEvent{kind: supervisorTerminal, err: terminalErr}) | |||
| 350 | + | return terminalErr | |||
| 351 | + | } | |||
| 352 | + | if terminated, termErr := s.retryFailure(ctx, input, supervisorTransientFailure, result.err); terminated { | |||
| 353 | + | return termErr | |||
| 354 | + | } | |||
| 355 | + | continue | |||
| 356 | + | } | |||
| 357 | + | ||||
| 358 | + | service, sub = result.service, result.sub | |||
| 359 | + | s.transition(supervisorEvent{kind: supervisorReady}) | |||
| 360 | + | ||||
| 361 | + | lossErr, terminated, termErr := s.awaitLoss(ctx, input, service) | |||
| 362 | + | retire() | |||
| 363 | + | if terminated { | |||
| 364 | + | s.transition(supervisorEvent{kind: supervisorTerminal, err: termErr}) | |||
| 365 | + | return termErr | |||
| 366 | + | } | |||
| 367 | + | if cause := ctx.Err(); cause != nil { | |||
| 368 | + | // Parent cancellation wins over a loss that settled at the same instant, | |||
| 369 | + | // exactly as it does at the attempt settle. The connection was already | |||
| 370 | + | // released above, so the loss is simply never turned into a retry | |||
| 371 | + | // cadence, a ConnectivityRetryWait render, or a notification for a run | |||
| 372 | + | // that was already cancelled. | |||
| 373 | + | s.transition(supervisorEvent{kind: supervisorTerminal, err: cause}) | |||
| 374 | + | return cause | |||
| 375 | + | } | |||
| 376 | + | if terminated, termErr := s.retryFailure(ctx, input, supervisorBrokerLoss, normalizeUnavailable(lossErr)); terminated { | |||
| 377 | + | return termErr | |||
| 378 | + | } | |||
| 379 | + | } | |||
| 380 | + | } | |||
| 381 | + | ||||
| 382 | + | // supervisorAttempt is the settled outcome of one connect/subscribe/first | |||
| 383 | + | // publication attempt. generation is the attempt's generation, claimed before | |||
| 384 | + | // the connector ran, so the completion is always attributable to the attempt | |||
| 385 | + | // that produced it (see adoptAttempt's generation fence). | |||
| 386 | + | type supervisorAttempt struct { | |||
| 387 | + | generation uint64 | |||
| 388 | + | service ports.BrokerService | |||
| 389 | + | sub ports.BrokerSubscription | |||
| 390 | + | err error | |||
| 391 | + | } | |||
| 392 | + | ||||
| 393 | + | // retire closes whatever the attempt produced. It is safe on a partially built | |||
| 394 | + | // or empty attempt, and it treats a typed-nil service or subscription as absent | |||
| 395 | + | // rather than calling into it. | |||
| 396 | + | func (a supervisorAttempt) retire() { | |||
| 397 | + | if !supervisorNil(a.sub) { | |||
| 398 | + | a.sub.Close() | |||
| 399 | + | } | |||
| 400 | + | if !supervisorNil(a.service) { | |||
| 401 | + | _ = a.service.Close() | |||
| 402 | + | } | |||
| 403 | + | } | |||
| 404 | + | ||||
| 405 | + | // adoptAttempt decides whether one settled attempt may be adopted, and reports the | |||
| 406 | + | // parent cancellation that must settle the run instead. The decision is made under | |||
| 407 | + | // the state lock, atomically with respect to the state it consults, so an attempt | |||
| 408 | + | // that settled after the parent cancelled the run is never adopted even though | |||
| 409 | + | // awaitAttempt's select may have taken its outcome over an already-ready | |||
| 410 | + | // ctx.Done. The generation fence is enforced at the same decision point: a | |||
| 411 | + | // superseded attempt is rejected too. It is a defensive invariant, not a | |||
| 412 | + | // currently reachable production path, because Run joins each attempt before it | |||
| 413 | + | // advances; it is kept so a future driver that admits more than one attempt at a | |||
| 414 | + | // time can never let a late completion drive the state of a newer attempt. | |||
| 415 | + | func (s *Supervisor) adoptAttempt(ctx context.Context, result supervisorAttempt) (adopted bool, cause error) { | |||
| 416 | + | s.mu.Lock() | |||
| 417 | + | defer s.mu.Unlock() | |||
| 418 | + | if cause := ctx.Err(); cause != nil { | |||
| 419 | + | return false, cause | |||
| 420 | + | } | |||
| 421 | + | if result.generation != s.state.Generation { | |||
| 422 | + | return false, nil | |||
| 423 | + | } | |||
| 424 | + | return true, nil | |||
| 425 | + | } | |||
| 426 | + | ||||
| 427 | + | // retryFailure records one retryable connectivity failure, surfaces it, and | |||
| 428 | + | // waits the cadence for the next attempt. It is the shared tail of the transient | |||
| 429 | + | // connect/subscribe failure and the established-connection loss paths, so both | |||
| 430 | + | // select the same observable transition, notification, and backoff. | |||
| 431 | + | func (s *Supervisor) retryFailure(ctx context.Context, input *terminalInputLifetime, kind supervisorEventKind, err error) (bool, error) { | |||
| 432 | + | if cause := ctx.Err(); cause != nil { | |||
| 433 | + | s.transition(supervisorEvent{kind: supervisorTerminal, err: cause}) | |||
| 434 | + | return true, cause | |||
| 435 | + | } | |||
| 436 | + | s.transition(supervisorEvent{kind: kind, err: err}) | |||
| 437 | + | s.notify(err) | |||
| 438 | + | if terminated, termErr := s.waitBackoff(ctx, input); terminated { | |||
| 439 | + | s.transition(supervisorEvent{kind: supervisorTerminal, err: termErr}) | |||
| 440 | + | return true, termErr | |||
| 441 | + | } | |||
| 442 | + | return false, nil | |||
| 443 | + | } | |||
| 444 | + | ||||
| 445 | + | // connectAttempt connects once, subscribes, and waits for the first committed | |||
| 446 | + | // publication. It returns a live service and subscription only when the | |||
| 447 | + | // connection is Ready; every failure closes whatever it opened before | |||
| 448 | + | // returning, so a caller has nothing to clean up on the error path. Every | |||
| 449 | + | // connect, subscribe, or first-publication failure is stamped with generation | |||
| 450 | + | // and normalized to a typed unavailable failure so the run loop never observes | |||
| 451 | + | // an untyped cause. | |||
| 452 | + | func (s *Supervisor) connectAttempt(ctx context.Context, generation uint64) supervisorAttempt { | |||
| 453 | + | service, err := s.cfg.Connector.Connect(ctx) | |||
| 454 | + | if err != nil { | |||
| 455 | + | return supervisorAttempt{generation: generation, err: normalizeUnavailable(err)} | |||
| 456 | + | } | |||
| 457 | + | if supervisorNil(service) { | |||
| 458 | + | return supervisorAttempt{generation: generation, err: ports.BrokerError{ | |||
| 459 | + | Code: ports.BrokerErrorUnavailable, | |||
| 460 | + | Text: "broker connector returned no service", | |||
| 461 | + | }} | |||
| 462 | + | } | |||
| 463 | + | sub, err := service.Subscribe() | |||
| 464 | + | if err != nil { | |||
| 465 | + | _ = service.Close() | |||
| 466 | + | return supervisorAttempt{generation: generation, err: normalizeUnavailable(err)} | |||
| 467 | + | } | |||
| 468 | + | if supervisorNil(sub) { | |||
| 469 | + | // A service that reports no subscription cannot ever become Ready, so it | |||
| 470 | + | // is a typed unavailable failure rather than a nil dereference on the | |||
| 471 | + | // first Changed() call. | |||
| 472 | + | _ = service.Close() | |||
| 473 | + | return supervisorAttempt{generation: generation, err: ports.BrokerError{ | |||
| 474 | + | Code: ports.BrokerErrorUnavailable, | |||
| 475 | + | Text: "broker service returned no subscription", | |||
| 476 | + | }} | |||
| 477 | + | } | |||
| 478 | + | for { | |||
| 479 | + | if service.Snapshot().Epoch != 0 { | |||
| 480 | + | return supervisorAttempt{generation: generation, service: service, sub: sub} | |||
| 481 | + | } | |||
| 482 | + | select { | |||
| 483 | + | case <-sub.Changed(): | |||
| 484 | + | continue | |||
| 485 | + | case <-service.Done(): | |||
| 486 | + | cause := normalizeUnavailable(service.Err()) | |||
| 487 | + | sub.Close() | |||
| 488 | + | _ = service.Close() | |||
| 489 | + | return supervisorAttempt{generation: generation, err: cause} | |||
| 490 | + | case <-ctx.Done(): | |||
| 491 | + | sub.Close() | |||
| 492 | + | _ = service.Close() | |||
| 493 | + | return supervisorAttempt{generation: generation, err: ctx.Err()} | |||
| 494 | + | } | |||
| 495 | + | } | |||
| 496 | + | } | |||
| 497 | + | ||||
| 498 | + | // awaitAttempt waits for the in-flight attempt to settle, or for the run to be | |||
| 499 | + | // terminated first. | |||
| 500 | + | func (s *Supervisor) awaitAttempt(ctx context.Context, input *terminalInputLifetime, outcome <-chan supervisorAttempt) (supervisorAttempt, bool, error) { | |||
| 501 | + | select { | |||
| 502 | + | case result := <-outcome: | |||
| 503 | + | return result, false, nil | |||
| 504 | + | case <-ctx.Done(): | |||
| 505 | + | return supervisorAttempt{}, true, ctx.Err() | |||
| 506 | + | case err := <-input.EOF(): | |||
| 507 | + | return supervisorAttempt{}, true, terminalReadCause(err) | |||
| 508 | + | } | |||
| 509 | + | } | |||
| 510 | + | ||||
| 511 | + | // awaitLoss waits for an established connection to be lost, or for the run to | |||
| 512 | + | // be terminated first. A loss is reported with the service's stable terminal | |||
| 513 | + | // cause. | |||
| 514 | + | func (s *Supervisor) awaitLoss(ctx context.Context, input *terminalInputLifetime, service ports.BrokerService) (error, bool, error) { | |||
| 515 | + | select { | |||
| 516 | + | case <-service.Done(): | |||
| 517 | + | return service.Err(), false, nil | |||
| 518 | + | case <-ctx.Done(): | |||
| 519 | + | return nil, true, ctx.Err() | |||
| 520 | + | case err := <-input.EOF(): | |||
| 521 | + | return nil, true, terminalReadCause(err) | |||
| 522 | + | } | |||
| 523 | + | } | |||
| 524 | + | ||||
| 525 | + | // awaitTermination parks the supervisor on the picker after a non-retryable | |||
| 526 | + | // failure until the terminal ends or the process is cancelled. A clean terminal | |||
| 527 | + | // EOF returns nil; every other cause is reported. | |||
| 528 | + | func (s *Supervisor) awaitTermination(ctx context.Context, input *terminalInputLifetime) error { | |||
| 529 | + | select { | |||
| 530 | + | case <-ctx.Done(): | |||
| 531 | + | return ctx.Err() | |||
| 532 | + | case err := <-input.EOF(): | |||
| 533 | + | return terminalReadCause(err) | |||
| 534 | + | } | |||
| 535 | + | } | |||
| 536 | + | ||||
| 537 | + | // waitBackoff waits one equal-jitter retry interval for the current failure | |||
| 538 | + | // count, or settles on cancellation or terminal EOF. The timer is always | |||
| 539 | + | // stopped and drained, so no timer outlives the attempt transition. | |||
| 540 | + | func (s *Supervisor) waitBackoff(ctx context.Context, input *terminalInputLifetime) (bool, error) { | |||
| 541 | + | delay := supervisorBackoffDelay(s.State().Attempt, s.cfg.Jitter) | |||
| 542 | + | if delay <= 0 { | |||
| 543 | + | return false, nil | |||
| 544 | + | } | |||
| 545 | + | timer := s.cfg.Clock.NewTimer(delay) | |||
| 546 | + | defer stopSupervisorTimer(timer) | |||
| 547 | + | select { | |||
| 548 | + | case <-timer.C(): | |||
| 549 | + | return false, nil | |||
| 550 | + | case <-ctx.Done(): | |||
| 551 | + | return true, ctx.Err() | |||
| 552 | + | case err := <-input.EOF(): | |||
| 553 | + | return true, terminalReadCause(err) | |||
| 554 | + | } | |||
| 555 | + | } | |||
| 556 | + | ||||
| 557 | + | // supervisorBackoffDelay is the equal-jitter exponential interval for one | |||
| 558 | + | // failure count: the cap doubles from supervisorRetryInitial to | |||
| 559 | + | // supervisorRetryMax, and the interval is half the cap plus a uniform fraction | |||
| 560 | + | // of the other half, so attempts spread across [cap/2, cap). It is pure so the | |||
| 561 | + | // cadence can be tested exactly. | |||
| 562 | + | func supervisorBackoffDelay(attempt uint64, jitter func() float64) time.Duration { | |||
| 563 | + | if attempt == 0 { | |||
| 564 | + | return 0 | |||
| 565 | + | } | |||
| 566 | + | limit := supervisorRetryInitial | |||
| 567 | + | for i := uint64(1); i < attempt && limit < supervisorRetryMax; i++ { | |||
| 568 | + | limit *= 2 | |||
| 569 | + | } | |||
| 570 | + | if limit > supervisorRetryMax { | |||
| 571 | + | limit = supervisorRetryMax | |||
| 572 | + | } | |||
| 573 | + | half := limit / 2 | |||
| 574 | + | fraction := jitter() | |||
| 575 | + | if !(fraction >= 0) { | |||
| 576 | + | // NaN is unordered: both fraction < 0 and fraction > 1 are false for | |||
| 577 | + | // it, so an explicit non-negative test maps NaN and every negative value | |||
| 578 | + | // to the minimum fraction. Without this, a NaN would reach the duration | |||
| 579 | + | // conversion and yield an undefined interval instead of the documented | |||
| 580 | + | // half-cap minimum. | |||
| 581 | + | fraction = 0 | |||
| 582 | + | } | |||
| 583 | + | if fraction > 1 { | |||
| 584 | + | fraction = 1 | |||
| 585 | + | } | |||
| 586 | + | return half + time.Duration(fraction*float64(half)) | |||
| 587 | + | } | |||
| 588 | + | ||||
| 589 | + | // terminalReadCause maps a terminal read end to the Run result: a clean EOF is | |||
| 590 | + | // an orderly exit (nil), any other read failure is reported. | |||
| 591 | + | func terminalReadCause(err error) error { | |||
| 592 | + | if err == nil || errors.Is(err, io.EOF) { | |||
| 593 | + | return nil | |||
| 594 | + | } | |||
| 595 | + | return err | |||
| 596 | + | } | |||
| 597 | + | ||||
| 598 | + | // stopSupervisorTimer stops and drains a timer, supporting buffered timer | |||
| 599 | + | // adapters with pre-Go-1.23 reset semantics. | |||
| 600 | + | func stopSupervisorTimer(timer ports.Timer) { | |||
| 601 | + | if timer == nil { | |||
| 602 | + | return | |||
| 603 | + | } | |||
| 604 | + | if !timer.Stop() { | |||
| 605 | + | select { | |||
| 606 | + | case <-timer.C(): | |||
| 607 | + | default: | |||
| 608 | + | } | |||
| 609 | + | } | |||
| 610 | + | } | |||
| 611 | + | ||||
| 612 | + | // supervisorTerminalFailure reports whether a broker failure must end the | |||
| 613 | + | // process. Only the domain's terminal codes do; every other failure returns the | |||
| 614 | + | // client to the picker. | |||
| 615 | + | func supervisorTerminalFailure(err error) bool { | |||
| 616 | + | var typed ports.BrokerError | |||
| 617 | + | return errors.As(err, &typed) && typed.Terminal() | |||
| 618 | + | } | |||
| 619 | + | ||||
| 620 | + | // supervisorRetryable reports whether a failure is worth another attempt after | |||
| 621 | + | // backoff. An incompatible carriage or a conflicting policy can never succeed | |||
| 622 | + | // by retrying, so it is surfaced once and automatic reconnection stops; every | |||
| 623 | + | // other failure is treated as transient. | |||
| 624 | + | func supervisorRetryable(err error) bool { | |||
| 625 | + | var typed ports.BrokerError | |||
| 626 | + | if errors.As(err, &typed) { | |||
| 627 | + | switch typed.Code { | |||
| 628 | + | case ports.BrokerErrorIncompatible, ports.BrokerErrorConflictingPolicy: | |||
| 629 | + | return false | |||
| 630 | + | } | |||
| 631 | + | } | |||
| 632 | + | return true | |||
| 633 | + | } | |||
| 634 | + | ||||
| 635 | + | // normalizeUnavailable presents one connect, subscribe, or connection-loss | |||
| 636 | + | // failure as a typed ports.BrokerError. A cause that is already a BrokerError is | |||
| 637 | + | // returned unchanged, so its own code (for example incompatible or conflicting | |||
| 638 | + | // policy) still selects the non-retryable path; every untyped failure, including | |||
| 639 | + | // a nil cause, becomes an unavailable BrokerError that preserves the original | |||
| 640 | + | // error as its local-only Cause. | |||
| 641 | + | func normalizeUnavailable(err error) error { | |||
| 642 | + | if err == nil { | |||
| 643 | + | return ports.BrokerError{Code: ports.BrokerErrorUnavailable, Text: "broker connection lost"} | |||
| 644 | + | } | |||
| 645 | + | var typed ports.BrokerError | |||
| 646 | + | if errors.As(err, &typed) { | |||
| 647 | + | return err | |||
| 648 | + | } | |||
| 649 | + | return ports.BrokerError{Code: ports.BrokerErrorUnavailable, Text: "broker connection lost", Cause: err} | |||
| 650 | + | } | |||
| 651 | + | ||||
| 652 | + | // notify surfaces a connectivity failure through the optional notifier. | |||
| 653 | + | func (s *Supervisor) notify(err error) { | |||
| 654 | + | if s.cfg.Notify == nil || err == nil { | |||
| 655 | + | return | |||
| 656 | + | } | |||
| 657 | + | s.cfg.Notify(s.State(), err) | |||
| 658 | + | } | |||
| 659 | + | ||||
| 660 | + | // transition applies one reducer event under the supervisor mutex and renders | |||
| 661 | + | // the resulting state outside it, so a renderer that reads State back never | |||
| 662 | + | // deadlocks. | |||
| 663 | + | func (s *Supervisor) transition(event supervisorEvent) { | |||
| 664 | + | s.mu.Lock() | |||
| 665 | + | s.state = reduceSupervisor(s.state, event) | |||
| 666 | + | state := s.state | |||
| 667 | + | s.mu.Unlock() | |||
| 668 | + | s.cfg.Render(state) | |||
| 669 | + | } | |||
| 670 | + | ||||
| 671 | + | // terminalInputLifetime owns the single read of the controlling terminal. It is | |||
| 672 | + | // started once and never replaced: a bare io.Reader cannot be interrupted, so | |||
| 673 | + | // the goroutine may outlive Run when stdin never reaches EOF, exactly like the | |||
| 674 | + | // attach client's input pump. It never closes caller-owned input, and P5.1a | |||
| 675 | + | // discards read bytes because picker interaction is a later slice. | |||
| 676 | + | type terminalInputLifetime struct { | |||
| 677 | + | eof chan error | |||
| 678 | + | once sync.Once | |||
| 679 | + | } | |||
| 680 | + | ||||
| 681 | + | // startTerminalInputLifetime starts the single terminal read. A nil reader is | |||
| 682 | + | // treated as an immediate orderly EOF. | |||
| 683 | + | func startTerminalInputLifetime(in io.Reader) *terminalInputLifetime { | |||
| 684 | + | lifetime := &terminalInputLifetime{eof: make(chan error, 1)} | |||
| 685 | + | if supervisorNil(in) { | |||
| 686 | + | lifetime.finish(io.EOF) | |||
| 687 | + | return lifetime | |||
| 688 | + | } | |||
| 689 | + | go func() { | |||
| 690 | + | buffer := make([]byte, 4096) | |||
| 691 | + | for { | |||
| 692 | + | n, err := in.Read(buffer) | |||
| 693 | + | if err != nil { | |||
| 694 | + | lifetime.finish(err) | |||
| 695 | + | return | |||
| 696 | + | } | |||
| 697 | + | if n == 0 { | |||
| 698 | + | // A zero-byte, nil-error read is legal but cannot make | |||
| 699 | + | // progress; keep reading so a well-behaved reader is never | |||
| 700 | + | // mistaken for EOF. | |||
| 701 | + | continue | |||
| 702 | + | } | |||
| 703 | + | } | |||
| 704 | + | }() | |||
| 705 | + | return lifetime | |||
| 706 | + | } | |||
| 707 | + | ||||
| 708 | + | // finish publishes the terminal cause exactly once. | |||
| 709 | + | func (l *terminalInputLifetime) finish(err error) { | |||
| 710 | + | l.once.Do(func() { l.eof <- err }) | |||
| 711 | + | } | |||
| 712 | + | ||||
| 713 | + | // EOF is closed-result channel delivering the terminal read cause exactly once. | |||
| 714 | + | func (l *terminalInputLifetime) EOF() <-chan error { return l.eof } | |||
| 715 | + | ||||
| 716 | + | // supervisorNil reports whether a dependency is nil in any shape. A typed nil | |||
| 717 | + | // satisfies a plain != nil comparison, so the guard would defer the failure to | |||
| 718 | + | // the first method call, which panics; the reflection check keeps it total. | |||
| 719 | + | func supervisorNil(dependency any) bool { | |||
| 720 | + | if dependency == nil { | |||
| 721 | + | return true | |||
| 722 | + | } | |||
| 723 | + | value := reflect.ValueOf(dependency) | |||
| 724 | + | switch value.Kind() { | |||
| 725 | + | case reflect.Chan, reflect.Func, reflect.Interface, reflect.Map, reflect.Ptr, reflect.Slice: | |||
| 726 | + | return value.IsNil() | |||
| 727 | + | default: | |||
| 728 | + | return false | |||
| 729 | + | } | |||
| 730 | + | } | |||
A internal/usecase/client/supervisor_integration_test.go +213 -0
| @@ -0,0 +1,213 @@ | |||
| 1 | + | package client | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "encoding/binary" | |
| 6 | + | "errors" | |
| 7 | + | "os" | |
| 8 | + | "path/filepath" | |
| 9 | + | "sync" | |
| 10 | + | "testing" | |
| 11 | + | "time" | |
| 12 | + | ||
| 13 | + | "github.com/stretchr/testify/require" | |
| 14 | + | ||
| 15 | + | "github.com/bnema/vev/internal/adapters/brokeripc" | |
| 16 | + | "github.com/bnema/vev/internal/ports" | |
| 17 | + | ) | |
| 18 | + | ||
| 19 | + | // Real connector integration (Plan 001 P5.1a, GO-007). These tests wire the | |
| 20 | + | // production brokeripc Connector into the autonomous supervisor over a real | |
| 21 | + | // AF_UNIX broker endpoint, so the readiness handshake, the established- | |
| 22 | + | // connection loss, and the cancellation lifetime are pinned against the true | |
| 23 | + | // adapter and carriage rather than a scripted fake. | |
| 24 | + | ||
| 25 | + | const integrationEpoch = ports.BrokerEpoch(0x71) | |
| 26 | + | ||
| 27 | + | // integrationCore is the minimal admitted broker service one accepted | |
| 28 | + | // connection is bound to: a fixed-epoch publication and a coalescing | |
| 29 | + | // subscription, which is all the supervisor's readiness handshake needs. | |
| 30 | + | type integrationCore struct { | |
| 31 | + | id ports.BrokerConnectionID | |
| 32 | + | epoch ports.BrokerEpoch | |
| 33 | + | changed chan struct{} | |
| 34 | + | done chan struct{} | |
| 35 | + | closed chan struct{} | |
| 36 | + | once sync.Once | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | func (c *integrationCore) ConnectionID() ports.BrokerConnectionID { return c.id } | |
| 40 | + | func (c *integrationCore) Done() <-chan struct{} { return c.done } | |
| 41 | + | func (c *integrationCore) Err() error { return nil } | |
| 42 | + | ||
| 43 | + | func (c *integrationCore) Snapshot() ports.BrokerSnapshot { | |
| 44 | + | return ports.BrokerSnapshot{Epoch: c.epoch, Revision: 1} | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | func (c *integrationCore) Subscribe() (ports.BrokerSubscription, error) { | |
| 48 | + | return integrationSubscription{changed: c.changed}, nil | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | func (c *integrationCore) OpenStream(context.Context, ports.BrokerOpenStreamRequest) (ports.BrokerLogicalConnection, error) { | |
| 52 | + | return nil, errors.New("integration core does not support streams") | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | func (c *integrationCore) CloseStream(ports.BrokerConnectionID, ports.BrokerStreamID) error { | |
| 56 | + | return nil | |
| 57 | + | } | |
| 58 | + | func (c *integrationCore) AddHost(context.Context, string) error { return nil } | |
| 59 | + | func (c *integrationCore) RemoveHost(context.Context, string) (bool, error) { return false, nil } | |
| 60 | + | func (c *integrationCore) RequestReconcile(string) {} | |
| 61 | + | ||
| 62 | + | func (c *integrationCore) Close() error { | |
| 63 | + | c.once.Do(func() { | |
| 64 | + | close(c.done) | |
| 65 | + | close(c.closed) | |
| 66 | + | }) | |
| 67 | + | return nil | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | // integrationSubscription is one idle coalescing subscription. | |
| 71 | + | type integrationSubscription struct{ changed chan struct{} } | |
| 72 | + | ||
| 73 | + | func (s integrationSubscription) Changed() <-chan struct{} { return s.changed } | |
| 74 | + | func (s integrationSubscription) Close() {} | |
| 75 | + | ||
| 76 | + | // integrationAuthority admits one connection per accepted carriage and records | |
| 77 | + | // every admitted core, so a test can observe the broker-side end of the | |
| 78 | + | // supervisor's connection lifetime. | |
| 79 | + | type integrationAuthority struct { | |
| 80 | + | epoch ports.BrokerEpoch | |
| 81 | + | ||
| 82 | + | mu sync.Mutex | |
| 83 | + | next uint64 | |
| 84 | + | cores []*integrationCore | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | var _ ports.BrokerAuthority = (*integrationAuthority)(nil) | |
| 88 | + | ||
| 89 | + | func (a *integrationAuthority) AdmitClient(ctx context.Context) (ports.BrokerService, error) { | |
| 90 | + | if err := ctx.Err(); err != nil { | |
| 91 | + | return nil, err | |
| 92 | + | } | |
| 93 | + | a.mu.Lock() | |
| 94 | + | a.next++ | |
| 95 | + | sequence := a.next | |
| 96 | + | a.mu.Unlock() | |
| 97 | + | var id ports.BrokerConnectionID | |
| 98 | + | binary.BigEndian.PutUint64(id[:8], uint64(a.epoch)) | |
| 99 | + | binary.BigEndian.PutUint64(id[8:], sequence) | |
| 100 | + | core := &integrationCore{ | |
| 101 | + | id: id, | |
| 102 | + | epoch: a.epoch, | |
| 103 | + | changed: make(chan struct{}), | |
| 104 | + | done: make(chan struct{}), | |
| 105 | + | closed: make(chan struct{}), | |
| 106 | + | } | |
| 107 | + | a.mu.Lock() | |
| 108 | + | a.cores = append(a.cores, core) | |
| 109 | + | a.mu.Unlock() | |
| 110 | + | return core, nil | |
| 111 | + | } | |
| 112 | + | ||
| 113 | + | func (a *integrationAuthority) lastCore() *integrationCore { | |
| 114 | + | a.mu.Lock() | |
| 115 | + | defer a.mu.Unlock() | |
| 116 | + | if len(a.cores) == 0 { | |
| 117 | + | return nil | |
| 118 | + | } | |
| 119 | + | return a.cores[len(a.cores)-1] | |
| 120 | + | } | |
| 121 | + | ||
| 122 | + | // startIntegrationBroker binds one real broker IPC endpoint in a short private | |
| 123 | + | // directory, so the AF_UNIX path stays well within the platform limit. | |
| 124 | + | func startIntegrationBroker(t *testing.T) (*integrationAuthority, ports.BrokerListener, string) { | |
| 125 | + | t.Helper() | |
| 126 | + | root, err := os.MkdirTemp("/tmp", "v") | |
| 127 | + | require.NoError(t, err) | |
| 128 | + | t.Cleanup(func() { _ = os.RemoveAll(root) }) | |
| 129 | + | path := brokeripc.SocketPath(filepath.Join(root, "vev")) | |
| 130 | + | authority := &integrationAuthority{epoch: integrationEpoch} | |
| 131 | + | listener, err := brokeripc.Listen(path, integrationEpoch, authority, brokeripc.Config{}) | |
| 132 | + | require.NoError(t, err) | |
| 133 | + | t.Cleanup(func() { _ = listener.Close() }) | |
| 134 | + | return authority, listener, path | |
| 135 | + | } | |
| 136 | + | ||
| 137 | + | // integrationSupervisor builds one autonomous supervisor over the production | |
| 138 | + | // connector and a pinned clock with a controlled terminal. | |
| 139 | + | func integrationSupervisor(t *testing.T, path string) (*Supervisor, *supervisorTestClock, *supervisorTestTerminal) { | |
| 140 | + | t.Helper() | |
| 141 | + | clock := newSupervisorTestClock() | |
| 142 | + | reader := newSupervisorTestReader() | |
| 143 | + | t.Cleanup(reader.unblock) | |
| 144 | + | terminal := newSupervisorTestTerminal(reader) | |
| 145 | + | sup := mustSupervisor(t, SupervisorConfig{ | |
| 146 | + | Connector: brokeripc.NewConnector(path, brokeripc.Config{}), | |
| 147 | + | Terminal: terminal, | |
| 148 | + | Clock: clock, | |
| 149 | + | Jitter: func() float64 { return 0 }, | |
| 150 | + | }) | |
| 151 | + | return sup, clock, terminal | |
| 152 | + | } | |
| 153 | + | ||
| 154 | + | // TestSupervisorRealConnectorReadinessAndLoss pins the readiness handshake and | |
| 155 | + | // the established-connection loss against a real broker endpoint: the | |
| 156 | + | // supervisor becomes Ready only after the broker publishes the admitted | |
| 157 | + | // connection's epoch, and a retired connection is observed through Done/Err and | |
| 158 | + | // retried with a typed unavailable cause. | |
| 159 | + | func TestSupervisorRealConnectorReadinessAndLoss(t *testing.T) { | |
| 160 | + | _, listener, path := startIntegrationBroker(t) | |
| 161 | + | sup, _, terminal := integrationSupervisor(t, path) | |
| 162 | + | ||
| 163 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 164 | + | defer cancel() | |
| 165 | + | runErr := make(chan error, 1) | |
| 166 | + | go func() { runErr <- sup.Run(ctx) }() | |
| 167 | + | ||
| 168 | + | require.Eventually(t, func() bool { return sup.State().Connectivity == ConnectivityReady }, 5*time.Second, time.Millisecond) | |
| 169 | + | require.Equal(t, uint64(1), sup.State().Generation) | |
| 170 | + | require.Equal(t, uint64(0), sup.State().Attempt) | |
| 171 | + | require.NoError(t, sup.State().Err) | |
| 172 | + | ||
| 173 | + | // The broker retires the accepted connection; the supervisor observes the | |
| 174 | + | // carriage loss through Done/Err and waits the cadence before retrying. | |
| 175 | + | server, err := listener.Accept() | |
| 176 | + | require.NoError(t, err) | |
| 177 | + | require.NoError(t, server.Close()) | |
| 178 | + | ||
| 179 | + | require.Eventually(t, func() bool { return sup.State().Connectivity == ConnectivityRetryWait }, 5*time.Second, time.Millisecond) | |
| 180 | + | var typed ports.BrokerError | |
| 181 | + | require.ErrorAs(t, sup.State().Err, &typed) | |
| 182 | + | require.Equal(t, ports.BrokerErrorUnavailable, typed.Code) | |
| 183 | + | ||
| 184 | + | cancel() | |
| 185 | + | require.ErrorIs(t, <-runErr, context.Canceled) | |
| 186 | + | require.Equal(t, 1, terminal.restoreCount()) | |
| 187 | + | } | |
| 188 | + | ||
| 189 | + | // TestSupervisorRealConnectorCancelClosesConnection pins the cancellation | |
| 190 | + | // lifetime: cancelling the run closes the supervisor's adopted connection, ends | |
| 191 | + | // the broker-side session, and retires the admitted core exactly once. | |
| 192 | + | func TestSupervisorRealConnectorCancelClosesConnection(t *testing.T) { | |
| 193 | + | authority, _, path := startIntegrationBroker(t) | |
| 194 | + | sup, _, terminal := integrationSupervisor(t, path) | |
| 195 | + | ||
| 196 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 197 | + | runErr := make(chan error, 1) | |
| 198 | + | go func() { runErr <- sup.Run(ctx) }() | |
| 199 | + | ||
| 200 | + | require.Eventually(t, func() bool { return sup.State().Connectivity == ConnectivityReady }, 5*time.Second, time.Millisecond) | |
| 201 | + | core := authority.lastCore() | |
| 202 | + | require.NotNil(t, core, "the broker must have admitted the connection") | |
| 203 | + | ||
| 204 | + | cancel() | |
| 205 | + | require.ErrorIs(t, <-runErr, context.Canceled) | |
| 206 | + | require.Equal(t, 1, terminal.restoreCount()) | |
| 207 | + | ||
| 208 | + | select { | |
| 209 | + | case <-core.closed: | |
| 210 | + | case <-time.After(5 * time.Second): | |
| 211 | + | t.Fatal("cancelling the supervisor must close the broker connection") | |
| 212 | + | } | |
| 213 | + | } | |
| @@ -0,0 +1,213 @@ | |||||
| 1 | + | package client | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "encoding/binary" | |||
| 6 | + | "errors" | |||
| 7 | + | "os" | |||
| 8 | + | "path/filepath" | |||
| 9 | + | "sync" | |||
| 10 | + | "testing" | |||
| 11 | + | "time" | |||
| 12 | + | ||||
| 13 | + | "github.com/stretchr/testify/require" | |||
| 14 | + | ||||
| 15 | + | "github.com/bnema/vev/internal/adapters/brokeripc" | |||
| 16 | + | "github.com/bnema/vev/internal/ports" | |||
| 17 | + | ) | |||
| 18 | + | ||||
| 19 | + | // Real connector integration (Plan 001 P5.1a, GO-007). These tests wire the | |||
| 20 | + | // production brokeripc Connector into the autonomous supervisor over a real | |||
| 21 | + | // AF_UNIX broker endpoint, so the readiness handshake, the established- | |||
| 22 | + | // connection loss, and the cancellation lifetime are pinned against the true | |||
| 23 | + | // adapter and carriage rather than a scripted fake. | |||
| 24 | + | ||||
| 25 | + | const integrationEpoch = ports.BrokerEpoch(0x71) | |||
| 26 | + | ||||
| 27 | + | // integrationCore is the minimal admitted broker service one accepted | |||
| 28 | + | // connection is bound to: a fixed-epoch publication and a coalescing | |||
| 29 | + | // subscription, which is all the supervisor's readiness handshake needs. | |||
| 30 | + | type integrationCore struct { | |||
| 31 | + | id ports.BrokerConnectionID | |||
| 32 | + | epoch ports.BrokerEpoch | |||
| 33 | + | changed chan struct{} | |||
| 34 | + | done chan struct{} | |||
| 35 | + | closed chan struct{} | |||
| 36 | + | once sync.Once | |||
| 37 | + | } | |||
| 38 | + | ||||
| 39 | + | func (c *integrationCore) ConnectionID() ports.BrokerConnectionID { return c.id } | |||
| 40 | + | func (c *integrationCore) Done() <-chan struct{} { return c.done } | |||
| 41 | + | func (c *integrationCore) Err() error { return nil } | |||
| 42 | + | ||||
| 43 | + | func (c *integrationCore) Snapshot() ports.BrokerSnapshot { | |||
| 44 | + | return ports.BrokerSnapshot{Epoch: c.epoch, Revision: 1} | |||
| 45 | + | } | |||
| 46 | + | ||||
| 47 | + | func (c *integrationCore) Subscribe() (ports.BrokerSubscription, error) { | |||
| 48 | + | return integrationSubscription{changed: c.changed}, nil | |||
| 49 | + | } | |||
| 50 | + | ||||
| 51 | + | func (c *integrationCore) OpenStream(context.Context, ports.BrokerOpenStreamRequest) (ports.BrokerLogicalConnection, error) { | |||
| 52 | + | return nil, errors.New("integration core does not support streams") | |||
| 53 | + | } | |||
| 54 | + | ||||
| 55 | + | func (c *integrationCore) CloseStream(ports.BrokerConnectionID, ports.BrokerStreamID) error { | |||
| 56 | + | return nil | |||
| 57 | + | } | |||
| 58 | + | func (c *integrationCore) AddHost(context.Context, string) error { return nil } | |||
| 59 | + | func (c *integrationCore) RemoveHost(context.Context, string) (bool, error) { return false, nil } | |||
| 60 | + | func (c *integrationCore) RequestReconcile(string) {} | |||
| 61 | + | ||||
| 62 | + | func (c *integrationCore) Close() error { | |||
| 63 | + | c.once.Do(func() { | |||
| 64 | + | close(c.done) | |||
| 65 | + | close(c.closed) | |||
| 66 | + | }) | |||
| 67 | + | return nil | |||
| 68 | + | } | |||
| 69 | + | ||||
| 70 | + | // integrationSubscription is one idle coalescing subscription. | |||
| 71 | + | type integrationSubscription struct{ changed chan struct{} } | |||
| 72 | + | ||||
| 73 | + | func (s integrationSubscription) Changed() <-chan struct{} { return s.changed } | |||
| 74 | + | func (s integrationSubscription) Close() {} | |||
| 75 | + | ||||
| 76 | + | // integrationAuthority admits one connection per accepted carriage and records | |||
| 77 | + | // every admitted core, so a test can observe the broker-side end of the | |||
| 78 | + | // supervisor's connection lifetime. | |||
| 79 | + | type integrationAuthority struct { | |||
| 80 | + | epoch ports.BrokerEpoch | |||
| 81 | + | ||||
| 82 | + | mu sync.Mutex | |||
| 83 | + | next uint64 | |||
| 84 | + | cores []*integrationCore | |||
| 85 | + | } | |||
| 86 | + | ||||
| 87 | + | var _ ports.BrokerAuthority = (*integrationAuthority)(nil) | |||
| 88 | + | ||||
| 89 | + | func (a *integrationAuthority) AdmitClient(ctx context.Context) (ports.BrokerService, error) { | |||
| 90 | + | if err := ctx.Err(); err != nil { | |||
| 91 | + | return nil, err | |||
| 92 | + | } | |||
| 93 | + | a.mu.Lock() | |||
| 94 | + | a.next++ | |||
| 95 | + | sequence := a.next | |||
| 96 | + | a.mu.Unlock() | |||
| 97 | + | var id ports.BrokerConnectionID | |||
| 98 | + | binary.BigEndian.PutUint64(id[:8], uint64(a.epoch)) | |||
| 99 | + | binary.BigEndian.PutUint64(id[8:], sequence) | |||
| 100 | + | core := &integrationCore{ | |||
| 101 | + | id: id, | |||
| 102 | + | epoch: a.epoch, | |||
| 103 | + | changed: make(chan struct{}), | |||
| 104 | + | done: make(chan struct{}), | |||
| 105 | + | closed: make(chan struct{}), | |||
| 106 | + | } | |||
| 107 | + | a.mu.Lock() | |||
| 108 | + | a.cores = append(a.cores, core) | |||
| 109 | + | a.mu.Unlock() | |||
| 110 | + | return core, nil | |||
| 111 | + | } | |||
| 112 | + | ||||
| 113 | + | func (a *integrationAuthority) lastCore() *integrationCore { | |||
| 114 | + | a.mu.Lock() | |||
| 115 | + | defer a.mu.Unlock() | |||
| 116 | + | if len(a.cores) == 0 { | |||
| 117 | + | return nil | |||
| 118 | + | } | |||
| 119 | + | return a.cores[len(a.cores)-1] | |||
| 120 | + | } | |||
| 121 | + | ||||
| 122 | + | // startIntegrationBroker binds one real broker IPC endpoint in a short private | |||
| 123 | + | // directory, so the AF_UNIX path stays well within the platform limit. | |||
| 124 | + | func startIntegrationBroker(t *testing.T) (*integrationAuthority, ports.BrokerListener, string) { | |||
| 125 | + | t.Helper() | |||
| 126 | + | root, err := os.MkdirTemp("/tmp", "v") | |||
| 127 | + | require.NoError(t, err) | |||
| 128 | + | t.Cleanup(func() { _ = os.RemoveAll(root) }) | |||
| 129 | + | path := brokeripc.SocketPath(filepath.Join(root, "vev")) | |||
| 130 | + | authority := &integrationAuthority{epoch: integrationEpoch} | |||
| 131 | + | listener, err := brokeripc.Listen(path, integrationEpoch, authority, brokeripc.Config{}) | |||
| 132 | + | require.NoError(t, err) | |||
| 133 | + | t.Cleanup(func() { _ = listener.Close() }) | |||
| 134 | + | return authority, listener, path | |||
| 135 | + | } | |||
| 136 | + | ||||
| 137 | + | // integrationSupervisor builds one autonomous supervisor over the production | |||
| 138 | + | // connector and a pinned clock with a controlled terminal. | |||
| 139 | + | func integrationSupervisor(t *testing.T, path string) (*Supervisor, *supervisorTestClock, *supervisorTestTerminal) { | |||
| 140 | + | t.Helper() | |||
| 141 | + | clock := newSupervisorTestClock() | |||
| 142 | + | reader := newSupervisorTestReader() | |||
| 143 | + | t.Cleanup(reader.unblock) | |||
| 144 | + | terminal := newSupervisorTestTerminal(reader) | |||
| 145 | + | sup := mustSupervisor(t, SupervisorConfig{ | |||
| 146 | + | Connector: brokeripc.NewConnector(path, brokeripc.Config{}), | |||
| 147 | + | Terminal: terminal, | |||
| 148 | + | Clock: clock, | |||
| 149 | + | Jitter: func() float64 { return 0 }, | |||
| 150 | + | }) | |||
| 151 | + | return sup, clock, terminal | |||
| 152 | + | } | |||
| 153 | + | ||||
| 154 | + | // TestSupervisorRealConnectorReadinessAndLoss pins the readiness handshake and | |||
| 155 | + | // the established-connection loss against a real broker endpoint: the | |||
| 156 | + | // supervisor becomes Ready only after the broker publishes the admitted | |||
| 157 | + | // connection's epoch, and a retired connection is observed through Done/Err and | |||
| 158 | + | // retried with a typed unavailable cause. | |||
| 159 | + | func TestSupervisorRealConnectorReadinessAndLoss(t *testing.T) { | |||
| 160 | + | _, listener, path := startIntegrationBroker(t) | |||
| 161 | + | sup, _, terminal := integrationSupervisor(t, path) | |||
| 162 | + | ||||
| 163 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 164 | + | defer cancel() | |||
| 165 | + | runErr := make(chan error, 1) | |||
| 166 | + | go func() { runErr <- sup.Run(ctx) }() | |||
| 167 | + | ||||
| 168 | + | require.Eventually(t, func() bool { return sup.State().Connectivity == ConnectivityReady }, 5*time.Second, time.Millisecond) | |||
| 169 | + | require.Equal(t, uint64(1), sup.State().Generation) | |||
| 170 | + | require.Equal(t, uint64(0), sup.State().Attempt) | |||
| 171 | + | require.NoError(t, sup.State().Err) | |||
| 172 | + | ||||
| 173 | + | // The broker retires the accepted connection; the supervisor observes the | |||
| 174 | + | // carriage loss through Done/Err and waits the cadence before retrying. | |||
| 175 | + | server, err := listener.Accept() | |||
| 176 | + | require.NoError(t, err) | |||
| 177 | + | require.NoError(t, server.Close()) | |||
| 178 | + | ||||
| 179 | + | require.Eventually(t, func() bool { return sup.State().Connectivity == ConnectivityRetryWait }, 5*time.Second, time.Millisecond) | |||
| 180 | + | var typed ports.BrokerError | |||
| 181 | + | require.ErrorAs(t, sup.State().Err, &typed) | |||
| 182 | + | require.Equal(t, ports.BrokerErrorUnavailable, typed.Code) | |||
| 183 | + | ||||
| 184 | + | cancel() | |||
| 185 | + | require.ErrorIs(t, <-runErr, context.Canceled) | |||
| 186 | + | require.Equal(t, 1, terminal.restoreCount()) | |||
| 187 | + | } | |||
| 188 | + | ||||
| 189 | + | // TestSupervisorRealConnectorCancelClosesConnection pins the cancellation | |||
| 190 | + | // lifetime: cancelling the run closes the supervisor's adopted connection, ends | |||
| 191 | + | // the broker-side session, and retires the admitted core exactly once. | |||
| 192 | + | func TestSupervisorRealConnectorCancelClosesConnection(t *testing.T) { | |||
| 193 | + | authority, _, path := startIntegrationBroker(t) | |||
| 194 | + | sup, _, terminal := integrationSupervisor(t, path) | |||
| 195 | + | ||||
| 196 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 197 | + | runErr := make(chan error, 1) | |||
| 198 | + | go func() { runErr <- sup.Run(ctx) }() | |||
| 199 | + | ||||
| 200 | + | require.Eventually(t, func() bool { return sup.State().Connectivity == ConnectivityReady }, 5*time.Second, time.Millisecond) | |||
| 201 | + | core := authority.lastCore() | |||
| 202 | + | require.NotNil(t, core, "the broker must have admitted the connection") | |||
| 203 | + | ||||
| 204 | + | cancel() | |||
| 205 | + | require.ErrorIs(t, <-runErr, context.Canceled) | |||
| 206 | + | require.Equal(t, 1, terminal.restoreCount()) | |||
| 207 | + | ||||
| 208 | + | select { | |||
| 209 | + | case <-core.closed: | |||
| 210 | + | case <-time.After(5 * time.Second): | |||
| 211 | + | t.Fatal("cancelling the supervisor must close the broker connection") | |||
| 212 | + | } | |||
| 213 | + | } | |||
A internal/usecase/client/supervisor_test.go +1284 -0
| @@ -0,0 +1,1284 @@ | |||
| 1 | + | package client | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "errors" | |
| 6 | + | "io" | |
| 7 | + | "math" | |
| 8 | + | "runtime" | |
| 9 | + | "strings" | |
| 10 | + | "sync" | |
| 11 | + | "testing" | |
| 12 | + | "time" | |
| 13 | + | ||
| 14 | + | "github.com/stretchr/testify/require" | |
| 15 | + | ||
| 16 | + | "github.com/bnema/vev/internal/domain" | |
| 17 | + | "github.com/bnema/vev/internal/ports" | |
| 18 | + | ) | |
| 19 | + | ||
| 20 | + | // supervisorTestClock is a manually fired ports.Clock. NewTimer publishes each | |
| 21 | + | // timer on a buffered channel so a test can fire it deterministically without a | |
| 22 | + | // sleep, and Stop is recorded so a test can prove every retry timer was | |
| 23 | + | // retired. | |
| 24 | + | type supervisorTestClock struct { | |
| 25 | + | mu sync.Mutex | |
| 26 | + | now time.Time | |
| 27 | + | timers []*supervisorTestTimer | |
| 28 | + | created chan *supervisorTestTimer | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | func newSupervisorTestClock() *supervisorTestClock { | |
| 32 | + | return &supervisorTestClock{now: time.Unix(1000, 0), created: make(chan *supervisorTestTimer, 32)} | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | func (c *supervisorTestClock) Now() time.Time { | |
| 36 | + | c.mu.Lock() | |
| 37 | + | defer c.mu.Unlock() | |
| 38 | + | return c.now | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | func (c *supervisorTestClock) NewTimer(delay time.Duration) ports.Timer { | |
| 42 | + | timer := &supervisorTestTimer{ch: make(chan time.Time, 1), delay: delay} | |
| 43 | + | c.mu.Lock() | |
| 44 | + | c.timers = append(c.timers, timer) | |
| 45 | + | c.mu.Unlock() | |
| 46 | + | select { | |
| 47 | + | case c.created <- timer: | |
| 48 | + | default: | |
| 49 | + | } | |
| 50 | + | return timer | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | func (c *supervisorTestClock) awaitTimer(t *testing.T) *supervisorTestTimer { | |
| 54 | + | t.Helper() | |
| 55 | + | select { | |
| 56 | + | case timer := <-c.created: | |
| 57 | + | return timer | |
| 58 | + | case <-time.After(5 * time.Second): | |
| 59 | + | t.Fatal("supervisor did not schedule a retry timer") | |
| 60 | + | return nil | |
| 61 | + | } | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | func (c *supervisorTestClock) timerCount() int { | |
| 65 | + | c.mu.Lock() | |
| 66 | + | defer c.mu.Unlock() | |
| 67 | + | return len(c.timers) | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | func (c *supervisorTestClock) liveTimers() int { | |
| 71 | + | c.mu.Lock() | |
| 72 | + | defer c.mu.Unlock() | |
| 73 | + | live := 0 | |
| 74 | + | for _, timer := range c.timers { | |
| 75 | + | if !timer.stopped() { | |
| 76 | + | live++ | |
| 77 | + | } | |
| 78 | + | } | |
| 79 | + | return live | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | func (c *supervisorTestClock) allStopped() bool { | |
| 83 | + | c.mu.Lock() | |
| 84 | + | defer c.mu.Unlock() | |
| 85 | + | for _, timer := range c.timers { | |
| 86 | + | if !timer.stopped() { | |
| 87 | + | return false | |
| 88 | + | } | |
| 89 | + | } | |
| 90 | + | return true | |
| 91 | + | } | |
| 92 | + | ||
| 93 | + | type supervisorTestTimer struct { | |
| 94 | + | ch chan time.Time | |
| 95 | + | delay time.Duration | |
| 96 | + | mu sync.Mutex | |
| 97 | + | stoppedFlag bool | |
| 98 | + | } | |
| 99 | + | ||
| 100 | + | func (t *supervisorTestTimer) C() <-chan time.Time { return t.ch } | |
| 101 | + | ||
| 102 | + | func (t *supervisorTestTimer) Reset(time.Duration) bool { return false } | |
| 103 | + | ||
| 104 | + | func (t *supervisorTestTimer) Stop() bool { | |
| 105 | + | t.mu.Lock() | |
| 106 | + | defer t.mu.Unlock() | |
| 107 | + | if t.stoppedFlag { | |
| 108 | + | return false | |
| 109 | + | } | |
| 110 | + | t.stoppedFlag = true | |
| 111 | + | return true | |
| 112 | + | } | |
| 113 | + | ||
| 114 | + | func (t *supervisorTestTimer) stopped() bool { | |
| 115 | + | t.mu.Lock() | |
| 116 | + | defer t.mu.Unlock() | |
| 117 | + | return t.stoppedFlag | |
| 118 | + | } | |
| 119 | + | ||
| 120 | + | // fire delivers the timer without blocking, so a test may fire before or after | |
| 121 | + | // the supervisor reaches its select. | |
| 122 | + | func (t *supervisorTestTimer) fire() { | |
| 123 | + | select { | |
| 124 | + | case t.ch <- time.Now(): | |
| 125 | + | default: | |
| 126 | + | } | |
| 127 | + | } | |
| 128 | + | ||
| 129 | + | // supervisorTestTerminal records raw-mode enters and restores. Its reader is | |
| 130 | + | // shared so tests can model input EOF and a live terminal. | |
| 131 | + | type supervisorTestTerminal struct { | |
| 132 | + | in io.Reader | |
| 133 | + | enterErr error | |
| 134 | + | ||
| 135 | + | mu sync.Mutex | |
| 136 | + | restores int | |
| 137 | + | } | |
| 138 | + | ||
| 139 | + | func newSupervisorTestTerminal(in io.Reader) *supervisorTestTerminal { | |
| 140 | + | return &supervisorTestTerminal{in: in} | |
| 141 | + | } | |
| 142 | + | ||
| 143 | + | func (t *supervisorTestTerminal) EnterRaw() (func() error, error) { | |
| 144 | + | if t.enterErr != nil { | |
| 145 | + | return nil, t.enterErr | |
| 146 | + | } | |
| 147 | + | return func() error { | |
| 148 | + | t.mu.Lock() | |
| 149 | + | t.restores++ | |
| 150 | + | t.mu.Unlock() | |
| 151 | + | return nil | |
| 152 | + | }, nil | |
| 153 | + | } | |
| 154 | + | ||
| 155 | + | func (t *supervisorTestTerminal) Geometry() (domain.Geometry, error) { | |
| 156 | + | return domain.Geometry{}, nil | |
| 157 | + | } | |
| 158 | + | ||
| 159 | + | func (t *supervisorTestTerminal) ResizeEvents() <-chan domain.Geometry { return nil } | |
| 160 | + | ||
| 161 | + | func (t *supervisorTestTerminal) In() io.Reader { return t.in } | |
| 162 | + | ||
| 163 | + | func (t *supervisorTestTerminal) Out() io.Writer { return io.Discard } | |
| 164 | + | ||
| 165 | + | func (t *supervisorTestTerminal) Flush() error { return nil } | |
| 166 | + | ||
| 167 | + | func (t *supervisorTestTerminal) restoreCount() int { | |
| 168 | + | t.mu.Lock() | |
| 169 | + | defer t.mu.Unlock() | |
| 170 | + | return t.restores | |
| 171 | + | } | |
| 172 | + | ||
| 173 | + | // supervisorTestConnector scripts connection attempts and records concurrency, | |
| 174 | + | // so a test can prove attempts never overlap. | |
| 175 | + | type supervisorTestConnector struct { | |
| 176 | + | connect func(ctx context.Context, call int) (ports.BrokerService, error) | |
| 177 | + | ||
| 178 | + | mu sync.Mutex | |
| 179 | + | calls int | |
| 180 | + | inFlight int | |
| 181 | + | maxInFlight int | |
| 182 | + | started chan int | |
| 183 | + | } | |
| 184 | + | ||
| 185 | + | func newSupervisorTestConnector(connect func(ctx context.Context, call int) (ports.BrokerService, error)) *supervisorTestConnector { | |
| 186 | + | return &supervisorTestConnector{connect: connect, started: make(chan int, 32)} | |
| 187 | + | } | |
| 188 | + | ||
| 189 | + | func (c *supervisorTestConnector) Connect(ctx context.Context) (ports.BrokerService, error) { | |
| 190 | + | c.mu.Lock() | |
| 191 | + | c.calls++ | |
| 192 | + | call := c.calls | |
| 193 | + | c.inFlight++ | |
| 194 | + | if c.inFlight > c.maxInFlight { | |
| 195 | + | c.maxInFlight = c.inFlight | |
| 196 | + | } | |
| 197 | + | c.mu.Unlock() | |
| 198 | + | select { | |
| 199 | + | case c.started <- call: | |
| 200 | + | default: | |
| 201 | + | } | |
| 202 | + | defer func() { | |
| 203 | + | c.mu.Lock() | |
| 204 | + | c.inFlight-- | |
| 205 | + | c.mu.Unlock() | |
| 206 | + | }() | |
| 207 | + | if c.connect == nil { | |
| 208 | + | return nil, ports.BrokerError{Code: ports.BrokerErrorUnavailable, Text: "no script"} | |
| 209 | + | } | |
| 210 | + | return c.connect(ctx, call) | |
| 211 | + | } | |
| 212 | + | ||
| 213 | + | func (c *supervisorTestConnector) callCount() int { | |
| 214 | + | c.mu.Lock() | |
| 215 | + | defer c.mu.Unlock() | |
| 216 | + | return c.calls | |
| 217 | + | } | |
| 218 | + | ||
| 219 | + | func (c *supervisorTestConnector) maxConcurrent() int { | |
| 220 | + | c.mu.Lock() | |
| 221 | + | defer c.mu.Unlock() | |
| 222 | + | return c.maxInFlight | |
| 223 | + | } | |
| 224 | + | ||
| 225 | + | func (c *supervisorTestConnector) inFlightCount() int { | |
| 226 | + | c.mu.Lock() | |
| 227 | + | defer c.mu.Unlock() | |
| 228 | + | return c.inFlight | |
| 229 | + | } | |
| 230 | + | ||
| 231 | + | func (c *supervisorTestConnector) awaitStart(t *testing.T) int { | |
| 232 | + | t.Helper() | |
| 233 | + | select { | |
| 234 | + | case call := <-c.started: | |
| 235 | + | return call | |
| 236 | + | case <-time.After(5 * time.Second): | |
| 237 | + | t.Fatal("supervisor did not start a connection attempt") | |
| 238 | + | return 0 | |
| 239 | + | } | |
| 240 | + | } | |
| 241 | + | ||
| 242 | + | // supervisorTestHub is one broker publication source. subscribe hands out the | |
| 243 | + | // current change channel, and publish retires and replaces it, so a stale | |
| 244 | + | // subscription can never observe a newer publication. | |
| 245 | + | type supervisorTestHub struct { | |
| 246 | + | mu sync.Mutex | |
| 247 | + | snapshot ports.BrokerSnapshot | |
| 248 | + | changed chan struct{} | |
| 249 | + | } | |
| 250 | + | ||
| 251 | + | func newSupervisorTestHub() *supervisorTestHub { | |
| 252 | + | return &supervisorTestHub{changed: make(chan struct{})} | |
| 253 | + | } | |
| 254 | + | ||
| 255 | + | func (h *supervisorTestHub) publish(snapshot ports.BrokerSnapshot) { | |
| 256 | + | h.mu.Lock() | |
| 257 | + | h.snapshot = snapshot | |
| 258 | + | changed := h.changed | |
| 259 | + | h.changed = make(chan struct{}) | |
| 260 | + | h.mu.Unlock() | |
| 261 | + | close(changed) | |
| 262 | + | } | |
| 263 | + | ||
| 264 | + | func (h *supervisorTestHub) current() ports.BrokerSnapshot { | |
| 265 | + | h.mu.Lock() | |
| 266 | + | defer h.mu.Unlock() | |
| 267 | + | return h.snapshot | |
| 268 | + | } | |
| 269 | + | ||
| 270 | + | func (h *supervisorTestHub) subscribe(closeFn func()) ports.BrokerSubscription { | |
| 271 | + | h.mu.Lock() | |
| 272 | + | changed := h.changed | |
| 273 | + | h.mu.Unlock() | |
| 274 | + | return supervisorTestSubscription{changed: changed, closeFn: closeFn} | |
| 275 | + | } | |
| 276 | + | ||
| 277 | + | type supervisorTestSubscription struct { | |
| 278 | + | changed <-chan struct{} | |
| 279 | + | closeFn func() | |
| 280 | + | } | |
| 281 | + | ||
| 282 | + | func (s supervisorTestSubscription) Changed() <-chan struct{} { return s.changed } | |
| 283 | + | ||
| 284 | + | func (s supervisorTestSubscription) Close() { | |
| 285 | + | if s.closeFn != nil { | |
| 286 | + | s.closeFn() | |
| 287 | + | } | |
| 288 | + | } | |
| 289 | + | ||
| 290 | + | // supervisorTestService is one scripted BrokerService. Close is observable so a | |
| 291 | + | // test can prove a retired or late service was closed, and Close can be held open | |
| 292 | + | // so a test can order a parent cancellation strictly before a settle decision that | |
| 293 | + | // runs after the connection is released. | |
| 294 | + | type supervisorTestService struct { | |
| 295 | + | id ports.BrokerConnectionID | |
| 296 | + | hub *supervisorTestHub | |
| 297 | + | ||
| 298 | + | mu sync.Mutex | |
| 299 | + | err error | |
| 300 | + | doneOnce sync.Once | |
| 301 | + | done chan struct{} | |
| 302 | + | ||
| 303 | + | closeOnce sync.Once | |
| 304 | + | closed chan struct{} | |
| 305 | + | subClosed chan struct{} | |
| 306 | + | ||
| 307 | + | // closeGate, when non-nil, holds Close until releaseClose is called, and | |
| 308 | + | // closeEntered reports that Close began waiting on it. | |
| 309 | + | closeGate chan struct{} | |
| 310 | + | closeEntered chan struct{} | |
| 311 | + | } | |
| 312 | + | ||
| 313 | + | func newSupervisorTestService(id ports.BrokerConnectionID) *supervisorTestService { | |
| 314 | + | return &supervisorTestService{ | |
| 315 | + | id: id, | |
| 316 | + | hub: newSupervisorTestHub(), | |
| 317 | + | done: make(chan struct{}), | |
| 318 | + | closed: make(chan struct{}), | |
| 319 | + | subClosed: make(chan struct{}, 1), | |
| 320 | + | } | |
| 321 | + | } | |
| 322 | + | ||
| 323 | + | func (s *supervisorTestService) ConnectionID() ports.BrokerConnectionID { return s.id } | |
| 324 | + | ||
| 325 | + | func (s *supervisorTestService) Done() <-chan struct{} { return s.done } | |
| 326 | + | ||
| 327 | + | func (s *supervisorTestService) Err() error { | |
| 328 | + | s.mu.Lock() | |
| 329 | + | defer s.mu.Unlock() | |
| 330 | + | return s.err | |
| 331 | + | } | |
| 332 | + | ||
| 333 | + | func (s *supervisorTestService) Snapshot() ports.BrokerSnapshot { return s.hub.current() } | |
| 334 | + | ||
| 335 | + | func (s *supervisorTestService) Subscribe() (ports.BrokerSubscription, error) { | |
| 336 | + | return s.hub.subscribe(func() { | |
| 337 | + | select { | |
| 338 | + | case s.subClosed <- struct{}{}: | |
| 339 | + | default: | |
| 340 | + | } | |
| 341 | + | }), nil | |
| 342 | + | } | |
| 343 | + | ||
| 344 | + | func (s *supervisorTestService) OpenStream(context.Context, ports.BrokerOpenStreamRequest) (ports.BrokerLogicalConnection, error) { | |
| 345 | + | return nil, errors.New("supervisor test service does not support streams") | |
| 346 | + | } | |
| 347 | + | ||
| 348 | + | func (s *supervisorTestService) CloseStream(ports.BrokerConnectionID, ports.BrokerStreamID) error { | |
| 349 | + | return nil | |
| 350 | + | } | |
| 351 | + | ||
| 352 | + | func (s *supervisorTestService) AddHost(context.Context, string) error { return nil } | |
| 353 | + | ||
| 354 | + | func (s *supervisorTestService) RemoveHost(context.Context, string) (bool, error) { | |
| 355 | + | return false, nil | |
| 356 | + | } | |
| 357 | + | ||
| 358 | + | func (s *supervisorTestService) RequestReconcile(string) {} | |
| 359 | + | ||
| 360 | + | func (s *supervisorTestService) Close() error { | |
| 361 | + | s.closeOnce.Do(func() { | |
| 362 | + | if s.closeGate != nil { | |
| 363 | + | select { | |
| 364 | + | case s.closeEntered <- struct{}{}: | |
| 365 | + | default: | |
| 366 | + | } | |
| 367 | + | <-s.closeGate | |
| 368 | + | } | |
| 369 | + | close(s.closed) | |
| 370 | + | s.terminate(nil) | |
| 371 | + | }) | |
| 372 | + | return nil | |
| 373 | + | } | |
| 374 | + | ||
| 375 | + | // holdClose makes Close block until releaseClose is called, so a test can order a | |
| 376 | + | // parent cancellation strictly before a settle decision that runs after the | |
| 377 | + | // connection is released. closingCh reports that the release was entered. | |
| 378 | + | func (s *supervisorTestService) holdClose() { | |
| 379 | + | s.closeGate = make(chan struct{}) | |
| 380 | + | s.closeEntered = make(chan struct{}, 1) | |
| 381 | + | } | |
| 382 | + | ||
| 383 | + | // releaseClose releases a held Close. | |
| 384 | + | func (s *supervisorTestService) releaseClose() { | |
| 385 | + | if s.closeGate != nil { | |
| 386 | + | close(s.closeGate) | |
| 387 | + | } | |
| 388 | + | } | |
| 389 | + | ||
| 390 | + | // closingCh reports that the held release was entered. | |
| 391 | + | func (s *supervisorTestService) closingCh() <-chan struct{} { return s.closeEntered } | |
| 392 | + | ||
| 393 | + | // terminate removes the connection, recording the first cause exactly once. | |
| 394 | + | func (s *supervisorTestService) terminate(err error) { | |
| 395 | + | if err != nil { | |
| 396 | + | s.mu.Lock() | |
| 397 | + | if s.err == nil { | |
| 398 | + | s.err = err | |
| 399 | + | } | |
| 400 | + | s.mu.Unlock() | |
| 401 | + | } | |
| 402 | + | s.doneOnce.Do(func() { close(s.done) }) | |
| 403 | + | } | |
| 404 | + | ||
| 405 | + | // lose models an unexplained broker-side loss. | |
| 406 | + | func (s *supervisorTestService) lose(err error) { s.terminate(err) } | |
| 407 | + | ||
| 408 | + | func (s *supervisorTestService) publish(epoch ports.BrokerEpoch, revision ports.BrokerRevision) { | |
| 409 | + | s.hub.publish(ports.BrokerSnapshot{Epoch: epoch, Revision: revision}) | |
| 410 | + | } | |
| 411 | + | ||
| 412 | + | func (s *supervisorTestService) closedCh() <-chan struct{} { return s.closed } | |
| 413 | + | ||
| 414 | + | func mustSupervisor(t *testing.T, cfg SupervisorConfig) *Supervisor { | |
| 415 | + | t.Helper() | |
| 416 | + | sup, err := NewSupervisor(cfg) | |
| 417 | + | require.NoError(t, err) | |
| 418 | + | return sup | |
| 419 | + | } | |
| 420 | + | ||
| 421 | + | // supervisorTestReader blocks until unblocked, so a test controls exactly when | |
| 422 | + | // the owned terminal input reaches EOF. | |
| 423 | + | type supervisorTestReader struct { | |
| 424 | + | mu sync.Mutex | |
| 425 | + | done chan struct{} | |
| 426 | + | } | |
| 427 | + | ||
| 428 | + | func newSupervisorTestReader() *supervisorTestReader { | |
| 429 | + | return &supervisorTestReader{done: make(chan struct{})} | |
| 430 | + | } | |
| 431 | + | ||
| 432 | + | func (r *supervisorTestReader) Read([]byte) (int, error) { | |
| 433 | + | <-r.done | |
| 434 | + | return 0, io.EOF | |
| 435 | + | } | |
| 436 | + | ||
| 437 | + | func (r *supervisorTestReader) unblock() { | |
| 438 | + | r.mu.Lock() | |
| 439 | + | defer r.mu.Unlock() | |
| 440 | + | select { | |
| 441 | + | case <-r.done: | |
| 442 | + | default: | |
| 443 | + | close(r.done) | |
| 444 | + | } | |
| 445 | + | } | |
| 446 | + | ||
| 447 | + | func TestSupervisorReducerTransitions(t *testing.T) { | |
| 448 | + | transient := errors.New("unavailable") | |
| 449 | + | incompatible := ports.BrokerError{Code: ports.BrokerErrorIncompatible, Text: "version"} | |
| 450 | + | exited := ports.BrokerError{Code: ports.BrokerErrorExplicitExit, Text: "exit"} | |
| 451 | + | picker := State{Presentation: PresentPicker, Connectivity: ConnectivityDisconnected} | |
| 452 | + | ||
| 453 | + | tests := []struct { | |
| 454 | + | name string | |
| 455 | + | state State | |
| 456 | + | event supervisorEvent | |
| 457 | + | want State | |
| 458 | + | }{ | |
| 459 | + | { | |
| 460 | + | name: "begin attempt claims a generation and connects", | |
| 461 | + | state: picker, | |
| 462 | + | event: supervisorEvent{kind: supervisorBeginAttempt}, | |
| 463 | + | want: State{Presentation: PresentPicker, Connectivity: ConnectivityConnectingBroker, Attempt: 0, Generation: 1}, | |
| 464 | + | }, | |
| 465 | + | { | |
| 466 | + | name: "ready resets the retry cadence", | |
| 467 | + | state: State{Presentation: PresentPicker, Connectivity: ConnectivityConnectingBroker, Attempt: 3, Generation: 4, Err: transient}, | |
| 468 | + | event: supervisorEvent{kind: supervisorReady}, | |
| 469 | + | want: State{Presentation: PresentPicker, Connectivity: ConnectivityReady, Attempt: 0, Generation: 4}, | |
| 470 | + | }, | |
| 471 | + | { | |
| 472 | + | name: "transient failure waits with a bumped attempt", | |
| 473 | + | state: State{Presentation: PresentPicker, Connectivity: ConnectivityConnectingBroker, Attempt: 0, Generation: 2}, | |
| 474 | + | event: supervisorEvent{kind: supervisorTransientFailure, err: transient}, | |
| 475 | + | want: State{Presentation: PresentPicker, Connectivity: ConnectivityRetryWait, Attempt: 1, Generation: 2, Err: transient}, | |
| 476 | + | }, | |
| 477 | + | { | |
| 478 | + | name: "broker loss returns to picker retry wait", | |
| 479 | + | state: State{Presentation: PresentPicker, Connectivity: ConnectivityReady, Attempt: 0, Generation: 5}, | |
| 480 | + | event: supervisorEvent{kind: supervisorBrokerLoss, err: transient}, | |
| 481 | + | want: State{Presentation: PresentPicker, Connectivity: ConnectivityRetryWait, Attempt: 1, Generation: 5, Err: transient}, | |
| 482 | + | }, | |
| 483 | + | { | |
| 484 | + | name: "non-retryable failure disconnects and stays on the picker", | |
| 485 | + | state: State{Presentation: PresentPicker, Connectivity: ConnectivityConnectingBroker, Attempt: 1, Generation: 3}, | |
| 486 | + | event: supervisorEvent{kind: supervisorNonRetryable, err: incompatible}, | |
| 487 | + | want: State{Presentation: PresentPicker, Connectivity: ConnectivityDisconnected, Attempt: 1, Generation: 3, Err: incompatible}, | |
| 488 | + | }, | |
| 489 | + | { | |
| 490 | + | name: "terminal failure terminates", | |
| 491 | + | state: State{Presentation: PresentPicker, Connectivity: ConnectivityReady, Generation: 7}, | |
| 492 | + | event: supervisorEvent{kind: supervisorTerminal, err: exited}, | |
| 493 | + | want: State{Presentation: PresentTerminating, Connectivity: ConnectivityDisconnected, Generation: 7, Err: exited}, | |
| 494 | + | }, | |
| 495 | + | } | |
| 496 | + | ||
| 497 | + | for _, tt := range tests { | |
| 498 | + | t.Run(tt.name, func(t *testing.T) { | |
| 499 | + | require.Equal(t, tt.want, reduceSupervisor(tt.state, tt.event)) | |
| 500 | + | }) | |
| 501 | + | } | |
| 502 | + | } | |
| 503 | + | ||
| 504 | + | func TestSupervisorBackoffCadence(t *testing.T) { | |
| 505 | + | zero := func() float64 { return 0 } | |
| 506 | + | half := func() float64 { return 0.5 } | |
| 507 | + | clamped := func() float64 { return 1.5 } | |
| 508 | + | nan := func() float64 { return math.NaN() } | |
| 509 | + | negInf := func() float64 { return math.Inf(-1) } | |
| 510 | + | posInf := func() float64 { return math.Inf(1) } | |
| 511 | + | ||
| 512 | + | tests := []struct { | |
| 513 | + | name string | |
| 514 | + | attempt uint64 | |
| 515 | + | jitter func() float64 | |
| 516 | + | want time.Duration | |
| 517 | + | }{ | |
| 518 | + | {"no attempt has no delay", 0, zero, 0}, | |
| 519 | + | {"first failure halves 100ms", 1, zero, 50 * time.Millisecond}, | |
| 520 | + | {"second failure halves 200ms", 2, zero, 100 * time.Millisecond}, | |
| 521 | + | {"third failure halves 400ms", 3, zero, 200 * time.Millisecond}, | |
| 522 | + | {"fourth failure halves 800ms", 4, zero, 400 * time.Millisecond}, | |
| 523 | + | {"fifth failure halves 1.6s", 5, zero, 800 * time.Millisecond}, | |
| 524 | + | {"cap reached at 2s", 6, zero, time.Second}, | |
| 525 | + | {"cap stays at 2s", 9, zero, time.Second}, | |
| 526 | + | {"equal jitter reaches three quarters", 1, half, 75 * time.Millisecond}, | |
| 527 | + | {"equal jitter scales with the cap", 6, half, 1500 * time.Millisecond}, | |
| 528 | + | {"full jitter reaches the cap", 1, clamped, 100 * time.Millisecond}, | |
| 529 | + | {"full jitter stays capped", 6, clamped, 2 * time.Second}, | |
| 530 | + | {"NaN jitter clamps to the minimum half", 1, nan, 50 * time.Millisecond}, | |
| 531 | + | {"NaN jitter stays at half the cap", 6, nan, time.Second}, | |
| 532 | + | {"negative jitter clamps to the minimum half", 1, negInf, 50 * time.Millisecond}, | |
| 533 | + | {"negative jitter stays at half the cap", 6, negInf, time.Second}, | |
| 534 | + | {"positive infinity clamps to the cap", 1, posInf, 100 * time.Millisecond}, | |
| 535 | + | {"positive infinity stays capped", 6, posInf, 2 * time.Second}, | |
| 536 | + | } | |
| 537 | + | ||
| 538 | + | for _, tt := range tests { | |
| 539 | + | t.Run(tt.name, func(t *testing.T) { | |
| 540 | + | require.Equal(t, tt.want, supervisorBackoffDelay(tt.attempt, tt.jitter)) | |
| 541 | + | }) | |
| 542 | + | } | |
| 543 | + | } | |
| 544 | + | ||
| 545 | + | func TestSupervisorPickerWithoutBroker(t *testing.T) { | |
| 546 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 547 | + | defer cancel() | |
| 548 | + | ||
| 549 | + | clock := newSupervisorTestClock() | |
| 550 | + | reader := newSupervisorTestReader() | |
| 551 | + | t.Cleanup(reader.unblock) | |
| 552 | + | terminal := newSupervisorTestTerminal(reader) | |
| 553 | + | connector := newSupervisorTestConnector(func(context.Context, int) (ports.BrokerService, error) { | |
| 554 | + | return nil, ports.BrokerError{Code: ports.BrokerErrorUnavailable, Text: "broker down"} | |
| 555 | + | }) | |
| 556 | + | ||
| 557 | + | var ( | |
| 558 | + | renderMu sync.Mutex | |
| 559 | + | renders []State | |
| 560 | + | ) | |
| 561 | + | sup := mustSupervisor(t, SupervisorConfig{ | |
| 562 | + | Connector: connector, | |
| 563 | + | Terminal: terminal, | |
| 564 | + | Clock: clock, | |
| 565 | + | Jitter: func() float64 { return 0 }, | |
| 566 | + | Render: func(state State) { | |
| 567 | + | renderMu.Lock() | |
| 568 | + | renders = append(renders, state) | |
| 569 | + | renderMu.Unlock() | |
| 570 | + | }, | |
| 571 | + | }) | |
| 572 | + | ||
| 573 | + | runErr := make(chan error, 1) | |
| 574 | + | go func() { runErr <- sup.Run(ctx) }() | |
| 575 | + | ||
| 576 | + | first := clock.awaitTimer(t) | |
| 577 | + | require.Equal(t, 50*time.Millisecond, first.delay, "the first retry is the equal-jitter half of 100ms") | |
| 578 | + | require.Equal(t, 1, connector.awaitStart(t)) | |
| 579 | + | require.Equal(t, State{Presentation: PresentPicker, Connectivity: ConnectivityRetryWait, Attempt: 1, Generation: 1, Err: ports.BrokerError{Code: ports.BrokerErrorUnavailable, Text: "broker down"}}, sup.State()) | |
| 580 | + | ||
| 581 | + | first.fire() | |
| 582 | + | second := clock.awaitTimer(t) | |
| 583 | + | require.Equal(t, 100*time.Millisecond, second.delay, "the second retry doubles the cap") | |
| 584 | + | require.Equal(t, 2, connector.awaitStart(t)) | |
| 585 | + | require.Equal(t, PresentPicker, sup.State().Presentation, "the picker stays up while disconnected") | |
| 586 | + | require.Equal(t, ConnectivityRetryWait, sup.State().Connectivity) | |
| 587 | + | require.Equal(t, uint64(2), sup.State().Generation) | |
| 588 | + | ||
| 589 | + | cancel() | |
| 590 | + | require.ErrorIs(t, <-runErr, context.Canceled) | |
| 591 | + | require.Equal(t, 1, terminal.restoreCount()) | |
| 592 | + | require.Equal(t, 1, connector.maxConcurrent()) | |
| 593 | + | ||
| 594 | + | renderMu.Lock() | |
| 595 | + | defer renderMu.Unlock() | |
| 596 | + | require.NotEmpty(t, renders) | |
| 597 | + | require.Equal(t, PresentTerminating, renders[len(renders)-1].Presentation) | |
| 598 | + | for _, state := range renders[:len(renders)-1] { | |
| 599 | + | require.Equal(t, PresentPicker, state.Presentation, "P5.1a never leaves the picker") | |
| 600 | + | } | |
| 601 | + | } | |
| 602 | + | ||
| 603 | + | func TestSupervisorBrokerLossReconnectsWithFreshGeneration(t *testing.T) { | |
| 604 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 605 | + | defer cancel() | |
| 606 | + | ||
| 607 | + | clock := newSupervisorTestClock() | |
| 608 | + | reader := newSupervisorTestReader() | |
| 609 | + | t.Cleanup(reader.unblock) | |
| 610 | + | terminal := newSupervisorTestTerminal(reader) | |
| 611 | + | ||
| 612 | + | first := newSupervisorTestService(ports.BrokerConnectionID{1}) | |
| 613 | + | first.publish(1, 1) | |
| 614 | + | second := newSupervisorTestService(ports.BrokerConnectionID{2}) | |
| 615 | + | ||
| 616 | + | connector := newSupervisorTestConnector(func(_ context.Context, call int) (ports.BrokerService, error) { | |
| 617 | + | switch call { | |
| 618 | + | case 1: | |
| 619 | + | return first, nil | |
| 620 | + | default: | |
| 621 | + | return second, nil | |
| 622 | + | } | |
| 623 | + | }) | |
| 624 | + | ||
| 625 | + | sup := mustSupervisor(t, SupervisorConfig{ | |
| 626 | + | Connector: connector, | |
| 627 | + | Terminal: terminal, | |
| 628 | + | Clock: clock, | |
| 629 | + | Jitter: func() float64 { return 0 }, | |
| 630 | + | }) | |
| 631 | + | ||
| 632 | + | runErr := make(chan error, 1) | |
| 633 | + | go func() { runErr <- sup.Run(ctx) }() | |
| 634 | + | ||
| 635 | + | require.Equal(t, 1, connector.awaitStart(t)) | |
| 636 | + | require.Eventually(t, func() bool { return sup.State().Connectivity == ConnectivityReady }, 5*time.Second, time.Millisecond) | |
| 637 | + | require.Equal(t, uint64(1), sup.State().Generation) | |
| 638 | + | require.Equal(t, uint64(0), sup.State().Attempt) | |
| 639 | + | ||
| 640 | + | // The idle broker connection is lost: the supervisor retires it and waits | |
| 641 | + | // the first cadence before reconnecting. | |
| 642 | + | first.lose(ports.BrokerError{Code: ports.BrokerErrorUnavailable, Text: "idle loss"}) | |
| 643 | + | require.Eventually(t, func() bool { return sup.State().Connectivity == ConnectivityRetryWait }, 5*time.Second, time.Millisecond) | |
| 644 | + | require.Equal(t, uint64(1), sup.State().Attempt) | |
| 645 | + | select { | |
| 646 | + | case <-first.closedCh(): | |
| 647 | + | default: | |
| 648 | + | t.Fatal("the lost connection was not retired") | |
| 649 | + | } | |
| 650 | + | select { | |
| 651 | + | case <-first.subClosed: | |
| 652 | + | default: | |
| 653 | + | t.Fatal("the lost subscription was not retired") | |
| 654 | + | } | |
| 655 | + | ||
| 656 | + | retry := clock.awaitTimer(t) | |
| 657 | + | require.Equal(t, 50*time.Millisecond, retry.delay) | |
| 658 | + | retry.fire() | |
| 659 | + | require.Equal(t, 2, connector.awaitStart(t)) | |
| 660 | + | ||
| 661 | + | // Wait until the replacement is waiting for its first publication. | |
| 662 | + | require.Eventually(t, func() bool { | |
| 663 | + | return sup.State().Connectivity == ConnectivityConnectingBroker && sup.State().Generation == 2 | |
| 664 | + | }, 5*time.Second, time.Millisecond) | |
| 665 | + | ||
| 666 | + | // A publication on the retired connection must never make the replacement | |
| 667 | + | // Ready; only the replacement's own fresh epoch does. | |
| 668 | + | first.publish(1, 2) | |
| 669 | + | require.Never(t, func() bool { return sup.State().Connectivity == ConnectivityReady }, 50*time.Millisecond, 5*time.Millisecond) | |
| 670 | + | ||
| 671 | + | second.publish(2, 1) | |
| 672 | + | require.Eventually(t, func() bool { return sup.State().Connectivity == ConnectivityReady }, 5*time.Second, time.Millisecond) | |
| 673 | + | require.Equal(t, uint64(2), sup.State().Generation) | |
| 674 | + | require.Equal(t, uint64(0), sup.State().Attempt, "readiness resets the cadence") | |
| 675 | + | ||
| 676 | + | cancel() | |
| 677 | + | require.ErrorIs(t, <-runErr, context.Canceled) | |
| 678 | + | require.Equal(t, 1, terminal.restoreCount()) | |
| 679 | + | select { | |
| 680 | + | case <-second.closedCh(): | |
| 681 | + | default: | |
| 682 | + | t.Fatal("the active connection was not retired on shutdown") | |
| 683 | + | } | |
| 684 | + | } | |
| 685 | + | ||
| 686 | + | func TestSupervisorCancellationClosesLateConnection(t *testing.T) { | |
| 687 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 688 | + | ||
| 689 | + | clock := newSupervisorTestClock() | |
| 690 | + | reader := newSupervisorTestReader() | |
| 691 | + | t.Cleanup(reader.unblock) | |
| 692 | + | terminal := newSupervisorTestTerminal(reader) | |
| 693 | + | ||
| 694 | + | release := make(chan struct{}) | |
| 695 | + | late := newSupervisorTestService(ports.BrokerConnectionID{9}) | |
| 696 | + | late.publish(1, 1) | |
| 697 | + | connector := newSupervisorTestConnector(func(context.Context, int) (ports.BrokerService, error) { | |
| 698 | + | <-release | |
| 699 | + | return late, nil | |
| 700 | + | }) | |
| 701 | + | ||
| 702 | + | sup := mustSupervisor(t, SupervisorConfig{ | |
| 703 | + | Connector: connector, | |
| 704 | + | Terminal: terminal, | |
| 705 | + | Clock: clock, | |
| 706 | + | Jitter: func() float64 { return 0 }, | |
| 707 | + | }) | |
| 708 | + | ||
| 709 | + | runErr := make(chan error, 1) | |
| 710 | + | go func() { runErr <- sup.Run(ctx) }() | |
| 711 | + | require.Equal(t, 1, connector.awaitStart(t)) | |
| 712 | + | ||
| 713 | + | // Cancel while the connector ignores cancellation, then let it return a | |
| 714 | + | // live service: the supervisor must close the late superseded success. | |
| 715 | + | cancel() | |
| 716 | + | close(release) | |
| 717 | + | ||
| 718 | + | require.ErrorIs(t, <-runErr, context.Canceled) | |
| 719 | + | select { | |
| 720 | + | case <-late.closedCh(): | |
| 721 | + | default: | |
| 722 | + | t.Fatal("a late superseded success must be closed") | |
| 723 | + | } | |
| 724 | + | require.Equal(t, 1, terminal.restoreCount()) | |
| 725 | + | require.Zero(t, connector.inFlightCount()) | |
| 726 | + | } | |
| 727 | + | ||
| 728 | + | func TestSupervisorIncompatibleStopsReconnecting(t *testing.T) { | |
| 729 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 730 | + | defer cancel() | |
| 731 | + | ||
| 732 | + | clock := newSupervisorTestClock() | |
| 733 | + | reader := newSupervisorTestReader() | |
| 734 | + | t.Cleanup(reader.unblock) | |
| 735 | + | terminal := newSupervisorTestTerminal(reader) | |
| 736 | + | ||
| 737 | + | incompatible := ports.BrokerError{Code: ports.BrokerErrorIncompatible, Text: "protocol"} | |
| 738 | + | connector := newSupervisorTestConnector(func(context.Context, int) (ports.BrokerService, error) { | |
| 739 | + | return nil, incompatible | |
| 740 | + | }) | |
| 741 | + | notified := make(chan error, 4) | |
| 742 | + | sup := mustSupervisor(t, SupervisorConfig{ | |
| 743 | + | Connector: connector, | |
| 744 | + | Terminal: terminal, | |
| 745 | + | Clock: clock, | |
| 746 | + | Jitter: func() float64 { return 0 }, | |
| 747 | + | Notify: func(_ State, err error) { notified <- err }, | |
| 748 | + | }) | |
| 749 | + | ||
| 750 | + | runErr := make(chan error, 1) | |
| 751 | + | go func() { runErr <- sup.Run(ctx) }() | |
| 752 | + | ||
| 753 | + | select { | |
| 754 | + | case err := <-notified: | |
| 755 | + | var typed ports.BrokerError | |
| 756 | + | require.ErrorAs(t, err, &typed) | |
| 757 | + | require.Equal(t, ports.BrokerErrorIncompatible, typed.Code) | |
| 758 | + | case <-time.After(5 * time.Second): | |
| 759 | + | t.Fatal("the incompatible failure was not surfaced") | |
| 760 | + | } | |
| 761 | + | ||
| 762 | + | require.Equal(t, State{Presentation: PresentPicker, Connectivity: ConnectivityDisconnected, Generation: 1, Err: incompatible}, sup.State()) | |
| 763 | + | require.Never(t, func() bool { return connector.callCount() > 1 }, 50*time.Millisecond, 5*time.Millisecond) | |
| 764 | + | require.Zero(t, clock.timerCount(), "a non-retryable failure schedules no retry timer") | |
| 765 | + | ||
| 766 | + | cancel() | |
| 767 | + | require.ErrorIs(t, <-runErr, context.Canceled) | |
| 768 | + | require.Equal(t, 1, terminal.restoreCount()) | |
| 769 | + | } | |
| 770 | + | ||
| 771 | + | func TestSupervisorTerminalEOFRestoresRawOnce(t *testing.T) { | |
| 772 | + | clock := newSupervisorTestClock() | |
| 773 | + | terminal := newSupervisorTestTerminal(strings.NewReader("")) | |
| 774 | + | connector := newSupervisorTestConnector(func(context.Context, int) (ports.BrokerService, error) { | |
| 775 | + | return nil, ports.BrokerError{Code: ports.BrokerErrorUnavailable, Text: "down"} | |
| 776 | + | }) | |
| 777 | + | ||
| 778 | + | sup := mustSupervisor(t, SupervisorConfig{ | |
| 779 | + | Connector: connector, | |
| 780 | + | Terminal: terminal, | |
| 781 | + | Clock: clock, | |
| 782 | + | Jitter: func() float64 { return 0 }, | |
| 783 | + | }) | |
| 784 | + | ||
| 785 | + | require.NoError(t, sup.Run(context.Background()), "a clean terminal EOF is an orderly exit") | |
| 786 | + | require.Equal(t, 1, terminal.restoreCount()) | |
| 787 | + | require.Equal(t, PresentTerminating, sup.State().Presentation) | |
| 788 | + | } | |
| 789 | + | ||
| 790 | + | func TestSupervisorRawModePrecedesBroker(t *testing.T) { | |
| 791 | + | clock := newSupervisorTestClock() | |
| 792 | + | reader := newSupervisorTestReader() | |
| 793 | + | t.Cleanup(reader.unblock) | |
| 794 | + | terminal := newSupervisorTestTerminal(reader) | |
| 795 | + | ||
| 796 | + | enteredAfterRaw := make(chan bool, 1) | |
| 797 | + | connector := newSupervisorTestConnector(func(context.Context, int) (ports.BrokerService, error) { | |
| 798 | + | enteredAfterRaw <- terminal.restoreCount() == 0 | |
| 799 | + | return nil, ports.BrokerError{Code: ports.BrokerErrorUnavailable, Text: "down"} | |
| 800 | + | }) | |
| 801 | + | ||
| 802 | + | sup := mustSupervisor(t, SupervisorConfig{ | |
| 803 | + | Connector: connector, | |
| 804 | + | Terminal: terminal, | |
| 805 | + | Clock: clock, | |
| 806 | + | Jitter: func() float64 { return 0 }, | |
| 807 | + | }) | |
| 808 | + | ||
| 809 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 810 | + | runErr := make(chan error, 1) | |
| 811 | + | go func() { runErr <- sup.Run(ctx) }() | |
| 812 | + | require.Equal(t, 1, connector.awaitStart(t)) | |
| 813 | + | require.True(t, <-enteredAfterRaw, "raw mode must be entered before the first broker connection") | |
| 814 | + | cancel() | |
| 815 | + | <-runErr | |
| 816 | + | } | |
| 817 | + | ||
| 818 | + | func TestSupervisorSingleAttemptAndTimer(t *testing.T) { | |
| 819 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 820 | + | ||
| 821 | + | clock := newSupervisorTestClock() | |
| 822 | + | reader := newSupervisorTestReader() | |
| 823 | + | t.Cleanup(reader.unblock) | |
| 824 | + | terminal := newSupervisorTestTerminal(reader) | |
| 825 | + | connector := newSupervisorTestConnector(func(context.Context, int) (ports.BrokerService, error) { | |
| 826 | + | return nil, ports.BrokerError{Code: ports.BrokerErrorUnavailable, Text: "down"} | |
| 827 | + | }) | |
| 828 | + | ||
| 829 | + | sup := mustSupervisor(t, SupervisorConfig{ | |
| 830 | + | Connector: connector, | |
| 831 | + | Terminal: terminal, | |
| 832 | + | Clock: clock, | |
| 833 | + | Jitter: func() float64 { return 0 }, | |
| 834 | + | }) | |
| 835 | + | ||
| 836 | + | runErr := make(chan error, 1) | |
| 837 | + | go func() { runErr <- sup.Run(ctx) }() | |
| 838 | + | ||
| 839 | + | first := clock.awaitTimer(t) | |
| 840 | + | first.fire() | |
| 841 | + | second := clock.awaitTimer(t) | |
| 842 | + | require.Equal(t, 100*time.Millisecond, second.delay) | |
| 843 | + | second.fire() | |
| 844 | + | clock.awaitTimer(t) | |
| 845 | + | ||
| 846 | + | require.Equal(t, 1, connector.maxConcurrent(), "attempts never overlap") | |
| 847 | + | require.Equal(t, 3, connector.callCount()) | |
| 848 | + | require.Equal(t, 1, clock.liveTimers(), "exactly one retry timer is live") | |
| 849 | + | require.Zero(t, connector.inFlightCount(), "the connector call has returned before the next wait") | |
| 850 | + | ||
| 851 | + | cancel() | |
| 852 | + | require.ErrorIs(t, <-runErr, context.Canceled) | |
| 853 | + | require.Eventually(t, clock.allStopped, time.Second, time.Millisecond, "every retry timer is retired") | |
| 854 | + | require.Equal(t, terminal.restoreCount(), 1) | |
| 855 | + | require.Zero(t, connector.inFlightCount()) | |
| 856 | + | } | |
| 857 | + | ||
| 858 | + | // supervisorCancellationRaceRounds is how many times each cancellation race is | |
| 859 | + | // replayed. Every round orders the parent cancellation strictly before the run's | |
| 860 | + | // settle decision by construction, so the rounds are deterministic rather than | |
| 861 | + | // timing-dependent; the repetition runs both settle points many times over. | |
| 862 | + | const supervisorCancellationRaceRounds = 100 | |
| 863 | + | ||
| 864 | + | // TestSupervisorCancellationRacingSettlementIsTerminal pins the parent | |
| 865 | + | // cancellation fence at both settle points: an attempt that settles at the same | |
| 866 | + | // instant as the parent cancellation is retired rather than adopted, and an | |
| 867 | + | // established-connection loss that settles at that instant is never turned into a | |
| 868 | + | // retry. Every round must end terminating with ctx.Err without scheduling a retry | |
| 869 | + | // cadence, rendering ConnectivityRetryWait, or notifying a connectivity failure | |
| 870 | + | // for a settle the parent had already given up on. | |
| 871 | + | // | |
| 872 | + | // The ordering is enforced, never hoped for: each round parks the settle at a | |
| 873 | + | // point the round itself releases (the state lock the run decides adoption under, | |
| 874 | + | // or the connection release the loss settle runs after) and cancels there, so the | |
| 875 | + | // decision sees the cancellation whichever branch the run's select took. | |
| 876 | + | func TestSupervisorCancellationRacingSettlementIsTerminal(t *testing.T) { | |
| 877 | + | transient := ports.BrokerError{Code: ports.BrokerErrorUnavailable, Text: "raced"} | |
| 878 | + | exited := ports.BrokerError{Code: ports.BrokerErrorExplicitExit, Text: "raced exit"} | |
| 879 | + | ||
| 880 | + | attempts := []struct { | |
| 881 | + | name string | |
| 882 | + | attempt func() (*supervisorTestService, error) | |
| 883 | + | }{ | |
| 884 | + | {name: "retryable failure", attempt: func() (*supervisorTestService, error) { return nil, transient }}, | |
| 885 | + | { | |
| 886 | + | name: "ready connection", | |
| 887 | + | attempt: func() (*supervisorTestService, error) { | |
| 888 | + | service := newSupervisorTestService(ports.BrokerConnectionID{3}) | |
| 889 | + | service.publish(1, 1) | |
| 890 | + | return service, nil | |
| 891 | + | }, | |
| 892 | + | }, | |
| 893 | + | {name: "terminal failure", attempt: func() (*supervisorTestService, error) { return nil, exited }}, | |
| 894 | + | } | |
| 895 | + | ||
| 896 | + | for _, tc := range attempts { | |
| 897 | + | t.Run("attempt "+tc.name, func(t *testing.T) { | |
| 898 | + | // One carrier thread for the round: yielding after the attempt is | |
| 899 | + | // released then runs that goroutine to its settlement before this one | |
| 900 | + | // resumes, so the outcome is delivered while the parent is still live | |
| 901 | + | // and the run's select takes the settled attempt rather than ctx.Done. | |
| 902 | + | defer runtime.GOMAXPROCS(runtime.GOMAXPROCS(1)) | |
| 903 | + | for round := 0; round < supervisorCancellationRaceRounds; round++ { | |
| 904 | + | release := make(chan struct{}) | |
| 905 | + | var raced *supervisorTestService | |
| 906 | + | race := startCancellationRace(t, func(context.Context, int) (ports.BrokerService, error) { | |
| 907 | + | <-release | |
| 908 | + | service, err := tc.attempt() | |
| 909 | + | raced = service | |
| 910 | + | if service == nil { | |
| 911 | + | return nil, err | |
| 912 | + | } | |
| 913 | + | return service, err | |
| 914 | + | }) | |
| 915 | + | require.Equal(t, 1, race.connector.awaitStart(t), "round %d", round) | |
| 916 | + | ||
| 917 | + | // Take the state lock the run decides adoption under, release the | |
| 918 | + | // attempt, let it settle, and cancel the parent while still holding | |
| 919 | + | // the lock: the decision is ordered strictly after the cancellation | |
| 920 | + | // by construction. | |
| 921 | + | race.supervisor.mu.Lock() | |
| 922 | + | close(release) | |
| 923 | + | runtime.Gosched() | |
| 924 | + | race.cancel() | |
| 925 | + | race.supervisor.mu.Unlock() | |
| 926 | + | ||
| 927 | + | race.assertSettledAsCancelled(t, round, false) | |
| 928 | + | if raced != nil { | |
| 929 | + | select { | |
| 930 | + | case <-raced.closedCh(): | |
| 931 | + | default: | |
| 932 | + | t.Fatalf("round %d: an attempt that raced cancellation must be retired, not adopted", round) | |
| 933 | + | } | |
| 934 | + | } | |
| 935 | + | } | |
| 936 | + | }) | |
| 937 | + | } | |
| 938 | + | ||
| 939 | + | t.Run("established loss", func(t *testing.T) { | |
| 940 | + | for round := 0; round < supervisorCancellationRaceRounds; round++ { | |
| 941 | + | losing := newSupervisorTestService(ports.BrokerConnectionID{4}) | |
| 942 | + | losing.publish(1, 1) | |
| 943 | + | losing.holdClose() | |
| 944 | + | race := startCancellationRace(t, func(context.Context, int) (ports.BrokerService, error) { | |
| 945 | + | return losing, nil | |
| 946 | + | }) | |
| 947 | + | require.Equal(t, 1, race.connector.awaitStart(t), "round %d", round) | |
| 948 | + | require.Eventually(t, func() bool { return race.supervisor.State().Connectivity == ConnectivityReady }, 5*time.Second, time.Millisecond, "round %d", round) | |
| 949 | + | ||
| 950 | + | // The loss wakes the run while the parent is still live, and its | |
| 951 | + | // connection release then parks the run before the settle decision, so | |
| 952 | + | // the cancellation below is ordered strictly before that decision. | |
| 953 | + | losing.lose(ports.BrokerError{Code: ports.BrokerErrorUnavailable, Text: "raced loss"}) | |
| 954 | + | select { | |
| 955 | + | case <-losing.closingCh(): | |
| 956 | + | case <-time.After(5 * time.Second): | |
| 957 | + | t.Fatalf("round %d: the run never released the lost connection", round) | |
| 958 | + | } | |
| 959 | + | race.cancel() | |
| 960 | + | losing.releaseClose() | |
| 961 | + | ||
| 962 | + | race.assertSettledAsCancelled(t, round, true) | |
| 963 | + | select { | |
| 964 | + | case <-losing.closedCh(): | |
| 965 | + | case <-time.After(5 * time.Second): | |
| 966 | + | t.Fatalf("round %d: the lost connection must be retired", round) | |
| 967 | + | } | |
| 968 | + | } | |
| 969 | + | }) | |
| 970 | + | } | |
| 971 | + | ||
| 972 | + | // cancellationRace is one scripted round of the parent-cancellation race: the | |
| 973 | + | // running supervisor with its fake clock, terminal, and recorder, plus the run's | |
| 974 | + | // result channel. A round cancels the parent itself, at the point that orders the | |
| 975 | + | // cancellation before the settle decision under test. | |
| 976 | + | type cancellationRace struct { | |
| 977 | + | cancel context.CancelFunc | |
| 978 | + | clock *supervisorTestClock | |
| 979 | + | terminal *supervisorTestTerminal | |
| 980 | + | recorder *cancellationRaceRecorder | |
| 981 | + | connector *supervisorTestConnector | |
| 982 | + | supervisor *Supervisor | |
| 983 | + | runErr chan error | |
| 984 | + | } | |
| 985 | + | ||
| 986 | + | // startCancellationRace starts one supervisor run over a scripted connector. The | |
| 987 | + | // reader is shared and never unblocked until the test ends, so the run can only | |
| 988 | + | // end through cancellation. | |
| 989 | + | func startCancellationRace(t *testing.T, connect func(ctx context.Context, call int) (ports.BrokerService, error)) *cancellationRace { | |
| 990 | + | t.Helper() | |
| 991 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 992 | + | reader := newSupervisorTestReader() | |
| 993 | + | t.Cleanup(reader.unblock) | |
| 994 | + | ||
| 995 | + | race := &cancellationRace{ | |
| 996 | + | cancel: cancel, | |
| 997 | + | clock: newSupervisorTestClock(), | |
| 998 | + | terminal: newSupervisorTestTerminal(reader), | |
| 999 | + | recorder: &cancellationRaceRecorder{}, | |
| 1000 | + | connector: newSupervisorTestConnector(connect), | |
| 1001 | + | runErr: make(chan error, 1), | |
| 1002 | + | } | |
| 1003 | + | race.supervisor = mustSupervisor(t, SupervisorConfig{ | |
| 1004 | + | Connector: race.connector, | |
| 1005 | + | Terminal: race.terminal, | |
| 1006 | + | Clock: race.clock, | |
| 1007 | + | Jitter: func() float64 { return 0 }, | |
| 1008 | + | Render: race.recorder.render, | |
| 1009 | + | Notify: race.recorder.notify, | |
| 1010 | + | }) | |
| 1011 | + | go func() { race.runErr <- race.supervisor.Run(ctx) }() | |
| 1012 | + | return race | |
| 1013 | + | } | |
| 1014 | + | ||
| 1015 | + | // assertSettledAsCancelled asserts one round's cancellation invariants: the run | |
| 1016 | + | // ends terminating with ctx.Err, raw mode is restored exactly once, no retry | |
| 1017 | + | // cadence was scheduled or notified, and every earlier render is the attempt that | |
| 1018 | + | // was still in flight. established marks a round whose connection was ready | |
| 1019 | + | // before the race, so its earlier ready render is expected. | |
| 1020 | + | func (r *cancellationRace) assertSettledAsCancelled(t *testing.T, round int, established bool) { | |
| 1021 | + | t.Helper() | |
| 1022 | + | require.ErrorIs(t, <-r.runErr, context.Canceled, "round %d: parent cancellation must settle the run", round) | |
| 1023 | + | renders, notified := r.recorder.recorded() | |
| 1024 | + | require.Empty(t, notified, "round %d: cancellation must not notify a connectivity failure", round) | |
| 1025 | + | require.Zero(t, r.clock.timerCount(), "round %d: cancellation must not schedule a retry timer", round) | |
| 1026 | + | require.Equal(t, 1, r.terminal.restoreCount(), "round %d: raw mode must be restored exactly once", round) | |
| 1027 | + | require.NotEmpty(t, renders, "round %d: the run must render its state", round) | |
| 1028 | + | require.Equal(t, PresentTerminating, renders[len(renders)-1].Presentation, "round %d: the run must end terminating", round) | |
| 1029 | + | for _, state := range renders[:len(renders)-1] { | |
| 1030 | + | require.Equal(t, PresentPicker, state.Presentation, "round %d: the run must stay in the picker", round) | |
| 1031 | + | require.NotEqual(t, ConnectivityRetryWait, state.Connectivity, | |
| 1032 | + | "round %d: no retry cadence may be published after the cancellation", round) | |
| 1033 | + | if established { | |
| 1034 | + | // The connection was ready before the race, so its ready render is the | |
| 1035 | + | // only settled state the round expects. | |
| 1036 | + | continue | |
| 1037 | + | } | |
| 1038 | + | require.Equal(t, ConnectivityConnectingBroker, state.Connectivity, | |
| 1039 | + | "round %d: no settled connection may be published for the cancelled attempt", round) | |
| 1040 | + | } | |
| 1041 | + | } | |
| 1042 | + | ||
| 1043 | + | // cancellationRaceRecorder records what one round's supervisor published. | |
| 1044 | + | type cancellationRaceRecorder struct { | |
| 1045 | + | mu sync.Mutex | |
| 1046 | + | renders []State | |
| 1047 | + | notified []error | |
| 1048 | + | } | |
| 1049 | + | ||
| 1050 | + | func (r *cancellationRaceRecorder) render(state State) { | |
| 1051 | + | r.mu.Lock() | |
| 1052 | + | r.renders = append(r.renders, state) | |
| 1053 | + | r.mu.Unlock() | |
| 1054 | + | } | |
| 1055 | + | ||
| 1056 | + | func (r *cancellationRaceRecorder) notify(_ State, err error) { | |
| 1057 | + | r.mu.Lock() | |
| 1058 | + | r.notified = append(r.notified, err) | |
| 1059 | + | r.mu.Unlock() | |
| 1060 | + | } | |
| 1061 | + | ||
| 1062 | + | func (r *cancellationRaceRecorder) recorded() ([]State, []error) { | |
| 1063 | + | r.mu.Lock() | |
| 1064 | + | defer r.mu.Unlock() | |
| 1065 | + | return append([]State(nil), r.renders...), append([]error(nil), r.notified...) | |
| 1066 | + | } | |
| 1067 | + | ||
| 1068 | + | // supervisorNilSub is a typed-nil subscription: it satisfies the interface but | |
| 1069 | + | // is nil underneath, so only the reflection guard keeps the supervisor from | |
| 1070 | + | // dereferencing it. | |
| 1071 | + | type supervisorNilSub struct{} | |
| 1072 | + | ||
| 1073 | + | func (*supervisorNilSub) Changed() <-chan struct{} { return nil } | |
| 1074 | + | func (*supervisorNilSub) Close() {} | |
| 1075 | + | ||
| 1076 | + | // supervisorNilSubService overrides Subscribe so a test can exercise the | |
| 1077 | + | // supervisor's nil-subscription guard without a live subscription. | |
| 1078 | + | type supervisorNilSubService struct { | |
| 1079 | + | *supervisorTestService | |
| 1080 | + | sub ports.BrokerSubscription | |
| 1081 | + | } | |
| 1082 | + | ||
| 1083 | + | func (s *supervisorNilSubService) Subscribe() (ports.BrokerSubscription, error) { | |
| 1084 | + | return s.sub, nil | |
| 1085 | + | } | |
| 1086 | + | ||
| 1087 | + | // TestSupervisorDefensiveGenerationFenceRejectsSupersededAttempt pins the | |
| 1088 | + | // defensive generation fence directly: a settled attempt whose generation is not | |
| 1089 | + | // the current one is retired and discarded, while the current generation is | |
| 1090 | + | // adopted unchanged. Run joins every attempt before it advances, so no | |
| 1091 | + | // superseded completion can reach adoptAttempt in the production driver today; | |
| 1092 | + | // pinning the fence here keeps a future concurrent driver from dropping it. | |
| 1093 | + | func TestSupervisorDefensiveGenerationFenceRejectsSupersededAttempt(t *testing.T) { | |
| 1094 | + | clock := newSupervisorTestClock() | |
| 1095 | + | terminal := newSupervisorTestTerminal(newSupervisorTestReader()) | |
| 1096 | + | sup := mustSupervisor(t, SupervisorConfig{ | |
| 1097 | + | Connector: newSupervisorTestConnector(nil), | |
| 1098 | + | Terminal: terminal, | |
| 1099 | + | Clock: clock, | |
| 1100 | + | Jitter: func() float64 { return 0 }, | |
| 1101 | + | }) | |
| 1102 | + | ||
| 1103 | + | // Claim a generation exactly as Run does before it launches the connector. | |
| 1104 | + | sup.transition(supervisorEvent{kind: supervisorBeginAttempt}) | |
| 1105 | + | current := sup.State().Generation | |
| 1106 | + | require.Equal(t, uint64(1), current) | |
| 1107 | + | ||
| 1108 | + | service := newSupervisorTestService(ports.BrokerConnectionID{1}) | |
| 1109 | + | subClosed := make(chan struct{}) | |
| 1110 | + | stale := supervisorAttempt{ | |
| 1111 | + | generation: current - 1, | |
| 1112 | + | service: service, | |
| 1113 | + | sub: supervisorTestSubscription{closeFn: func() { close(subClosed) }}, | |
| 1114 | + | } | |
| 1115 | + | adopted, cause := sup.adoptAttempt(context.Background(), stale) | |
| 1116 | + | require.False(t, adopted, "a superseded generation must not be adopted") | |
| 1117 | + | require.NoError(t, cause, "a superseded generation is not a cancellation") | |
| 1118 | + | // Run retires every attempt it does not adopt. | |
| 1119 | + | stale.retire() | |
| 1120 | + | select { | |
| 1121 | + | case <-service.closedCh(): | |
| 1122 | + | default: | |
| 1123 | + | t.Fatal("a superseded service must be closed") | |
| 1124 | + | } | |
| 1125 | + | select { | |
| 1126 | + | case <-subClosed: | |
| 1127 | + | default: | |
| 1128 | + | t.Fatal("a superseded subscription must be closed") | |
| 1129 | + | } | |
| 1130 | + | ||
| 1131 | + | // The current generation is adopted, so its live service is left untouched. | |
| 1132 | + | live := newSupervisorTestService(ports.BrokerConnectionID{2}) | |
| 1133 | + | adopted, cause = sup.adoptAttempt(context.Background(), supervisorAttempt{generation: current, service: live}) | |
| 1134 | + | require.True(t, adopted, "the current generation must be adopted") | |
| 1135 | + | require.NoError(t, cause) | |
| 1136 | + | select { | |
| 1137 | + | case <-live.closedCh(): | |
| 1138 | + | t.Fatal("the current attempt must be adopted, not retired") | |
| 1139 | + | default: | |
| 1140 | + | } | |
| 1141 | + | } | |
| 1142 | + | ||
| 1143 | + | // TestSupervisorNormalizesUntypedConnectFailure proves an untyped connect | |
| 1144 | + | // failure becomes a typed unavailable failure that preserves its cause, and that | |
| 1145 | + | // the same typed value reaches the notifier. | |
| 1146 | + | func TestSupervisorNormalizesUntypedConnectFailure(t *testing.T) { | |
| 1147 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 1148 | + | defer cancel() | |
| 1149 | + | ||
| 1150 | + | clock := newSupervisorTestClock() | |
| 1151 | + | reader := newSupervisorTestReader() | |
| 1152 | + | t.Cleanup(reader.unblock) | |
| 1153 | + | terminal := newSupervisorTestTerminal(reader) | |
| 1154 | + | ||
| 1155 | + | cause := errors.New("dial: connection refused") | |
| 1156 | + | connector := newSupervisorTestConnector(func(context.Context, int) (ports.BrokerService, error) { | |
| 1157 | + | return nil, cause | |
| 1158 | + | }) | |
| 1159 | + | notified := make(chan error, 4) | |
| 1160 | + | sup := mustSupervisor(t, SupervisorConfig{ | |
| 1161 | + | Connector: connector, | |
| 1162 | + | Terminal: terminal, | |
| 1163 | + | Clock: clock, | |
| 1164 | + | Jitter: func() float64 { return 0 }, | |
| 1165 | + | Notify: func(_ State, err error) { notified <- err }, | |
| 1166 | + | }) | |
| 1167 | + | ||
| 1168 | + | runErr := make(chan error, 1) | |
| 1169 | + | go func() { runErr <- sup.Run(ctx) }() | |
| 1170 | + | clock.awaitTimer(t) | |
| 1171 | + | ||
| 1172 | + | var typed ports.BrokerError | |
| 1173 | + | require.ErrorAs(t, sup.State().Err, &typed) | |
| 1174 | + | require.Equal(t, ports.BrokerErrorUnavailable, typed.Code) | |
| 1175 | + | require.ErrorIs(t, typed.Cause, cause, "the untyped cause is preserved") | |
| 1176 | + | ||
| 1177 | + | select { | |
| 1178 | + | case err := <-notified: | |
| 1179 | + | var surfaced ports.BrokerError | |
| 1180 | + | require.ErrorAs(t, err, &surfaced) | |
| 1181 | + | require.Equal(t, ports.BrokerErrorUnavailable, surfaced.Code) | |
| 1182 | + | require.ErrorIs(t, surfaced.Cause, cause) | |
| 1183 | + | case <-time.After(5 * time.Second): | |
| 1184 | + | t.Fatal("the normalized failure was not surfaced") | |
| 1185 | + | } | |
| 1186 | + | ||
| 1187 | + | cancel() | |
| 1188 | + | require.ErrorIs(t, <-runErr, context.Canceled) | |
| 1189 | + | require.Equal(t, 1, terminal.restoreCount()) | |
| 1190 | + | } | |
| 1191 | + | ||
| 1192 | + | // TestSupervisorNormalizesUntypedLoss proves an untyped established-connection | |
| 1193 | + | // loss is normalized to a typed unavailable failure that preserves its cause. | |
| 1194 | + | func TestSupervisorNormalizesUntypedLoss(t *testing.T) { | |
| 1195 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 1196 | + | defer cancel() | |
| 1197 | + | ||
| 1198 | + | clock := newSupervisorTestClock() | |
| 1199 | + | reader := newSupervisorTestReader() | |
| 1200 | + | t.Cleanup(reader.unblock) | |
| 1201 | + | terminal := newSupervisorTestTerminal(reader) | |
| 1202 | + | ||
| 1203 | + | service := newSupervisorTestService(ports.BrokerConnectionID{1}) | |
| 1204 | + | service.publish(1, 1) | |
| 1205 | + | connector := newSupervisorTestConnector(func(context.Context, int) (ports.BrokerService, error) { | |
| 1206 | + | return service, nil | |
| 1207 | + | }) | |
| 1208 | + | sup := mustSupervisor(t, SupervisorConfig{ | |
| 1209 | + | Connector: connector, | |
| 1210 | + | Terminal: terminal, | |
| 1211 | + | Clock: clock, | |
| 1212 | + | Jitter: func() float64 { return 0 }, | |
| 1213 | + | }) | |
| 1214 | + | ||
| 1215 | + | runErr := make(chan error, 1) | |
| 1216 | + | go func() { runErr <- sup.Run(ctx) }() | |
| 1217 | + | require.Equal(t, 1, connector.awaitStart(t)) | |
| 1218 | + | require.Eventually(t, func() bool { return sup.State().Connectivity == ConnectivityReady }, 5*time.Second, time.Millisecond) | |
| 1219 | + | ||
| 1220 | + | cause := errors.New("carriage reset") | |
| 1221 | + | service.lose(cause) | |
| 1222 | + | require.Eventually(t, func() bool { return sup.State().Connectivity == ConnectivityRetryWait }, 5*time.Second, time.Millisecond) | |
| 1223 | + | ||
| 1224 | + | var typed ports.BrokerError | |
| 1225 | + | require.ErrorAs(t, sup.State().Err, &typed) | |
| 1226 | + | require.Equal(t, ports.BrokerErrorUnavailable, typed.Code) | |
| 1227 | + | require.ErrorIs(t, typed.Cause, cause, "the untyped loss cause is preserved") | |
| 1228 | + | ||
| 1229 | + | cancel() | |
| 1230 | + | require.ErrorIs(t, <-runErr, context.Canceled) | |
| 1231 | + | } | |
| 1232 | + | ||
| 1233 | + | // TestSupervisorNilSubscriptionIsTypedUnavailable proves a service that reports | |
| 1234 | + | // no subscription (a nil interface or a typed nil) is closed and treated as a | |
| 1235 | + | // typed unavailable failure instead of panicking on the first Changed call. | |
| 1236 | + | func TestSupervisorNilSubscriptionIsTypedUnavailable(t *testing.T) { | |
| 1237 | + | cases := []struct { | |
| 1238 | + | name string | |
| 1239 | + | sub ports.BrokerSubscription | |
| 1240 | + | }{ | |
| 1241 | + | {"nil interface", nil}, | |
| 1242 | + | {"typed nil", (*supervisorNilSub)(nil)}, | |
| 1243 | + | } | |
| 1244 | + | for _, tc := range cases { | |
| 1245 | + | t.Run(tc.name, func(t *testing.T) { | |
| 1246 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 1247 | + | defer cancel() | |
| 1248 | + | ||
| 1249 | + | clock := newSupervisorTestClock() | |
| 1250 | + | reader := newSupervisorTestReader() | |
| 1251 | + | t.Cleanup(reader.unblock) | |
| 1252 | + | terminal := newSupervisorTestTerminal(reader) | |
| 1253 | + | ||
| 1254 | + | base := newSupervisorTestService(ports.BrokerConnectionID{7}) | |
| 1255 | + | svc := &supervisorNilSubService{supervisorTestService: base, sub: tc.sub} | |
| 1256 | + | connector := newSupervisorTestConnector(func(context.Context, int) (ports.BrokerService, error) { | |
| 1257 | + | return svc, nil | |
| 1258 | + | }) | |
| 1259 | + | sup := mustSupervisor(t, SupervisorConfig{ | |
| 1260 | + | Connector: connector, | |
| 1261 | + | Terminal: terminal, | |
| 1262 | + | Clock: clock, | |
| 1263 | + | Jitter: func() float64 { return 0 }, | |
| 1264 | + | }) | |
| 1265 | + | ||
| 1266 | + | runErr := make(chan error, 1) | |
| 1267 | + | go func() { runErr <- sup.Run(ctx) }() | |
| 1268 | + | clock.awaitTimer(t) | |
| 1269 | + | ||
| 1270 | + | var typed ports.BrokerError | |
| 1271 | + | require.ErrorAs(t, sup.State().Err, &typed) | |
| 1272 | + | require.Equal(t, ports.BrokerErrorUnavailable, typed.Code) | |
| 1273 | + | select { | |
| 1274 | + | case <-base.closedCh(): | |
| 1275 | + | default: | |
| 1276 | + | t.Fatal("a service that returned no subscription must be closed") | |
| 1277 | + | } | |
| 1278 | + | ||
| 1279 | + | cancel() | |
| 1280 | + | require.ErrorIs(t, <-runErr, context.Canceled) | |
| 1281 | + | require.Equal(t, 1, terminal.restoreCount()) | |
| 1282 | + | }) | |
| 1283 | + | } | |
| 1284 | + | } | |
| @@ -0,0 +1,1284 @@ | |||||
| 1 | + | package client | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "errors" | |||
| 6 | + | "io" | |||
| 7 | + | "math" | |||
| 8 | + | "runtime" | |||
| 9 | + | "strings" | |||
| 10 | + | "sync" | |||
| 11 | + | "testing" | |||
| 12 | + | "time" | |||
| 13 | + | ||||
| 14 | + | "github.com/stretchr/testify/require" | |||
| 15 | + | ||||
| 16 | + | "github.com/bnema/vev/internal/domain" | |||
| 17 | + | "github.com/bnema/vev/internal/ports" | |||
| 18 | + | ) | |||
| 19 | + | ||||
| 20 | + | // supervisorTestClock is a manually fired ports.Clock. NewTimer publishes each | |||
| 21 | + | // timer on a buffered channel so a test can fire it deterministically without a | |||
| 22 | + | // sleep, and Stop is recorded so a test can prove every retry timer was | |||
| 23 | + | // retired. | |||
| 24 | + | type supervisorTestClock struct { | |||
| 25 | + | mu sync.Mutex | |||
| 26 | + | now time.Time | |||
| 27 | + | timers []*supervisorTestTimer | |||
| 28 | + | created chan *supervisorTestTimer | |||
| 29 | + | } | |||
| 30 | + | ||||
| 31 | + | func newSupervisorTestClock() *supervisorTestClock { | |||
| 32 | + | return &supervisorTestClock{now: time.Unix(1000, 0), created: make(chan *supervisorTestTimer, 32)} | |||
| 33 | + | } | |||
| 34 | + | ||||
| 35 | + | func (c *supervisorTestClock) Now() time.Time { | |||
| 36 | + | c.mu.Lock() | |||
| 37 | + | defer c.mu.Unlock() | |||
| 38 | + | return c.now | |||
| 39 | + | } | |||
| 40 | + | ||||
| 41 | + | func (c *supervisorTestClock) NewTimer(delay time.Duration) ports.Timer { | |||
| 42 | + | timer := &supervisorTestTimer{ch: make(chan time.Time, 1), delay: delay} | |||
| 43 | + | c.mu.Lock() | |||
| 44 | + | c.timers = append(c.timers, timer) | |||
| 45 | + | c.mu.Unlock() | |||
| 46 | + | select { | |||
| 47 | + | case c.created <- timer: | |||
| 48 | + | default: | |||
| 49 | + | } | |||
| 50 | + | return timer | |||
| 51 | + | } | |||
| 52 | + | ||||
| 53 | + | func (c *supervisorTestClock) awaitTimer(t *testing.T) *supervisorTestTimer { | |||
| 54 | + | t.Helper() | |||
| 55 | + | select { | |||
| 56 | + | case timer := <-c.created: | |||
| 57 | + | return timer | |||
| 58 | + | case <-time.After(5 * time.Second): | |||
| 59 | + | t.Fatal("supervisor did not schedule a retry timer") | |||
| 60 | + | return nil | |||
| 61 | + | } | |||
| 62 | + | } | |||
| 63 | + | ||||
| 64 | + | func (c *supervisorTestClock) timerCount() int { | |||
| 65 | + | c.mu.Lock() | |||
| 66 | + | defer c.mu.Unlock() | |||
| 67 | + | return len(c.timers) | |||
| 68 | + | } | |||
| 69 | + | ||||
| 70 | + | func (c *supervisorTestClock) liveTimers() int { | |||
| 71 | + | c.mu.Lock() | |||
| 72 | + | defer c.mu.Unlock() | |||
| 73 | + | live := 0 | |||
| 74 | + | for _, timer := range c.timers { | |||
| 75 | + | if !timer.stopped() { | |||
| 76 | + | live++ | |||
| 77 | + | } | |||
| 78 | + | } | |||
| 79 | + | return live | |||
| 80 | + | } | |||
| 81 | + | ||||
| 82 | + | func (c *supervisorTestClock) allStopped() bool { | |||
| 83 | + | c.mu.Lock() | |||
| 84 | + | defer c.mu.Unlock() | |||
| 85 | + | for _, timer := range c.timers { | |||
| 86 | + | if !timer.stopped() { | |||
| 87 | + | return false | |||
| 88 | + | } | |||
| 89 | + | } | |||
| 90 | + | return true | |||
| 91 | + | } | |||
| 92 | + | ||||
| 93 | + | type supervisorTestTimer struct { | |||
| 94 | + | ch chan time.Time | |||
| 95 | + | delay time.Duration | |||
| 96 | + | mu sync.Mutex | |||
| 97 | + | stoppedFlag bool | |||
| 98 | + | } | |||
| 99 | + | ||||
| 100 | + | func (t *supervisorTestTimer) C() <-chan time.Time { return t.ch } | |||
| 101 | + | ||||
| 102 | + | func (t *supervisorTestTimer) Reset(time.Duration) bool { return false } | |||
| 103 | + | ||||
| 104 | + | func (t *supervisorTestTimer) Stop() bool { | |||
| 105 | + | t.mu.Lock() | |||
| 106 | + | defer t.mu.Unlock() | |||
| 107 | + | if t.stoppedFlag { | |||
| 108 | + | return false | |||
| 109 | + | } | |||
| 110 | + | t.stoppedFlag = true | |||
| 111 | + | return true | |||
| 112 | + | } | |||
| 113 | + | ||||
| 114 | + | func (t *supervisorTestTimer) stopped() bool { | |||
| 115 | + | t.mu.Lock() | |||
| 116 | + | defer t.mu.Unlock() | |||
| 117 | + | return t.stoppedFlag | |||
| 118 | + | } | |||
| 119 | + | ||||
| 120 | + | // fire delivers the timer without blocking, so a test may fire before or after | |||
| 121 | + | // the supervisor reaches its select. | |||
| 122 | + | func (t *supervisorTestTimer) fire() { | |||
| 123 | + | select { | |||
| 124 | + | case t.ch <- time.Now(): | |||
| 125 | + | default: | |||
| 126 | + | } | |||
| 127 | + | } | |||
| 128 | + | ||||
| 129 | + | // supervisorTestTerminal records raw-mode enters and restores. Its reader is | |||
| 130 | + | // shared so tests can model input EOF and a live terminal. | |||
| 131 | + | type supervisorTestTerminal struct { | |||
| 132 | + | in io.Reader | |||
| 133 | + | enterErr error | |||
| 134 | + | ||||
| 135 | + | mu sync.Mutex | |||
| 136 | + | restores int | |||
| 137 | + | } | |||
| 138 | + | ||||
| 139 | + | func newSupervisorTestTerminal(in io.Reader) *supervisorTestTerminal { | |||
| 140 | + | return &supervisorTestTerminal{in: in} | |||
| 141 | + | } | |||
| 142 | + | ||||
| 143 | + | func (t *supervisorTestTerminal) EnterRaw() (func() error, error) { | |||
| 144 | + | if t.enterErr != nil { | |||
| 145 | + | return nil, t.enterErr | |||
| 146 | + | } | |||
| 147 | + | return func() error { | |||
| 148 | + | t.mu.Lock() | |||
| 149 | + | t.restores++ | |||
| 150 | + | t.mu.Unlock() | |||
| 151 | + | return nil | |||
| 152 | + | }, nil | |||
| 153 | + | } | |||
| 154 | + | ||||
| 155 | + | func (t *supervisorTestTerminal) Geometry() (domain.Geometry, error) { | |||
| 156 | + | return domain.Geometry{}, nil | |||
| 157 | + | } | |||
| 158 | + | ||||
| 159 | + | func (t *supervisorTestTerminal) ResizeEvents() <-chan domain.Geometry { return nil } | |||
| 160 | + | ||||
| 161 | + | func (t *supervisorTestTerminal) In() io.Reader { return t.in } | |||
| 162 | + | ||||
| 163 | + | func (t *supervisorTestTerminal) Out() io.Writer { return io.Discard } | |||
| 164 | + | ||||
| 165 | + | func (t *supervisorTestTerminal) Flush() error { return nil } | |||
| 166 | + | ||||
| 167 | + | func (t *supervisorTestTerminal) restoreCount() int { | |||
| 168 | + | t.mu.Lock() | |||
| 169 | + | defer t.mu.Unlock() | |||
| 170 | + | return t.restores | |||
| 171 | + | } | |||
| 172 | + | ||||
| 173 | + | // supervisorTestConnector scripts connection attempts and records concurrency, | |||
| 174 | + | // so a test can prove attempts never overlap. | |||
| 175 | + | type supervisorTestConnector struct { | |||
| 176 | + | connect func(ctx context.Context, call int) (ports.BrokerService, error) | |||
| 177 | + | ||||
| 178 | + | mu sync.Mutex | |||
| 179 | + | calls int | |||
| 180 | + | inFlight int | |||
| 181 | + | maxInFlight int | |||
| 182 | + | started chan int | |||
| 183 | + | } | |||
| 184 | + | ||||
| 185 | + | func newSupervisorTestConnector(connect func(ctx context.Context, call int) (ports.BrokerService, error)) *supervisorTestConnector { | |||
| 186 | + | return &supervisorTestConnector{connect: connect, started: make(chan int, 32)} | |||
| 187 | + | } | |||
| 188 | + | ||||
| 189 | + | func (c *supervisorTestConnector) Connect(ctx context.Context) (ports.BrokerService, error) { | |||
| 190 | + | c.mu.Lock() | |||
| 191 | + | c.calls++ | |||
| 192 | + | call := c.calls | |||
| 193 | + | c.inFlight++ | |||
| 194 | + | if c.inFlight > c.maxInFlight { | |||
| 195 | + | c.maxInFlight = c.inFlight | |||
| 196 | + | } | |||
| 197 | + | c.mu.Unlock() | |||
| 198 | + | select { | |||
| 199 | + | case c.started <- call: | |||
| 200 | + | default: | |||
| 201 | + | } | |||
| 202 | + | defer func() { | |||
| 203 | + | c.mu.Lock() | |||
| 204 | + | c.inFlight-- | |||
| 205 | + | c.mu.Unlock() | |||
| 206 | + | }() | |||
| 207 | + | if c.connect == nil { | |||
| 208 | + | return nil, ports.BrokerError{Code: ports.BrokerErrorUnavailable, Text: "no script"} | |||
| 209 | + | } | |||
| 210 | + | return c.connect(ctx, call) | |||
| 211 | + | } | |||
| 212 | + | ||||
| 213 | + | func (c *supervisorTestConnector) callCount() int { | |||
| 214 | + | c.mu.Lock() | |||
| 215 | + | defer c.mu.Unlock() | |||
| 216 | + | return c.calls | |||
| 217 | + | } | |||
| 218 | + | ||||
| 219 | + | func (c *supervisorTestConnector) maxConcurrent() int { | |||
| 220 | + | c.mu.Lock() | |||
| 221 | + | defer c.mu.Unlock() | |||
| 222 | + | return c.maxInFlight | |||
| 223 | + | } | |||
| 224 | + | ||||
| 225 | + | func (c *supervisorTestConnector) inFlightCount() int { | |||
| 226 | + | c.mu.Lock() | |||
| 227 | + | defer c.mu.Unlock() | |||
| 228 | + | return c.inFlight | |||
| 229 | + | } | |||
| 230 | + | ||||
| 231 | + | func (c *supervisorTestConnector) awaitStart(t *testing.T) int { | |||
| 232 | + | t.Helper() | |||
| 233 | + | select { | |||
| 234 | + | case call := <-c.started: | |||
| 235 | + | return call | |||
| 236 | + | case <-time.After(5 * time.Second): | |||
| 237 | + | t.Fatal("supervisor did not start a connection attempt") | |||
| 238 | + | return 0 | |||
| 239 | + | } | |||
| 240 | + | } | |||
| 241 | + | ||||
| 242 | + | // supervisorTestHub is one broker publication source. subscribe hands out the | |||
| 243 | + | // current change channel, and publish retires and replaces it, so a stale | |||
| 244 | + | // subscription can never observe a newer publication. | |||
| 245 | + | type supervisorTestHub struct { | |||
| 246 | + | mu sync.Mutex | |||
| 247 | + | snapshot ports.BrokerSnapshot | |||
| 248 | + | changed chan struct{} | |||
| 249 | + | } | |||
| 250 | + | ||||
| 251 | + | func newSupervisorTestHub() *supervisorTestHub { | |||
| 252 | + | return &supervisorTestHub{changed: make(chan struct{})} | |||
| 253 | + | } | |||
| 254 | + | ||||
| 255 | + | func (h *supervisorTestHub) publish(snapshot ports.BrokerSnapshot) { | |||
| 256 | + | h.mu.Lock() | |||
| 257 | + | h.snapshot = snapshot | |||
| 258 | + | changed := h.changed | |||
| 259 | + | h.changed = make(chan struct{}) | |||
| 260 | + | h.mu.Unlock() | |||
| 261 | + | close(changed) | |||
| 262 | + | } | |||
| 263 | + | ||||
| 264 | + | func (h *supervisorTestHub) current() ports.BrokerSnapshot { | |||
| 265 | + | h.mu.Lock() | |||
| 266 | + | defer h.mu.Unlock() | |||
| 267 | + | return h.snapshot | |||
| 268 | + | } | |||
| 269 | + | ||||
| 270 | + | func (h *supervisorTestHub) subscribe(closeFn func()) ports.BrokerSubscription { | |||
| 271 | + | h.mu.Lock() | |||
| 272 | + | changed := h.changed | |||
| 273 | + | h.mu.Unlock() | |||
| 274 | + | return supervisorTestSubscription{changed: changed, closeFn: closeFn} | |||
| 275 | + | } | |||
| 276 | + | ||||
| 277 | + | type supervisorTestSubscription struct { | |||
| 278 | + | changed <-chan struct{} | |||
| 279 | + | closeFn func() | |||
| 280 | + | } | |||
| 281 | + | ||||
| 282 | + | func (s supervisorTestSubscription) Changed() <-chan struct{} { return s.changed } | |||
| 283 | + | ||||
| 284 | + | func (s supervisorTestSubscription) Close() { | |||
| 285 | + | if s.closeFn != nil { | |||
| 286 | + | s.closeFn() | |||
| 287 | + | } | |||
| 288 | + | } | |||
| 289 | + | ||||
| 290 | + | // supervisorTestService is one scripted BrokerService. Close is observable so a | |||
| 291 | + | // test can prove a retired or late service was closed, and Close can be held open | |||
| 292 | + | // so a test can order a parent cancellation strictly before a settle decision that | |||
| 293 | + | // runs after the connection is released. | |||
| 294 | + | type supervisorTestService struct { | |||
| 295 | + | id ports.BrokerConnectionID | |||
| 296 | + | hub *supervisorTestHub | |||
| 297 | + | ||||
| 298 | + | mu sync.Mutex | |||
| 299 | + | err error | |||
| 300 | + | doneOnce sync.Once | |||
| 301 | + | done chan struct{} | |||
| 302 | + | ||||
| 303 | + | closeOnce sync.Once | |||
| 304 | + | closed chan struct{} | |||
| 305 | + | subClosed chan struct{} | |||
| 306 | + | ||||
| 307 | + | // closeGate, when non-nil, holds Close until releaseClose is called, and | |||
| 308 | + | // closeEntered reports that Close began waiting on it. | |||
| 309 | + | closeGate chan struct{} | |||
| 310 | + | closeEntered chan struct{} | |||
| 311 | + | } | |||
| 312 | + | ||||
| 313 | + | func newSupervisorTestService(id ports.BrokerConnectionID) *supervisorTestService { | |||
| 314 | + | return &supervisorTestService{ | |||
| 315 | + | id: id, | |||
| 316 | + | hub: newSupervisorTestHub(), | |||
| 317 | + | done: make(chan struct{}), | |||
| 318 | + | closed: make(chan struct{}), | |||
| 319 | + | subClosed: make(chan struct{}, 1), | |||
| 320 | + | } | |||
| 321 | + | } | |||
| 322 | + | ||||
| 323 | + | func (s *supervisorTestService) ConnectionID() ports.BrokerConnectionID { return s.id } | |||
| 324 | + | ||||
| 325 | + | func (s *supervisorTestService) Done() <-chan struct{} { return s.done } | |||
| 326 | + | ||||
| 327 | + | func (s *supervisorTestService) Err() error { | |||
| 328 | + | s.mu.Lock() | |||
| 329 | + | defer s.mu.Unlock() | |||
| 330 | + | return s.err | |||
| 331 | + | } | |||
| 332 | + | ||||
| 333 | + | func (s *supervisorTestService) Snapshot() ports.BrokerSnapshot { return s.hub.current() } | |||
| 334 | + | ||||
| 335 | + | func (s *supervisorTestService) Subscribe() (ports.BrokerSubscription, error) { | |||
| 336 | + | return s.hub.subscribe(func() { | |||
| 337 | + | select { | |||
| 338 | + | case s.subClosed <- struct{}{}: | |||
| 339 | + | default: | |||
| 340 | + | } | |||
| 341 | + | }), nil | |||
| 342 | + | } | |||
| 343 | + | ||||
| 344 | + | func (s *supervisorTestService) OpenStream(context.Context, ports.BrokerOpenStreamRequest) (ports.BrokerLogicalConnection, error) { | |||
| 345 | + | return nil, errors.New("supervisor test service does not support streams") | |||
| 346 | + | } | |||
| 347 | + | ||||
| 348 | + | func (s *supervisorTestService) CloseStream(ports.BrokerConnectionID, ports.BrokerStreamID) error { | |||
| 349 | + | return nil | |||
| 350 | + | } | |||
| 351 | + | ||||
| 352 | + | func (s *supervisorTestService) AddHost(context.Context, string) error { return nil } | |||
| 353 | + | ||||
| 354 | + | func (s *supervisorTestService) RemoveHost(context.Context, string) (bool, error) { | |||
| 355 | + | return false, nil | |||
| 356 | + | } | |||
| 357 | + | ||||
| 358 | + | func (s *supervisorTestService) RequestReconcile(string) {} | |||
| 359 | + | ||||
| 360 | + | func (s *supervisorTestService) Close() error { | |||
| 361 | + | s.closeOnce.Do(func() { | |||
| 362 | + | if s.closeGate != nil { | |||
| 363 | + | select { | |||
| 364 | + | case s.closeEntered <- struct{}{}: | |||
| 365 | + | default: | |||
| 366 | + | } | |||
| 367 | + | <-s.closeGate | |||
| 368 | + | } | |||
| 369 | + | close(s.closed) | |||
| 370 | + | s.terminate(nil) | |||
| 371 | + | }) | |||
| 372 | + | return nil | |||
| 373 | + | } | |||
| 374 | + | ||||
| 375 | + | // holdClose makes Close block until releaseClose is called, so a test can order a | |||
| 376 | + | // parent cancellation strictly before a settle decision that runs after the | |||
| 377 | + | // connection is released. closingCh reports that the release was entered. | |||
| 378 | + | func (s *supervisorTestService) holdClose() { | |||
| 379 | + | s.closeGate = make(chan struct{}) | |||
| 380 | + | s.closeEntered = make(chan struct{}, 1) | |||
| 381 | + | } | |||
| 382 | + | ||||
| 383 | + | // releaseClose releases a held Close. | |||
| 384 | + | func (s *supervisorTestService) releaseClose() { | |||
| 385 | + | if s.closeGate != nil { | |||
| 386 | + | close(s.closeGate) | |||
| 387 | + | } | |||
| 388 | + | } | |||
| 389 | + | ||||
| 390 | + | // closingCh reports that the held release was entered. | |||
| 391 | + | func (s *supervisorTestService) closingCh() <-chan struct{} { return s.closeEntered } | |||
| 392 | + | ||||
| 393 | + | // terminate removes the connection, recording the first cause exactly once. | |||
| 394 | + | func (s *supervisorTestService) terminate(err error) { | |||
| 395 | + | if err != nil { | |||
| 396 | + | s.mu.Lock() | |||
| 397 | + | if s.err == nil { | |||
| 398 | + | s.err = err | |||
| 399 | + | } | |||
| 400 | + | s.mu.Unlock() | |||
| 401 | + | } | |||
| 402 | + | s.doneOnce.Do(func() { close(s.done) }) | |||
| 403 | + | } | |||
| 404 | + | ||||
| 405 | + | // lose models an unexplained broker-side loss. | |||
| 406 | + | func (s *supervisorTestService) lose(err error) { s.terminate(err) } | |||
| 407 | + | ||||
| 408 | + | func (s *supervisorTestService) publish(epoch ports.BrokerEpoch, revision ports.BrokerRevision) { | |||
| 409 | + | s.hub.publish(ports.BrokerSnapshot{Epoch: epoch, Revision: revision}) | |||
| 410 | + | } | |||
| 411 | + | ||||
| 412 | + | func (s *supervisorTestService) closedCh() <-chan struct{} { return s.closed } | |||
| 413 | + | ||||
| 414 | + | func mustSupervisor(t *testing.T, cfg SupervisorConfig) *Supervisor { | |||
| 415 | + | t.Helper() | |||
| 416 | + | sup, err := NewSupervisor(cfg) | |||
| 417 | + | require.NoError(t, err) | |||
| 418 | + | return sup | |||
| 419 | + | } | |||
| 420 | + | ||||
| 421 | + | // supervisorTestReader blocks until unblocked, so a test controls exactly when | |||
| 422 | + | // the owned terminal input reaches EOF. | |||
| 423 | + | type supervisorTestReader struct { | |||
| 424 | + | mu sync.Mutex | |||
| 425 | + | done chan struct{} | |||
| 426 | + | } | |||
| 427 | + | ||||
| 428 | + | func newSupervisorTestReader() *supervisorTestReader { | |||
| 429 | + | return &supervisorTestReader{done: make(chan struct{})} | |||
| 430 | + | } | |||
| 431 | + | ||||
| 432 | + | func (r *supervisorTestReader) Read([]byte) (int, error) { | |||
| 433 | + | <-r.done | |||
| 434 | + | return 0, io.EOF | |||
| 435 | + | } | |||
| 436 | + | ||||
| 437 | + | func (r *supervisorTestReader) unblock() { | |||
| 438 | + | r.mu.Lock() | |||
| 439 | + | defer r.mu.Unlock() | |||
| 440 | + | select { | |||
| 441 | + | case <-r.done: | |||
| 442 | + | default: | |||
| 443 | + | close(r.done) | |||
| 444 | + | } | |||
| 445 | + | } | |||
| 446 | + | ||||
| 447 | + | func TestSupervisorReducerTransitions(t *testing.T) { | |||
| 448 | + | transient := errors.New("unavailable") | |||
| 449 | + | incompatible := ports.BrokerError{Code: ports.BrokerErrorIncompatible, Text: "version"} | |||
| 450 | + | exited := ports.BrokerError{Code: ports.BrokerErrorExplicitExit, Text: "exit"} | |||
| 451 | + | picker := State{Presentation: PresentPicker, Connectivity: ConnectivityDisconnected} | |||
| 452 | + | ||||
| 453 | + | tests := []struct { | |||
| 454 | + | name string | |||
| 455 | + | state State | |||
| 456 | + | event supervisorEvent | |||
| 457 | + | want State | |||
| 458 | + | }{ | |||
| 459 | + | { | |||
| 460 | + | name: "begin attempt claims a generation and connects", | |||
| 461 | + | state: picker, | |||
| 462 | + | event: supervisorEvent{kind: supervisorBeginAttempt}, | |||
| 463 | + | want: State{Presentation: PresentPicker, Connectivity: ConnectivityConnectingBroker, Attempt: 0, Generation: 1}, | |||
| 464 | + | }, | |||
| 465 | + | { | |||
| 466 | + | name: "ready resets the retry cadence", | |||
| 467 | + | state: State{Presentation: PresentPicker, Connectivity: ConnectivityConnectingBroker, Attempt: 3, Generation: 4, Err: transient}, | |||
| 468 | + | event: supervisorEvent{kind: supervisorReady}, | |||
| 469 | + | want: State{Presentation: PresentPicker, Connectivity: ConnectivityReady, Attempt: 0, Generation: 4}, | |||
| 470 | + | }, | |||
| 471 | + | { | |||
| 472 | + | name: "transient failure waits with a bumped attempt", | |||
| 473 | + | state: State{Presentation: PresentPicker, Connectivity: ConnectivityConnectingBroker, Attempt: 0, Generation: 2}, | |||
| 474 | + | event: supervisorEvent{kind: supervisorTransientFailure, err: transient}, | |||
| 475 | + | want: State{Presentation: PresentPicker, Connectivity: ConnectivityRetryWait, Attempt: 1, Generation: 2, Err: transient}, | |||
| 476 | + | }, | |||
| 477 | + | { | |||
| 478 | + | name: "broker loss returns to picker retry wait", | |||
| 479 | + | state: State{Presentation: PresentPicker, Connectivity: ConnectivityReady, Attempt: 0, Generation: 5}, | |||
| 480 | + | event: supervisorEvent{kind: supervisorBrokerLoss, err: transient}, | |||
| 481 | + | want: State{Presentation: PresentPicker, Connectivity: ConnectivityRetryWait, Attempt: 1, Generation: 5, Err: transient}, | |||
| 482 | + | }, | |||
| 483 | + | { | |||
| 484 | + | name: "non-retryable failure disconnects and stays on the picker", | |||
| 485 | + | state: State{Presentation: PresentPicker, Connectivity: ConnectivityConnectingBroker, Attempt: 1, Generation: 3}, | |||
| 486 | + | event: supervisorEvent{kind: supervisorNonRetryable, err: incompatible}, | |||
| 487 | + | want: State{Presentation: PresentPicker, Connectivity: ConnectivityDisconnected, Attempt: 1, Generation: 3, Err: incompatible}, | |||
| 488 | + | }, | |||
| 489 | + | { | |||
| 490 | + | name: "terminal failure terminates", | |||
| 491 | + | state: State{Presentation: PresentPicker, Connectivity: ConnectivityReady, Generation: 7}, | |||
| 492 | + | event: supervisorEvent{kind: supervisorTerminal, err: exited}, | |||
| 493 | + | want: State{Presentation: PresentTerminating, Connectivity: ConnectivityDisconnected, Generation: 7, Err: exited}, | |||
| 494 | + | }, | |||
| 495 | + | } | |||
| 496 | + | ||||
| 497 | + | for _, tt := range tests { | |||
| 498 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 499 | + | require.Equal(t, tt.want, reduceSupervisor(tt.state, tt.event)) | |||
| 500 | + | }) | |||
| 501 | + | } | |||
| 502 | + | } | |||
| 503 | + | ||||
| 504 | + | func TestSupervisorBackoffCadence(t *testing.T) { | |||
| 505 | + | zero := func() float64 { return 0 } | |||
| 506 | + | half := func() float64 { return 0.5 } | |||
| 507 | + | clamped := func() float64 { return 1.5 } | |||
| 508 | + | nan := func() float64 { return math.NaN() } | |||
| 509 | + | negInf := func() float64 { return math.Inf(-1) } | |||
| 510 | + | posInf := func() float64 { return math.Inf(1) } | |||
| 511 | + | ||||
| 512 | + | tests := []struct { | |||
| 513 | + | name string | |||
| 514 | + | attempt uint64 | |||
| 515 | + | jitter func() float64 | |||
| 516 | + | want time.Duration | |||
| 517 | + | }{ | |||
| 518 | + | {"no attempt has no delay", 0, zero, 0}, | |||
| 519 | + | {"first failure halves 100ms", 1, zero, 50 * time.Millisecond}, | |||
| 520 | + | {"second failure halves 200ms", 2, zero, 100 * time.Millisecond}, | |||
| 521 | + | {"third failure halves 400ms", 3, zero, 200 * time.Millisecond}, | |||
| 522 | + | {"fourth failure halves 800ms", 4, zero, 400 * time.Millisecond}, | |||
| 523 | + | {"fifth failure halves 1.6s", 5, zero, 800 * time.Millisecond}, | |||
| 524 | + | {"cap reached at 2s", 6, zero, time.Second}, | |||
| 525 | + | {"cap stays at 2s", 9, zero, time.Second}, | |||
| 526 | + | {"equal jitter reaches three quarters", 1, half, 75 * time.Millisecond}, | |||
| 527 | + | {"equal jitter scales with the cap", 6, half, 1500 * time.Millisecond}, | |||
| 528 | + | {"full jitter reaches the cap", 1, clamped, 100 * time.Millisecond}, | |||
| 529 | + | {"full jitter stays capped", 6, clamped, 2 * time.Second}, | |||
| 530 | + | {"NaN jitter clamps to the minimum half", 1, nan, 50 * time.Millisecond}, | |||
| 531 | + | {"NaN jitter stays at half the cap", 6, nan, time.Second}, | |||
| 532 | + | {"negative jitter clamps to the minimum half", 1, negInf, 50 * time.Millisecond}, | |||
| 533 | + | {"negative jitter stays at half the cap", 6, negInf, time.Second}, | |||
| 534 | + | {"positive infinity clamps to the cap", 1, posInf, 100 * time.Millisecond}, | |||
| 535 | + | {"positive infinity stays capped", 6, posInf, 2 * time.Second}, | |||
| 536 | + | } | |||
| 537 | + | ||||
| 538 | + | for _, tt := range tests { | |||
| 539 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 540 | + | require.Equal(t, tt.want, supervisorBackoffDelay(tt.attempt, tt.jitter)) | |||
| 541 | + | }) | |||
| 542 | + | } | |||
| 543 | + | } | |||
| 544 | + | ||||
| 545 | + | func TestSupervisorPickerWithoutBroker(t *testing.T) { | |||
| 546 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 547 | + | defer cancel() | |||
| 548 | + | ||||
| 549 | + | clock := newSupervisorTestClock() | |||
| 550 | + | reader := newSupervisorTestReader() | |||
| 551 | + | t.Cleanup(reader.unblock) | |||
| 552 | + | terminal := newSupervisorTestTerminal(reader) | |||
| 553 | + | connector := newSupervisorTestConnector(func(context.Context, int) (ports.BrokerService, error) { | |||
| 554 | + | return nil, ports.BrokerError{Code: ports.BrokerErrorUnavailable, Text: "broker down"} | |||
| 555 | + | }) | |||
| 556 | + | ||||
| 557 | + | var ( | |||
| 558 | + | renderMu sync.Mutex | |||
| 559 | + | renders []State | |||
| 560 | + | ) | |||
| 561 | + | sup := mustSupervisor(t, SupervisorConfig{ | |||
| 562 | + | Connector: connector, | |||
| 563 | + | Terminal: terminal, | |||
| 564 | + | Clock: clock, | |||
| 565 | + | Jitter: func() float64 { return 0 }, | |||
| 566 | + | Render: func(state State) { | |||
| 567 | + | renderMu.Lock() | |||
| 568 | + | renders = append(renders, state) | |||
| 569 | + | renderMu.Unlock() | |||
| 570 | + | }, | |||
| 571 | + | }) | |||
| 572 | + | ||||
| 573 | + | runErr := make(chan error, 1) | |||
| 574 | + | go func() { runErr <- sup.Run(ctx) }() | |||
| 575 | + | ||||
| 576 | + | first := clock.awaitTimer(t) | |||
| 577 | + | require.Equal(t, 50*time.Millisecond, first.delay, "the first retry is the equal-jitter half of 100ms") | |||
| 578 | + | require.Equal(t, 1, connector.awaitStart(t)) | |||
| 579 | + | require.Equal(t, State{Presentation: PresentPicker, Connectivity: ConnectivityRetryWait, Attempt: 1, Generation: 1, Err: ports.BrokerError{Code: ports.BrokerErrorUnavailable, Text: "broker down"}}, sup.State()) | |||
| 580 | + | ||||
| 581 | + | first.fire() | |||
| 582 | + | second := clock.awaitTimer(t) | |||
| 583 | + | require.Equal(t, 100*time.Millisecond, second.delay, "the second retry doubles the cap") | |||
| 584 | + | require.Equal(t, 2, connector.awaitStart(t)) | |||
| 585 | + | require.Equal(t, PresentPicker, sup.State().Presentation, "the picker stays up while disconnected") | |||
| 586 | + | require.Equal(t, ConnectivityRetryWait, sup.State().Connectivity) | |||
| 587 | + | require.Equal(t, uint64(2), sup.State().Generation) | |||
| 588 | + | ||||
| 589 | + | cancel() | |||
| 590 | + | require.ErrorIs(t, <-runErr, context.Canceled) | |||
| 591 | + | require.Equal(t, 1, terminal.restoreCount()) | |||
| 592 | + | require.Equal(t, 1, connector.maxConcurrent()) | |||
| 593 | + | ||||
| 594 | + | renderMu.Lock() | |||
| 595 | + | defer renderMu.Unlock() | |||
| 596 | + | require.NotEmpty(t, renders) | |||
| 597 | + | require.Equal(t, PresentTerminating, renders[len(renders)-1].Presentation) | |||
| 598 | + | for _, state := range renders[:len(renders)-1] { | |||
| 599 | + | require.Equal(t, PresentPicker, state.Presentation, "P5.1a never leaves the picker") | |||
| 600 | + | } | |||
| 601 | + | } | |||
| 602 | + | ||||
| 603 | + | func TestSupervisorBrokerLossReconnectsWithFreshGeneration(t *testing.T) { | |||
| 604 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 605 | + | defer cancel() | |||
| 606 | + | ||||
| 607 | + | clock := newSupervisorTestClock() | |||
| 608 | + | reader := newSupervisorTestReader() | |||
| 609 | + | t.Cleanup(reader.unblock) | |||
| 610 | + | terminal := newSupervisorTestTerminal(reader) | |||
| 611 | + | ||||
| 612 | + | first := newSupervisorTestService(ports.BrokerConnectionID{1}) | |||
| 613 | + | first.publish(1, 1) | |||
| 614 | + | second := newSupervisorTestService(ports.BrokerConnectionID{2}) | |||
| 615 | + | ||||
| 616 | + | connector := newSupervisorTestConnector(func(_ context.Context, call int) (ports.BrokerService, error) { | |||
| 617 | + | switch call { | |||
| 618 | + | case 1: | |||
| 619 | + | return first, nil | |||
| 620 | + | default: | |||
| 621 | + | return second, nil | |||
| 622 | + | } | |||
| 623 | + | }) | |||
| 624 | + | ||||
| 625 | + | sup := mustSupervisor(t, SupervisorConfig{ | |||
| 626 | + | Connector: connector, | |||
| 627 | + | Terminal: terminal, | |||
| 628 | + | Clock: clock, | |||
| 629 | + | Jitter: func() float64 { return 0 }, | |||
| 630 | + | }) | |||
| 631 | + | ||||
| 632 | + | runErr := make(chan error, 1) | |||
| 633 | + | go func() { runErr <- sup.Run(ctx) }() | |||
| 634 | + | ||||
| 635 | + | require.Equal(t, 1, connector.awaitStart(t)) | |||
| 636 | + | require.Eventually(t, func() bool { return sup.State().Connectivity == ConnectivityReady }, 5*time.Second, time.Millisecond) | |||
| 637 | + | require.Equal(t, uint64(1), sup.State().Generation) | |||
| 638 | + | require.Equal(t, uint64(0), sup.State().Attempt) | |||
| 639 | + | ||||
| 640 | + | // The idle broker connection is lost: the supervisor retires it and waits | |||
| 641 | + | // the first cadence before reconnecting. | |||
| 642 | + | first.lose(ports.BrokerError{Code: ports.BrokerErrorUnavailable, Text: "idle loss"}) | |||
| 643 | + | require.Eventually(t, func() bool { return sup.State().Connectivity == ConnectivityRetryWait }, 5*time.Second, time.Millisecond) | |||
| 644 | + | require.Equal(t, uint64(1), sup.State().Attempt) | |||
| 645 | + | select { | |||
| 646 | + | case <-first.closedCh(): | |||
| 647 | + | default: | |||
| 648 | + | t.Fatal("the lost connection was not retired") | |||
| 649 | + | } | |||
| 650 | + | select { | |||
| 651 | + | case <-first.subClosed: | |||
| 652 | + | default: | |||
| 653 | + | t.Fatal("the lost subscription was not retired") | |||
| 654 | + | } | |||
| 655 | + | ||||
| 656 | + | retry := clock.awaitTimer(t) | |||
| 657 | + | require.Equal(t, 50*time.Millisecond, retry.delay) | |||
| 658 | + | retry.fire() | |||
| 659 | + | require.Equal(t, 2, connector.awaitStart(t)) | |||
| 660 | + | ||||
| 661 | + | // Wait until the replacement is waiting for its first publication. | |||
| 662 | + | require.Eventually(t, func() bool { | |||
| 663 | + | return sup.State().Connectivity == ConnectivityConnectingBroker && sup.State().Generation == 2 | |||
| 664 | + | }, 5*time.Second, time.Millisecond) | |||
| 665 | + | ||||
| 666 | + | // A publication on the retired connection must never make the replacement | |||
| 667 | + | // Ready; only the replacement's own fresh epoch does. | |||
| 668 | + | first.publish(1, 2) | |||
| 669 | + | require.Never(t, func() bool { return sup.State().Connectivity == ConnectivityReady }, 50*time.Millisecond, 5*time.Millisecond) | |||
| 670 | + | ||||
| 671 | + | second.publish(2, 1) | |||
| 672 | + | require.Eventually(t, func() bool { return sup.State().Connectivity == ConnectivityReady }, 5*time.Second, time.Millisecond) | |||
| 673 | + | require.Equal(t, uint64(2), sup.State().Generation) | |||
| 674 | + | require.Equal(t, uint64(0), sup.State().Attempt, "readiness resets the cadence") | |||
| 675 | + | ||||
| 676 | + | cancel() | |||
| 677 | + | require.ErrorIs(t, <-runErr, context.Canceled) | |||
| 678 | + | require.Equal(t, 1, terminal.restoreCount()) | |||
| 679 | + | select { | |||
| 680 | + | case <-second.closedCh(): | |||
| 681 | + | default: | |||
| 682 | + | t.Fatal("the active connection was not retired on shutdown") | |||
| 683 | + | } | |||
| 684 | + | } | |||
| 685 | + | ||||
| 686 | + | func TestSupervisorCancellationClosesLateConnection(t *testing.T) { | |||
| 687 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 688 | + | ||||
| 689 | + | clock := newSupervisorTestClock() | |||
| 690 | + | reader := newSupervisorTestReader() | |||
| 691 | + | t.Cleanup(reader.unblock) | |||
| 692 | + | terminal := newSupervisorTestTerminal(reader) | |||
| 693 | + | ||||
| 694 | + | release := make(chan struct{}) | |||
| 695 | + | late := newSupervisorTestService(ports.BrokerConnectionID{9}) | |||
| 696 | + | late.publish(1, 1) | |||
| 697 | + | connector := newSupervisorTestConnector(func(context.Context, int) (ports.BrokerService, error) { | |||
| 698 | + | <-release | |||
| 699 | + | return late, nil | |||
| 700 | + | }) | |||
| 701 | + | ||||
| 702 | + | sup := mustSupervisor(t, SupervisorConfig{ | |||
| 703 | + | Connector: connector, | |||
| 704 | + | Terminal: terminal, | |||
| 705 | + | Clock: clock, | |||
| 706 | + | Jitter: func() float64 { return 0 }, | |||
| 707 | + | }) | |||
| 708 | + | ||||
| 709 | + | runErr := make(chan error, 1) | |||
| 710 | + | go func() { runErr <- sup.Run(ctx) }() | |||
| 711 | + | require.Equal(t, 1, connector.awaitStart(t)) | |||
| 712 | + | ||||
| 713 | + | // Cancel while the connector ignores cancellation, then let it return a | |||
| 714 | + | // live service: the supervisor must close the late superseded success. | |||
| 715 | + | cancel() | |||
| 716 | + | close(release) | |||
| 717 | + | ||||
| 718 | + | require.ErrorIs(t, <-runErr, context.Canceled) | |||
| 719 | + | select { | |||
| 720 | + | case <-late.closedCh(): | |||
| 721 | + | default: | |||
| 722 | + | t.Fatal("a late superseded success must be closed") | |||
| 723 | + | } | |||
| 724 | + | require.Equal(t, 1, terminal.restoreCount()) | |||
| 725 | + | require.Zero(t, connector.inFlightCount()) | |||
| 726 | + | } | |||
| 727 | + | ||||
| 728 | + | func TestSupervisorIncompatibleStopsReconnecting(t *testing.T) { | |||
| 729 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 730 | + | defer cancel() | |||
| 731 | + | ||||
| 732 | + | clock := newSupervisorTestClock() | |||
| 733 | + | reader := newSupervisorTestReader() | |||
| 734 | + | t.Cleanup(reader.unblock) | |||
| 735 | + | terminal := newSupervisorTestTerminal(reader) | |||
| 736 | + | ||||
| 737 | + | incompatible := ports.BrokerError{Code: ports.BrokerErrorIncompatible, Text: "protocol"} | |||
| 738 | + | connector := newSupervisorTestConnector(func(context.Context, int) (ports.BrokerService, error) { | |||
| 739 | + | return nil, incompatible | |||
| 740 | + | }) | |||
| 741 | + | notified := make(chan error, 4) | |||
| 742 | + | sup := mustSupervisor(t, SupervisorConfig{ | |||
| 743 | + | Connector: connector, | |||
| 744 | + | Terminal: terminal, | |||
| 745 | + | Clock: clock, | |||
| 746 | + | Jitter: func() float64 { return 0 }, | |||
| 747 | + | Notify: func(_ State, err error) { notified <- err }, | |||
| 748 | + | }) | |||
| 749 | + | ||||
| 750 | + | runErr := make(chan error, 1) | |||
| 751 | + | go func() { runErr <- sup.Run(ctx) }() | |||
| 752 | + | ||||
| 753 | + | select { | |||
| 754 | + | case err := <-notified: | |||
| 755 | + | var typed ports.BrokerError | |||
| 756 | + | require.ErrorAs(t, err, &typed) | |||
| 757 | + | require.Equal(t, ports.BrokerErrorIncompatible, typed.Code) | |||
| 758 | + | case <-time.After(5 * time.Second): | |||
| 759 | + | t.Fatal("the incompatible failure was not surfaced") | |||
| 760 | + | } | |||
| 761 | + | ||||
| 762 | + | require.Equal(t, State{Presentation: PresentPicker, Connectivity: ConnectivityDisconnected, Generation: 1, Err: incompatible}, sup.State()) | |||
| 763 | + | require.Never(t, func() bool { return connector.callCount() > 1 }, 50*time.Millisecond, 5*time.Millisecond) | |||
| 764 | + | require.Zero(t, clock.timerCount(), "a non-retryable failure schedules no retry timer") | |||
| 765 | + | ||||
| 766 | + | cancel() | |||
| 767 | + | require.ErrorIs(t, <-runErr, context.Canceled) | |||
| 768 | + | require.Equal(t, 1, terminal.restoreCount()) | |||
| 769 | + | } | |||
| 770 | + | ||||
| 771 | + | func TestSupervisorTerminalEOFRestoresRawOnce(t *testing.T) { | |||
| 772 | + | clock := newSupervisorTestClock() | |||
| 773 | + | terminal := newSupervisorTestTerminal(strings.NewReader("")) | |||
| 774 | + | connector := newSupervisorTestConnector(func(context.Context, int) (ports.BrokerService, error) { | |||
| 775 | + | return nil, ports.BrokerError{Code: ports.BrokerErrorUnavailable, Text: "down"} | |||
| 776 | + | }) | |||
| 777 | + | ||||
| 778 | + | sup := mustSupervisor(t, SupervisorConfig{ | |||
| 779 | + | Connector: connector, | |||
| 780 | + | Terminal: terminal, | |||
| 781 | + | Clock: clock, | |||
| 782 | + | Jitter: func() float64 { return 0 }, | |||
| 783 | + | }) | |||
| 784 | + | ||||
| 785 | + | require.NoError(t, sup.Run(context.Background()), "a clean terminal EOF is an orderly exit") | |||
| 786 | + | require.Equal(t, 1, terminal.restoreCount()) | |||
| 787 | + | require.Equal(t, PresentTerminating, sup.State().Presentation) | |||
| 788 | + | } | |||
| 789 | + | ||||
| 790 | + | func TestSupervisorRawModePrecedesBroker(t *testing.T) { | |||
| 791 | + | clock := newSupervisorTestClock() | |||
| 792 | + | reader := newSupervisorTestReader() | |||
| 793 | + | t.Cleanup(reader.unblock) | |||
| 794 | + | terminal := newSupervisorTestTerminal(reader) | |||
| 795 | + | ||||
| 796 | + | enteredAfterRaw := make(chan bool, 1) | |||
| 797 | + | connector := newSupervisorTestConnector(func(context.Context, int) (ports.BrokerService, error) { | |||
| 798 | + | enteredAfterRaw <- terminal.restoreCount() == 0 | |||
| 799 | + | return nil, ports.BrokerError{Code: ports.BrokerErrorUnavailable, Text: "down"} | |||
| 800 | + | }) | |||
| 801 | + | ||||
| 802 | + | sup := mustSupervisor(t, SupervisorConfig{ | |||
| 803 | + | Connector: connector, | |||
| 804 | + | Terminal: terminal, | |||
| 805 | + | Clock: clock, | |||
| 806 | + | Jitter: func() float64 { return 0 }, | |||
| 807 | + | }) | |||
| 808 | + | ||||
| 809 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 810 | + | runErr := make(chan error, 1) | |||
| 811 | + | go func() { runErr <- sup.Run(ctx) }() | |||
| 812 | + | require.Equal(t, 1, connector.awaitStart(t)) | |||
| 813 | + | require.True(t, <-enteredAfterRaw, "raw mode must be entered before the first broker connection") | |||
| 814 | + | cancel() | |||
| 815 | + | <-runErr | |||
| 816 | + | } | |||
| 817 | + | ||||
| 818 | + | func TestSupervisorSingleAttemptAndTimer(t *testing.T) { | |||
| 819 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 820 | + | ||||
| 821 | + | clock := newSupervisorTestClock() | |||
| 822 | + | reader := newSupervisorTestReader() | |||
| 823 | + | t.Cleanup(reader.unblock) | |||
| 824 | + | terminal := newSupervisorTestTerminal(reader) | |||
| 825 | + | connector := newSupervisorTestConnector(func(context.Context, int) (ports.BrokerService, error) { | |||
| 826 | + | return nil, ports.BrokerError{Code: ports.BrokerErrorUnavailable, Text: "down"} | |||
| 827 | + | }) | |||
| 828 | + | ||||
| 829 | + | sup := mustSupervisor(t, SupervisorConfig{ | |||
| 830 | + | Connector: connector, | |||
| 831 | + | Terminal: terminal, | |||
| 832 | + | Clock: clock, | |||
| 833 | + | Jitter: func() float64 { return 0 }, | |||
| 834 | + | }) | |||
| 835 | + | ||||
| 836 | + | runErr := make(chan error, 1) | |||
| 837 | + | go func() { runErr <- sup.Run(ctx) }() | |||
| 838 | + | ||||
| 839 | + | first := clock.awaitTimer(t) | |||
| 840 | + | first.fire() | |||
| 841 | + | second := clock.awaitTimer(t) | |||
| 842 | + | require.Equal(t, 100*time.Millisecond, second.delay) | |||
| 843 | + | second.fire() | |||
| 844 | + | clock.awaitTimer(t) | |||
| 845 | + | ||||
| 846 | + | require.Equal(t, 1, connector.maxConcurrent(), "attempts never overlap") | |||
| 847 | + | require.Equal(t, 3, connector.callCount()) | |||
| 848 | + | require.Equal(t, 1, clock.liveTimers(), "exactly one retry timer is live") | |||
| 849 | + | require.Zero(t, connector.inFlightCount(), "the connector call has returned before the next wait") | |||
| 850 | + | ||||
| 851 | + | cancel() | |||
| 852 | + | require.ErrorIs(t, <-runErr, context.Canceled) | |||
| 853 | + | require.Eventually(t, clock.allStopped, time.Second, time.Millisecond, "every retry timer is retired") | |||
| 854 | + | require.Equal(t, terminal.restoreCount(), 1) | |||
| 855 | + | require.Zero(t, connector.inFlightCount()) | |||
| 856 | + | } | |||
| 857 | + | ||||
| 858 | + | // supervisorCancellationRaceRounds is how many times each cancellation race is | |||
| 859 | + | // replayed. Every round orders the parent cancellation strictly before the run's | |||
| 860 | + | // settle decision by construction, so the rounds are deterministic rather than | |||
| 861 | + | // timing-dependent; the repetition runs both settle points many times over. | |||
| 862 | + | const supervisorCancellationRaceRounds = 100 | |||
| 863 | + | ||||
| 864 | + | // TestSupervisorCancellationRacingSettlementIsTerminal pins the parent | |||
| 865 | + | // cancellation fence at both settle points: an attempt that settles at the same | |||
| 866 | + | // instant as the parent cancellation is retired rather than adopted, and an | |||
| 867 | + | // established-connection loss that settles at that instant is never turned into a | |||
| 868 | + | // retry. Every round must end terminating with ctx.Err without scheduling a retry | |||
| 869 | + | // cadence, rendering ConnectivityRetryWait, or notifying a connectivity failure | |||
| 870 | + | // for a settle the parent had already given up on. | |||
| 871 | + | // | |||
| 872 | + | // The ordering is enforced, never hoped for: each round parks the settle at a | |||
| 873 | + | // point the round itself releases (the state lock the run decides adoption under, | |||
| 874 | + | // or the connection release the loss settle runs after) and cancels there, so the | |||
| 875 | + | // decision sees the cancellation whichever branch the run's select took. | |||
| 876 | + | func TestSupervisorCancellationRacingSettlementIsTerminal(t *testing.T) { | |||
| 877 | + | transient := ports.BrokerError{Code: ports.BrokerErrorUnavailable, Text: "raced"} | |||
| 878 | + | exited := ports.BrokerError{Code: ports.BrokerErrorExplicitExit, Text: "raced exit"} | |||
| 879 | + | ||||
| 880 | + | attempts := []struct { | |||
| 881 | + | name string | |||
| 882 | + | attempt func() (*supervisorTestService, error) | |||
| 883 | + | }{ | |||
| 884 | + | {name: "retryable failure", attempt: func() (*supervisorTestService, error) { return nil, transient }}, | |||
| 885 | + | { | |||
| 886 | + | name: "ready connection", | |||
| 887 | + | attempt: func() (*supervisorTestService, error) { | |||
| 888 | + | service := newSupervisorTestService(ports.BrokerConnectionID{3}) | |||
| 889 | + | service.publish(1, 1) | |||
| 890 | + | return service, nil | |||
| 891 | + | }, | |||
| 892 | + | }, | |||
| 893 | + | {name: "terminal failure", attempt: func() (*supervisorTestService, error) { return nil, exited }}, | |||
| 894 | + | } | |||
| 895 | + | ||||
| 896 | + | for _, tc := range attempts { | |||
| 897 | + | t.Run("attempt "+tc.name, func(t *testing.T) { | |||
| 898 | + | // One carrier thread for the round: yielding after the attempt is | |||
| 899 | + | // released then runs that goroutine to its settlement before this one | |||
| 900 | + | // resumes, so the outcome is delivered while the parent is still live | |||
| 901 | + | // and the run's select takes the settled attempt rather than ctx.Done. | |||
| 902 | + | defer runtime.GOMAXPROCS(runtime.GOMAXPROCS(1)) | |||
| 903 | + | for round := 0; round < supervisorCancellationRaceRounds; round++ { | |||
| 904 | + | release := make(chan struct{}) | |||
| 905 | + | var raced *supervisorTestService | |||
| 906 | + | race := startCancellationRace(t, func(context.Context, int) (ports.BrokerService, error) { | |||
| 907 | + | <-release | |||
| 908 | + | service, err := tc.attempt() | |||
| 909 | + | raced = service | |||
| 910 | + | if service == nil { | |||
| 911 | + | return nil, err | |||
| 912 | + | } | |||
| 913 | + | return service, err | |||
| 914 | + | }) | |||
| 915 | + | require.Equal(t, 1, race.connector.awaitStart(t), "round %d", round) | |||
| 916 | + | ||||
| 917 | + | // Take the state lock the run decides adoption under, release the | |||
| 918 | + | // attempt, let it settle, and cancel the parent while still holding | |||
| 919 | + | // the lock: the decision is ordered strictly after the cancellation | |||
| 920 | + | // by construction. | |||
| 921 | + | race.supervisor.mu.Lock() | |||
| 922 | + | close(release) | |||
| 923 | + | runtime.Gosched() | |||
| 924 | + | race.cancel() | |||
| 925 | + | race.supervisor.mu.Unlock() | |||
| 926 | + | ||||
| 927 | + | race.assertSettledAsCancelled(t, round, false) | |||
| 928 | + | if raced != nil { | |||
| 929 | + | select { | |||
| 930 | + | case <-raced.closedCh(): | |||
| 931 | + | default: | |||
| 932 | + | t.Fatalf("round %d: an attempt that raced cancellation must be retired, not adopted", round) | |||
| 933 | + | } | |||
| 934 | + | } | |||
| 935 | + | } | |||
| 936 | + | }) | |||
| 937 | + | } | |||
| 938 | + | ||||
| 939 | + | t.Run("established loss", func(t *testing.T) { | |||
| 940 | + | for round := 0; round < supervisorCancellationRaceRounds; round++ { | |||
| 941 | + | losing := newSupervisorTestService(ports.BrokerConnectionID{4}) | |||
| 942 | + | losing.publish(1, 1) | |||
| 943 | + | losing.holdClose() | |||
| 944 | + | race := startCancellationRace(t, func(context.Context, int) (ports.BrokerService, error) { | |||
| 945 | + | return losing, nil | |||
| 946 | + | }) | |||
| 947 | + | require.Equal(t, 1, race.connector.awaitStart(t), "round %d", round) | |||
| 948 | + | require.Eventually(t, func() bool { return race.supervisor.State().Connectivity == ConnectivityReady }, 5*time.Second, time.Millisecond, "round %d", round) | |||
| 949 | + | ||||
| 950 | + | // The loss wakes the run while the parent is still live, and its | |||
| 951 | + | // connection release then parks the run before the settle decision, so | |||
| 952 | + | // the cancellation below is ordered strictly before that decision. | |||
| 953 | + | losing.lose(ports.BrokerError{Code: ports.BrokerErrorUnavailable, Text: "raced loss"}) | |||
| 954 | + | select { | |||
| 955 | + | case <-losing.closingCh(): | |||
| 956 | + | case <-time.After(5 * time.Second): | |||
| 957 | + | t.Fatalf("round %d: the run never released the lost connection", round) | |||
| 958 | + | } | |||
| 959 | + | race.cancel() | |||
| 960 | + | losing.releaseClose() | |||
| 961 | + | ||||
| 962 | + | race.assertSettledAsCancelled(t, round, true) | |||
| 963 | + | select { | |||
| 964 | + | case <-losing.closedCh(): | |||
| 965 | + | case <-time.After(5 * time.Second): | |||
| 966 | + | t.Fatalf("round %d: the lost connection must be retired", round) | |||
| 967 | + | } | |||
| 968 | + | } | |||
| 969 | + | }) | |||
| 970 | + | } | |||
| 971 | + | ||||
| 972 | + | // cancellationRace is one scripted round of the parent-cancellation race: the | |||
| 973 | + | // running supervisor with its fake clock, terminal, and recorder, plus the run's | |||
| 974 | + | // result channel. A round cancels the parent itself, at the point that orders the | |||
| 975 | + | // cancellation before the settle decision under test. | |||
| 976 | + | type cancellationRace struct { | |||
| 977 | + | cancel context.CancelFunc | |||
| 978 | + | clock *supervisorTestClock | |||
| 979 | + | terminal *supervisorTestTerminal | |||
| 980 | + | recorder *cancellationRaceRecorder | |||
| 981 | + | connector *supervisorTestConnector | |||
| 982 | + | supervisor *Supervisor | |||
| 983 | + | runErr chan error | |||
| 984 | + | } | |||
| 985 | + | ||||
| 986 | + | // startCancellationRace starts one supervisor run over a scripted connector. The | |||
| 987 | + | // reader is shared and never unblocked until the test ends, so the run can only | |||
| 988 | + | // end through cancellation. | |||
| 989 | + | func startCancellationRace(t *testing.T, connect func(ctx context.Context, call int) (ports.BrokerService, error)) *cancellationRace { | |||
| 990 | + | t.Helper() | |||
| 991 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 992 | + | reader := newSupervisorTestReader() | |||
| 993 | + | t.Cleanup(reader.unblock) | |||
| 994 | + | ||||
| 995 | + | race := &cancellationRace{ | |||
| 996 | + | cancel: cancel, | |||
| 997 | + | clock: newSupervisorTestClock(), | |||
| 998 | + | terminal: newSupervisorTestTerminal(reader), | |||
| 999 | + | recorder: &cancellationRaceRecorder{}, | |||
| 1000 | + | connector: newSupervisorTestConnector(connect), | |||
| 1001 | + | runErr: make(chan error, 1), | |||
| 1002 | + | } | |||
| 1003 | + | race.supervisor = mustSupervisor(t, SupervisorConfig{ | |||
| 1004 | + | Connector: race.connector, | |||
| 1005 | + | Terminal: race.terminal, | |||
| 1006 | + | Clock: race.clock, | |||
| 1007 | + | Jitter: func() float64 { return 0 }, | |||
| 1008 | + | Render: race.recorder.render, | |||
| 1009 | + | Notify: race.recorder.notify, | |||
| 1010 | + | }) | |||
| 1011 | + | go func() { race.runErr <- race.supervisor.Run(ctx) }() | |||
| 1012 | + | return race | |||
| 1013 | + | } | |||
| 1014 | + | ||||
| 1015 | + | // assertSettledAsCancelled asserts one round's cancellation invariants: the run | |||
| 1016 | + | // ends terminating with ctx.Err, raw mode is restored exactly once, no retry | |||
| 1017 | + | // cadence was scheduled or notified, and every earlier render is the attempt that | |||
| 1018 | + | // was still in flight. established marks a round whose connection was ready | |||
| 1019 | + | // before the race, so its earlier ready render is expected. | |||
| 1020 | + | func (r *cancellationRace) assertSettledAsCancelled(t *testing.T, round int, established bool) { | |||
| 1021 | + | t.Helper() | |||
| 1022 | + | require.ErrorIs(t, <-r.runErr, context.Canceled, "round %d: parent cancellation must settle the run", round) | |||
| 1023 | + | renders, notified := r.recorder.recorded() | |||
| 1024 | + | require.Empty(t, notified, "round %d: cancellation must not notify a connectivity failure", round) | |||
| 1025 | + | require.Zero(t, r.clock.timerCount(), "round %d: cancellation must not schedule a retry timer", round) | |||
| 1026 | + | require.Equal(t, 1, r.terminal.restoreCount(), "round %d: raw mode must be restored exactly once", round) | |||
| 1027 | + | require.NotEmpty(t, renders, "round %d: the run must render its state", round) | |||
| 1028 | + | require.Equal(t, PresentTerminating, renders[len(renders)-1].Presentation, "round %d: the run must end terminating", round) | |||
| 1029 | + | for _, state := range renders[:len(renders)-1] { | |||
| 1030 | + | require.Equal(t, PresentPicker, state.Presentation, "round %d: the run must stay in the picker", round) | |||
| 1031 | + | require.NotEqual(t, ConnectivityRetryWait, state.Connectivity, | |||
| 1032 | + | "round %d: no retry cadence may be published after the cancellation", round) | |||
| 1033 | + | if established { | |||
| 1034 | + | // The connection was ready before the race, so its ready render is the | |||
| 1035 | + | // only settled state the round expects. | |||
| 1036 | + | continue | |||
| 1037 | + | } | |||
| 1038 | + | require.Equal(t, ConnectivityConnectingBroker, state.Connectivity, | |||
| 1039 | + | "round %d: no settled connection may be published for the cancelled attempt", round) | |||
| 1040 | + | } | |||
| 1041 | + | } | |||
| 1042 | + | ||||
| 1043 | + | // cancellationRaceRecorder records what one round's supervisor published. | |||
| 1044 | + | type cancellationRaceRecorder struct { | |||
| 1045 | + | mu sync.Mutex | |||
| 1046 | + | renders []State | |||
| 1047 | + | notified []error | |||
| 1048 | + | } | |||
| 1049 | + | ||||
| 1050 | + | func (r *cancellationRaceRecorder) render(state State) { | |||
| 1051 | + | r.mu.Lock() | |||
| 1052 | + | r.renders = append(r.renders, state) | |||
| 1053 | + | r.mu.Unlock() | |||
| 1054 | + | } | |||
| 1055 | + | ||||
| 1056 | + | func (r *cancellationRaceRecorder) notify(_ State, err error) { | |||
| 1057 | + | r.mu.Lock() | |||
| 1058 | + | r.notified = append(r.notified, err) | |||
| 1059 | + | r.mu.Unlock() | |||
| 1060 | + | } | |||
| 1061 | + | ||||
| 1062 | + | func (r *cancellationRaceRecorder) recorded() ([]State, []error) { | |||
| 1063 | + | r.mu.Lock() | |||
| 1064 | + | defer r.mu.Unlock() | |||
| 1065 | + | return append([]State(nil), r.renders...), append([]error(nil), r.notified...) | |||
| 1066 | + | } | |||
| 1067 | + | ||||
| 1068 | + | // supervisorNilSub is a typed-nil subscription: it satisfies the interface but | |||
| 1069 | + | // is nil underneath, so only the reflection guard keeps the supervisor from | |||
| 1070 | + | // dereferencing it. | |||
| 1071 | + | type supervisorNilSub struct{} | |||
| 1072 | + | ||||
| 1073 | + | func (*supervisorNilSub) Changed() <-chan struct{} { return nil } | |||
| 1074 | + | func (*supervisorNilSub) Close() {} | |||
| 1075 | + | ||||
| 1076 | + | // supervisorNilSubService overrides Subscribe so a test can exercise the | |||
| 1077 | + | // supervisor's nil-subscription guard without a live subscription. | |||
| 1078 | + | type supervisorNilSubService struct { | |||
| 1079 | + | *supervisorTestService | |||
| 1080 | + | sub ports.BrokerSubscription | |||
| 1081 | + | } | |||
| 1082 | + | ||||
| 1083 | + | func (s *supervisorNilSubService) Subscribe() (ports.BrokerSubscription, error) { | |||
| 1084 | + | return s.sub, nil | |||
| 1085 | + | } | |||
| 1086 | + | ||||
| 1087 | + | // TestSupervisorDefensiveGenerationFenceRejectsSupersededAttempt pins the | |||
| 1088 | + | // defensive generation fence directly: a settled attempt whose generation is not | |||
| 1089 | + | // the current one is retired and discarded, while the current generation is | |||
| 1090 | + | // adopted unchanged. Run joins every attempt before it advances, so no | |||
| 1091 | + | // superseded completion can reach adoptAttempt in the production driver today; | |||
| 1092 | + | // pinning the fence here keeps a future concurrent driver from dropping it. | |||
| 1093 | + | func TestSupervisorDefensiveGenerationFenceRejectsSupersededAttempt(t *testing.T) { | |||
| 1094 | + | clock := newSupervisorTestClock() | |||
| 1095 | + | terminal := newSupervisorTestTerminal(newSupervisorTestReader()) | |||
| 1096 | + | sup := mustSupervisor(t, SupervisorConfig{ | |||
| 1097 | + | Connector: newSupervisorTestConnector(nil), | |||
| 1098 | + | Terminal: terminal, | |||
| 1099 | + | Clock: clock, | |||
| 1100 | + | Jitter: func() float64 { return 0 }, | |||
| 1101 | + | }) | |||
| 1102 | + | ||||
| 1103 | + | // Claim a generation exactly as Run does before it launches the connector. | |||
| 1104 | + | sup.transition(supervisorEvent{kind: supervisorBeginAttempt}) | |||
| 1105 | + | current := sup.State().Generation | |||
| 1106 | + | require.Equal(t, uint64(1), current) | |||
| 1107 | + | ||||
| 1108 | + | service := newSupervisorTestService(ports.BrokerConnectionID{1}) | |||
| 1109 | + | subClosed := make(chan struct{}) | |||
| 1110 | + | stale := supervisorAttempt{ | |||
| 1111 | + | generation: current - 1, | |||
| 1112 | + | service: service, | |||
| 1113 | + | sub: supervisorTestSubscription{closeFn: func() { close(subClosed) }}, | |||
| 1114 | + | } | |||
| 1115 | + | adopted, cause := sup.adoptAttempt(context.Background(), stale) | |||
| 1116 | + | require.False(t, adopted, "a superseded generation must not be adopted") | |||
| 1117 | + | require.NoError(t, cause, "a superseded generation is not a cancellation") | |||
| 1118 | + | // Run retires every attempt it does not adopt. | |||
| 1119 | + | stale.retire() | |||
| 1120 | + | select { | |||
| 1121 | + | case <-service.closedCh(): | |||
| 1122 | + | default: | |||
| 1123 | + | t.Fatal("a superseded service must be closed") | |||
| 1124 | + | } | |||
| 1125 | + | select { | |||
| 1126 | + | case <-subClosed: | |||
| 1127 | + | default: | |||
| 1128 | + | t.Fatal("a superseded subscription must be closed") | |||
| 1129 | + | } | |||
| 1130 | + | ||||
| 1131 | + | // The current generation is adopted, so its live service is left untouched. | |||
| 1132 | + | live := newSupervisorTestService(ports.BrokerConnectionID{2}) | |||
| 1133 | + | adopted, cause = sup.adoptAttempt(context.Background(), supervisorAttempt{generation: current, service: live}) | |||
| 1134 | + | require.True(t, adopted, "the current generation must be adopted") | |||
| 1135 | + | require.NoError(t, cause) | |||
| 1136 | + | select { | |||
| 1137 | + | case <-live.closedCh(): | |||
| 1138 | + | t.Fatal("the current attempt must be adopted, not retired") | |||
| 1139 | + | default: | |||
| 1140 | + | } | |||
| 1141 | + | } | |||
| 1142 | + | ||||
| 1143 | + | // TestSupervisorNormalizesUntypedConnectFailure proves an untyped connect | |||
| 1144 | + | // failure becomes a typed unavailable failure that preserves its cause, and that | |||
| 1145 | + | // the same typed value reaches the notifier. | |||
| 1146 | + | func TestSupervisorNormalizesUntypedConnectFailure(t *testing.T) { | |||
| 1147 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 1148 | + | defer cancel() | |||
| 1149 | + | ||||
| 1150 | + | clock := newSupervisorTestClock() | |||
| 1151 | + | reader := newSupervisorTestReader() | |||
| 1152 | + | t.Cleanup(reader.unblock) | |||
| 1153 | + | terminal := newSupervisorTestTerminal(reader) | |||
| 1154 | + | ||||
| 1155 | + | cause := errors.New("dial: connection refused") | |||
| 1156 | + | connector := newSupervisorTestConnector(func(context.Context, int) (ports.BrokerService, error) { | |||
| 1157 | + | return nil, cause | |||
| 1158 | + | }) | |||
| 1159 | + | notified := make(chan error, 4) | |||
| 1160 | + | sup := mustSupervisor(t, SupervisorConfig{ | |||
| 1161 | + | Connector: connector, | |||
| 1162 | + | Terminal: terminal, | |||
| 1163 | + | Clock: clock, | |||
| 1164 | + | Jitter: func() float64 { return 0 }, | |||
| 1165 | + | Notify: func(_ State, err error) { notified <- err }, | |||
| 1166 | + | }) | |||
| 1167 | + | ||||
| 1168 | + | runErr := make(chan error, 1) | |||
| 1169 | + | go func() { runErr <- sup.Run(ctx) }() | |||
| 1170 | + | clock.awaitTimer(t) | |||
| 1171 | + | ||||
| 1172 | + | var typed ports.BrokerError | |||
| 1173 | + | require.ErrorAs(t, sup.State().Err, &typed) | |||
| 1174 | + | require.Equal(t, ports.BrokerErrorUnavailable, typed.Code) | |||
| 1175 | + | require.ErrorIs(t, typed.Cause, cause, "the untyped cause is preserved") | |||
| 1176 | + | ||||
| 1177 | + | select { | |||
| 1178 | + | case err := <-notified: | |||
| 1179 | + | var surfaced ports.BrokerError | |||
| 1180 | + | require.ErrorAs(t, err, &surfaced) | |||
| 1181 | + | require.Equal(t, ports.BrokerErrorUnavailable, surfaced.Code) | |||
| 1182 | + | require.ErrorIs(t, surfaced.Cause, cause) | |||
| 1183 | + | case <-time.After(5 * time.Second): | |||
| 1184 | + | t.Fatal("the normalized failure was not surfaced") | |||
| 1185 | + | } | |||
| 1186 | + | ||||
| 1187 | + | cancel() | |||
| 1188 | + | require.ErrorIs(t, <-runErr, context.Canceled) | |||
| 1189 | + | require.Equal(t, 1, terminal.restoreCount()) | |||
| 1190 | + | } | |||
| 1191 | + | ||||
| 1192 | + | // TestSupervisorNormalizesUntypedLoss proves an untyped established-connection | |||
| 1193 | + | // loss is normalized to a typed unavailable failure that preserves its cause. | |||
| 1194 | + | func TestSupervisorNormalizesUntypedLoss(t *testing.T) { | |||
| 1195 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 1196 | + | defer cancel() | |||
| 1197 | + | ||||
| 1198 | + | clock := newSupervisorTestClock() | |||
| 1199 | + | reader := newSupervisorTestReader() | |||
| 1200 | + | t.Cleanup(reader.unblock) | |||
| 1201 | + | terminal := newSupervisorTestTerminal(reader) | |||
| 1202 | + | ||||
| 1203 | + | service := newSupervisorTestService(ports.BrokerConnectionID{1}) | |||
| 1204 | + | service.publish(1, 1) | |||
| 1205 | + | connector := newSupervisorTestConnector(func(context.Context, int) (ports.BrokerService, error) { | |||
| 1206 | + | return service, nil | |||
| 1207 | + | }) | |||
| 1208 | + | sup := mustSupervisor(t, SupervisorConfig{ | |||
| 1209 | + | Connector: connector, | |||
| 1210 | + | Terminal: terminal, | |||
| 1211 | + | Clock: clock, | |||
| 1212 | + | Jitter: func() float64 { return 0 }, | |||
| 1213 | + | }) | |||
| 1214 | + | ||||
| 1215 | + | runErr := make(chan error, 1) | |||
| 1216 | + | go func() { runErr <- sup.Run(ctx) }() | |||
| 1217 | + | require.Equal(t, 1, connector.awaitStart(t)) | |||
| 1218 | + | require.Eventually(t, func() bool { return sup.State().Connectivity == ConnectivityReady }, 5*time.Second, time.Millisecond) | |||
| 1219 | + | ||||
| 1220 | + | cause := errors.New("carriage reset") | |||
| 1221 | + | service.lose(cause) | |||
| 1222 | + | require.Eventually(t, func() bool { return sup.State().Connectivity == ConnectivityRetryWait }, 5*time.Second, time.Millisecond) | |||
| 1223 | + | ||||
| 1224 | + | var typed ports.BrokerError | |||
| 1225 | + | require.ErrorAs(t, sup.State().Err, &typed) | |||
| 1226 | + | require.Equal(t, ports.BrokerErrorUnavailable, typed.Code) | |||
| 1227 | + | require.ErrorIs(t, typed.Cause, cause, "the untyped loss cause is preserved") | |||
| 1228 | + | ||||
| 1229 | + | cancel() | |||
| 1230 | + | require.ErrorIs(t, <-runErr, context.Canceled) | |||
| 1231 | + | } | |||
| 1232 | + | ||||
| 1233 | + | // TestSupervisorNilSubscriptionIsTypedUnavailable proves a service that reports | |||
| 1234 | + | // no subscription (a nil interface or a typed nil) is closed and treated as a | |||
| 1235 | + | // typed unavailable failure instead of panicking on the first Changed call. | |||
| 1236 | + | func TestSupervisorNilSubscriptionIsTypedUnavailable(t *testing.T) { | |||
| 1237 | + | cases := []struct { | |||
| 1238 | + | name string | |||
| 1239 | + | sub ports.BrokerSubscription | |||
| 1240 | + | }{ | |||
| 1241 | + | {"nil interface", nil}, | |||
| 1242 | + | {"typed nil", (*supervisorNilSub)(nil)}, | |||
| 1243 | + | } | |||
| 1244 | + | for _, tc := range cases { | |||
| 1245 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 1246 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 1247 | + | defer cancel() | |||
| 1248 | + | ||||
| 1249 | + | clock := newSupervisorTestClock() | |||
| 1250 | + | reader := newSupervisorTestReader() | |||
| 1251 | + | t.Cleanup(reader.unblock) | |||
| 1252 | + | terminal := newSupervisorTestTerminal(reader) | |||
| 1253 | + | ||||
| 1254 | + | base := newSupervisorTestService(ports.BrokerConnectionID{7}) | |||
| 1255 | + | svc := &supervisorNilSubService{supervisorTestService: base, sub: tc.sub} | |||
| 1256 | + | connector := newSupervisorTestConnector(func(context.Context, int) (ports.BrokerService, error) { | |||
| 1257 | + | return svc, nil | |||
| 1258 | + | }) | |||
| 1259 | + | sup := mustSupervisor(t, SupervisorConfig{ | |||
| 1260 | + | Connector: connector, | |||
| 1261 | + | Terminal: terminal, | |||
| 1262 | + | Clock: clock, | |||
| 1263 | + | Jitter: func() float64 { return 0 }, | |||
| 1264 | + | }) | |||
| 1265 | + | ||||
| 1266 | + | runErr := make(chan error, 1) | |||
| 1267 | + | go func() { runErr <- sup.Run(ctx) }() | |||
| 1268 | + | clock.awaitTimer(t) | |||
| 1269 | + | ||||
| 1270 | + | var typed ports.BrokerError | |||
| 1271 | + | require.ErrorAs(t, sup.State().Err, &typed) | |||
| 1272 | + | require.Equal(t, ports.BrokerErrorUnavailable, typed.Code) | |||
| 1273 | + | select { | |||
| 1274 | + | case <-base.closedCh(): | |||
| 1275 | + | default: | |||
| 1276 | + | t.Fatal("a service that returned no subscription must be closed") | |||
| 1277 | + | } | |||
| 1278 | + | ||||
| 1279 | + | cancel() | |||
| 1280 | + | require.ErrorIs(t, <-runErr, context.Canceled) | |||
| 1281 | + | require.Equal(t, 1, terminal.restoreCount()) | |||
| 1282 | + | }) | |||
| 1283 | + | } | |||
| 1284 | + | } | |||