refactor(app): delete dead broker render, local probe, kill, and spawn helpers
brokerClientRender, newLocalRouteProbe(WithConnector), connectivityOwnerFor,
joinLifecycleReleaseError, and the whole sendKillRequest/receiveKillResult/
newControlRequestID/ensureDaemon/retryDial/spawnDaemonWithEnvironment
cluster have zero production callers after the broker cutover.
Tests that verified real behavior through them are rewritten onto the
production path instead of deleted:
- render tests now build *brokerClientPresentation directly, exactly as
the terminal composition does.
- newLocalRouteProbe/WithConnector are retained as test-only convenience
wrappers (local_route_probe_test_helpers_test.go) around
newDynamicLocalRouteProbe, the constructor brokerLocalProbe uses in
production.
- connectivityOwnerFor's three call sites now range over connectivityMatrix
directly, the pattern every other pinning test in the file already uses.
spawn_test.go's ensureDaemon/retryDial-only test functions are deleted with
the functions they exercised; TestMain and the realSpawn/spawn-lock/retry-
attempts tests, which cover code that is still live, are untouched.- commit
- e120b49a593685b0ec85ba63b7a8d7e504df219d
- author
- brice · · 2026-09-23 01:55 UTC
- parent
- 3e67bb0
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
10 files · +67 -324
M internal/app/broker_client.go +0 -7
| @@ -531,13 +531,6 @@ func (c *productionBrokerConnector) Connect(ctx context.Context) (ports.BrokerSe | |||
| 531 | 531 | return c.connect(ctx) | |
| 532 | 532 | } | |
| 533 | 533 | ||
| 534 | - | // brokerClientRender adapts the shared presentation to the plain render callback | |
| 535 | - | // the composition tests exercise directly. | |
| 536 | - | func brokerClientRender(terminal ports.Terminal, picker *client.Picker, onState func(client.State)) func(client.State) { | |
| 537 | - | presentation := &brokerClientPresentation{terminal: terminal, picker: picker, onState: onState} | |
| 538 | - | return presentation.Render | |
| 539 | - | } | |
| 540 | - | ||
| 541 | 534 | // terminalBrokerSeams are the optional observation callbacks of one terminal | |
| 542 | 535 | // run. Production supplies none: runBrokerClient reports a failure on the | |
| 543 | 536 | // terminal itself through the same writer the picker paints with. The terminal | |
| @@ -531,13 +531,6 @@ func (c *productionBrokerConnector) Connect(ctx context.Context) (ports.BrokerSe | |||||
| 531 | return c.connect(ctx) | 531 | return c.connect(ctx) | ||
| 532 | } | 532 | } | ||
| 533 | 533 | ||||
| 534 | - | // brokerClientRender adapts the shared presentation to the plain render callback | |||
| 535 | - | // the composition tests exercise directly. | |||
| 536 | - | func brokerClientRender(terminal ports.Terminal, picker *client.Picker, onState func(client.State)) func(client.State) { | |||
| 537 | - | presentation := &brokerClientPresentation{terminal: terminal, picker: picker, onState: onState} | |||
| 538 | - | return presentation.Render | |||
| 539 | - | } | |||
| 540 | - | ||||
| 541 | // terminalBrokerSeams are the optional observation callbacks of one terminal | 534 | // terminalBrokerSeams are the optional observation callbacks of one terminal | ||
| 542 | // run. Production supplies none: runBrokerClient reports a failure on the | 535 | // run. Production supplies none: runBrokerClient reports a failure on the | ||
| 543 | // terminal itself through the same writer the picker paints with. The terminal | 536 | // terminal itself through the same writer the picker paints with. The terminal | ||
M internal/app/broker_offline_client_render_test.go +13 -4
| @@ -17,6 +17,15 @@ import ( | |||
| 17 | 17 | "github.com/bnema/vev/internal/usecase/client" | |
| 18 | 18 | ) | |
| 19 | 19 | ||
| 20 | + | // testBrokerClientRender builds the same *brokerClientPresentation the | |
| 21 | + | // production terminal composition renders through (see runBrokerClient's | |
| 22 | + | // presentation construction), so tests exercise the real Render method | |
| 23 | + | // instead of a test-only indirection. | |
| 24 | + | func testBrokerClientRender(terminal ports.Terminal, picker *client.Picker, onState func(client.State)) func(client.State) { | |
| 25 | + | presentation := &brokerClientPresentation{terminal: terminal, picker: picker, onState: onState} | |
| 26 | + | return presentation.Render | |
| 27 | + | } | |
| 28 | + | ||
| 20 | 29 | // The resize presentation fence at the composition seam. The supervisor asks | |
| 21 | 30 | // its renderer to repaint whenever a resize invalidation reaches a serialized | |
| 22 | 31 | // picker wait, and the production offline composition paints only while the | |
| @@ -152,7 +161,7 @@ func TestOfflineClientRenderRefusesNonPickerPresentations(t *testing.T) { | |||
| 152 | 161 | picker := offlineRenderPicker(t) | |
| 153 | 162 | ||
| 154 | 163 | observed := make(chan client.State, 8) | |
| 155 | - | render := brokerClientRender(terminal, picker, func(state client.State) { | |
| 164 | + | render := testBrokerClientRender(terminal, picker, func(state client.State) { | |
| 156 | 165 | select { | |
| 157 | 166 | case observed <- state: | |
| 158 | 167 | default: | |
| @@ -192,7 +201,7 @@ func TestOfflineClientRenderFenceIsPickerPresentation(t *testing.T) { | |||
| 192 | 201 | for _, state := range states { | |
| 193 | 202 | t.Run(state.Presentation.String(), func(t *testing.T) { | |
| 194 | 203 | terminal := newOfflineRenderTerminal(small) | |
| 195 | - | render := brokerClientRender(terminal, offlineRenderPicker(t), nil) | |
| 204 | + | render := testBrokerClientRender(terminal, offlineRenderPicker(t), nil) | |
| 196 | 205 | ||
| 197 | 206 | render(state) | |
| 198 | 207 | ||
| @@ -218,7 +227,7 @@ func TestOfflineClientRenderPaintsPickerResize(t *testing.T) { | |||
| 218 | 227 | ||
| 219 | 228 | terminal := newOfflineRenderTerminal(small) | |
| 220 | 229 | picker := offlineRenderPicker(t) | |
| 221 | - | render := brokerClientRender(terminal, picker, nil) | |
| 230 | + | render := testBrokerClientRender(terminal, picker, nil) | |
| 222 | 231 | ||
| 223 | 232 | render(client.State{Presentation: client.PresentPicker, Connectivity: client.ConnectivityReady, Generation: 1}) | |
| 224 | 233 | initial, flushes := terminal.written() | |
| @@ -284,7 +293,7 @@ func (t *transactionRecordingTerminal) published() []ports.UIContext { | |||
| 284 | 293 | // succeeds only after the frame was written and flushed. | |
| 285 | 294 | func TestOfflineClientPickerPaintCommitsPickerTransaction(t *testing.T) { | |
| 286 | 295 | terminal := newTransactionRecordingTerminal(domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}}) | |
| 287 | - | render := brokerClientRender(terminal, offlineRenderPicker(t), nil) | |
| 296 | + | render := testBrokerClientRender(terminal, offlineRenderPicker(t), nil) | |
| 288 | 297 | ||
| 289 | 298 | render(client.State{Presentation: client.PresentPicker, Connectivity: client.ConnectivityReady, Generation: 1}) | |
| 290 | 299 | written, _ := terminal.written() | |
| @@ -17,6 +17,15 @@ import ( | |||||
| 17 | "github.com/bnema/vev/internal/usecase/client" | 17 | "github.com/bnema/vev/internal/usecase/client" | ||
| 18 | ) | 18 | ) | ||
| 19 | 19 | ||||
| 20 | + | // testBrokerClientRender builds the same *brokerClientPresentation the | |||
| 21 | + | // production terminal composition renders through (see runBrokerClient's | |||
| 22 | + | // presentation construction), so tests exercise the real Render method | |||
| 23 | + | // instead of a test-only indirection. | |||
| 24 | + | func testBrokerClientRender(terminal ports.Terminal, picker *client.Picker, onState func(client.State)) func(client.State) { | |||
| 25 | + | presentation := &brokerClientPresentation{terminal: terminal, picker: picker, onState: onState} | |||
| 26 | + | return presentation.Render | |||
| 27 | + | } | |||
| 28 | + | ||||
| 20 | // The resize presentation fence at the composition seam. The supervisor asks | 29 | // The resize presentation fence at the composition seam. The supervisor asks | ||
| 21 | // its renderer to repaint whenever a resize invalidation reaches a serialized | 30 | // its renderer to repaint whenever a resize invalidation reaches a serialized | ||
| 22 | // picker wait, and the production offline composition paints only while the | 31 | // picker wait, and the production offline composition paints only while the | ||
| @@ -152,7 +161,7 @@ func TestOfflineClientRenderRefusesNonPickerPresentations(t *testing.T) { | |||||
| 152 | picker := offlineRenderPicker(t) | 161 | picker := offlineRenderPicker(t) | ||
| 153 | 162 | ||||
| 154 | observed := make(chan client.State, 8) | 163 | observed := make(chan client.State, 8) | ||
| 155 | - | render := brokerClientRender(terminal, picker, func(state client.State) { | 164 | + | render := testBrokerClientRender(terminal, picker, func(state client.State) { |
| 156 | select { | 165 | select { | ||
| 157 | case observed <- state: | 166 | case observed <- state: | ||
| 158 | default: | 167 | default: | ||
| @@ -192,7 +201,7 @@ func TestOfflineClientRenderFenceIsPickerPresentation(t *testing.T) { | |||||
| 192 | for _, state := range states { | 201 | for _, state := range states { | ||
| 193 | t.Run(state.Presentation.String(), func(t *testing.T) { | 202 | t.Run(state.Presentation.String(), func(t *testing.T) { | ||
| 194 | terminal := newOfflineRenderTerminal(small) | 203 | terminal := newOfflineRenderTerminal(small) | ||
| 195 | - | render := brokerClientRender(terminal, offlineRenderPicker(t), nil) | 204 | + | render := testBrokerClientRender(terminal, offlineRenderPicker(t), nil) |
| 196 | 205 | ||||
| 197 | render(state) | 206 | render(state) | ||
| 198 | 207 | ||||
| @@ -218,7 +227,7 @@ func TestOfflineClientRenderPaintsPickerResize(t *testing.T) { | |||||
| 218 | 227 | ||||
| 219 | terminal := newOfflineRenderTerminal(small) | 228 | terminal := newOfflineRenderTerminal(small) | ||
| 220 | picker := offlineRenderPicker(t) | 229 | picker := offlineRenderPicker(t) | ||
| 221 | - | render := brokerClientRender(terminal, picker, nil) | 230 | + | render := testBrokerClientRender(terminal, picker, nil) |
| 222 | 231 | ||||
| 223 | render(client.State{Presentation: client.PresentPicker, Connectivity: client.ConnectivityReady, Generation: 1}) | 232 | render(client.State{Presentation: client.PresentPicker, Connectivity: client.ConnectivityReady, Generation: 1}) | ||
| 224 | initial, flushes := terminal.written() | 233 | initial, flushes := terminal.written() | ||
| @@ -284,7 +293,7 @@ func (t *transactionRecordingTerminal) published() []ports.UIContext { | |||||
| 284 | // succeeds only after the frame was written and flushed. | 293 | // succeeds only after the frame was written and flushed. | ||
| 285 | func TestOfflineClientPickerPaintCommitsPickerTransaction(t *testing.T) { | 294 | func TestOfflineClientPickerPaintCommitsPickerTransaction(t *testing.T) { | ||
| 286 | terminal := newTransactionRecordingTerminal(domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}}) | 295 | terminal := newTransactionRecordingTerminal(domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}}) | ||
| 287 | - | render := brokerClientRender(terminal, offlineRenderPicker(t), nil) | 296 | + | render := testBrokerClientRender(terminal, offlineRenderPicker(t), nil) |
| 288 | 297 | ||||
| 289 | render(client.State{Presentation: client.PresentPicker, Connectivity: client.ConnectivityReady, Generation: 1}) | 298 | render(client.State{Presentation: client.PresentPicker, Connectivity: client.ConnectivityReady, Generation: 1}) | ||
| 290 | written, _ := terminal.written() | 299 | written, _ := terminal.written() | ||
M internal/app/broker_offline_client_test.go +1 -1
| @@ -299,7 +299,7 @@ func TestParseBrokerClientArgsAndProductionDispatchIsolation(t *testing.T) { | |||
| 299 | 299 | ||
| 300 | 300 | func mustConnectivityOwner(t *testing.T, kind string) connectivityOwner { | |
| 301 | 301 | t.Helper() | |
| 302 | - | owner, ok := connectivityOwnerFor(kind) | |
| 302 | + | owner, ok := testConnectivityOwnerFor(kind) | |
| 303 | 303 | require.True(t, ok) | |
| 304 | 304 | return owner | |
| 305 | 305 | } | |
| @@ -299,7 +299,7 @@ func TestParseBrokerClientArgsAndProductionDispatchIsolation(t *testing.T) { | |||||
| 299 | 299 | ||||
| 300 | func mustConnectivityOwner(t *testing.T, kind string) connectivityOwner { | 300 | func mustConnectivityOwner(t *testing.T, kind string) connectivityOwner { | ||
| 301 | t.Helper() | 301 | t.Helper() | ||
| 302 | - | owner, ok := connectivityOwnerFor(kind) | 302 | + | owner, ok := testConnectivityOwnerFor(kind) |
| 303 | require.True(t, ok) | 303 | require.True(t, ok) | ||
| 304 | return owner | 304 | return owner | ||
| 305 | } | 305 | } | ||
M internal/app/broker_offline_local.go +0 -30
| @@ -64,36 +64,6 @@ type localRouteProbe struct { | |||
| 64 | 64 | ||
| 65 | 65 | var _ broker.LocalProbe = (*localRouteProbe)(nil) | |
| 66 | 66 | ||
| 67 | - | // newLocalRouteProbe fences one broker-owned local binding into a probe over a | |
| 68 | - | // dial seam. The binding's identity, policy, and route address must validate, | |
| 69 | - | // the ceilings must be a valid advertisement, the epoch must be the broker | |
| 70 | - | // process incarnation every local stream is scoped to, and a nil dial is | |
| 71 | - | // refused: a probe that cannot establish a carriage must not exist. It builds | |
| 72 | - | // its own endpoint connector; a composition that already owns the pooled | |
| 73 | - | // connector passes it to newLocalRouteProbeWithConnector instead, so one broker | |
| 74 | - | // process never holds two connector owners for the same route. | |
| 75 | - | func newLocalRouteProbe(binding brokerconfig.LocalBinding, epoch ports.BrokerEpoch, dial localCarrierDialer, ceilings daemonmux.MuxCeilings) (*localRouteProbe, error) { | |
| 76 | - | if dial == nil { | |
| 77 | - | return nil, errors.New("vev: broker local probe requires a dialer") | |
| 78 | - | } | |
| 79 | - | connector, err := daemonmux.NewEndpointConnector(daemonmux.RawCarrierDialer(dial), ceilings) | |
| 80 | - | if err != nil { | |
| 81 | - | return nil, fmt.Errorf("vev: broker local probe: %w", err) | |
| 82 | - | } | |
| 83 | - | return newLocalRouteProbeWithConnector(binding, epoch, connector) | |
| 84 | - | } | |
| 85 | - | ||
| 86 | - | // newLocalRouteProbeWithConnector fences one broker-owned local binding into a | |
| 87 | - | // probe over an existing connector. The connector is the same setup half a | |
| 88 | - | // pooled physical connection uses, so the probe composes exactly the transport | |
| 89 | - | // owner the broker already has and never becomes a second one. The binding's | |
| 90 | - | // identity, policy, and route must validate and the epoch must be nonzero. | |
| 91 | - | func newLocalRouteProbeWithConnector(binding brokerconfig.LocalBinding, epoch ports.BrokerEpoch, connector ports.BrokerEndpointConnector) (*localRouteProbe, error) { | |
| 92 | - | return newDynamicLocalRouteProbe(binding.Route, binding.Policy, epoch, connector, func() (ports.BrokerDaemonIdentity, error) { | |
| 93 | - | return binding.Identity, nil | |
| 94 | - | }) | |
| 95 | - | } | |
| 96 | - | ||
| 97 | 67 | func newDynamicLocalRouteProbe(route brokerconfig.Route, policy ports.BrokerPolicy, epoch ports.BrokerEpoch, connector ports.BrokerEndpointConnector, loadIdentity localIdentityLoader) (*localRouteProbe, error) { | |
| 98 | 68 | if epoch == 0 { | |
| 99 | 69 | return nil, errors.New("vev: broker local probe requires a broker epoch") | |
| @@ -64,36 +64,6 @@ type localRouteProbe struct { | |||||
| 64 | 64 | ||||
| 65 | var _ broker.LocalProbe = (*localRouteProbe)(nil) | 65 | var _ broker.LocalProbe = (*localRouteProbe)(nil) | ||
| 66 | 66 | ||||
| 67 | - | // newLocalRouteProbe fences one broker-owned local binding into a probe over a | |||
| 68 | - | // dial seam. The binding's identity, policy, and route address must validate, | |||
| 69 | - | // the ceilings must be a valid advertisement, the epoch must be the broker | |||
| 70 | - | // process incarnation every local stream is scoped to, and a nil dial is | |||
| 71 | - | // refused: a probe that cannot establish a carriage must not exist. It builds | |||
| 72 | - | // its own endpoint connector; a composition that already owns the pooled | |||
| 73 | - | // connector passes it to newLocalRouteProbeWithConnector instead, so one broker | |||
| 74 | - | // process never holds two connector owners for the same route. | |||
| 75 | - | func newLocalRouteProbe(binding brokerconfig.LocalBinding, epoch ports.BrokerEpoch, dial localCarrierDialer, ceilings daemonmux.MuxCeilings) (*localRouteProbe, error) { | |||
| 76 | - | if dial == nil { | |||
| 77 | - | return nil, errors.New("vev: broker local probe requires a dialer") | |||
| 78 | - | } | |||
| 79 | - | connector, err := daemonmux.NewEndpointConnector(daemonmux.RawCarrierDialer(dial), ceilings) | |||
| 80 | - | if err != nil { | |||
| 81 | - | return nil, fmt.Errorf("vev: broker local probe: %w", err) | |||
| 82 | - | } | |||
| 83 | - | return newLocalRouteProbeWithConnector(binding, epoch, connector) | |||
| 84 | - | } | |||
| 85 | - | ||||
| 86 | - | // newLocalRouteProbeWithConnector fences one broker-owned local binding into a | |||
| 87 | - | // probe over an existing connector. The connector is the same setup half a | |||
| 88 | - | // pooled physical connection uses, so the probe composes exactly the transport | |||
| 89 | - | // owner the broker already has and never becomes a second one. The binding's | |||
| 90 | - | // identity, policy, and route must validate and the epoch must be nonzero. | |||
| 91 | - | func newLocalRouteProbeWithConnector(binding brokerconfig.LocalBinding, epoch ports.BrokerEpoch, connector ports.BrokerEndpointConnector) (*localRouteProbe, error) { | |||
| 92 | - | return newDynamicLocalRouteProbe(binding.Route, binding.Policy, epoch, connector, func() (ports.BrokerDaemonIdentity, error) { | |||
| 93 | - | return binding.Identity, nil | |||
| 94 | - | }) | |||
| 95 | - | } | |||
| 96 | - | ||||
| 97 | func newDynamicLocalRouteProbe(route brokerconfig.Route, policy ports.BrokerPolicy, epoch ports.BrokerEpoch, connector ports.BrokerEndpointConnector, loadIdentity localIdentityLoader) (*localRouteProbe, error) { | 67 | func newDynamicLocalRouteProbe(route brokerconfig.Route, policy ports.BrokerPolicy, epoch ports.BrokerEpoch, connector ports.BrokerEndpointConnector, loadIdentity localIdentityLoader) (*localRouteProbe, error) { | ||
| 98 | if epoch == 0 { | 68 | if epoch == 0 { | ||
| 99 | return nil, errors.New("vev: broker local probe requires a broker epoch") | 69 | return nil, errors.New("vev: broker local probe requires a broker epoch") | ||
M internal/app/connectivity_matrix.go +0 -10
| @@ -265,13 +265,3 @@ var connectivityMatrix = []connectivityEntry{ | |||
| 265 | 265 | Notes: "Mints one ephemeral authenticated QUIC server, admits exactly one carriage, and bridges it to the single provisioned private Unix daemonmux carriage. It never dials the production daemon socket, never fabricates a daemon incarnation, and starts the daemon behind that carriage only under the propagated --daemon-start if-needed authorization and its own provisioned launch policy.", | |
| 266 | 266 | }, | |
| 267 | 267 | } | |
| 268 | - | ||
| 269 | - | // connectivityOwnerFor returns the matrix owner for a cmdKind identifier. | |
| 270 | - | func connectivityOwnerFor(kind string) (connectivityOwner, bool) { | |
| 271 | - | for _, entry := range connectivityMatrix { | |
| 272 | - | if entry.Kind == kind { | |
| 273 | - | return entry.Owner, true | |
| 274 | - | } | |
| 275 | - | } | |
| 276 | - | return 0, false | |
| 277 | - | } | |
| @@ -265,13 +265,3 @@ var connectivityMatrix = []connectivityEntry{ | |||||
| 265 | Notes: "Mints one ephemeral authenticated QUIC server, admits exactly one carriage, and bridges it to the single provisioned private Unix daemonmux carriage. It never dials the production daemon socket, never fabricates a daemon incarnation, and starts the daemon behind that carriage only under the propagated --daemon-start if-needed authorization and its own provisioned launch policy.", | 265 | Notes: "Mints one ephemeral authenticated QUIC server, admits exactly one carriage, and bridges it to the single provisioned private Unix daemonmux carriage. It never dials the production daemon socket, never fabricates a daemon incarnation, and starts the daemon behind that carriage only under the propagated --daemon-start if-needed authorization and its own provisioned launch policy.", | ||
| 266 | }, | 266 | }, | ||
| 267 | } | 267 | } | ||
| 268 | - | ||||
| 269 | - | // connectivityOwnerFor returns the matrix owner for a cmdKind identifier. | |||
| 270 | - | func connectivityOwnerFor(kind string) (connectivityOwner, bool) { | |||
| 271 | - | for _, entry := range connectivityMatrix { | |||
| 272 | - | if entry.Kind == kind { | |||
| 273 | - | return entry.Owner, true | |||
| 274 | - | } | |||
| 275 | - | } | |||
| 276 | - | return 0, false | |||
| 277 | - | } | |||
M internal/app/connectivity_matrix_test.go +15 -3
| @@ -14,6 +14,18 @@ import ( | |||
| 14 | 14 | "github.com/stretchr/testify/require" | |
| 15 | 15 | ) | |
| 16 | 16 | ||
| 17 | + | // testConnectivityOwnerFor looks up the matrix owner for a cmdKind identifier | |
| 18 | + | // directly against connectivityMatrix, the same declarative fixture every | |
| 19 | + | // other pinning test in this file ranges over. | |
| 20 | + | func testConnectivityOwnerFor(kind string) (connectivityOwner, bool) { | |
| 21 | + | for _, entry := range connectivityMatrix { | |
| 22 | + | if entry.Kind == kind { | |
| 23 | + | return entry.Owner, true | |
| 24 | + | } | |
| 25 | + | } | |
| 26 | + | return 0, false | |
| 27 | + | } | |
| 28 | + | ||
| 17 | 29 | // TestConnectivityMatrixCoversEveryCommandKind pins P1.2: every cmdKind | |
| 18 | 30 | // constant declared in run.go has exactly one matrix row. A new CLI entry | |
| 19 | 31 | // point without a row fails here before any transport work can ignore it. | |
| @@ -53,10 +65,10 @@ func TestConnectivityMatrixCoversEveryCommandKind(t *testing.T) { | |||
| 53 | 65 | require.Empty(t, missing, "matrix is missing cmdKind rows") | |
| 54 | 66 | require.Empty(t, extra, "matrix references unknown cmdKind rows") | |
| 55 | 67 | ||
| 56 | - | owner, ok := connectivityOwnerFor("kindAttach") | |
| 68 | + | owner, ok := testConnectivityOwnerFor("kindAttach") | |
| 57 | 69 | require.True(t, ok, "matrix lookup must resolve known kinds") | |
| 58 | 70 | require.Equal(t, connectivityBrokerOnly, owner) | |
| 59 | - | _, ok = connectivityOwnerFor("kindDoesNotExist") | |
| 71 | + | _, ok = testConnectivityOwnerFor("kindDoesNotExist") | |
| 60 | 72 | require.False(t, ok, "matrix lookup must reject unknown kinds") | |
| 61 | 73 | } | |
| 62 | 74 | ||
| @@ -220,7 +232,7 @@ func TestConnectivityMatrixSingleHostStoreWriterSet(t *testing.T) { | |||
| 220 | 232 | func TestConnectivityMatrixReadyProbeIsLocalDialOnly(t *testing.T) { | |
| 221 | 233 | t.Parallel() | |
| 222 | 234 | ||
| 223 | - | owner, ok := connectivityOwnerFor("kindBrokerReady") | |
| 235 | + | owner, ok := testConnectivityOwnerFor("kindBrokerReady") | |
| 224 | 236 | require.True(t, ok, "the readiness probe must have a matrix row") | |
| 225 | 237 | require.Equal(t, connectivityLocalOnly, owner, "the readiness probe is dial-only local infrastructure") | |
| 226 | 238 | for _, entry := range connectivityMatrix { | |
| @@ -14,6 +14,18 @@ import ( | |||||
| 14 | "github.com/stretchr/testify/require" | 14 | "github.com/stretchr/testify/require" | ||
| 15 | ) | 15 | ) | ||
| 16 | 16 | ||||
| 17 | + | // testConnectivityOwnerFor looks up the matrix owner for a cmdKind identifier | |||
| 18 | + | // directly against connectivityMatrix, the same declarative fixture every | |||
| 19 | + | // other pinning test in this file ranges over. | |||
| 20 | + | func testConnectivityOwnerFor(kind string) (connectivityOwner, bool) { | |||
| 21 | + | for _, entry := range connectivityMatrix { | |||
| 22 | + | if entry.Kind == kind { | |||
| 23 | + | return entry.Owner, true | |||
| 24 | + | } | |||
| 25 | + | } | |||
| 26 | + | return 0, false | |||
| 27 | + | } | |||
| 28 | + | ||||
| 17 | // TestConnectivityMatrixCoversEveryCommandKind pins P1.2: every cmdKind | 29 | // TestConnectivityMatrixCoversEveryCommandKind pins P1.2: every cmdKind | ||
| 18 | // constant declared in run.go has exactly one matrix row. A new CLI entry | 30 | // constant declared in run.go has exactly one matrix row. A new CLI entry | ||
| 19 | // point without a row fails here before any transport work can ignore it. | 31 | // point without a row fails here before any transport work can ignore it. | ||
| @@ -53,10 +65,10 @@ func TestConnectivityMatrixCoversEveryCommandKind(t *testing.T) { | |||||
| 53 | require.Empty(t, missing, "matrix is missing cmdKind rows") | 65 | require.Empty(t, missing, "matrix is missing cmdKind rows") | ||
| 54 | require.Empty(t, extra, "matrix references unknown cmdKind rows") | 66 | require.Empty(t, extra, "matrix references unknown cmdKind rows") | ||
| 55 | 67 | ||||
| 56 | - | owner, ok := connectivityOwnerFor("kindAttach") | 68 | + | owner, ok := testConnectivityOwnerFor("kindAttach") |
| 57 | require.True(t, ok, "matrix lookup must resolve known kinds") | 69 | require.True(t, ok, "matrix lookup must resolve known kinds") | ||
| 58 | require.Equal(t, connectivityBrokerOnly, owner) | 70 | require.Equal(t, connectivityBrokerOnly, owner) | ||
| 59 | - | _, ok = connectivityOwnerFor("kindDoesNotExist") | 71 | + | _, ok = testConnectivityOwnerFor("kindDoesNotExist") |
| 60 | require.False(t, ok, "matrix lookup must reject unknown kinds") | 72 | require.False(t, ok, "matrix lookup must reject unknown kinds") | ||
| 61 | } | 73 | } | ||
| 62 | 74 | ||||
| @@ -220,7 +232,7 @@ func TestConnectivityMatrixSingleHostStoreWriterSet(t *testing.T) { | |||||
| 220 | func TestConnectivityMatrixReadyProbeIsLocalDialOnly(t *testing.T) { | 232 | func TestConnectivityMatrixReadyProbeIsLocalDialOnly(t *testing.T) { | ||
| 221 | t.Parallel() | 233 | t.Parallel() | ||
| 222 | 234 | ||||
| 223 | - | owner, ok := connectivityOwnerFor("kindBrokerReady") | 235 | + | owner, ok := testConnectivityOwnerFor("kindBrokerReady") |
| 224 | require.True(t, ok, "the readiness probe must have a matrix row") | 236 | require.True(t, ok, "the readiness probe must have a matrix row") | ||
| 225 | require.Equal(t, connectivityLocalOnly, owner, "the readiness probe is dial-only local infrastructure") | 237 | require.Equal(t, connectivityLocalOnly, owner, "the readiness probe is dial-only local infrastructure") | ||
| 226 | for _, entry := range connectivityMatrix { | 238 | for _, entry := range connectivityMatrix { | ||
A internal/app/local_route_probe_test_helpers_test.go +38 -0
| @@ -0,0 +1,38 @@ | |||
| 1 | + | package app | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "errors" | |
| 5 | + | "fmt" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/vev/internal/adapters/brokerconfig" | |
| 8 | + | "github.com/bnema/vev/internal/adapters/daemonmux" | |
| 9 | + | "github.com/bnema/vev/internal/ports" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | // newLocalRouteProbe fences one broker-owned local binding into a probe over a | |
| 13 | + | // dial seam. The binding's identity, policy, and route address must validate, | |
| 14 | + | // the ceilings must be a valid advertisement, the epoch must be the broker | |
| 15 | + | // process incarnation every local stream is scoped to, and a nil dial is | |
| 16 | + | // refused: a probe that cannot establish a carriage must not exist. It builds | |
| 17 | + | // its own endpoint connector; a caller that already owns the pooled connector | |
| 18 | + | // uses newLocalRouteProbeWithConnector instead, exactly as brokerLocalProbe | |
| 19 | + | // does in production. | |
| 20 | + | func newLocalRouteProbe(binding brokerconfig.LocalBinding, epoch ports.BrokerEpoch, dial localCarrierDialer, ceilings daemonmux.MuxCeilings) (*localRouteProbe, error) { | |
| 21 | + | if dial == nil { | |
| 22 | + | return nil, errors.New("vev: broker local probe requires a dialer") | |
| 23 | + | } | |
| 24 | + | connector, err := daemonmux.NewEndpointConnector(daemonmux.RawCarrierDialer(dial), ceilings) | |
| 25 | + | if err != nil { | |
| 26 | + | return nil, fmt.Errorf("vev: broker local probe: %w", err) | |
| 27 | + | } | |
| 28 | + | return newLocalRouteProbeWithConnector(binding, epoch, connector) | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | // newLocalRouteProbeWithConnector fences one broker-owned local binding into a | |
| 32 | + | // probe over an existing connector, delegating to the same | |
| 33 | + | // newDynamicLocalRouteProbe production constructor brokerLocalProbe uses. | |
| 34 | + | func newLocalRouteProbeWithConnector(binding brokerconfig.LocalBinding, epoch ports.BrokerEpoch, connector ports.BrokerEndpointConnector) (*localRouteProbe, error) { | |
| 35 | + | return newDynamicLocalRouteProbe(binding.Route, binding.Policy, epoch, connector, func() (ports.BrokerDaemonIdentity, error) { | |
| 36 | + | return binding.Identity, nil | |
| 37 | + | }) | |
| 38 | + | } | |
| @@ -0,0 +1,38 @@ | |||||
| 1 | + | package app | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "errors" | |||
| 5 | + | "fmt" | |||
| 6 | + | ||||
| 7 | + | "github.com/bnema/vev/internal/adapters/brokerconfig" | |||
| 8 | + | "github.com/bnema/vev/internal/adapters/daemonmux" | |||
| 9 | + | "github.com/bnema/vev/internal/ports" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | // newLocalRouteProbe fences one broker-owned local binding into a probe over a | |||
| 13 | + | // dial seam. The binding's identity, policy, and route address must validate, | |||
| 14 | + | // the ceilings must be a valid advertisement, the epoch must be the broker | |||
| 15 | + | // process incarnation every local stream is scoped to, and a nil dial is | |||
| 16 | + | // refused: a probe that cannot establish a carriage must not exist. It builds | |||
| 17 | + | // its own endpoint connector; a caller that already owns the pooled connector | |||
| 18 | + | // uses newLocalRouteProbeWithConnector instead, exactly as brokerLocalProbe | |||
| 19 | + | // does in production. | |||
| 20 | + | func newLocalRouteProbe(binding brokerconfig.LocalBinding, epoch ports.BrokerEpoch, dial localCarrierDialer, ceilings daemonmux.MuxCeilings) (*localRouteProbe, error) { | |||
| 21 | + | if dial == nil { | |||
| 22 | + | return nil, errors.New("vev: broker local probe requires a dialer") | |||
| 23 | + | } | |||
| 24 | + | connector, err := daemonmux.NewEndpointConnector(daemonmux.RawCarrierDialer(dial), ceilings) | |||
| 25 | + | if err != nil { | |||
| 26 | + | return nil, fmt.Errorf("vev: broker local probe: %w", err) | |||
| 27 | + | } | |||
| 28 | + | return newLocalRouteProbeWithConnector(binding, epoch, connector) | |||
| 29 | + | } | |||
| 30 | + | ||||
| 31 | + | // newLocalRouteProbeWithConnector fences one broker-owned local binding into a | |||
| 32 | + | // probe over an existing connector, delegating to the same | |||
| 33 | + | // newDynamicLocalRouteProbe production constructor brokerLocalProbe uses. | |||
| 34 | + | func newLocalRouteProbeWithConnector(binding brokerconfig.LocalBinding, epoch ports.BrokerEpoch, connector ports.BrokerEndpointConnector) (*localRouteProbe, error) { | |||
| 35 | + | return newDynamicLocalRouteProbe(binding.Route, binding.Policy, epoch, connector, func() (ports.BrokerDaemonIdentity, error) { | |||
| 36 | + | return binding.Identity, nil | |||
| 37 | + | }) | |||
| 38 | + | } | |||
M internal/app/run.go +0 -75
| @@ -648,10 +648,6 @@ type lifecycleOwnership interface { | |||
| 648 | 648 | Release() error | |
| 649 | 649 | } | |
| 650 | 650 | ||
| 651 | - | func joinLifecycleReleaseError(retErr *error, owner lifecycleOwnership) { | |
| 652 | - | *retErr = errors.Join(*retErr, owner.Release()) | |
| 653 | - | } | |
| 654 | - | ||
| 655 | 651 | type lifecycleStartupDeps struct { | |
| 656 | 652 | ensurePrivate func(string) error | |
| 657 | 653 | acquire func(context.Context, string, time.Duration) (lifecycleOwnership, error) | |
| @@ -982,79 +978,8 @@ const daemonStopTimeout = 2 * time.Second | |||
| 982 | 978 | var ( | |
| 983 | 979 | errDaemonNotRunning = errors.New("vev: no daemon running") | |
| 984 | 980 | errKillOutcomeUnknown = errors.New("vev: kill outcome unknown") | |
| 985 | - | // errKillNotSent reports a Kill that was never placed on the wire because | |
| 986 | - | // its caller was already canceled. The outcome is definite: the daemon | |
| 987 | - | // cannot have acted, so it must not be reported as an unknown outcome. | |
| 988 | - | errKillNotSent = errors.New("vev: kill request not sent") | |
| 989 | 981 | ) | |
| 990 | 982 | ||
| 991 | - | func newControlRequestID() (uint64, error) { | |
| 992 | - | var raw [8]byte | |
| 993 | - | if _, err := rand.Read(raw[:]); err != nil { | |
| 994 | - | return 0, err | |
| 995 | - | } | |
| 996 | - | id := uint64(0) | |
| 997 | - | for _, b := range raw { | |
| 998 | - | id = id<<8 | uint64(b) | |
| 999 | - | } | |
| 1000 | - | if id == 0 { | |
| 1001 | - | id = 1 | |
| 1002 | - | } | |
| 1003 | - | return id, nil | |
| 1004 | - | } | |
| 1005 | - | ||
| 1006 | - | // sendKillRequest allocates a unique nonzero RequestID and sends one Kill | |
| 1007 | - | // request, returning the ID its matching KillResult must carry. A caller | |
| 1008 | - | // already canceled before the send returns errKillNotSent (a definite | |
| 1009 | - | // not-sent outcome) instead of sending a request whose reply could no longer be | |
| 1010 | - | // awaited; the request is never replayed. | |
| 1011 | - | func sendKillRequest(ctx context.Context, connection ports.ClientConnection, request protocol.Kill) (uint64, error) { | |
| 1012 | - | requestID, err := newControlRequestID() | |
| 1013 | - | if err != nil { | |
| 1014 | - | return 0, fmt.Errorf("preparing request: %w", err) | |
| 1015 | - | } | |
| 1016 | - | request.RequestID = requestID | |
| 1017 | - | if err := ctx.Err(); err != nil { | |
| 1018 | - | return 0, fmt.Errorf("%w: %w", errKillNotSent, err) | |
| 1019 | - | } | |
| 1020 | - | if err := connection.SendClient(request); err != nil { | |
| 1021 | - | return 0, err | |
| 1022 | - | } | |
| 1023 | - | return requestID, nil | |
| 1024 | - | } | |
| 1025 | - | ||
| 1026 | - | // receiveKillResult consumes exactly one correlated KillResult from a control | |
| 1027 | - | // connection. A definite failed result becomes a plain error; a close, | |
| 1028 | - | // cancellation, or an uncorrelated reply becomes errKillOutcomeUnknown, so a | |
| 1029 | - | // caller never treats a lost result as success and never replays the kill. | |
| 1030 | - | func receiveKillResult(ctx context.Context, connection ports.ClientConnection, requestID uint64) error { | |
| 1031 | - | // Cancellation after the request was sent closes the exact connection so a | |
| 1032 | - | // blocked receive unwinds; the lost reply stays outcome-unknown rather than | |
| 1033 | - | // becoming a silent success or a blind retry. | |
| 1034 | - | stop := context.AfterFunc(ctx, func() { _ = connection.Close() }) | |
| 1035 | - | defer stop() | |
| 1036 | - | ||
| 1037 | - | reply, err := connection.ReceiveServer() | |
| 1038 | - | if err != nil { | |
| 1039 | - | if ctxErr := ctx.Err(); ctxErr != nil { | |
| 1040 | - | return fmt.Errorf("%w: awaiting explicit result: %v", errKillOutcomeUnknown, ctxErr) | |
| 1041 | - | } | |
| 1042 | - | return fmt.Errorf("%w: awaiting explicit result: %v", errKillOutcomeUnknown, err) | |
| 1043 | - | } | |
| 1044 | - | result, ok := reply.(protocol.KillResult) | |
| 1045 | - | if !ok || result.RequestID != requestID { | |
| 1046 | - | return fmt.Errorf("%w: unexpected reply %T", errKillOutcomeUnknown, reply) | |
| 1047 | - | } | |
| 1048 | - | switch result.Outcome { | |
| 1049 | - | case protocol.KillSucceeded: | |
| 1050 | - | return nil | |
| 1051 | - | case protocol.KillFailed: | |
| 1052 | - | return fmt.Errorf("vev: %s", result.Text) | |
| 1053 | - | default: | |
| 1054 | - | return fmt.Errorf("%w: %s", errKillOutcomeUnknown, result.Text) | |
| 1055 | - | } | |
| 1056 | - | } | |
| 1057 | - | ||
| 1058 | 983 | func createDetachedLocalSession(ctx context.Context, name string) error { | |
| 1059 | 984 | service, err := connectBroker(ctx) | |
| 1060 | 985 | if err != nil { | |
| @@ -648,10 +648,6 @@ type lifecycleOwnership interface { | |||||
| 648 | Release() error | 648 | Release() error | ||
| 649 | } | 649 | } | ||
| 650 | 650 | ||||
| 651 | - | func joinLifecycleReleaseError(retErr *error, owner lifecycleOwnership) { | |||
| 652 | - | *retErr = errors.Join(*retErr, owner.Release()) | |||
| 653 | - | } | |||
| 654 | - | ||||
| 655 | type lifecycleStartupDeps struct { | 651 | type lifecycleStartupDeps struct { | ||
| 656 | ensurePrivate func(string) error | 652 | ensurePrivate func(string) error | ||
| 657 | acquire func(context.Context, string, time.Duration) (lifecycleOwnership, error) | 653 | acquire func(context.Context, string, time.Duration) (lifecycleOwnership, error) | ||
| @@ -982,79 +978,8 @@ const daemonStopTimeout = 2 * time.Second | |||||
| 982 | var ( | 978 | var ( | ||
| 983 | errDaemonNotRunning = errors.New("vev: no daemon running") | 979 | errDaemonNotRunning = errors.New("vev: no daemon running") | ||
| 984 | errKillOutcomeUnknown = errors.New("vev: kill outcome unknown") | 980 | errKillOutcomeUnknown = errors.New("vev: kill outcome unknown") | ||
| 985 | - | // errKillNotSent reports a Kill that was never placed on the wire because | |||
| 986 | - | // its caller was already canceled. The outcome is definite: the daemon | |||
| 987 | - | // cannot have acted, so it must not be reported as an unknown outcome. | |||
| 988 | - | errKillNotSent = errors.New("vev: kill request not sent") | |||
| 989 | ) | 981 | ) | ||
| 990 | 982 | ||||
| 991 | - | func newControlRequestID() (uint64, error) { | |||
| 992 | - | var raw [8]byte | |||
| 993 | - | if _, err := rand.Read(raw[:]); err != nil { | |||
| 994 | - | return 0, err | |||
| 995 | - | } | |||
| 996 | - | id := uint64(0) | |||
| 997 | - | for _, b := range raw { | |||
| 998 | - | id = id<<8 | uint64(b) | |||
| 999 | - | } | |||
| 1000 | - | if id == 0 { | |||
| 1001 | - | id = 1 | |||
| 1002 | - | } | |||
| 1003 | - | return id, nil | |||
| 1004 | - | } | |||
| 1005 | - | ||||
| 1006 | - | // sendKillRequest allocates a unique nonzero RequestID and sends one Kill | |||
| 1007 | - | // request, returning the ID its matching KillResult must carry. A caller | |||
| 1008 | - | // already canceled before the send returns errKillNotSent (a definite | |||
| 1009 | - | // not-sent outcome) instead of sending a request whose reply could no longer be | |||
| 1010 | - | // awaited; the request is never replayed. | |||
| 1011 | - | func sendKillRequest(ctx context.Context, connection ports.ClientConnection, request protocol.Kill) (uint64, error) { | |||
| 1012 | - | requestID, err := newControlRequestID() | |||
| 1013 | - | if err != nil { | |||
| 1014 | - | return 0, fmt.Errorf("preparing request: %w", err) | |||
| 1015 | - | } | |||
| 1016 | - | request.RequestID = requestID | |||
| 1017 | - | if err := ctx.Err(); err != nil { | |||
| 1018 | - | return 0, fmt.Errorf("%w: %w", errKillNotSent, err) | |||
| 1019 | - | } | |||
| 1020 | - | if err := connection.SendClient(request); err != nil { | |||
| 1021 | - | return 0, err | |||
| 1022 | - | } | |||
| 1023 | - | return requestID, nil | |||
| 1024 | - | } | |||
| 1025 | - | ||||
| 1026 | - | // receiveKillResult consumes exactly one correlated KillResult from a control | |||
| 1027 | - | // connection. A definite failed result becomes a plain error; a close, | |||
| 1028 | - | // cancellation, or an uncorrelated reply becomes errKillOutcomeUnknown, so a | |||
| 1029 | - | // caller never treats a lost result as success and never replays the kill. | |||
| 1030 | - | func receiveKillResult(ctx context.Context, connection ports.ClientConnection, requestID uint64) error { | |||
| 1031 | - | // Cancellation after the request was sent closes the exact connection so a | |||
| 1032 | - | // blocked receive unwinds; the lost reply stays outcome-unknown rather than | |||
| 1033 | - | // becoming a silent success or a blind retry. | |||
| 1034 | - | stop := context.AfterFunc(ctx, func() { _ = connection.Close() }) | |||
| 1035 | - | defer stop() | |||
| 1036 | - | ||||
| 1037 | - | reply, err := connection.ReceiveServer() | |||
| 1038 | - | if err != nil { | |||
| 1039 | - | if ctxErr := ctx.Err(); ctxErr != nil { | |||
| 1040 | - | return fmt.Errorf("%w: awaiting explicit result: %v", errKillOutcomeUnknown, ctxErr) | |||
| 1041 | - | } | |||
| 1042 | - | return fmt.Errorf("%w: awaiting explicit result: %v", errKillOutcomeUnknown, err) | |||
| 1043 | - | } | |||
| 1044 | - | result, ok := reply.(protocol.KillResult) | |||
| 1045 | - | if !ok || result.RequestID != requestID { | |||
| 1046 | - | return fmt.Errorf("%w: unexpected reply %T", errKillOutcomeUnknown, reply) | |||
| 1047 | - | } | |||
| 1048 | - | switch result.Outcome { | |||
| 1049 | - | case protocol.KillSucceeded: | |||
| 1050 | - | return nil | |||
| 1051 | - | case protocol.KillFailed: | |||
| 1052 | - | return fmt.Errorf("vev: %s", result.Text) | |||
| 1053 | - | default: | |||
| 1054 | - | return fmt.Errorf("%w: %s", errKillOutcomeUnknown, result.Text) | |||
| 1055 | - | } | |||
| 1056 | - | } | |||
| 1057 | - | ||||
| 1058 | func createDetachedLocalSession(ctx context.Context, name string) error { | 983 | func createDetachedLocalSession(ctx context.Context, name string) error { | ||
| 1059 | service, err := connectBroker(ctx) | 984 | service, err := connectBroker(ctx) | ||
| 1060 | if err != nil { | 985 | if err != nil { | ||
M internal/app/spawn.go +0 -43
| @@ -190,13 +190,6 @@ func ensureTargetWithLifecycle[T any](ctx context.Context, lockDir string, dial | |||
| 190 | 190 | return ensureTarget(ctx, lockDir, dial, spawn, cfg) | |
| 191 | 191 | } | |
| 192 | 192 | ||
| 193 | - | // ensureDaemon returns a transport to a running daemon, spawning one if | |
| 194 | - | // necessary after ensureDaemonWithLifecycle has established lifecycle | |
| 195 | - | // availability. Tests also exercise this lower-level spawn election directly. | |
| 196 | - | func ensureDaemon(ctx context.Context, dir string, dial dialFunc, spawn spawnFunc, cfg backoffConfig) (wire.Transport, error) { | |
| 197 | - | return ensureTarget(ctx, dir, func(ctx context.Context) (wire.Transport, error) { return dial(ctx, dir) }, spawn, cfg) | |
| 198 | - | } | |
| 199 | - | ||
| 200 | 193 | // ensureTarget is the shared spawn election: it dials once, then takes the | |
| 201 | 194 | // spawn lock in lockDir, and only the elected winner spawns. Every dialer — | |
| 202 | 195 | // the session transport and the daemonmux carriage — therefore observes the | |
| @@ -265,12 +258,6 @@ func acquireSpawnLock(dir string) (release func(), acquired bool, err error) { | |||
| 265 | 258 | return func() {}, false, nil | |
| 266 | 259 | } | |
| 267 | 260 | ||
| 268 | - | // retryDial dials repeatedly with exponential backoff until the daemon | |
| 269 | - | // answers, the context is cancelled, or the total budget is exhausted. | |
| 270 | - | func retryDial(ctx context.Context, dir string, dial dialFunc, cfg backoffConfig) (wire.Transport, error) { | |
| 271 | - | return retryTarget(ctx, dir, func(ctx context.Context) (wire.Transport, error) { return dial(ctx, dir) }, cfg) | |
| 272 | - | } | |
| 273 | - | ||
| 274 | 261 | // retryTarget is the shared bounded redial: it repeats the same dial with | |
| 275 | 262 | // exponential backoff until it answers, the caller's context ends, or the total | |
| 276 | 263 | // budget is exhausted. It is generic so the daemonmux carriage retries under | |
| @@ -343,36 +330,6 @@ func withoutPerformanceTraceEnv(env []string) []string { | |||
| 343 | 330 | return filtered | |
| 344 | 331 | } | |
| 345 | 332 | ||
| 346 | - | // spawnDaemonWithEnvironment launches an explicitly selected daemon binary | |
| 347 | - | // with the complete environment owned by a launch configuration. It retains | |
| 348 | - | // the same double-fork boundary as realSpawn without invoking a shell or | |
| 349 | - | // inheriting ambient variables. | |
| 350 | - | func spawnDaemonWithEnvironment(executable string, environment []string) error { | |
| 351 | - | if executable == "" { | |
| 352 | - | return errors.New("vev: missing launch executable") | |
| 353 | - | } | |
| 354 | - | devNull, err := os.OpenFile(os.DevNull, os.O_RDWR, 0) | |
| 355 | - | if err != nil { | |
| 356 | - | return fmt.Errorf("opening %s: %w", os.DevNull, err) | |
| 357 | - | } | |
| 358 | - | defer func() { _ = devNull.Close() }() | |
| 359 | - | ||
| 360 | - | var stderr bytes.Buffer | |
| 361 | - | cmd := exec.Command(executable, "--daemon-launcher") | |
| 362 | - | cmd.Env = append([]string(nil), environment...) | |
| 363 | - | cmd.Dir = platform.DirOrHome("") | |
| 364 | - | cmd.Stdin = devNull | |
| 365 | - | cmd.Stdout = devNull | |
| 366 | - | cmd.Stderr = &stderr | |
| 367 | - | if err := cmd.Run(); err != nil { | |
| 368 | - | if detail := bytes.TrimSpace(stderr.Bytes()); len(detail) > 0 { | |
| 369 | - | return fmt.Errorf("%w: %s", err, detail) | |
| 370 | - | } | |
| 371 | - | return err | |
| 372 | - | } | |
| 373 | - | return nil | |
| 374 | - | } | |
| 375 | - | ||
| 376 | 333 | // runDaemonLauncher is the intermediate half of the double-fork. It starts | |
| 377 | 334 | // the long-lived daemon in a new session and exits immediately; realSpawn | |
| 378 | 335 | // waits for this exit so the daemon is no longer descended from the client. | |
| @@ -190,13 +190,6 @@ func ensureTargetWithLifecycle[T any](ctx context.Context, lockDir string, dial | |||||
| 190 | return ensureTarget(ctx, lockDir, dial, spawn, cfg) | 190 | return ensureTarget(ctx, lockDir, dial, spawn, cfg) | ||
| 191 | } | 191 | } | ||
| 192 | 192 | ||||
| 193 | - | // ensureDaemon returns a transport to a running daemon, spawning one if | |||
| 194 | - | // necessary after ensureDaemonWithLifecycle has established lifecycle | |||
| 195 | - | // availability. Tests also exercise this lower-level spawn election directly. | |||
| 196 | - | func ensureDaemon(ctx context.Context, dir string, dial dialFunc, spawn spawnFunc, cfg backoffConfig) (wire.Transport, error) { | |||
| 197 | - | return ensureTarget(ctx, dir, func(ctx context.Context) (wire.Transport, error) { return dial(ctx, dir) }, spawn, cfg) | |||
| 198 | - | } | |||
| 199 | - | ||||
| 200 | // ensureTarget is the shared spawn election: it dials once, then takes the | 193 | // ensureTarget is the shared spawn election: it dials once, then takes the | ||
| 201 | // spawn lock in lockDir, and only the elected winner spawns. Every dialer — | 194 | // spawn lock in lockDir, and only the elected winner spawns. Every dialer — | ||
| 202 | // the session transport and the daemonmux carriage — therefore observes the | 195 | // the session transport and the daemonmux carriage — therefore observes the | ||
| @@ -265,12 +258,6 @@ func acquireSpawnLock(dir string) (release func(), acquired bool, err error) { | |||||
| 265 | return func() {}, false, nil | 258 | return func() {}, false, nil | ||
| 266 | } | 259 | } | ||
| 267 | 260 | ||||
| 268 | - | // retryDial dials repeatedly with exponential backoff until the daemon | |||
| 269 | - | // answers, the context is cancelled, or the total budget is exhausted. | |||
| 270 | - | func retryDial(ctx context.Context, dir string, dial dialFunc, cfg backoffConfig) (wire.Transport, error) { | |||
| 271 | - | return retryTarget(ctx, dir, func(ctx context.Context) (wire.Transport, error) { return dial(ctx, dir) }, cfg) | |||
| 272 | - | } | |||
| 273 | - | ||||
| 274 | // retryTarget is the shared bounded redial: it repeats the same dial with | 261 | // retryTarget is the shared bounded redial: it repeats the same dial with | ||
| 275 | // exponential backoff until it answers, the caller's context ends, or the total | 262 | // exponential backoff until it answers, the caller's context ends, or the total | ||
| 276 | // budget is exhausted. It is generic so the daemonmux carriage retries under | 263 | // budget is exhausted. It is generic so the daemonmux carriage retries under | ||
| @@ -343,36 +330,6 @@ func withoutPerformanceTraceEnv(env []string) []string { | |||||
| 343 | return filtered | 330 | return filtered | ||
| 344 | } | 331 | } | ||
| 345 | 332 | ||||
| 346 | - | // spawnDaemonWithEnvironment launches an explicitly selected daemon binary | |||
| 347 | - | // with the complete environment owned by a launch configuration. It retains | |||
| 348 | - | // the same double-fork boundary as realSpawn without invoking a shell or | |||
| 349 | - | // inheriting ambient variables. | |||
| 350 | - | func spawnDaemonWithEnvironment(executable string, environment []string) error { | |||
| 351 | - | if executable == "" { | |||
| 352 | - | return errors.New("vev: missing launch executable") | |||
| 353 | - | } | |||
| 354 | - | devNull, err := os.OpenFile(os.DevNull, os.O_RDWR, 0) | |||
| 355 | - | if err != nil { | |||
| 356 | - | return fmt.Errorf("opening %s: %w", os.DevNull, err) | |||
| 357 | - | } | |||
| 358 | - | defer func() { _ = devNull.Close() }() | |||
| 359 | - | ||||
| 360 | - | var stderr bytes.Buffer | |||
| 361 | - | cmd := exec.Command(executable, "--daemon-launcher") | |||
| 362 | - | cmd.Env = append([]string(nil), environment...) | |||
| 363 | - | cmd.Dir = platform.DirOrHome("") | |||
| 364 | - | cmd.Stdin = devNull | |||
| 365 | - | cmd.Stdout = devNull | |||
| 366 | - | cmd.Stderr = &stderr | |||
| 367 | - | if err := cmd.Run(); err != nil { | |||
| 368 | - | if detail := bytes.TrimSpace(stderr.Bytes()); len(detail) > 0 { | |||
| 369 | - | return fmt.Errorf("%w: %s", err, detail) | |||
| 370 | - | } | |||
| 371 | - | return err | |||
| 372 | - | } | |||
| 373 | - | return nil | |||
| 374 | - | } | |||
| 375 | - | ||||
| 376 | // runDaemonLauncher is the intermediate half of the double-fork. It starts | 333 | // runDaemonLauncher is the intermediate half of the double-fork. It starts | ||
| 377 | // the long-lived daemon in a new session and exits immediately; realSpawn | 334 | // the long-lived daemon in a new session and exits immediately; realSpawn | ||
| 378 | // waits for this exit so the daemon is no longer descended from the client. | 335 | // waits for this exit so the daemon is no longer descended from the client. | ||
M internal/app/spawn_test.go +0 -151
| @@ -12,9 +12,6 @@ import ( | |||
| 12 | 12 | "syscall" | |
| 13 | 13 | "testing" | |
| 14 | 14 | "time" | |
| 15 | - | ||
| 16 | - | "github.com/bnema/vev/internal/protocol/wire" | |
| 17 | - | wiremocks "github.com/bnema/vev/internal/protocol/wire/mocks" | |
| 18 | 15 | ) | |
| 19 | 16 | ||
| 20 | 17 | var errDialFailed = errors.New("dial failed") | |
| @@ -347,151 +344,3 @@ func TestRetryAttemptsClampsNonPositiveInitialDelay(t *testing.T) { | |||
| 347 | 344 | t.Fatalf("retryAttempts retried after %s, want a positive initial delay", elapsed) | |
| 348 | 345 | } | |
| 349 | 346 | } | |
| 350 | - | ||
| 351 | - | func TestRetryDialSucceedsAfterTransientFailures(t *testing.T) { | |
| 352 | - | want := wiremocks.NewMockTransport(t) | |
| 353 | - | var calls atomic.Int32 | |
| 354 | - | dial := func(context.Context, string) (wire.Transport, error) { | |
| 355 | - | if calls.Add(1) <= 3 { | |
| 356 | - | return nil, errDialFailed | |
| 357 | - | } | |
| 358 | - | return want, nil | |
| 359 | - | } | |
| 360 | - | ||
| 361 | - | got, err := retryDial(context.Background(), t.TempDir(), dial, fastBackoff) | |
| 362 | - | if err != nil { | |
| 363 | - | t.Fatalf("retryDial: %v", err) | |
| 364 | - | } | |
| 365 | - | if got != want { | |
| 366 | - | t.Fatalf("retryDial returned unexpected transport") | |
| 367 | - | } | |
| 368 | - | if calls.Load() != 4 { | |
| 369 | - | t.Fatalf("dial called %d times, want 4", calls.Load()) | |
| 370 | - | } | |
| 371 | - | } | |
| 372 | - | ||
| 373 | - | func TestRetryDialPermanentFailure(t *testing.T) { | |
| 374 | - | dial := func(context.Context, string) (wire.Transport, error) { return nil, errDialFailed } | |
| 375 | - | _, err := retryDial(context.Background(), t.TempDir(), dial, fastBackoff) | |
| 376 | - | if !errors.Is(err, ErrDaemonUnreachable) { | |
| 377 | - | t.Fatalf("retryDial error = %v, want ErrDaemonUnreachable", err) | |
| 378 | - | } | |
| 379 | - | } | |
| 380 | - | ||
| 381 | - | func TestRetryDialContextCancelled(t *testing.T) { | |
| 382 | - | ctx, cancel := context.WithCancel(context.Background()) | |
| 383 | - | cancel() | |
| 384 | - | var calls atomic.Int32 | |
| 385 | - | dial := func(context.Context, string) (wire.Transport, error) { | |
| 386 | - | calls.Add(1) | |
| 387 | - | return nil, errDialFailed | |
| 388 | - | } | |
| 389 | - | _, err := retryDial(ctx, t.TempDir(), dial, fastBackoff) | |
| 390 | - | if !errors.Is(err, context.Canceled) { | |
| 391 | - | t.Fatalf("retryDial error = %v, want context.Canceled", err) | |
| 392 | - | } | |
| 393 | - | if calls.Load() != 0 { | |
| 394 | - | t.Fatalf("dial called %d times for canceled context, want 0", calls.Load()) | |
| 395 | - | } | |
| 396 | - | } | |
| 397 | - | ||
| 398 | - | func TestEnsureDaemonContextCancelledDoesNotDialOrSpawn(t *testing.T) { | |
| 399 | - | ctx, cancel := context.WithCancel(context.Background()) | |
| 400 | - | cancel() | |
| 401 | - | dial := func(context.Context, string) (wire.Transport, error) { | |
| 402 | - | t.Fatal("ensureDaemon dialed with a canceled context") | |
| 403 | - | return nil, nil | |
| 404 | - | } | |
| 405 | - | spawn := func() error { | |
| 406 | - | t.Fatal("ensureDaemon spawned with a canceled context") | |
| 407 | - | return nil | |
| 408 | - | } | |
| 409 | - | ||
| 410 | - | _, err := ensureDaemon(ctx, t.TempDir(), dial, spawn, fastBackoff) | |
| 411 | - | if !errors.Is(err, context.Canceled) { | |
| 412 | - | t.Fatalf("ensureDaemon error = %v, want context.Canceled", err) | |
| 413 | - | } | |
| 414 | - | } | |
| 415 | - | ||
| 416 | - | func TestEnsureDaemonDoesNotSpawnWhenContextCancelsAfterFailedDial(t *testing.T) { | |
| 417 | - | ctx, cancel := context.WithCancel(context.Background()) | |
| 418 | - | defer cancel() | |
| 419 | - | var spawnCalls atomic.Int32 | |
| 420 | - | dial := func(context.Context, string) (wire.Transport, error) { | |
| 421 | - | cancel() | |
| 422 | - | return nil, errDialFailed | |
| 423 | - | } | |
| 424 | - | spawn := func() error { | |
| 425 | - | spawnCalls.Add(1) | |
| 426 | - | return nil | |
| 427 | - | } | |
| 428 | - | ||
| 429 | - | _, err := ensureDaemon(ctx, filepath.Join(t.TempDir(), "vev"), dial, spawn, fastBackoff) | |
| 430 | - | if !errors.Is(err, context.Canceled) { | |
| 431 | - | t.Fatalf("ensureDaemon error = %v, want context.Canceled", err) | |
| 432 | - | } | |
| 433 | - | if got := spawnCalls.Load(); got != 0 { | |
| 434 | - | t.Fatalf("spawn called %d times after canceled failed dial, want 0", got) | |
| 435 | - | } | |
| 436 | - | } | |
| 437 | - | ||
| 438 | - | func TestEnsureDaemonSpawnsThenDials(t *testing.T) { | |
| 439 | - | dir := filepath.Join(t.TempDir(), "vev") | |
| 440 | - | want := wiremocks.NewMockTransport(t) | |
| 441 | - | var dialCalls, spawnCalls atomic.Int32 | |
| 442 | - | dial := func(context.Context, string) (wire.Transport, error) { | |
| 443 | - | // First (pre-spawn) dial fails; the post-spawn retry succeeds. | |
| 444 | - | if dialCalls.Add(1) == 1 { | |
| 445 | - | return nil, errDialFailed | |
| 446 | - | } | |
| 447 | - | return want, nil | |
| 448 | - | } | |
| 449 | - | spawn := func() error { | |
| 450 | - | spawnCalls.Add(1) | |
| 451 | - | return nil | |
| 452 | - | } | |
| 453 | - | ||
| 454 | - | got, err := ensureDaemon(context.Background(), dir, dial, spawn, fastBackoff) | |
| 455 | - | if err != nil { | |
| 456 | - | t.Fatalf("ensureDaemon: %v", err) | |
| 457 | - | } | |
| 458 | - | if got != want { | |
| 459 | - | t.Fatalf("ensureDaemon returned unexpected transport") | |
| 460 | - | } | |
| 461 | - | if spawnCalls.Load() != 1 { | |
| 462 | - | t.Fatalf("spawn called %d times, want 1", spawnCalls.Load()) | |
| 463 | - | } | |
| 464 | - | // The spawn lock must be released once ensureDaemon returns. | |
| 465 | - | if _, err := os.Stat(filepath.Join(dir, spawnLockName)); !os.IsNotExist(err) { | |
| 466 | - | t.Fatalf("spawn lock not released: stat err=%v", err) | |
| 467 | - | } | |
| 468 | - | } | |
| 469 | - | ||
| 470 | - | func TestEnsureDaemonReturnsExistingDaemon(t *testing.T) { | |
| 471 | - | want := wiremocks.NewMockTransport(t) | |
| 472 | - | var spawnCalls atomic.Int32 | |
| 473 | - | dial := func(context.Context, string) (wire.Transport, error) { return want, nil } | |
| 474 | - | spawn := func() error { spawnCalls.Add(1); return nil } | |
| 475 | - | ||
| 476 | - | got, err := ensureDaemon(context.Background(), t.TempDir(), dial, spawn, fastBackoff) | |
| 477 | - | if err != nil { | |
| 478 | - | t.Fatalf("ensureDaemon: %v", err) | |
| 479 | - | } | |
| 480 | - | if got != want { | |
| 481 | - | t.Fatalf("ensureDaemon returned unexpected transport") | |
| 482 | - | } | |
| 483 | - | if spawnCalls.Load() != 0 { | |
| 484 | - | t.Fatalf("spawn should not be called when daemon already up, got %d", spawnCalls.Load()) | |
| 485 | - | } | |
| 486 | - | } | |
| 487 | - | ||
| 488 | - | func TestEnsureDaemonSpawnFailure(t *testing.T) { | |
| 489 | - | spawnErr := errors.New("exec boom") | |
| 490 | - | dial := func(context.Context, string) (wire.Transport, error) { return nil, errDialFailed } | |
| 491 | - | spawn := func() error { return spawnErr } | |
| 492 | - | ||
| 493 | - | _, err := ensureDaemon(context.Background(), filepath.Join(t.TempDir(), "vev"), dial, spawn, fastBackoff) | |
| 494 | - | if !errors.Is(err, spawnErr) { | |
| 495 | - | t.Fatalf("ensureDaemon error = %v, want wrapped %v", err, spawnErr) | |
| 496 | - | } | |
| 497 | - | } | |
| @@ -12,9 +12,6 @@ import ( | |||||
| 12 | "syscall" | 12 | "syscall" | ||
| 13 | "testing" | 13 | "testing" | ||
| 14 | "time" | 14 | "time" | ||
| 15 | - | ||||
| 16 | - | "github.com/bnema/vev/internal/protocol/wire" | |||
| 17 | - | wiremocks "github.com/bnema/vev/internal/protocol/wire/mocks" | |||
| 18 | ) | 15 | ) | ||
| 19 | 16 | ||||
| 20 | var errDialFailed = errors.New("dial failed") | 17 | var errDialFailed = errors.New("dial failed") | ||
| @@ -347,151 +344,3 @@ func TestRetryAttemptsClampsNonPositiveInitialDelay(t *testing.T) { | |||||
| 347 | t.Fatalf("retryAttempts retried after %s, want a positive initial delay", elapsed) | 344 | t.Fatalf("retryAttempts retried after %s, want a positive initial delay", elapsed) | ||
| 348 | } | 345 | } | ||
| 349 | } | 346 | } | ||
| 350 | - | ||||
| 351 | - | func TestRetryDialSucceedsAfterTransientFailures(t *testing.T) { | |||
| 352 | - | want := wiremocks.NewMockTransport(t) | |||
| 353 | - | var calls atomic.Int32 | |||
| 354 | - | dial := func(context.Context, string) (wire.Transport, error) { | |||
| 355 | - | if calls.Add(1) <= 3 { | |||
| 356 | - | return nil, errDialFailed | |||
| 357 | - | } | |||
| 358 | - | return want, nil | |||
| 359 | - | } | |||
| 360 | - | ||||
| 361 | - | got, err := retryDial(context.Background(), t.TempDir(), dial, fastBackoff) | |||
| 362 | - | if err != nil { | |||
| 363 | - | t.Fatalf("retryDial: %v", err) | |||
| 364 | - | } | |||
| 365 | - | if got != want { | |||
| 366 | - | t.Fatalf("retryDial returned unexpected transport") | |||
| 367 | - | } | |||
| 368 | - | if calls.Load() != 4 { | |||
| 369 | - | t.Fatalf("dial called %d times, want 4", calls.Load()) | |||
| 370 | - | } | |||
| 371 | - | } | |||
| 372 | - | ||||
| 373 | - | func TestRetryDialPermanentFailure(t *testing.T) { | |||
| 374 | - | dial := func(context.Context, string) (wire.Transport, error) { return nil, errDialFailed } | |||
| 375 | - | _, err := retryDial(context.Background(), t.TempDir(), dial, fastBackoff) | |||
| 376 | - | if !errors.Is(err, ErrDaemonUnreachable) { | |||
| 377 | - | t.Fatalf("retryDial error = %v, want ErrDaemonUnreachable", err) | |||
| 378 | - | } | |||
| 379 | - | } | |||
| 380 | - | ||||
| 381 | - | func TestRetryDialContextCancelled(t *testing.T) { | |||
| 382 | - | ctx, cancel := context.WithCancel(context.Background()) | |||
| 383 | - | cancel() | |||
| 384 | - | var calls atomic.Int32 | |||
| 385 | - | dial := func(context.Context, string) (wire.Transport, error) { | |||
| 386 | - | calls.Add(1) | |||
| 387 | - | return nil, errDialFailed | |||
| 388 | - | } | |||
| 389 | - | _, err := retryDial(ctx, t.TempDir(), dial, fastBackoff) | |||
| 390 | - | if !errors.Is(err, context.Canceled) { | |||
| 391 | - | t.Fatalf("retryDial error = %v, want context.Canceled", err) | |||
| 392 | - | } | |||
| 393 | - | if calls.Load() != 0 { | |||
| 394 | - | t.Fatalf("dial called %d times for canceled context, want 0", calls.Load()) | |||
| 395 | - | } | |||
| 396 | - | } | |||
| 397 | - | ||||
| 398 | - | func TestEnsureDaemonContextCancelledDoesNotDialOrSpawn(t *testing.T) { | |||
| 399 | - | ctx, cancel := context.WithCancel(context.Background()) | |||
| 400 | - | cancel() | |||
| 401 | - | dial := func(context.Context, string) (wire.Transport, error) { | |||
| 402 | - | t.Fatal("ensureDaemon dialed with a canceled context") | |||
| 403 | - | return nil, nil | |||
| 404 | - | } | |||
| 405 | - | spawn := func() error { | |||
| 406 | - | t.Fatal("ensureDaemon spawned with a canceled context") | |||
| 407 | - | return nil | |||
| 408 | - | } | |||
| 409 | - | ||||
| 410 | - | _, err := ensureDaemon(ctx, t.TempDir(), dial, spawn, fastBackoff) | |||
| 411 | - | if !errors.Is(err, context.Canceled) { | |||
| 412 | - | t.Fatalf("ensureDaemon error = %v, want context.Canceled", err) | |||
| 413 | - | } | |||
| 414 | - | } | |||
| 415 | - | ||||
| 416 | - | func TestEnsureDaemonDoesNotSpawnWhenContextCancelsAfterFailedDial(t *testing.T) { | |||
| 417 | - | ctx, cancel := context.WithCancel(context.Background()) | |||
| 418 | - | defer cancel() | |||
| 419 | - | var spawnCalls atomic.Int32 | |||
| 420 | - | dial := func(context.Context, string) (wire.Transport, error) { | |||
| 421 | - | cancel() | |||
| 422 | - | return nil, errDialFailed | |||
| 423 | - | } | |||
| 424 | - | spawn := func() error { | |||
| 425 | - | spawnCalls.Add(1) | |||
| 426 | - | return nil | |||
| 427 | - | } | |||
| 428 | - | ||||
| 429 | - | _, err := ensureDaemon(ctx, filepath.Join(t.TempDir(), "vev"), dial, spawn, fastBackoff) | |||
| 430 | - | if !errors.Is(err, context.Canceled) { | |||
| 431 | - | t.Fatalf("ensureDaemon error = %v, want context.Canceled", err) | |||
| 432 | - | } | |||
| 433 | - | if got := spawnCalls.Load(); got != 0 { | |||
| 434 | - | t.Fatalf("spawn called %d times after canceled failed dial, want 0", got) | |||
| 435 | - | } | |||
| 436 | - | } | |||
| 437 | - | ||||
| 438 | - | func TestEnsureDaemonSpawnsThenDials(t *testing.T) { | |||
| 439 | - | dir := filepath.Join(t.TempDir(), "vev") | |||
| 440 | - | want := wiremocks.NewMockTransport(t) | |||
| 441 | - | var dialCalls, spawnCalls atomic.Int32 | |||
| 442 | - | dial := func(context.Context, string) (wire.Transport, error) { | |||
| 443 | - | // First (pre-spawn) dial fails; the post-spawn retry succeeds. | |||
| 444 | - | if dialCalls.Add(1) == 1 { | |||
| 445 | - | return nil, errDialFailed | |||
| 446 | - | } | |||
| 447 | - | return want, nil | |||
| 448 | - | } | |||
| 449 | - | spawn := func() error { | |||
| 450 | - | spawnCalls.Add(1) | |||
| 451 | - | return nil | |||
| 452 | - | } | |||
| 453 | - | ||||
| 454 | - | got, err := ensureDaemon(context.Background(), dir, dial, spawn, fastBackoff) | |||
| 455 | - | if err != nil { | |||
| 456 | - | t.Fatalf("ensureDaemon: %v", err) | |||
| 457 | - | } | |||
| 458 | - | if got != want { | |||
| 459 | - | t.Fatalf("ensureDaemon returned unexpected transport") | |||
| 460 | - | } | |||
| 461 | - | if spawnCalls.Load() != 1 { | |||
| 462 | - | t.Fatalf("spawn called %d times, want 1", spawnCalls.Load()) | |||
| 463 | - | } | |||
| 464 | - | // The spawn lock must be released once ensureDaemon returns. | |||
| 465 | - | if _, err := os.Stat(filepath.Join(dir, spawnLockName)); !os.IsNotExist(err) { | |||
| 466 | - | t.Fatalf("spawn lock not released: stat err=%v", err) | |||
| 467 | - | } | |||
| 468 | - | } | |||
| 469 | - | ||||
| 470 | - | func TestEnsureDaemonReturnsExistingDaemon(t *testing.T) { | |||
| 471 | - | want := wiremocks.NewMockTransport(t) | |||
| 472 | - | var spawnCalls atomic.Int32 | |||
| 473 | - | dial := func(context.Context, string) (wire.Transport, error) { return want, nil } | |||
| 474 | - | spawn := func() error { spawnCalls.Add(1); return nil } | |||
| 475 | - | ||||
| 476 | - | got, err := ensureDaemon(context.Background(), t.TempDir(), dial, spawn, fastBackoff) | |||
| 477 | - | if err != nil { | |||
| 478 | - | t.Fatalf("ensureDaemon: %v", err) | |||
| 479 | - | } | |||
| 480 | - | if got != want { | |||
| 481 | - | t.Fatalf("ensureDaemon returned unexpected transport") | |||
| 482 | - | } | |||
| 483 | - | if spawnCalls.Load() != 0 { | |||
| 484 | - | t.Fatalf("spawn should not be called when daemon already up, got %d", spawnCalls.Load()) | |||
| 485 | - | } | |||
| 486 | - | } | |||
| 487 | - | ||||
| 488 | - | func TestEnsureDaemonSpawnFailure(t *testing.T) { | |||
| 489 | - | spawnErr := errors.New("exec boom") | |||
| 490 | - | dial := func(context.Context, string) (wire.Transport, error) { return nil, errDialFailed } | |||
| 491 | - | spawn := func() error { return spawnErr } | |||
| 492 | - | ||||
| 493 | - | _, err := ensureDaemon(context.Background(), filepath.Join(t.TempDir(), "vev"), dial, spawn, fastBackoff) | |||
| 494 | - | if !errors.Is(err, spawnErr) { | |||
| 495 | - | t.Fatalf("ensureDaemon error = %v, want wrapped %v", err, spawnErr) | |||
| 496 | - | } | |||
| 497 | - | } | |||