refactor(app): split run.go, single CLI error prefix, group daemon test seams (#310)
* refactor(app): split run.go by concern and parse subcommands separately Move CLI parsing to cli.go, the daemon composition to daemon_run.go, the attach paths to attach.go, session list/kill to sessions.go, and runtime tracing to trace.go. parseArgs now delegates to per-subcommand parsers without goto, and the three UI flags become one interactiveUIOptions field. * refactor(errors): add the vev: prefix only in the CLI entry point Internal errors no longer start with "vev: "; cliError in main.go adds the single user-facing prefix, so the prefix-stripping loop is removed. The interactive missing-session prompt and direct stderr notices keep their prefix because they bypass cliError. Visible effect: TUI toasts, uidriver JSONL errors, and broker error text no longer carry "vev:", and the client failure line no longer prints a doubled "vev: vev:". * refactor(daemon): group test seams into an embedded daemonTestHooks struct The 37 before/after hooks move out of the Daemon field list into one embedded struct, so production state reads separately from test seams. Embedding keeps every d.beforeX call site unchanged. * refactor(app): place broker seams with their callers and widen source guards Move the broker connection seams, list helpers, and errKillOutcomeUnknown next to their callers in sessions.go and run.go, and the graceful teardown waiter to broker_offline_mux.go. trace.go keeps only tracing. Drop the inner vev: prefix from the unreadable-catalogue error, extend the terminal composition source guard to sessions.go and run.go, and add parser cases for leading UI flags and kill --. * test(daemonmux): tolerate a broker reset racing the refused-open settlement The refusing acceptor sent Refused and then settled its local stream. The broker can answer the refusal with a Reset that terminalizes the stream first, so the later local Refused returned ErrStreamState and failed TestLogicalOpenRefused on loaded CI runners. A stream already terminal at that point is settled and no longer counts as a failure.
- commit
- 5ac801757a752bbb8fe31d95d287b810cd4a7d2f
- author
- bnema · · 2026-10-04 16:20 UTC
- committer
- GitHub · · 2026-10-04 16:20 UTC
- parent
- 6f8be80
- signature
- GPG Unverified
B5690EEEBB952194The key is unknown
52 files · +1573 -1478
M boundary_test.go +1 -1
| @@ -369,7 +369,7 @@ func inspectExternalDependencies(t *testing.T) []string { | |||||
| 369 | // carriage, so the composition is pinned to the multi-policy call, and the | 369 | // carriage, so the composition is pinned to the multi-policy call, and the | ||
| 370 | // shorthand stays a fixtures-only convenience. | 370 | // shorthand stays a fixtures-only convenience. | ||
| 371 | func TestProductionDaemonBindingComposition(t *testing.T) { | 371 | func TestProductionDaemonBindingComposition(t *testing.T) { | ||
| 372 | - | const runFile = "internal/app/run.go" | 372 | + | const runFile = "internal/app/daemon_run.go" |
| 373 | 373 | ||||
| 374 | var ( | 374 | var ( | ||
| 375 | bindingSites []string | 375 | bindingSites []string | ||
M internal/adapters/daemonmux/logical_conn_test.go +11 -1
| @@ -260,7 +260,17 @@ func (a *daemonAcceptor) startRefused(id PhysicalStreamID, deadline time.Time) { | |||||
| 260 | a.fail(err) | 260 | a.fail(err) | ||
| 261 | return | 261 | return | ||
| 262 | } | 262 | } | ||
| 263 | - | if err := a.pump.Engine().Refused(Refused{Ref: status.Ref, Error: refusal}); err != nil { | 263 | + | // The scheduler only sends for an admitted stream, so the local |
| 264 | + | // settlement follows the send. The broker may react to the refusal | |||
| 265 | + | // with a Reset that terminalizes the stream first; that is a settled | |||
| 266 | + | // stream, not a failure. | |||
| 267 | + | err = a.pump.Engine().Refused(Refused{Ref: status.Ref, Error: refusal}) | |||
| 268 | + | if errors.Is(err, ErrStreamState) { | |||
| 269 | + | if current, ok := a.pump.Engine().Status(id); ok && current.State == StreamTerminal { | |||
| 270 | + | err = nil | |||
| 271 | + | } | |||
| 272 | + | } | |||
| 273 | + | if err != nil { | |||
| 264 | a.fail(err) | 274 | a.fail(err) | ||
| 265 | return | 275 | return | ||
| 266 | } | 276 | } | ||
A internal/app/attach.go +162 -0
| @@ -0,0 +1,162 @@ | |||||
| 1 | + | package app | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "errors" | |||
| 6 | + | "fmt" | |||
| 7 | + | "os" | |||
| 8 | + | ||||
| 9 | + | "github.com/bnema/vev/internal/adapters/clock" | |||
| 10 | + | "github.com/bnema/vev/internal/adapters/ipc" | |||
| 11 | + | "github.com/bnema/vev/internal/adapters/term" | |||
| 12 | + | "github.com/bnema/vev/internal/adapters/uidriver" | |||
| 13 | + | "github.com/bnema/vev/internal/adapters/uiterm" | |||
| 14 | + | "github.com/bnema/vev/internal/logging" | |||
| 15 | + | "github.com/bnema/vev/internal/ports" | |||
| 16 | + | "github.com/bnema/vev/internal/protocol" | |||
| 17 | + | "github.com/bnema/vev/internal/usecase/client" | |||
| 18 | + | ) | |||
| 19 | + | ||||
| 20 | + | // runAttach dials (auto-spawning the daemon if needed) and runs the client | |||
| 21 | + | // attach loop. Logging goes to the shared file: the client must never write | |||
| 22 | + | // to the console while the terminal is raw. | |||
| 23 | + | func runAttach(ctx context.Context, intent uint8, name, remoteTarget string) (retErr error) { | |||
| 24 | + | if os.Getenv("VEV") != "" { | |||
| 25 | + | if remoteTarget == "" && intent == protocol.IntentNew { | |||
| 26 | + | return createDetachedTerminalSession(ctx, name) | |||
| 27 | + | } | |||
| 28 | + | return errors.New("sessions should be nested with care; unset VEV to force") | |||
| 29 | + | } | |||
| 30 | + | ctx, stop := clientSignalContext(ctx) | |||
| 31 | + | defer stop() | |||
| 32 | + | log, logCloser, err := configureLogging(logging.Client, false) | |||
| 33 | + | if err != nil { | |||
| 34 | + | return err | |||
| 35 | + | } | |||
| 36 | + | defer func() { _ = logCloser.Close() }() | |||
| 37 | + | clk := clock.New() | |||
| 38 | + | observer, observerCloser, err := newPerformanceTrace(clk) | |||
| 39 | + | if err != nil { | |||
| 40 | + | return fmt.Errorf("performance trace: %w", err) | |||
| 41 | + | } | |||
| 42 | + | if observerCloser != nil { | |||
| 43 | + | defer func() { retErr = errors.Join(retErr, observerCloser.Close()) }() | |||
| 44 | + | } | |||
| 45 | + | if observer != nil { | |||
| 46 | + | observer.ObserveRuntime(ports.NewRuntimeMark("client", ports.RuntimeTransportDiagnostic, 0, true)) | |||
| 47 | + | } | |||
| 48 | + | terminal := terminalForAttach() | |||
| 49 | + | intent, preconnected, err := resolveAttachCreationIntent(ctx, intent, name, remoteTarget, terminal) | |||
| 50 | + | if err != nil { | |||
| 51 | + | return err | |||
| 52 | + | } | |||
| 53 | + | navigation, resolver, err := terminalBrokerNavigation(intent, name, remoteTarget) | |||
| 54 | + | if err != nil { | |||
| 55 | + | if preconnected != nil { | |||
| 56 | + | _ = preconnected.Close() | |||
| 57 | + | } | |||
| 58 | + | return err | |||
| 59 | + | } | |||
| 60 | + | callbacks := terminalBrokerCallbacks() | |||
| 61 | + | return runBrokerClient(ctx, brokerClientConfig{ | |||
| 62 | + | Logger: log, Connector: withPreconnected(newProductionBrokerConnector(), preconnected), Terminal: terminal, Clock: clk, | |||
| 63 | + | InitialNavigation: navigation, ResolveInitialNavigation: resolver, | |||
| 64 | + | AttachmentEnvironment: outerTerminalAttachmentEnvironment(log), SessionEnvironment: terminalSessionEnvironment(), | |||
| 65 | + | OnState: callbacks.OnState, OnLifecycle: callbacks.OnLifecycle, OnFailure: callbacks.OnFailure, | |||
| 66 | + | }) | |||
| 67 | + | } | |||
| 68 | + | ||||
| 69 | + | // runAttachWithOptions is the composition path for ordinary interactive | |||
| 70 | + | // clients. Observation is opt-in; the default path remains term.New(). | |||
| 71 | + | // | |||
| 72 | + | // The observed path composes the physical terminal, one VT mirror, the UI | |||
| 73 | + | // service over that mirror, and the same broker connector and initial-navigation | |||
| 74 | + | // translation the ordinary terminal attach uses, then delegates the whole run to | |||
| 75 | + | // the shared runBrokerClient. It owns no daemon dialer, no remote carriage, and | |||
| 76 | + | // no session: mirroring observes bytes at the serialized terminal writer and the | |||
| 77 | + | // broker owns every transport. | |||
| 78 | + | func runAttachWithOptions(ctx context.Context, intent uint8, name, remoteTarget string, options interactiveUIOptions) (retErr error) { | |||
| 79 | + | if !options.enabled() { | |||
| 80 | + | return runAttach(ctx, intent, name, remoteTarget) | |||
| 81 | + | } | |||
| 82 | + | if options.control { | |||
| 83 | + | options.observe = true | |||
| 84 | + | } | |||
| 85 | + | ctx, stop := clientSignalContext(ctx) | |||
| 86 | + | defer stop() | |||
| 87 | + | log, logCloser, err := configureLogging(logging.Client, false) | |||
| 88 | + | if err != nil { | |||
| 89 | + | return err | |||
| 90 | + | } | |||
| 91 | + | defer func() { _ = logCloser.Close() }() | |||
| 92 | + | clk := clock.New() | |||
| 93 | + | // The process trace is still created and joined here so an operator's trace | |||
| 94 | + | // file keeps its exact lifecycle, including a close failure joined into the | |||
| 95 | + | // run's result. Client-side transport spans are no longer emitted: the | |||
| 96 | + | // broker owns carriage, so the observed composition keeps no dialer seam. | |||
| 97 | + | _, observerCloser, err := newPerformanceTrace(clk) | |||
| 98 | + | if err != nil { | |||
| 99 | + | return fmt.Errorf("performance trace: %w", err) | |||
| 100 | + | } | |||
| 101 | + | if observerCloser != nil { | |||
| 102 | + | defer func() { retErr = errors.Join(retErr, observerCloser.Close()) }() | |||
| 103 | + | } | |||
| 104 | + | physical := term.NewWithFilesAndObservation(os.Stdin, os.Stdout, nil) | |||
| 105 | + | geometry, err := physical.Geometry() | |||
| 106 | + | if err != nil { | |||
| 107 | + | return fmt.Errorf("reading terminal geometry: %w", err) | |||
| 108 | + | } | |||
| 109 | + | mirror, err := uiterm.NewMirror(ctx, geometry, "") | |||
| 110 | + | if err != nil { | |||
| 111 | + | return fmt.Errorf("create UI mirror: %w", err) | |||
| 112 | + | } | |||
| 113 | + | defer mirror.Close() | |||
| 114 | + | physical = term.NewWithFilesAndObservation(os.Stdin, os.Stdout, mirror) | |||
| 115 | + | ui := client.NewUI(mirror, clk) | |||
| 116 | + | var endpoint *uidriver.UnixEndpoint | |||
| 117 | + | if options.observe { | |||
| 118 | + | server := uidriver.New(ui, clk) | |||
| 119 | + | socketPath := options.socket | |||
| 120 | + | if socketPath == "" { | |||
| 121 | + | socketPath = uidriver.DefaultSocketPath(ipc.SocketDir(), ui.Handle()) | |||
| 122 | + | } | |||
| 123 | + | endpoint, err = uidriver.ListenUnix(socketPath, server, func() uidriver.Ready { | |||
| 124 | + | ready := uidriver.Ready{Attachment: ui.Handle(), Control: options.control, Status: ports.UIStatusPicker} | |||
| 125 | + | if snapshot, snapshotErr := ui.Capture(ui.Handle()); snapshotErr == nil { | |||
| 126 | + | ready = uidriverReady(ui, snapshot, options.control) | |||
| 127 | + | } | |||
| 128 | + | return ready | |||
| 129 | + | }) | |||
| 130 | + | if err != nil { | |||
| 131 | + | return err | |||
| 132 | + | } | |||
| 133 | + | fmt.Fprintln(os.Stderr, socketPath) | |||
| 134 | + | defer func() { _ = endpoint.Close() }() | |||
| 135 | + | } | |||
| 136 | + | terminal := observedTerminal{Terminal: physical, UIOutputTransaction: mirror} | |||
| 137 | + | intent, preconnected, err := resolveAttachCreationIntent(ctx, intent, name, remoteTarget, terminal) | |||
| 138 | + | if err != nil { | |||
| 139 | + | return err | |||
| 140 | + | } | |||
| 141 | + | navigation, resolver, err := terminalBrokerNavigation(intent, name, remoteTarget) | |||
| 142 | + | if err != nil { | |||
| 143 | + | if preconnected != nil { | |||
| 144 | + | _ = preconnected.Close() | |||
| 145 | + | } | |||
| 146 | + | return err | |||
| 147 | + | } | |||
| 148 | + | sessionEnv := terminalSessionEnvironment() | |||
| 149 | + | attachmentEnv := configuredTerminalAttachmentEnvironment(log) | |||
| 150 | + | attachmentEnv.Cwd = sessionEnv.Cwd | |||
| 151 | + | return runBrokerClient(ctx, brokerClientConfig{ | |||
| 152 | + | Logger: log, | |||
| 153 | + | Connector: withPreconnected(newProductionBrokerConnector(), preconnected), | |||
| 154 | + | Terminal: terminal, | |||
| 155 | + | Clock: clk, | |||
| 156 | + | UI: ui, | |||
| 157 | + | InitialNavigation: navigation, | |||
| 158 | + | ResolveInitialNavigation: resolver, | |||
| 159 | + | AttachmentEnvironment: attachmentEnv, | |||
| 160 | + | SessionEnvironment: sessionEnv, | |||
| 161 | + | }) | |||
| 162 | + | } | |||
M internal/app/broker_client.go +3 -3
| @@ -264,7 +264,7 @@ func (p *brokerClientPresentation) Failure(state client.State, err error) { | |||||
| 264 | // cancellation, joined with any failure the supervisor classified as terminal. | 264 | // cancellation, joined with any failure the supervisor classified as terminal. | ||
| 265 | func runBrokerClient(ctx context.Context, cfg brokerClientConfig) error { | 265 | func runBrokerClient(ctx context.Context, cfg brokerClientConfig) error { | ||
| 266 | if cfg.Terminal == nil { | 266 | if cfg.Terminal == nil { | ||
| 267 | - | return errors.New("vev: broker client requires a terminal") | 267 | + | return errors.New("broker client requires a terminal") |
| 268 | } | 268 | } | ||
| 269 | clk := cfg.Clock | 269 | clk := cfg.Clock | ||
| 270 | if clk == nil { | 270 | if clk == nil { | ||
| @@ -341,7 +341,7 @@ func localEphemeralNavigation() client.InitialNavigation { | |||||
| 341 | // no destination was dialed, so the supervisor surfaces it as a bounded | 341 | // no destination was dialed, so the supervisor surfaces it as a bounded | ||
| 342 | // selection-unavailable notice and returns to the picker instead of attaching a | 342 | // selection-unavailable notice and returns to the picker instead of attaching a | ||
| 343 | // fallback or creating implicitly. | 343 | // fallback or creating implicitly. | ||
| 344 | - | var errTerminalNavigationUnresolved = errors.New("vev: attach host is not in the broker catalogue") | 344 | + | var errTerminalNavigationUnresolved = errors.New("attach host is not in the broker catalogue") |
| 345 | 345 | ||||
| 346 | // terminalBrokerNavigation translates the parsed terminal CLI intent into the | 346 | // terminalBrokerNavigation translates the parsed terminal CLI intent into the | ||
| 347 | // closed initial-navigation union. An intent that is fully determined before | 347 | // closed initial-navigation union. An intent that is fully determined before | ||
| @@ -573,7 +573,7 @@ func newProductionBrokerConnector() ports.BrokerConnector { | |||||
| 573 | // cadence. | 573 | // cadence. | ||
| 574 | func (c *productionBrokerConnector) Connect(ctx context.Context) (ports.BrokerNavigator, error) { | 574 | func (c *productionBrokerConnector) Connect(ctx context.Context) (ports.BrokerNavigator, error) { | ||
| 575 | if c == nil || c.connect == nil { | 575 | if c == nil || c.connect == nil { | ||
| 576 | - | return nil, errors.New("vev: broker connector is not configured") | 576 | + | return nil, errors.New("broker connector is not configured") |
| 577 | } | 577 | } | ||
| 578 | service, err := c.connect(ctx) | 578 | service, err := c.connect(ctx) | ||
| 579 | if err != nil { | 579 | if err != nil { | ||
M internal/app/broker_client_test.go +2 -1
| @@ -1012,9 +1012,10 @@ func terminalCounts(terminal *terminalCompositionTerminal) (int, int, int) { | |||||
| 1012 | // outright, so this fails if any is reintroduced as a bypass. | 1012 | // outright, so this fails if any is reintroduced as a bypass. | ||
| 1013 | func TestTerminalCompositionUsesTheBrokerConnector(t *testing.T) { | 1013 | func TestTerminalCompositionUsesTheBrokerConnector(t *testing.T) { | ||
| 1014 | sources := loadAppSources(t, false) | 1014 | sources := loadAppSources(t, false) | ||
| 1015 | - | body := sources["run.go"] | 1015 | + | body := sources["attach.go"] |
| 1016 | require.Contains(t, body, "runBrokerClient(ctx, brokerClientConfig{") | 1016 | require.Contains(t, body, "runBrokerClient(ctx, brokerClientConfig{") | ||
| 1017 | require.Contains(t, body, "newProductionBrokerConnector()") | 1017 | require.Contains(t, body, "newProductionBrokerConnector()") | ||
| 1018 | + | body += sources["sessions.go"] + sources["run.go"] | |||
| 1018 | for _, forbidden := range []string{ | 1019 | for _, forbidden := range []string{ | ||
| 1019 | "runAttachWithDeps", "runAttachDeps", "localDaemonDialer", "dialOnlyLocalDialer", | 1020 | "runAttachWithDeps", "runAttachDeps", "localDaemonDialer", "dialOnlyLocalDialer", | ||
| 1020 | "defaultLocalDialer", "resolveMissingSessionAttach", "newClientHostRegistry", | 1021 | "defaultLocalDialer", "resolveMissingSessionAttach", "newClientHostRegistry", | ||
M internal/app/broker_daemon_start.go +6 -6
| @@ -144,7 +144,7 @@ func dialBrokerDaemonCarriage(ctx context.Context, carriage string, target ports | |||||
| 144 | } | 144 | } | ||
| 145 | starter := localDaemonStarter | 145 | starter := localDaemonStarter | ||
| 146 | if starter.dial == nil || starter.spawn == nil { | 146 | if starter.dial == nil || starter.spawn == nil { | ||
| 147 | - | return nil, ports.BrokerError{Code: ports.BrokerErrorIncompatible, Cause: errors.New("vev: daemon starter is incomplete")} | 147 | + | return nil, ports.BrokerError{Code: ports.BrokerErrorIncompatible, Cause: errors.New("daemon starter is incomplete")} |
| 148 | } | 148 | } | ||
| 149 | raw, err := starter.dial(ctx, carriage) | 149 | raw, err := starter.dial(ctx, carriage) | ||
| 150 | if err == nil { | 150 | if err == nil { | ||
| @@ -172,12 +172,12 @@ func dialBrokerDaemonCarriage(ctx context.Context, carriage string, target ports | |||||
| 172 | // or empty path can never trigger a spawn. | 172 | // or empty path can never trigger a spawn. | ||
| 173 | func validateDaemonCarriage(carriage string) error { | 173 | func validateDaemonCarriage(carriage string) error { | ||
| 174 | if carriage == "" { | 174 | if carriage == "" { | ||
| 175 | - | return ports.BrokerError{Code: ports.BrokerErrorIncompatible, Cause: errors.New("vev: daemon carriage path is empty")} | 175 | + | return ports.BrokerError{Code: ports.BrokerErrorIncompatible, Cause: errors.New("daemon carriage path is empty")} |
| 176 | } | 176 | } | ||
| 177 | if !filepath.IsAbs(carriage) || filepath.Clean(carriage) != carriage { | 177 | if !filepath.IsAbs(carriage) || filepath.Clean(carriage) != carriage { | ||
| 178 | return ports.BrokerError{ | 178 | return ports.BrokerError{ | ||
| 179 | Code: ports.BrokerErrorIncompatible, | 179 | Code: ports.BrokerErrorIncompatible, | ||
| 180 | - | Cause: fmt.Errorf("vev: daemon carriage %q is not an absolute cleaned path", carriage), | 180 | + | Cause: fmt.Errorf("daemon carriage %q is not an absolute cleaned path", carriage), |
| 181 | } | 181 | } | ||
| 182 | } | 182 | } | ||
| 183 | return nil | 183 | return nil | ||
| @@ -191,7 +191,7 @@ func validateDaemonCarriageStart(carriage string, starter brokerDaemonStarter) e | |||||
| 191 | if want := starter.effectivePublished(); carriage != want { | 191 | if want := starter.effectivePublished(); carriage != want { | ||
| 192 | return ports.BrokerError{ | 192 | return ports.BrokerError{ | ||
| 193 | Code: ports.BrokerErrorIncompatible, | 193 | Code: ports.BrokerErrorIncompatible, | ||
| 194 | - | Cause: fmt.Errorf("vev: refusing to start a daemon for carriage %q: this process publishes %q", carriage, want), | 194 | + | Cause: fmt.Errorf("refusing to start a daemon for carriage %q: this process publishes %q", carriage, want), |
| 195 | } | 195 | } | ||
| 196 | } | 196 | } | ||
| 197 | return nil | 197 | return nil | ||
| @@ -231,7 +231,7 @@ func brokerDaemonStartArgvFlag(mode ports.BrokerDaemonStartMode) (string, error) | |||||
| 231 | case ports.BrokerDaemonStartIfNeeded: | 231 | case ports.BrokerDaemonStartIfNeeded: | ||
| 232 | return "if-needed", nil | 232 | return "if-needed", nil | ||
| 233 | default: | 233 | default: | ||
| 234 | - | return "", fmt.Errorf("vev: invalid broker daemon start mode %s", mode) | 234 | + | return "", fmt.Errorf("invalid broker daemon start mode %s", mode) |
| 235 | } | 235 | } | ||
| 236 | } | 236 | } | ||
| 237 | 237 | ||||
| @@ -245,6 +245,6 @@ func parseBrokerDaemonStartArgvFlag(value string) (ports.BrokerDaemonStartMode, | |||||
| 245 | case "if-needed": | 245 | case "if-needed": | ||
| 246 | return ports.BrokerDaemonStartIfNeeded, nil | 246 | return ports.BrokerDaemonStartIfNeeded, nil | ||
| 247 | default: | 247 | default: | ||
| 248 | - | return 0, fmt.Errorf("vev: unknown daemon start mode %q (want existing-only or if-needed)", value) | 248 | + | return 0, fmt.Errorf("unknown daemon start mode %q (want existing-only or if-needed)", value) |
| 249 | } | 249 | } | ||
| 250 | } | 250 | } | ||
M internal/app/broker_offline.go +16 -16
| @@ -96,14 +96,14 @@ func (b productionIdentityBinder) BindAuthenticatedIdentity(ctx context.Context, | |||||
| 96 | return b.delegate.BindAuthenticatedIdentity(ctx, request) | 96 | return b.delegate.BindAuthenticatedIdentity(ctx, request) | ||
| 97 | } | 97 | } | ||
| 98 | if request.Policy != b.policy { | 98 | if request.Policy != b.policy { | ||
| 99 | - | return "", errors.New("vev: local daemon policy authority changed") | 99 | + | return "", errors.New("local daemon policy authority changed") |
| 100 | } | 100 | } | ||
| 101 | identity, err := daemonidentity.Load(b.stateDir) | 101 | identity, err := daemonidentity.Load(b.stateDir) | ||
| 102 | if err != nil { | 102 | if err != nil { | ||
| 103 | - | return "", fmt.Errorf("vev: revalidate daemon authority: %w", err) | 103 | + | return "", fmt.Errorf("revalidate daemon authority: %w", err) |
| 104 | } | 104 | } | ||
| 105 | if identity != request.Identity { | 105 | if identity != request.Identity { | ||
| 106 | - | return "", errors.New("vev: local daemon identity authority changed") | 106 | + | return "", errors.New("local daemon identity authority changed") |
| 107 | } | 107 | } | ||
| 108 | return identity, nil | 108 | return identity, nil | ||
| 109 | } | 109 | } | ||
| @@ -139,7 +139,7 @@ func runBrokerServeCommand(ctx context.Context, options brokerServeOptions) (ret | |||||
| 139 | defer stop() | 139 | defer stop() | ||
| 140 | observer, observerCloser, err := newPerformanceTrace(clock.New()) | 140 | observer, observerCloser, err := newPerformanceTrace(clock.New()) | ||
| 141 | if err != nil { | 141 | if err != nil { | ||
| 142 | - | return fmt.Errorf("vev: performance trace: %w", err) | 142 | + | return fmt.Errorf("performance trace: %w", err) |
| 143 | } | 143 | } | ||
| 144 | if observer != nil { | 144 | if observer != nil { | ||
| 145 | observer.ObserveRuntime(ports.NewRuntimeMark("broker", ports.RuntimeTransportDiagnostic, 0, true)) | 145 | observer.ObserveRuntime(ports.NewRuntimeMark("broker", ports.RuntimeTransportDiagnostic, 0, true)) | ||
| @@ -165,7 +165,7 @@ func sandboxLogging(dir string) (*slog.Logger, io.Closer, error) { | |||||
| 165 | func newBrokerEpoch() (ports.BrokerEpoch, error) { | 165 | func newBrokerEpoch() (ports.BrokerEpoch, error) { | ||
| 166 | var raw [8]byte | 166 | var raw [8]byte | ||
| 167 | if _, err := rand.Read(raw[:]); err != nil { | 167 | if _, err := rand.Read(raw[:]); err != nil { | ||
| 168 | - | return 0, fmt.Errorf("vev: sample broker epoch: %w", err) | 168 | + | return 0, fmt.Errorf("sample broker epoch: %w", err) |
| 169 | } | 169 | } | ||
| 170 | epoch := ports.BrokerEpoch(binary.BigEndian.Uint64(raw[:])) | 170 | epoch := ports.BrokerEpoch(binary.BigEndian.Uint64(raw[:])) | ||
| 171 | if epoch == 0 { | 171 | if epoch == 0 { | ||
| @@ -211,26 +211,26 @@ func runBrokerServe(ctx context.Context, options brokerServeOptions, deps broker | |||||
| 211 | return err | 211 | return err | ||
| 212 | } | 212 | } | ||
| 213 | if err := safedir.EnsurePrivate(ipc.SocketDir()); err != nil { | 213 | if err := safedir.EnsurePrivate(ipc.SocketDir()); err != nil { | ||
| 214 | - | return fmt.Errorf("vev: secure broker runtime parent: %w", err) | 214 | + | return fmt.Errorf("secure broker runtime parent: %w", err) |
| 215 | } | 215 | } | ||
| 216 | layout := productionBrokerLayout() | 216 | layout := productionBrokerLayout() | ||
| 217 | configPath := productionBrokerConfigPath() | 217 | configPath := productionBrokerConfigPath() | ||
| 218 | for _, dir := range []string{layout.Root, layout.Runtime, layout.State, layout.Log} { | 218 | for _, dir := range []string{layout.Root, layout.Runtime, layout.State, layout.Log} { | ||
| 219 | if err := safedir.EnsurePrivate(dir); err != nil { | 219 | if err := safedir.EnsurePrivate(dir); err != nil { | ||
| 220 | - | return fmt.Errorf("vev: secure broker sandbox directory %s: %w", dir, err) | 220 | + | return fmt.Errorf("secure broker sandbox directory %s: %w", dir, err) |
| 221 | } | 221 | } | ||
| 222 | } | 222 | } | ||
| 223 | // Re-walk every component now that EnsurePrivate created them, so a | 223 | // Re-walk every component now that EnsurePrivate created them, so a | ||
| 224 | // component swapped for a symlink in that window fails closed. | 224 | // component swapped for a symlink in that window fails closed. | ||
| 225 | if err := layout.VerifyCreated(); err != nil { | 225 | if err := layout.VerifyCreated(); err != nil { | ||
| 226 | - | return fmt.Errorf("vev: verify broker paths: %w", err) | 226 | + | return fmt.Errorf("verify broker paths: %w", err) |
| 227 | } | 227 | } | ||
| 228 | // The broker is allowed to precede the daemon. Identity is therefore an | 228 | // The broker is allowed to precede the daemon. Identity is therefore an | ||
| 229 | // optional existing authority here; acquisition binds it after an | 229 | // optional existing authority here; acquisition binds it after an | ||
| 230 | // authenticated StartIfNeeded connection when the daemon is first born. | 230 | // authenticated StartIfNeeded connection when the daemon is first born. | ||
| 231 | identity, err := daemonidentity.Load(platform.StateDir()) | 231 | identity, err := daemonidentity.Load(platform.StateDir()) | ||
| 232 | if err != nil && !errors.Is(err, os.ErrNotExist) { | 232 | if err != nil && !errors.Is(err, os.ErrNotExist) { | ||
| 233 | - | return fmt.Errorf("vev: load daemon authority: %w", err) | 233 | + | return fmt.Errorf("load daemon authority: %w", err) |
| 234 | } | 234 | } | ||
| 235 | config, err := brokerconfig.LoadProduction(layout, configPath, identity, localDaemonPolicy(), daemonmux.SocketPath(ipc.SocketDir())) | 235 | config, err := brokerconfig.LoadProduction(layout, configPath, identity, localDaemonPolicy(), daemonmux.SocketPath(ipc.SocketDir())) | ||
| 236 | if err != nil { | 236 | if err != nil { | ||
| @@ -242,7 +242,7 @@ func runBrokerServe(ctx context.Context, options brokerServeOptions, deps broker | |||||
| 242 | } | 242 | } | ||
| 243 | owner, err := deps.acquire(layout.Runtime) | 243 | owner, err := deps.acquire(layout.Runtime) | ||
| 244 | if err != nil { | 244 | if err != nil { | ||
| 245 | - | return fmt.Errorf("vev: broker sandbox ownership: %w", err) | 245 | + | return fmt.Errorf("broker sandbox ownership: %w", err) |
| 246 | } | 246 | } | ||
| 247 | defer func() { retErr = errors.Join(retErr, owner.Release()) }() | 247 | defer func() { retErr = errors.Join(retErr, owner.Release()) }() | ||
| 248 | 248 | ||||
| @@ -258,7 +258,7 @@ func runBrokerServe(ctx context.Context, options brokerServeOptions, deps broker | |||||
| 258 | for _, endpoint := range config.Endpoints() { | 258 | for _, endpoint := range config.Endpoints() { | ||
| 259 | registration, _ := config.Registration(endpoint) | 259 | registration, _ := config.Registration(endpoint) | ||
| 260 | if registration.Route.KnownHostsFile() != "" || registration.Route.ConnectTimeout() != 0 { | 260 | if registration.Route.KnownHostsFile() != "" || registration.Route.ConnectTimeout() != 0 { | ||
| 261 | - | return errors.New("vev: broker routes require SSH trust and timeouts in OpenSSH configuration") | 261 | + | return errors.New("broker routes require SSH trust and timeouts in OpenSSH configuration") |
| 262 | } | 262 | } | ||
| 263 | } | 263 | } | ||
| 264 | initialHosts, err := configuredBrokerHosts(config) | 264 | initialHosts, err := configuredBrokerHosts(config) | ||
| @@ -267,7 +267,7 @@ func runBrokerServe(ctx context.Context, options brokerServeOptions, deps broker | |||||
| 267 | } | 267 | } | ||
| 268 | store, err := brokerstore.Open(brokerstore.Options{Dir: layout.State, InitialHosts: initialHosts, InitialImportProvided: true}) | 268 | store, err := brokerstore.Open(brokerstore.Options{Dir: layout.State, InitialHosts: initialHosts, InitialImportProvided: true}) | ||
| 269 | if err != nil { | 269 | if err != nil { | ||
| 270 | - | return fmt.Errorf("vev: open broker sandbox store: %w", err) | 270 | + | return fmt.Errorf("open broker sandbox store: %w", err) |
| 271 | } | 271 | } | ||
| 272 | defer func() { retErr = errors.Join(retErr, store.Close()) }() | 272 | defer func() { retErr = errors.Join(retErr, store.Close()) }() | ||
| 273 | if err := upgradeBrokerHostVersions(store); err != nil { | 273 | if err := upgradeBrokerHostVersions(store); err != nil { | ||
| @@ -356,7 +356,7 @@ func runBrokerServe(ctx context.Context, options brokerServeOptions, deps broker | |||||
| 356 | socketPath := brokeripc.SocketPath(layout.Runtime) | 356 | socketPath := brokeripc.SocketPath(layout.Runtime) | ||
| 357 | listener, err := deps.listen(socketPath, epoch, authority, brokeripc.Config{OnRetire: cancel}) | 357 | listener, err := deps.listen(socketPath, epoch, authority, brokeripc.Config{OnRetire: cancel}) | ||
| 358 | if err != nil { | 358 | if err != nil { | ||
| 359 | - | return fmt.Errorf("vev: listen on broker sandbox socket: %w", err) | 359 | + | return fmt.Errorf("listen on broker sandbox socket: %w", err) |
| 360 | } | 360 | } | ||
| 361 | if err := supervisor.RegisterCloseable("listener", listener); err != nil { | 361 | if err := supervisor.RegisterCloseable("listener", listener); err != nil { | ||
| 362 | _ = listener.Close() | 362 | _ = listener.Close() | ||
| @@ -410,11 +410,11 @@ func configuredBrokerHosts(config *brokerconfig.Config) ([]ports.BrokerHostRecor | |||||
| 410 | for _, endpoint := range config.Endpoints() { | 410 | for _, endpoint := range config.Endpoints() { | ||
| 411 | registration, ok := config.Registration(endpoint) | 411 | registration, ok := config.Registration(endpoint) | ||
| 412 | if !ok { | 412 | if !ok { | ||
| 413 | - | return nil, fmt.Errorf("vev: missing configured broker registration %q", endpoint) | 413 | + | return nil, fmt.Errorf("missing configured broker registration %q", endpoint) |
| 414 | } | 414 | } | ||
| 415 | route, err := durableBrokerRoute(registration.Route) | 415 | route, err := durableBrokerRoute(registration.Route) | ||
| 416 | if err != nil { | 416 | if err != nil { | ||
| 417 | - | return nil, fmt.Errorf("vev: import configured broker host %q: %w", endpoint, err) | 417 | + | return nil, fmt.Errorf("import configured broker host %q: %w", endpoint, err) |
| 418 | } | 418 | } | ||
| 419 | records = append(records, ports.BrokerHostRecord{Registration: registration.Registration, Pinned: true, Policy: registration.Policy, Identity: registration.Identity, Route: route}) | 419 | records = append(records, ports.BrokerHostRecord{Registration: registration.Registration, Pinned: true, Policy: registration.Policy, Identity: registration.Identity, Route: route}) | ||
| 420 | } | 420 | } | ||
| @@ -456,7 +456,7 @@ func durableBrokerRoute(route brokerconfig.Route) (ports.BrokerRouteSpec, error) | |||||
| 456 | // with, exactly as before. | 456 | // with, exactly as before. | ||
| 457 | func offlineRegistryConfig(config *brokerconfig.Config, epoch ports.BrokerEpoch, connector ports.BrokerEndpointConnector, loaders ...broker.LocalIdentityLoader) (broker.RegistryConfig, error) { | 457 | func offlineRegistryConfig(config *brokerconfig.Config, epoch ports.BrokerEpoch, connector ports.BrokerEndpointConnector, loaders ...broker.LocalIdentityLoader) (broker.RegistryConfig, error) { | ||
| 458 | if config == nil { | 458 | if config == nil { | ||
| 459 | - | return broker.RegistryConfig{}, errors.New("vev: offline registry requires configuration") | 459 | + | return broker.RegistryConfig{}, errors.New("offline registry requires configuration") |
| 460 | } | 460 | } | ||
| 461 | if _, ok := config.LocalBinding(); !ok { | 461 | if _, ok := config.LocalBinding(); !ok { | ||
| 462 | mode := broker.MembershipImmutable | 462 | mode := broker.MembershipImmutable | ||
M internal/app/broker_offline_client_test_helpers_test.go +1 -1
| @@ -20,7 +20,7 @@ import ( | |||||
| 20 | // or stream of its own. | 20 | // or stream of its own. | ||
| 21 | func runOfflineClient(ctx context.Context, socket string, terminal ports.Terminal, ui *client.UI, log *slog.Logger, onState func(client.State), onFailure func(error)) error { | 21 | func runOfflineClient(ctx context.Context, socket string, terminal ports.Terminal, ui *client.UI, log *slog.Logger, onState func(client.State), onFailure func(error)) error { | ||
| 22 | if terminal == nil { | 22 | if terminal == nil { | ||
| 23 | - | return errors.New("vev: offline client requires a terminal") | 23 | + | return errors.New("offline client requires a terminal") |
| 24 | } | 24 | } | ||
| 25 | if log != nil { | 25 | if log != nil { | ||
| 26 | log.Debug("broker_offline_client", "socket", socket) | 26 | log.Debug("broker_offline_client", "socket", socket) | ||
M internal/app/broker_offline_local.go +4 -4
| @@ -30,10 +30,10 @@ func newDynamicLocalRouteProbe(route brokerconfig.Route, policy ports.BrokerPoli | |||||
| 30 | // time; this re-checks the exported binding a caller may have built itself, | 30 | // time; this re-checks the exported binding a caller may have built itself, | ||
| 31 | // because the dial seam routes an ssh address to a child process. | 31 | // because the dial seam routes an ssh address to a child process. | ||
| 32 | if !route.IsLocal() { | 32 | if !route.IsLocal() { | ||
| 33 | - | return nil, errors.New("vev: broker local probe requires a local route") | 33 | + | return nil, errors.New("broker local probe requires a local route") |
| 34 | } | 34 | } | ||
| 35 | if err := route.Validate(); err != nil { | 35 | if err := route.Validate(); err != nil { | ||
| 36 | - | return nil, fmt.Errorf("vev: broker local probe: %w", err) | 36 | + | return nil, fmt.Errorf("broker local probe: %w", err) |
| 37 | } | 37 | } | ||
| 38 | return broker.NewLocalDaemonProbe(epoch, route.Address(), policy, connector, sessionwire.BrokerCodec{}, loadIdentity) | 38 | return broker.NewLocalDaemonProbe(epoch, route.Address(), policy, connector, sessionwire.BrokerCodec{}, loadIdentity) | ||
| 39 | } | 39 | } | ||
| @@ -43,7 +43,7 @@ func newDynamicLocalRouteProbe(route brokerconfig.Route, policy ports.BrokerPoli | |||||
| 43 | func brokerLocalProbe(config *brokerconfig.Config, epoch ports.BrokerEpoch, connector ports.BrokerEndpointConnector, loaders ...broker.LocalIdentityLoader) (*broker.LocalDaemonProbe, error) { | 43 | func brokerLocalProbe(config *brokerconfig.Config, epoch ports.BrokerEpoch, connector ports.BrokerEndpointConnector, loaders ...broker.LocalIdentityLoader) (*broker.LocalDaemonProbe, error) { | ||
| 44 | binding, ok := config.LocalBinding() | 44 | binding, ok := config.LocalBinding() | ||
| 45 | if !ok { | 45 | if !ok { | ||
| 46 | - | return nil, errors.New("vev: broker local probe requires a provisioned local binding") | 46 | + | return nil, errors.New("broker local probe requires a provisioned local binding") |
| 47 | } | 47 | } | ||
| 48 | loader := broker.LocalIdentityLoader(func() (ports.BrokerDaemonIdentity, error) { return binding.Identity, nil }) | 48 | loader := broker.LocalIdentityLoader(func() (ports.BrokerDaemonIdentity, error) { return binding.Identity, nil }) | ||
| 49 | if len(loaders) != 0 { | 49 | if len(loaders) != 0 { | ||
| @@ -58,7 +58,7 @@ func brokerLocalProbe(config *brokerconfig.Config, epoch ports.BrokerEpoch, conn | |||||
| 58 | func brokerLocalObservation(config *brokerconfig.Config, epoch ports.BrokerEpoch, connector ports.BrokerEndpointConnector, loaders ...broker.LocalIdentityLoader) (*broker.LocalObservation, error) { | 58 | func brokerLocalObservation(config *brokerconfig.Config, epoch ports.BrokerEpoch, connector ports.BrokerEndpointConnector, loaders ...broker.LocalIdentityLoader) (*broker.LocalObservation, error) { | ||
| 59 | binding, ok := config.LocalBinding() | 59 | binding, ok := config.LocalBinding() | ||
| 60 | if !ok { | 60 | if !ok { | ||
| 61 | - | return nil, errors.New("vev: broker local observation requires a provisioned local binding") | 61 | + | return nil, errors.New("broker local observation requires a provisioned local binding") |
| 62 | } | 62 | } | ||
| 63 | probe, err := brokerLocalProbe(config, epoch, connector, loaders...) | 63 | probe, err := brokerLocalProbe(config, epoch, connector, loaders...) | ||
| 64 | if err != nil { | 64 | if err != nil { | ||
M internal/app/broker_offline_mux.go +33 -9
| @@ -181,7 +181,7 @@ func brokerDynamicMuxConnector(config *brokerconfig.Config, routes *broker.Route | |||||
| 181 | } | 181 | } | ||
| 182 | binding, ok := config.LocalBinding() | 182 | binding, ok := config.LocalBinding() | ||
| 183 | if !ok || binding.Route.Address() != target.Address || !binding.Policy.Compatible(target.Policy) { | 183 | if !ok || binding.Route.Address() != target.Address || !binding.Policy.Compatible(target.Policy) { | ||
| 184 | - | return nil, ports.BrokerError{Code: ports.BrokerErrorConflictingPolicy, Cause: errors.New("vev: broker mux: local route authority does not match the resolved target")} | 184 | + | return nil, ports.BrokerError{Code: ports.BrokerErrorConflictingPolicy, Cause: errors.New("broker mux: local route authority does not match the resolved target")} |
| 185 | } | 185 | } | ||
| 186 | return dialBrokerRoute(ctx, binding.Route, target, log) | 186 | return dialBrokerRoute(ctx, binding.Route, target, log) | ||
| 187 | }, daemonmux.DefaultMuxCeilings()) | 187 | }, daemonmux.DefaultMuxCeilings()) | ||
| @@ -221,7 +221,7 @@ func dialBrokerRoute(ctx context.Context, route brokerconfig.Route, target ports | |||||
| 221 | case brokerconfig.RouteSSHQUIC: | 221 | case brokerconfig.RouteSSHQUIC: | ||
| 222 | return dialBrokerQUICRoute(ctx, route, target.StartMode, log) | 222 | return dialBrokerQUICRoute(ctx, route, target.StartMode, log) | ||
| 223 | default: | 223 | default: | ||
| 224 | - | return nil, fmt.Errorf("vev: broker mux: unsupported route kind %q", route.Kind()) | 224 | + | return nil, fmt.Errorf("broker mux: unsupported route kind %q", route.Kind()) |
| 225 | } | 225 | } | ||
| 226 | } | 226 | } | ||
| 227 | 227 | ||||
| @@ -241,7 +241,7 @@ func sshMuxCommandSpec(route brokerconfig.Route, mode ports.BrokerDaemonStartMod | |||||
| 241 | argv := route.Argv() | 241 | argv := route.Argv() | ||
| 242 | for _, word := range argv { | 242 | for _, word := range argv { | ||
| 243 | if word == brokerDaemonStartArg { | 243 | if word == brokerDaemonStartArg { | ||
| 244 | - | return sshstdio.CommandSpec{}, fmt.Errorf("vev: broker mux route provisions its own %s", brokerDaemonStartArg) | 244 | + | return sshstdio.CommandSpec{}, fmt.Errorf("broker mux route provisions its own %s", brokerDaemonStartArg) |
| 245 | } | 245 | } | ||
| 246 | } | 246 | } | ||
| 247 | argv = append(argv, brokerDaemonStartArg, flag) | 247 | argv = append(argv, brokerDaemonStartArg, flag) | ||
| @@ -452,7 +452,7 @@ func brokerQUICUnavailable(action string, err error) error { | |||||
| 452 | if err == nil { | 452 | if err == nil { | ||
| 453 | err = errors.New("unavailable") | 453 | err = errors.New("unavailable") | ||
| 454 | } | 454 | } | ||
| 455 | - | return fmt.Errorf("vev: broker mux QUIC unavailable: %s: %w", action, err) | 455 | + | return fmt.Errorf("broker mux QUIC unavailable: %s: %w", action, err) |
| 456 | } | 456 | } | ||
| 457 | 457 | ||||
| 458 | // runBrokerMuxStdioCommand bridges the process' own stdio to the single | 458 | // runBrokerMuxStdioCommand bridges the process' own stdio to the single | ||
| @@ -464,12 +464,12 @@ func runBrokerMuxStdioCommand(ctx context.Context, options brokerMuxOptions) err | |||||
| 464 | if daemonCarriageAbsence(err) { | 464 | if daemonCarriageAbsence(err) { | ||
| 465 | return &exitCoded{code: sshstdio.MuxExitNoDaemon, err: err} | 465 | return &exitCoded{code: sshstdio.MuxExitNoDaemon, err: err} | ||
| 466 | } | 466 | } | ||
| 467 | - | return fmt.Errorf("vev: broker mux stdio: dial daemonmux carriage: %w", err) | 467 | + | return fmt.Errorf("broker mux stdio: dial daemonmux carriage: %w", err) |
| 468 | } | 468 | } | ||
| 469 | bounded, ok := raw.(wire.BoundedTransport) | 469 | bounded, ok := raw.(wire.BoundedTransport) | ||
| 470 | if !ok { | 470 | if !ok { | ||
| 471 | _ = raw.Close() | 471 | _ = raw.Close() | ||
| 472 | - | return errors.New("vev: broker mux stdio: carriage is not a bounded transport") | 472 | + | return errors.New("broker mux stdio: carriage is not a bounded transport") |
| 473 | } | 473 | } | ||
| 474 | return bridgeMuxTransports(ctx, sshstdio.NewStdioTransport(), bounded) | 474 | return bridgeMuxTransports(ctx, sshstdio.NewStdioTransport(), bounded) | ||
| 475 | } | 475 | } | ||
| @@ -522,7 +522,7 @@ func runBrokerMuxQUICBootstrapCommand(ctx context.Context, options brokerMuxOpti | |||||
| 522 | if err != nil { | 522 | if err != nil { | ||
| 523 | _ = cmd.Process.Kill() | 523 | _ = cmd.Process.Kill() | ||
| 524 | _ = cmd.Wait() | 524 | _ = cmd.Wait() | ||
| 525 | - | return fmt.Errorf("vev: broker mux bootstrap readiness: %w", err) | 525 | + | return fmt.Errorf("broker mux bootstrap readiness: %w", err) |
| 526 | } | 526 | } | ||
| 527 | line, err := quic.EncodeReadiness(readiness) | 527 | line, err := quic.EncodeReadiness(readiness) | ||
| 528 | if err != nil { | 528 | if err != nil { | ||
| @@ -576,12 +576,12 @@ func runBrokerMuxQUICProxyCommand(ctx context.Context, options brokerMuxOptions) | |||||
| 576 | if daemonCarriageAbsence(err) { | 576 | if daemonCarriageAbsence(err) { | ||
| 577 | return &exitCoded{code: sshstdio.MuxExitNoDaemon, err: err} | 577 | return &exitCoded{code: sshstdio.MuxExitNoDaemon, err: err} | ||
| 578 | } | 578 | } | ||
| 579 | - | return fmt.Errorf("vev: broker mux quic proxy: dial daemonmux carriage: %w", err) | 579 | + | return fmt.Errorf("broker mux quic proxy: dial daemonmux carriage: %w", err) |
| 580 | } | 580 | } | ||
| 581 | bounded, ok := raw.(wire.BoundedTransport) | 581 | bounded, ok := raw.(wire.BoundedTransport) | ||
| 582 | if !ok { | 582 | if !ok { | ||
| 583 | _ = raw.Close() | 583 | _ = raw.Close() | ||
| 584 | - | return errors.New("vev: broker mux quic proxy: carriage is not a bounded transport") | 584 | + | return errors.New("broker mux quic proxy: carriage is not a bounded transport") |
| 585 | } | 585 | } | ||
| 586 | return bridgeMuxTransports(ctx, transport, bounded) | 586 | return bridgeMuxTransports(ctx, transport, bounded) | ||
| 587 | } | 587 | } | ||
| @@ -643,3 +643,27 @@ func orderlyRelay(err error) error { | |||||
| 643 | } | 643 | } | ||
| 644 | return err | 644 | return err | ||
| 645 | } | 645 | } | ||
| 646 | + | ||||
| 647 | + | // gracefulTeardownWaiter is implemented by carriages (QUIC) whose Close defers | |||
| 648 | + | // a bounded connection close past its return. The broker mux QUIC proxy owns its | |||
| 649 | + | // process, so it must wait for that close before returning: exiting first | |||
| 650 | + | // would discard the final synchronous envelope Close already handed to the | |||
| 651 | + | // stream. | |||
| 652 | + | type gracefulTeardownWaiter interface { | |||
| 653 | + | WaitGracefulTeardown(ctx context.Context) error | |||
| 654 | + | } | |||
| 655 | + | ||||
| 656 | + | // proxyTeardownTimeout bounds the proxy's wait for a deferred graceful close, | |||
| 657 | + | // leaving headroom beyond the adapter's own graceful window. A carriage that | |||
| 658 | + | // does not defer its close is not waited on. | |||
| 659 | + | const proxyTeardownTimeout = 2 * time.Second | |||
| 660 | + | ||||
| 661 | + | func waitGracefulTeardown(transport wire.Transport) error { | |||
| 662 | + | waiter, ok := transport.(gracefulTeardownWaiter) | |||
| 663 | + | if !ok { | |||
| 664 | + | return nil | |||
| 665 | + | } | |||
| 666 | + | ctx, cancel := context.WithTimeout(context.Background(), proxyTeardownTimeout) | |||
| 667 | + | defer cancel() | |||
| 668 | + | return waiter.WaitGracefulTeardown(ctx) | |||
| 669 | + | } | |||
M internal/app/broker_offline_status.go +20 -20
| @@ -72,15 +72,15 @@ var ( | |||||
| 72 | // errBrokerAbsent reports that no broker is bound at the endpoint, so a | 72 | // errBrokerAbsent reports that no broker is bound at the endpoint, so a | ||
| 73 | // spawn may recover it. A missing socket and a stale socket left by a dead | 73 | // spawn may recover it. A missing socket and a stale socket left by a dead | ||
| 74 | // owner are both absence. | 74 | // owner are both absence. | ||
| 75 | - | errBrokerAbsent = errors.New("vev: broker endpoint is absent") | 75 | + | errBrokerAbsent = errors.New("broker endpoint is absent") |
| 76 | // errBrokerIncompatible reports that the endpoint exists but is not a | 76 | // errBrokerIncompatible reports that the endpoint exists but is not a | ||
| 77 | // compatible broker (a foreign path, a live endpoint that fails the broker | 77 | // compatible broker (a foreign path, a live endpoint that fails the broker | ||
| 78 | // handshake, or a handshake that stalls past its bound). It is never an | 78 | // handshake, or a handshake that stalls past its bound). It is never an | ||
| 79 | // invitation to spawn. | 79 | // invitation to spawn. | ||
| 80 | - | errBrokerIncompatible = errors.New("vev: broker endpoint is not a compatible broker") | 80 | + | errBrokerIncompatible = errors.New("broker endpoint is not a compatible broker") |
| 81 | // errBrokerNotReady reports that --ensure did not observe a ready broker | 81 | // errBrokerNotReady reports that --ensure did not observe a ready broker | ||
| 82 | // before the overall deadline. | 82 | // before the overall deadline. | ||
| 83 | - | errBrokerNotReady = errors.New("vev: broker did not become ready") | 83 | + | errBrokerNotReady = errors.New("broker did not become ready") |
| 84 | ) | 84 | ) | ||
| 85 | 85 | ||||
| 86 | // brokerStatusReport is the bounded JSON status document. It carries exactly | 86 | // brokerStatusReport is the bounded JSON status document. It carries exactly | ||
| @@ -160,11 +160,11 @@ func runProductionBrokerLauncherCommand(context.Context) error { | |||||
| 160 | func startDetachedBrokerServe(args []string, crashPath string) error { | 160 | func startDetachedBrokerServe(args []string, crashPath string) error { | ||
| 161 | exePath, err := selfExePath() | 161 | exePath, err := selfExePath() | ||
| 162 | if err != nil { | 162 | if err != nil { | ||
| 163 | - | return fmt.Errorf("vev: resolving executable path: %w", err) | 163 | + | return fmt.Errorf("resolving executable path: %w", err) |
| 164 | } | 164 | } | ||
| 165 | devNull, err := os.OpenFile(os.DevNull, os.O_RDWR, 0) | 165 | devNull, err := os.OpenFile(os.DevNull, os.O_RDWR, 0) | ||
| 166 | if err != nil { | 166 | if err != nil { | ||
| 167 | - | return fmt.Errorf("vev: opening %s: %w", os.DevNull, err) | 167 | + | return fmt.Errorf("opening %s: %w", os.DevNull, err) |
| 168 | } | 168 | } | ||
| 169 | defer func() { _ = devNull.Close() }() | 169 | defer func() { _ = devNull.Close() }() | ||
| 170 | 170 | ||||
| @@ -177,21 +177,21 @@ func startDetachedBrokerServe(args []string, crashPath string) error { | |||||
| 177 | cmd.Stderr = devNull | 177 | cmd.Stderr = devNull | ||
| 178 | } else { | 178 | } else { | ||
| 179 | if err := safedir.EnsurePrivate(filepath.Dir(crashPath)); err != nil { | 179 | if err := safedir.EnsurePrivate(filepath.Dir(crashPath)); err != nil { | ||
| 180 | - | return fmt.Errorf("vev: secure broker crash log directory: %w", err) | 180 | + | return fmt.Errorf("secure broker crash log directory: %w", err) |
| 181 | } | 181 | } | ||
| 182 | if err := os.Rename(crashPath, crashPath+".prev"); err != nil && !errors.Is(err, os.ErrNotExist) { | 182 | if err := os.Rename(crashPath, crashPath+".prev"); err != nil && !errors.Is(err, os.ErrNotExist) { | ||
| 183 | - | return fmt.Errorf("vev: preserve previous broker crash log: %w", err) | 183 | + | return fmt.Errorf("preserve previous broker crash log: %w", err) |
| 184 | } | 184 | } | ||
| 185 | crashLog, err := os.OpenFile(crashPath, os.O_CREATE|os.O_WRONLY|os.O_EXCL, 0o600) | 185 | crashLog, err := os.OpenFile(crashPath, os.O_CREATE|os.O_WRONLY|os.O_EXCL, 0o600) | ||
| 186 | if err != nil { | 186 | if err != nil { | ||
| 187 | - | return fmt.Errorf("vev: open broker crash log: %w", err) | 187 | + | return fmt.Errorf("open broker crash log: %w", err) |
| 188 | } | 188 | } | ||
| 189 | defer func() { _ = crashLog.Close() }() | 189 | defer func() { _ = crashLog.Close() }() | ||
| 190 | cmd.Stderr = crashLog | 190 | cmd.Stderr = crashLog | ||
| 191 | } | 191 | } | ||
| 192 | cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true} | 192 | cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true} | ||
| 193 | if err := cmd.Start(); err != nil { | 193 | if err := cmd.Start(); err != nil { | ||
| 194 | - | return fmt.Errorf("vev: starting detached broker: %w", err) | 194 | + | return fmt.Errorf("starting detached broker: %w", err) |
| 195 | } | 195 | } | ||
| 196 | return cmd.Process.Release() | 196 | return cmd.Process.Release() | ||
| 197 | } | 197 | } | ||
| @@ -209,11 +209,11 @@ func spawnProductionBrokerLauncher(ctx context.Context) error { | |||||
| 209 | func runBrokerLauncherProcess(ctx context.Context, args []string) error { | 209 | func runBrokerLauncherProcess(ctx context.Context, args []string) error { | ||
| 210 | exePath, err := selfExePath() | 210 | exePath, err := selfExePath() | ||
| 211 | if err != nil { | 211 | if err != nil { | ||
| 212 | - | return fmt.Errorf("vev: resolving executable path: %w", err) | 212 | + | return fmt.Errorf("resolving executable path: %w", err) |
| 213 | } | 213 | } | ||
| 214 | devNull, err := os.OpenFile(os.DevNull, os.O_RDWR, 0) | 214 | devNull, err := os.OpenFile(os.DevNull, os.O_RDWR, 0) | ||
| 215 | if err != nil { | 215 | if err != nil { | ||
| 216 | - | return fmt.Errorf("vev: opening %s: %w", os.DevNull, err) | 216 | + | return fmt.Errorf("opening %s: %w", os.DevNull, err) |
| 217 | } | 217 | } | ||
| 218 | defer func() { _ = devNull.Close() }() | 218 | defer func() { _ = devNull.Close() }() | ||
| 219 | 219 | ||||
| @@ -226,9 +226,9 @@ func runBrokerLauncherProcess(ctx context.Context, args []string) error { | |||||
| 226 | cmd.Stderr = &stderr | 226 | cmd.Stderr = &stderr | ||
| 227 | if err := cmd.Run(); err != nil { | 227 | if err := cmd.Run(); err != nil { | ||
| 228 | if detail := bytes.TrimSpace(stderr.Bytes()); len(detail) > 0 { | 228 | if detail := bytes.TrimSpace(stderr.Bytes()); len(detail) > 0 { | ||
| 229 | - | return fmt.Errorf("vev: launching broker: %w: %s", err, detail) | 229 | + | return fmt.Errorf("launching broker: %w: %s", err, detail) |
| 230 | } | 230 | } | ||
| 231 | - | return fmt.Errorf("vev: launching broker: %w", err) | 231 | + | return fmt.Errorf("launching broker: %w", err) |
| 232 | } | 232 | } | ||
| 233 | return nil | 233 | return nil | ||
| 234 | } | 234 | } | ||
| @@ -255,10 +255,10 @@ func ensureBrokerReady(ctx context.Context, req brokerStatusRequest, deps broker | |||||
| 255 | // directory lives beneath the runtime directory, and both must be private | 255 | // directory lives beneath the runtime directory, and both must be private | ||
| 256 | // and symlink-free before they can hold an exclusive lock. | 256 | // and symlink-free before they can hold an exclusive lock. | ||
| 257 | if err := safedir.EnsurePrivate(req.layout.Spawn); err != nil { | 257 | if err := safedir.EnsurePrivate(req.layout.Spawn); err != nil { | ||
| 258 | - | return brokerStatusReport{}, fmt.Errorf("vev: secure broker sandbox spawn directory: %w", err) | 258 | + | return brokerStatusReport{}, fmt.Errorf("secure broker sandbox spawn directory: %w", err) |
| 259 | } | 259 | } | ||
| 260 | if err := req.layout.VerifyCreated(); err != nil { | 260 | if err := req.layout.VerifyCreated(); err != nil { | ||
| 261 | - | return brokerStatusReport{}, fmt.Errorf("vev: verify broker sandbox paths: %w", err) | 261 | + | return brokerStatusReport{}, fmt.Errorf("verify broker sandbox paths: %w", err) |
| 262 | } | 262 | } | ||
| 263 | 263 | ||||
| 264 | var held brokerStatusLock | 264 | var held brokerStatusLock | ||
| @@ -293,7 +293,7 @@ func ensureBrokerReady(ctx context.Context, req brokerStatusRequest, deps broker | |||||
| 293 | return brokerStatusReport{}, err | 293 | return brokerStatusReport{}, err | ||
| 294 | } | 294 | } | ||
| 295 | if !errors.Is(err, errBrokerAbsent) { | 295 | if !errors.Is(err, errBrokerAbsent) { | ||
| 296 | - | return brokerStatusReport{}, fmt.Errorf("vev: broker endpoint %s is live but incompatible: %w; run \"vev kill --all\" and retry", req.socketPath, err) | 296 | + | return brokerStatusReport{}, fmt.Errorf("broker endpoint %s is live but incompatible: %w; run \"vev kill --all\" and retry", req.socketPath, err) |
| 297 | } | 297 | } | ||
| 298 | 298 | ||||
| 299 | if held == nil { | 299 | if held == nil { | ||
| @@ -314,7 +314,7 @@ func ensureBrokerReady(ctx context.Context, req brokerStatusRequest, deps broker | |||||
| 314 | return brokerStatusReport{}, err | 314 | return brokerStatusReport{}, err | ||
| 315 | } | 315 | } | ||
| 316 | if !errors.Is(err, errBrokerAbsent) { | 316 | if !errors.Is(err, errBrokerAbsent) { | ||
| 317 | - | return brokerStatusReport{}, fmt.Errorf("vev: broker endpoint %s is live but incompatible: %w; run \"vev kill --all\" and retry", req.socketPath, err) | 317 | + | return brokerStatusReport{}, fmt.Errorf("broker endpoint %s is live but incompatible: %w; run \"vev kill --all\" and retry", req.socketPath, err) |
| 318 | } | 318 | } | ||
| 319 | case errors.Is(err, lifecycle.ErrBusy): | 319 | case errors.Is(err, lifecycle.ErrBusy): | ||
| 320 | // Another caller is elected and spawning. Only the elected | 320 | // Another caller is elected and spawning. Only the elected | ||
| @@ -329,13 +329,13 @@ func ensureBrokerReady(ctx context.Context, req brokerStatusRequest, deps broker | |||||
| 329 | } | 329 | } | ||
| 330 | continue | 330 | continue | ||
| 331 | default: | 331 | default: | ||
| 332 | - | return brokerStatusReport{}, fmt.Errorf("vev: elect broker spawner: %w", err) | 332 | + | return brokerStatusReport{}, fmt.Errorf("elect broker spawner: %w", err) |
| 333 | } | 333 | } | ||
| 334 | } | 334 | } | ||
| 335 | if held != nil && !spawned { | 335 | if held != nil && !spawned { | ||
| 336 | busy, err := deps.lifetimeOwner(req.layout.Runtime) | 336 | busy, err := deps.lifetimeOwner(req.layout.Runtime) | ||
| 337 | if err != nil { | 337 | if err != nil { | ||
| 338 | - | return brokerStatusReport{}, fmt.Errorf("vev: inspect broker lifetime owner: %w", err) | 338 | + | return brokerStatusReport{}, fmt.Errorf("inspect broker lifetime owner: %w", err) |
| 339 | } | 339 | } | ||
| 340 | if !busy { | 340 | if !busy { | ||
| 341 | if err := deps.spawn(ctx, req.root, req.grace, req.graceSet); err != nil { | 341 | if err := deps.spawn(ctx, req.root, req.grace, req.graceSet); err != nil { | ||
| @@ -379,7 +379,7 @@ func probeBrokerStatus(ctx context.Context, socketPath string) (brokerStatusRepo | |||||
| 379 | case errors.Is(err, fs.ErrNotExist): | 379 | case errors.Is(err, fs.ErrNotExist): | ||
| 380 | return brokerStatusReport{}, absentError(socketPath) | 380 | return brokerStatusReport{}, absentError(socketPath) | ||
| 381 | case err != nil: | 381 | case err != nil: | ||
| 382 | - | return brokerStatusReport{}, fmt.Errorf("vev: inspect broker endpoint %s: %w", socketPath, err) | 382 | + | return brokerStatusReport{}, fmt.Errorf("inspect broker endpoint %s: %w", socketPath, err) |
| 383 | case info.Mode()&os.ModeSocket == 0: | 383 | case info.Mode()&os.ModeSocket == 0: | ||
| 384 | return brokerStatusReport{}, incompatibleError(socketPath, errors.New("path is not a Unix socket")) | 384 | return brokerStatusReport{}, incompatibleError(socketPath, errors.New("path is not a Unix socket")) | ||
| 385 | } | 385 | } | ||
M internal/app/broker_production.go +7 -7
| @@ -30,14 +30,14 @@ func productionBrokerConfigPath() string { | |||||
| 30 | func ensureProductionBrokerConfig() error { | 30 | func ensureProductionBrokerConfig() error { | ||
| 31 | path := productionBrokerConfigPath() | 31 | path := productionBrokerConfigPath() | ||
| 32 | if err := safedir.EnsurePrivate(filepath.Dir(path)); err != nil { | 32 | if err := safedir.EnsurePrivate(filepath.Dir(path)); err != nil { | ||
| 33 | - | return fmt.Errorf("vev: secure broker configuration directory: %w", err) | 33 | + | return fmt.Errorf("secure broker configuration directory: %w", err) |
| 34 | } | 34 | } | ||
| 35 | file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) | 35 | file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) | ||
| 36 | if errors.Is(err, os.ErrExist) { | 36 | if errors.Is(err, os.ErrExist) { | ||
| 37 | return nil | 37 | return nil | ||
| 38 | } | 38 | } | ||
| 39 | if err != nil { | 39 | if err != nil { | ||
| 40 | - | return fmt.Errorf("vev: create broker configuration: %w", err) | 40 | + | return fmt.Errorf("create broker configuration: %w", err) |
| 41 | } | 41 | } | ||
| 42 | _, writeErr := file.WriteString(`{"marker":"vev.broker.offline/v1","registrations":[]}`) | 42 | _, writeErr := file.WriteString(`{"marker":"vev.broker.offline/v1","registrations":[]}`) | ||
| 43 | closeErr := file.Close() | 43 | closeErr := file.Close() | ||
| @@ -46,9 +46,9 @@ func ensureProductionBrokerConfig() error { | |||||
| 46 | } | 46 | } | ||
| 47 | removeErr := os.Remove(path) | 47 | removeErr := os.Remove(path) | ||
| 48 | if writeErr != nil { | 48 | if writeErr != nil { | ||
| 49 | - | return errors.Join(fmt.Errorf("vev: write broker configuration: %w", writeErr), closeErr, removeErr) | 49 | + | return errors.Join(fmt.Errorf("write broker configuration: %w", writeErr), closeErr, removeErr) |
| 50 | } | 50 | } | ||
| 51 | - | return errors.Join(fmt.Errorf("vev: close broker configuration: %w", closeErr), removeErr) | 51 | + | return errors.Join(fmt.Errorf("close broker configuration: %w", closeErr), removeErr) |
| 52 | } | 52 | } | ||
| 53 | 53 | ||||
| 54 | // remoteBrokerPolicy builds the remote host policy for a CLI transport name | 54 | // remoteBrokerPolicy builds the remote host policy for a CLI transport name | ||
| @@ -57,7 +57,7 @@ func ensureProductionBrokerConfig() error { | |||||
| 57 | func remoteBrokerPolicy(transport string) ports.BrokerPolicy { | 57 | func remoteBrokerPolicy(transport string) ports.BrokerPolicy { | ||
| 58 | route, ok := hostTransportRoutes[transport] | 58 | route, ok := hostTransportRoutes[transport] | ||
| 59 | if !ok { | 59 | if !ok { | ||
| 60 | - | panic(fmt.Sprintf("vev: unknown host transport %q", transport)) | 60 | + | panic(fmt.Sprintf("unknown host transport %q", transport)) |
| 61 | } | 61 | } | ||
| 62 | return ports.BrokerPolicy{ | 62 | return ports.BrokerPolicy{ | ||
| 63 | ProtocolVersion: protocol.Version, CatalogSchemaVersion: catalogue.RemoteCatalogSchemaVersion, | 63 | ProtocolVersion: protocol.Version, CatalogSchemaVersion: catalogue.RemoteCatalogSchemaVersion, | ||
| @@ -73,7 +73,7 @@ func remoteBrokerPolicy(transport string) ports.BrokerPolicy { | |||||
| 73 | func upgradeBrokerHostVersions(store ports.BrokerHostStore) error { | 73 | func upgradeBrokerHostVersions(store ports.BrokerHostStore) error { | ||
| 74 | hosts, err := store.LoadHosts() | 74 | hosts, err := store.LoadHosts() | ||
| 75 | if err != nil { | 75 | if err != nil { | ||
| 76 | - | return fmt.Errorf("vev: load broker hosts: %w", err) | 76 | + | return fmt.Errorf("load broker hosts: %w", err) |
| 77 | } | 77 | } | ||
| 78 | changed := false | 78 | changed := false | ||
| 79 | records := ports.CloneBrokerHostRecords(hosts.Hosts) | 79 | records := ports.CloneBrokerHostRecords(hosts.Hosts) | ||
| @@ -95,7 +95,7 @@ func upgradeBrokerHostVersions(store ports.BrokerHostStore) error { | |||||
| 95 | return nil | 95 | return nil | ||
| 96 | } | 96 | } | ||
| 97 | if err := store.ReplaceHosts(hosts.Revision, records); err != nil { | 97 | if err := store.ReplaceHosts(hosts.Revision, records); err != nil { | ||
| 98 | - | return fmt.Errorf("vev: upgrade broker host versions: %w", err) | 98 | + | return fmt.Errorf("upgrade broker host versions: %w", err) |
| 99 | } | 99 | } | ||
| 100 | return nil | 100 | return nil | ||
| 101 | } | 101 | } | ||
M internal/app/broker_production_connect.go +6 -6
| @@ -43,7 +43,7 @@ func connectProductionBroker(ctx context.Context) (ports.BrokerService, error) { | |||||
| 43 | // Validate the shared parent before creating broker-owned descendants, | 43 | // Validate the shared parent before creating broker-owned descendants, | ||
| 44 | // especially when a long XDG root maps directly into /tmp. | 44 | // especially when a long XDG root maps directly into /tmp. | ||
| 45 | if err := safedir.EnsurePrivate(ipc.SocketDir()); err != nil { | 45 | if err := safedir.EnsurePrivate(ipc.SocketDir()); err != nil { | ||
| 46 | - | return nil, fmt.Errorf("vev: secure broker runtime parent: %w", err) | 46 | + | return nil, fmt.Errorf("secure broker runtime parent: %w", err) |
| 47 | } | 47 | } | ||
| 48 | layout := productionBrokerLayout() | 48 | layout := productionBrokerLayout() | ||
| 49 | request := brokerStatusRequest{ | 49 | request := brokerStatusRequest{ | ||
| @@ -57,7 +57,7 @@ func connectProductionBroker(ctx context.Context) (ports.BrokerService, error) { | |||||
| 57 | } | 57 | } | ||
| 58 | connect := func() (ports.BrokerService, error) { | 58 | connect := func() (ports.BrokerService, error) { | ||
| 59 | if _, err := ensureBrokerReady(ctx, request, deps); err != nil { | 59 | if _, err := ensureBrokerReady(ctx, request, deps); err != nil { | ||
| 60 | - | return nil, fmt.Errorf("vev: ensure broker: %w", err) | 60 | + | return nil, fmt.Errorf("ensure broker: %w", err) |
| 61 | } | 61 | } | ||
| 62 | return brokeripc.NewConnector(request.socketPath, brokeripc.Config{}).Connect(ctx) | 62 | return brokeripc.NewConnector(request.socketPath, brokeripc.Config{}).Connect(ctx) | ||
| 63 | } | 63 | } | ||
| @@ -65,23 +65,23 @@ func connectProductionBroker(ctx context.Context) (ports.BrokerService, error) { | |||||
| 65 | if errors.Is(err, brokeripc.ErrBrokerRetired) { | 65 | if errors.Is(err, brokeripc.ErrBrokerRetired) { | ||
| 66 | owner, acquireErr := lifecycle.Acquire(ctx, layout.Runtime, 50*time.Millisecond) | 66 | owner, acquireErr := lifecycle.Acquire(ctx, layout.Runtime, 50*time.Millisecond) | ||
| 67 | if acquireErr != nil { | 67 | if acquireErr != nil { | ||
| 68 | - | return nil, fmt.Errorf("vev: wait for retiring broker: %w", acquireErr) | 68 | + | return nil, fmt.Errorf("wait for retiring broker: %w", acquireErr) |
| 69 | } | 69 | } | ||
| 70 | if releaseErr := owner.Release(); releaseErr != nil { | 70 | if releaseErr := owner.Release(); releaseErr != nil { | ||
| 71 | - | return nil, fmt.Errorf("vev: release broker ownership: %w", releaseErr) | 71 | + | return nil, fmt.Errorf("release broker ownership: %w", releaseErr) |
| 72 | } | 72 | } | ||
| 73 | request.deadline = time.Now().Add(productionBrokerStartupTimeout) | 73 | request.deadline = time.Now().Add(productionBrokerStartupTimeout) | ||
| 74 | service, err = connect() | 74 | service, err = connect() | ||
| 75 | } | 75 | } | ||
| 76 | if err != nil { | 76 | if err != nil { | ||
| 77 | - | return nil, fmt.Errorf("vev: connect broker: %w", err) | 77 | + | return nil, fmt.Errorf("connect broker: %w", err) |
| 78 | } | 78 | } | ||
| 79 | if _, stale := brokeripc.StaleBuild(service); stale { | 79 | if _, stale := brokeripc.StaleBuild(service); stale { | ||
| 80 | fmt.Fprintln(os.Stderr, `vev: the running broker is from another build; run "vev kill --all" to restart it`) | 80 | fmt.Fprintln(os.Stderr, `vev: the running broker is from another build; run "vev kill --all" to restart it`) | ||
| 81 | } | 81 | } | ||
| 82 | if err := awaitBrokerPublication(ctx, service); err != nil { | 82 | if err := awaitBrokerPublication(ctx, service); err != nil { | ||
| 83 | _ = service.Close() | 83 | _ = service.Close() | ||
| 84 | - | return nil, fmt.Errorf("vev: await broker publication: %w", err) | 84 | + | return nil, fmt.Errorf("await broker publication: %w", err) |
| 85 | } | 85 | } | ||
| 86 | return service, nil | 86 | return service, nil | ||
| 87 | } | 87 | } | ||
A internal/app/cli.go +394 -0
| @@ -0,0 +1,394 @@ | |||||
| 1 | + | package app | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "fmt" | |||
| 5 | + | "strings" | |||
| 6 | + | ||||
| 7 | + | "github.com/bnema/vev/internal/domain" | |||
| 8 | + | "github.com/bnema/vev/internal/protocol" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | // cmdKind identifies which sub-command the CLI parsed. | |||
| 12 | + | type cmdKind int | |||
| 13 | + | ||||
| 14 | + | const ( | |||
| 15 | + | kindAttach cmdKind = iota // ephemeral/new/attach — distinguished by intent | |||
| 16 | + | kindList | |||
| 17 | + | kindHost | |||
| 18 | + | kindKill | |||
| 19 | + | kindCmd | |||
| 20 | + | kindEnv | |||
| 21 | + | kindDaemon | |||
| 22 | + | kindDaemonLauncher | |||
| 23 | + | kindUIDriver | |||
| 24 | + | kindUIRemoteCleanup | |||
| 25 | + | kindDaemonStop | |||
| 26 | + | kindWebDaemon | |||
| 27 | + | kindWebRenew | |||
| 28 | + | kindWebServe | |||
| 29 | + | kindBrokerMuxStdio | |||
| 30 | + | kindBrokerMuxQUICBootstrap | |||
| 31 | + | kindBrokerMuxQUICProxy | |||
| 32 | + | kindProductionBrokerServe | |||
| 33 | + | kindProductionBrokerLauncher | |||
| 34 | + | kindBrokerReady | |||
| 35 | + | kindHelp | |||
| 36 | + | kindVersion | |||
| 37 | + | ) | |||
| 38 | + | ||||
| 39 | + | // command is the parsed CLI invocation: what to do, plus the attach intent | |||
| 40 | + | // and session name where relevant. | |||
| 41 | + | type command struct { | |||
| 42 | + | kind cmdKind | |||
| 43 | + | intent uint8 | |||
| 44 | + | name string | |||
| 45 | + | remoteTarget string | |||
| 46 | + | listHost string | |||
| 47 | + | listAll bool | |||
| 48 | + | hostAction string | |||
| 49 | + | hostTarget string | |||
| 50 | + | // hostTransport is the `host add` carriage: hostTransportQUIC or hostTransportSSH. | |||
| 51 | + | hostTransport string | |||
| 52 | + | killAll bool | |||
| 53 | + | killSessions bool | |||
| 54 | + | cmd cmdInvocation | |||
| 55 | + | env envInvocation | |||
| 56 | + | brokerServe brokerServeOptions | |||
| 57 | + | brokerMux brokerMuxOptions | |||
| 58 | + | brokerReady brokerReadyOptions | |||
| 59 | + | uiDriver uiDriverOptions | |||
| 60 | + | ui interactiveUIOptions | |||
| 61 | + | web webOptions | |||
| 62 | + | } | |||
| 63 | + | ||||
| 64 | + | // usageError is a user-facing argument error; the app prints it (with usage) | |||
| 65 | + | // rather than a stack of wrapped internals. | |||
| 66 | + | type usageError struct{ msg string } | |||
| 67 | + | ||||
| 68 | + | func (e *usageError) Error() string { return e.msg } | |||
| 69 | + | ||||
| 70 | + | func usagef(format string, args ...any) error { | |||
| 71 | + | return &usageError{msg: fmt.Sprintf(format, args...)} | |||
| 72 | + | } | |||
| 73 | + | ||||
| 74 | + | const usageText = `vev — a terminal multiplexer | |||
| 75 | + | ||||
| 76 | + | usage: | |||
| 77 | + | vev attach to (or create) an ephemeral session | |||
| 78 | + | vev new <name> create and attach to a named session | |||
| 79 | + | vev attach <name> attach to an existing session (alias: a) | |||
| 80 | + | vev attach user@host[:session] | |||
| 81 | + | attach through SSH to a remote vev daemon | |||
| 82 | + | vev ls list local sessions | |||
| 83 | + | vev ls <host> list sessions on a known remote host | |||
| 84 | + | vev ls --all list local and remote sessions | |||
| 85 | + | vev host add [--transport quic|ssh] <host> | |||
| 86 | + | add a pinned remote host (default transport: quic) | |||
| 87 | + | vev host rm <host> remove a pinned remote host | |||
| 88 | + | vev host list list known remote hosts | |||
| 89 | + | vev kill <name> kill a session | |||
| 90 | + | vev kill --sessions kill all sessions (vev keeps running) | |||
| 91 | + | vev kill --all stop everything: every vev window, the broker, and the | |||
| 92 | + | daemon (named sessions come back on the next start) | |||
| 93 | + | vev cmd <command> run a control command (vev cmd --help) | |||
| 94 | + | vev env <fish|sh> print shell code that refreshes desktop variables | |||
| 95 | + | vev --ui-observe expose passive observation for this interactive client | |||
| 96 | + | (optional: --ui-socket PATH) | |||
| 97 | + | vev --ui-control expose observation and input control for this client | |||
| 98 | + | (optional: --ui-socket PATH) | |||
| 99 | + | vev --web-daemon start the private web terminal or print its current link | |||
| 100 | + | vev --web-renew-token revoke web access and print a fresh link | |||
| 101 | + | Web options: --web-listen IP:PORT --web-origin http(s)://HOST[:PORT] | |||
| 102 | + | vev --help show this help | |||
| 103 | + | vev --version show version` | |||
| 104 | + | ||||
| 105 | + | // parseArgs turns the raw argv tail into a command. It is deliberately a | |||
| 106 | + | // pure function so the full dispatch table can be unit-tested without any | |||
| 107 | + | // I/O. | |||
| 108 | + | func parseArgs(args []string) (command, error) { | |||
| 109 | + | if len(args) > 0 && args[0] == "--ui-driver" { | |||
| 110 | + | options, err := parseUIDriverArgs(args[1:]) | |||
| 111 | + | if err != nil { | |||
| 112 | + | return command{}, err | |||
| 113 | + | } | |||
| 114 | + | return command{kind: kindUIDriver, uiDriver: options}, nil | |||
| 115 | + | } | |||
| 116 | + | ui, args, err := parseLeadingUIFlags(args) | |||
| 117 | + | if err != nil { | |||
| 118 | + | return command{}, err | |||
| 119 | + | } | |||
| 120 | + | if len(args) == 0 { | |||
| 121 | + | return command{kind: kindAttach, intent: protocol.IntentEphemeral, ui: ui}, nil | |||
| 122 | + | } | |||
| 123 | + | switch args[0] { | |||
| 124 | + | case "new": | |||
| 125 | + | return parseNewArgs(args[1:], ui) | |||
| 126 | + | case "attach", "a": | |||
| 127 | + | return parseAttachArgs(args[1:], ui) | |||
| 128 | + | } | |||
| 129 | + | if ui.enabled() { | |||
| 130 | + | return command{}, usagef("UI flags are only valid for an attach command") | |||
| 131 | + | } | |||
| 132 | + | return parseSubcommand(args) | |||
| 133 | + | } | |||
| 134 | + | ||||
| 135 | + | // parseLeadingUIFlags consumes the interactive UI flags placed before the | |||
| 136 | + | // subcommand and returns the remaining arguments. | |||
| 137 | + | func parseLeadingUIFlags(args []string) (interactiveUIOptions, []string, error) { | |||
| 138 | + | end := 0 | |||
| 139 | + | for end < len(args) { | |||
| 140 | + | switch args[end] { | |||
| 141 | + | case "--ui-observe", "--ui-control": | |||
| 142 | + | end++ | |||
| 143 | + | case "--ui-socket": | |||
| 144 | + | if end+1 >= len(args) || args[end+1] == "" { | |||
| 145 | + | return interactiveUIOptions{}, nil, usagef("`--ui-socket` requires a path") | |||
| 146 | + | } | |||
| 147 | + | end += 2 | |||
| 148 | + | default: | |||
| 149 | + | ui, err := parseInteractiveUIFlags(args[:end], interactiveUIOptions{}) | |||
| 150 | + | return ui, args[end:], err | |||
| 151 | + | } | |||
| 152 | + | } | |||
| 153 | + | ui, err := parseInteractiveUIFlags(args, interactiveUIOptions{}) | |||
| 154 | + | return ui, nil, err | |||
| 155 | + | } | |||
| 156 | + | ||||
| 157 | + | // parseSubcommand parses every subcommand that does not accept UI flags. | |||
| 158 | + | func parseSubcommand(args []string) (command, error) { | |||
| 159 | + | switch args[0] { | |||
| 160 | + | case "--web-daemon", "--web-serve": | |||
| 161 | + | return parseWebArgs(args) | |||
| 162 | + | case "--web-renew-token": | |||
| 163 | + | return parseNoArgs(args, kindWebRenew) | |||
| 164 | + | case "--daemon": | |||
| 165 | + | return command{kind: kindDaemon}, nil | |||
| 166 | + | case "--daemon-launcher": | |||
| 167 | + | return command{kind: kindDaemonLauncher}, nil | |||
| 168 | + | case productionBrokerServeCommand: | |||
| 169 | + | return parseProductionBrokerServeArgs(args[1:]) | |||
| 170 | + | case productionBrokerLauncherCommand: | |||
| 171 | + | return parseProductionBrokerLauncherArgs(args[1:]) | |||
| 172 | + | case brokerReadyCommand: | |||
| 173 | + | options, err := parseBrokerReadyArgs(args[1:]) | |||
| 174 | + | return command{kind: kindBrokerReady, brokerReady: options}, err | |||
| 175 | + | case brokerMuxStdioCommand: | |||
| 176 | + | return parseBrokerMuxArgs(brokerMuxStdioCommand, kindBrokerMuxStdio, args[1:]) | |||
| 177 | + | case brokerMuxQUICBootstrapCommand: | |||
| 178 | + | return parseBrokerMuxArgs(brokerMuxQUICBootstrapCommand, kindBrokerMuxQUICBootstrap, args[1:]) | |||
| 179 | + | case brokerMuxQUICProxyCommand: | |||
| 180 | + | return parseBrokerMuxArgs(brokerMuxQUICProxyCommand, kindBrokerMuxQUICProxy, args[1:]) | |||
| 181 | + | case uiRemoteCleanupCommand: | |||
| 182 | + | return parseNoArgs(args, kindUIRemoteCleanup) | |||
| 183 | + | case daemonStopCommand: | |||
| 184 | + | return parseNoArgs(args, kindDaemonStop) | |||
| 185 | + | case "ls", "list": | |||
| 186 | + | return parseListArgs(args[1:]) | |||
| 187 | + | case "host": | |||
| 188 | + | return parseHostArgs(args[1:]) | |||
| 189 | + | case "env": | |||
| 190 | + | invocation, err := parseEnvArgs(args[1:]) | |||
| 191 | + | if err != nil { | |||
| 192 | + | return command{}, err | |||
| 193 | + | } | |||
| 194 | + | return command{kind: kindEnv, env: invocation}, nil | |||
| 195 | + | case "cmd": | |||
| 196 | + | invocation, err := parseCmdArgs(args[1:]) | |||
| 197 | + | if err != nil { | |||
| 198 | + | return command{}, err | |||
| 199 | + | } | |||
| 200 | + | return command{kind: kindCmd, cmd: invocation}, nil | |||
| 201 | + | case "kill": | |||
| 202 | + | return parseKillArgs(args[1:]) | |||
| 203 | + | case "-h", "--help", "help": | |||
| 204 | + | return command{kind: kindHelp}, nil | |||
| 205 | + | case "--version", "version": | |||
| 206 | + | return command{kind: kindVersion}, nil | |||
| 207 | + | default: | |||
| 208 | + | return command{}, usagef("unknown command %q", args[0]) | |||
| 209 | + | } | |||
| 210 | + | } | |||
| 211 | + | ||||
| 212 | + | // parseNoArgs accepts a flag-style subcommand that takes no arguments. | |||
| 213 | + | func parseNoArgs(args []string, kind cmdKind) (command, error) { | |||
| 214 | + | if len(args) != 1 { | |||
| 215 | + | return command{}, usagef("`%s` does not accept arguments", args[0]) | |||
| 216 | + | } | |||
| 217 | + | return command{kind: kind}, nil | |||
| 218 | + | } | |||
| 219 | + | ||||
| 220 | + | // parseNewArgs parses `new <name> [user@host] [UI flags]`. | |||
| 221 | + | func parseNewArgs(args []string, ui interactiveUIOptions) (command, error) { | |||
| 222 | + | if len(args) == 0 || args[0] == "" { | |||
| 223 | + | return command{}, usagef("`new` requires a session name") | |||
| 224 | + | } | |||
| 225 | + | name, args := args[0], args[1:] | |||
| 226 | + | var remoteTarget string | |||
| 227 | + | if len(args) > 0 && !strings.HasPrefix(args[0], "--") { | |||
| 228 | + | remoteTarget, args = args[0], args[1:] | |||
| 229 | + | if err := domain.ValidateRemoteHostTarget(remoteTarget); err != nil { | |||
| 230 | + | return command{}, err | |||
| 231 | + | } | |||
| 232 | + | } | |||
| 233 | + | ui, err := parseInteractiveUIFlags(args, ui) | |||
| 234 | + | if err != nil { | |||
| 235 | + | return command{}, err | |||
| 236 | + | } | |||
| 237 | + | if err := domain.ValidateSessionName(name); err != nil { | |||
| 238 | + | return command{}, err | |||
| 239 | + | } | |||
| 240 | + | return command{kind: kindAttach, intent: protocol.IntentNew, name: name, remoteTarget: remoteTarget, ui: ui}, nil | |||
| 241 | + | } | |||
| 242 | + | ||||
| 243 | + | // parseAttachArgs parses `attach <name|user@host[:session]> [UI flags]`. | |||
| 244 | + | func parseAttachArgs(args []string, ui interactiveUIOptions) (command, error) { | |||
| 245 | + | if len(args) == 0 || args[0] == "" { | |||
| 246 | + | return command{}, usagef("`attach` requires a session name") | |||
| 247 | + | } | |||
| 248 | + | ui, err := parseInteractiveUIFlags(args[1:], ui) | |||
| 249 | + | if err != nil { | |||
| 250 | + | return command{}, err | |||
| 251 | + | } | |||
| 252 | + | cmd := command{kind: kindAttach, intent: protocol.IntentAttach, name: args[0], ui: ui} | |||
| 253 | + | target, session, ok := parseRemoteAttachTarget(args[0]) | |||
| 254 | + | if !ok { | |||
| 255 | + | return cmd, nil | |||
| 256 | + | } | |||
| 257 | + | if err := domain.ValidateRemoteHostTarget(target); err != nil { | |||
| 258 | + | return command{}, err | |||
| 259 | + | } | |||
| 260 | + | if session == "" { | |||
| 261 | + | cmd.intent = protocol.IntentEphemeral | |||
| 262 | + | } else if err := domain.ValidateSessionName(session); err != nil { | |||
| 263 | + | return command{}, err | |||
| 264 | + | } | |||
| 265 | + | cmd.remoteTarget = target | |||
| 266 | + | cmd.name = session | |||
| 267 | + | return cmd, nil | |||
| 268 | + | } | |||
| 269 | + | ||||
| 270 | + | // parseKillArgs parses `kill <name>`, `kill -- <name>`, `kill --sessions`, and | |||
| 271 | + | // `kill --all`. | |||
| 272 | + | func parseKillArgs(args []string) (command, error) { | |||
| 273 | + | if len(args) == 0 || args[0] == "" { | |||
| 274 | + | return command{}, usagef("`kill` requires a session name, --sessions, or --all") | |||
| 275 | + | } | |||
| 276 | + | if args[0] == "--" { | |||
| 277 | + | if len(args) != 2 || args[1] == "" { | |||
| 278 | + | return command{}, usagef("`kill --` requires a session name") | |||
| 279 | + | } | |||
| 280 | + | return command{kind: kindKill, name: args[1]}, nil | |||
| 281 | + | } | |||
| 282 | + | if len(args) > 1 { | |||
| 283 | + | return command{}, usagef("`kill` accepts exactly one session name, --sessions, or --all") | |||
| 284 | + | } | |||
| 285 | + | switch args[0] { | |||
| 286 | + | case "--sessions": | |||
| 287 | + | return command{kind: kindKill, killSessions: true}, nil | |||
| 288 | + | case "--all": | |||
| 289 | + | return command{kind: kindKill, killAll: true}, nil | |||
| 290 | + | } | |||
| 291 | + | if strings.HasPrefix(args[0], "-") { | |||
| 292 | + | return command{}, usagef("unknown flag %q for `kill`; use `kill -- NAME` for a dashed session name", args[0]) | |||
| 293 | + | } | |||
| 294 | + | return command{kind: kindKill, name: args[0]}, nil | |||
| 295 | + | } | |||
| 296 | + | ||||
| 297 | + | func parseListArgs(args []string) (command, error) { | |||
| 298 | + | if len(args) == 0 { | |||
| 299 | + | return command{kind: kindList}, nil | |||
| 300 | + | } | |||
| 301 | + | if len(args) > 1 { | |||
| 302 | + | return command{}, usagef("`ls` accepts at most one host or --all") | |||
| 303 | + | } | |||
| 304 | + | switch args[0] { | |||
| 305 | + | case "--all": | |||
| 306 | + | return command{kind: kindList, listAll: true}, nil | |||
| 307 | + | default: | |||
| 308 | + | if strings.HasPrefix(args[0], "-") { | |||
| 309 | + | return command{}, usagef("unknown flag %q for `ls`", args[0]) | |||
| 310 | + | } | |||
| 311 | + | if err := domain.ValidateRemoteHostTarget(args[0]); err != nil { | |||
| 312 | + | return command{}, err | |||
| 313 | + | } | |||
| 314 | + | return command{kind: kindList, listHost: args[0]}, nil | |||
| 315 | + | } | |||
| 316 | + | } | |||
| 317 | + | ||||
| 318 | + | func parseHostArgs(args []string) (command, error) { | |||
| 319 | + | if len(args) == 0 { | |||
| 320 | + | return command{}, usagef("`host` requires add, rm, or list") | |||
| 321 | + | } | |||
| 322 | + | switch args[0] { | |||
| 323 | + | case hostActionAdd: | |||
| 324 | + | return parseHostAddArgs(args[1:]) | |||
| 325 | + | case hostActionRm: | |||
| 326 | + | if len(args) < 2 || args[1] == "" { | |||
| 327 | + | return command{}, usagef("`host rm` requires a host target") | |||
| 328 | + | } | |||
| 329 | + | if strings.HasPrefix(args[1], "-") { | |||
| 330 | + | return command{}, usagef("unknown flag %q for `host rm`", args[1]) | |||
| 331 | + | } | |||
| 332 | + | if len(args) > 2 { | |||
| 333 | + | return command{}, usagef("`host rm` accepts exactly one host target") | |||
| 334 | + | } | |||
| 335 | + | if err := domain.ValidateRemoteHostTarget(args[1]); err != nil { | |||
| 336 | + | return command{}, err | |||
| 337 | + | } | |||
| 338 | + | return command{kind: kindHost, hostAction: hostActionRm, hostTarget: args[1]}, nil | |||
| 339 | + | case hostActionList: | |||
| 340 | + | if len(args) > 1 { | |||
| 341 | + | if strings.HasPrefix(args[1], "-") { | |||
| 342 | + | return command{}, usagef("unknown flag %q for `host list`", args[1]) | |||
| 343 | + | } | |||
| 344 | + | return command{}, usagef("`host list` accepts no arguments") | |||
| 345 | + | } | |||
| 346 | + | return command{kind: kindHost, hostAction: hostActionList}, nil | |||
| 347 | + | default: | |||
| 348 | + | return command{}, usagef("unknown host action %q", args[0]) | |||
| 349 | + | } | |||
| 350 | + | } | |||
| 351 | + | ||||
| 352 | + | // parseHostAddArgs parses `host add [--transport quic|ssh] <host>`. | |||
| 353 | + | func parseHostAddArgs(args []string) (command, error) { | |||
| 354 | + | transport, target := "", "" | |||
| 355 | + | for i := 0; i < len(args); i++ { | |||
| 356 | + | arg := args[i] | |||
| 357 | + | switch { | |||
| 358 | + | case arg == "--transport" || strings.HasPrefix(arg, "--transport="): | |||
| 359 | + | if transport != "" { | |||
| 360 | + | return command{}, usagef("`host add` accepts --transport once") | |||
| 361 | + | } | |||
| 362 | + | value, hasValue := strings.CutPrefix(arg, "--transport=") | |||
| 363 | + | if !hasValue { | |||
| 364 | + | if i+1 >= len(args) { | |||
| 365 | + | return command{}, usagef("`--transport` requires a value (%s or %s)", hostTransportQUIC, hostTransportSSH) | |||
| 366 | + | } | |||
| 367 | + | i++ | |||
| 368 | + | value = args[i] | |||
| 369 | + | } | |||
| 370 | + | switch value { | |||
| 371 | + | case hostTransportQUIC, hostTransportSSH: | |||
| 372 | + | transport = value | |||
| 373 | + | default: | |||
| 374 | + | return command{}, usagef("invalid transport %q (want %q or %q)", value, hostTransportQUIC, hostTransportSSH) | |||
| 375 | + | } | |||
| 376 | + | case strings.HasPrefix(arg, "-"): | |||
| 377 | + | return command{}, usagef("unknown flag %q for `host add`", arg) | |||
| 378 | + | case target != "": | |||
| 379 | + | return command{}, usagef("`host add` accepts exactly one host target") | |||
| 380 | + | default: | |||
| 381 | + | target = arg | |||
| 382 | + | } | |||
| 383 | + | } | |||
| 384 | + | if target == "" { | |||
| 385 | + | return command{}, usagef("`host add` requires a host target") | |||
| 386 | + | } | |||
| 387 | + | if err := domain.ValidateRemoteHostTarget(target); err != nil { | |||
| 388 | + | return command{}, err | |||
| 389 | + | } | |||
| 390 | + | if transport == "" { | |||
| 391 | + | transport = hostTransportQUIC | |||
| 392 | + | } | |||
| 393 | + | return command{kind: kindHost, hostAction: hostActionAdd, hostTarget: target, hostTransport: transport}, nil | |||
| 394 | + | } | |||
M internal/app/cmd.go +1 -1
| @@ -137,7 +137,7 @@ var errCommandNotSent = errors.New("command request not sent") | |||||
| 137 | // uncorrelated, malformed, or never observed before the deadline. The daemon may | 137 | // uncorrelated, malformed, or never observed before the deadline. The daemon may | ||
| 138 | // already have committed the command, so the outcome is indeterminate, the | 138 | // already have committed the command, so the outcome is indeterminate, the | ||
| 139 | // request is never replayed, and a late result cannot change the verdict. | 139 | // request is never replayed, and a late result cannot change the verdict. | ||
| 140 | - | var errCommandOutcomeUnknown = errors.New("vev: command outcome unknown") | 140 | + | var errCommandOutcomeUnknown = errors.New("command outcome unknown") |
| 141 | 141 | ||||
| 142 | // commandOutcomeUnknown classifies one observation detail as an indeterminate | 142 | // commandOutcomeUnknown classifies one observation detail as an indeterminate | ||
| 143 | // command outcome while preserving that detail's exact message and unwrap chain. | 143 | // command outcome while preserving that detail's exact message and unwrap chain. | ||
A internal/app/daemon_run.go +319 -0
| @@ -0,0 +1,319 @@ | |||||
| 1 | + | package app | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "crypto/rand" | |||
| 6 | + | "errors" | |||
| 7 | + | "fmt" | |||
| 8 | + | "log/slog" | |||
| 9 | + | "net" | |||
| 10 | + | "net/http" | |||
| 11 | + | "os" | |||
| 12 | + | "os/signal" | |||
| 13 | + | "path/filepath" | |||
| 14 | + | "strings" | |||
| 15 | + | "syscall" | |||
| 16 | + | "time" | |||
| 17 | + | ||||
| 18 | + | snapshotadapter "github.com/bnema/vev/internal/adapters/snapshot" | |||
| 19 | + | ||||
| 20 | + | "github.com/bnema/vev/internal/adapters/clock" | |||
| 21 | + | "github.com/bnema/vev/internal/adapters/config" | |||
| 22 | + | "github.com/bnema/vev/internal/adapters/daemonidentity" | |||
| 23 | + | "github.com/bnema/vev/internal/adapters/daemonmux" | |||
| 24 | + | "github.com/bnema/vev/internal/adapters/ipc" | |||
| 25 | + | "github.com/bnema/vev/internal/adapters/lifecycle" | |||
| 26 | + | "github.com/bnema/vev/internal/adapters/noticefile" | |||
| 27 | + | "github.com/bnema/vev/internal/adapters/pty" | |||
| 28 | + | "github.com/bnema/vev/internal/adapters/sessionwire" | |||
| 29 | + | "github.com/bnema/vev/internal/adapters/shellcmd" | |||
| 30 | + | "github.com/bnema/vev/internal/domain" | |||
| 31 | + | "github.com/bnema/vev/internal/logging" | |||
| 32 | + | "github.com/bnema/vev/internal/persist" | |||
| 33 | + | "github.com/bnema/vev/internal/platform" | |||
| 34 | + | "github.com/bnema/vev/internal/ports" | |||
| 35 | + | "github.com/bnema/vev/internal/protocol/wire" | |||
| 36 | + | "github.com/bnema/vev/internal/usecase/daemon" | |||
| 37 | + | "github.com/bnema/vev/internal/usecase/recovery" | |||
| 38 | + | "github.com/bnema/vev/pkg/safedir" | |||
| 39 | + | ) | |||
| 40 | + | ||||
| 41 | + | // Daemon composition seams replaced by tests. | |||
| 42 | + | var ( | |||
| 43 | + | openCatalogue = persist.OpenOrCreate | |||
| 44 | + | listenDaemon = func(dir string, observer ports.SerializedRuntimeObserver) (wire.Listener, error) { | |||
| 45 | + | return ipc.Listen(dir, ipc.WithRuntimeObserver(observer)) | |||
| 46 | + | } | |||
| 47 | + | ) | |||
| 48 | + | ||||
| 49 | + | func snapshotDir() string { | |||
| 50 | + | return filepath.Join(platform.StateDir(), "snapshots") | |||
| 51 | + | } | |||
| 52 | + | ||||
| 53 | + | func developmentTempDirOption(ensurePrivate func(string) error) (daemon.Option, error) { | |||
| 54 | + | dir, active := platform.DevelopmentEnvironmentTempDir() | |||
| 55 | + | if !active { | |||
| 56 | + | return nil, nil | |||
| 57 | + | } | |||
| 58 | + | if err := ensurePrivate(dir); err != nil { | |||
| 59 | + | return nil, fmt.Errorf("secure development temp directory: %w", err) | |||
| 60 | + | } | |||
| 61 | + | return daemon.WithTempDir(dir), nil | |||
| 62 | + | } | |||
| 63 | + | ||||
| 64 | + | func pprofAddrIsLoopback(addr string) bool { | |||
| 65 | + | host, _, err := net.SplitHostPort(addr) | |||
| 66 | + | if err != nil || host == "" { | |||
| 67 | + | return false | |||
| 68 | + | } | |||
| 69 | + | if strings.EqualFold(host, "localhost") { | |||
| 70 | + | return true | |||
| 71 | + | } | |||
| 72 | + | ip := net.ParseIP(host) | |||
| 73 | + | return ip != nil && ip.IsLoopback() | |||
| 74 | + | } | |||
| 75 | + | ||||
| 76 | + | type ownedDaemonStart func(context.Context) error | |||
| 77 | + | ||||
| 78 | + | type lifecycleOwnership interface { | |||
| 79 | + | Release() error | |||
| 80 | + | } | |||
| 81 | + | ||||
| 82 | + | type lifecycleStartupDeps struct { | |||
| 83 | + | ensurePrivate func(string) error | |||
| 84 | + | acquire func(context.Context, string, time.Duration) (lifecycleOwnership, error) | |||
| 85 | + | log *slog.Logger | |||
| 86 | + | } | |||
| 87 | + | ||||
| 88 | + | const lifecycleAcquireRetry = 25 * time.Millisecond | |||
| 89 | + | ||||
| 90 | + | func lifecycleStartupDependencies(log *slog.Logger) lifecycleStartupDeps { | |||
| 91 | + | return lifecycleStartupDeps{ | |||
| 92 | + | ensurePrivate: safedir.EnsurePrivate, | |||
| 93 | + | acquire: func(ctx context.Context, runtimeDir string, retry time.Duration) (lifecycleOwnership, error) { | |||
| 94 | + | return lifecycle.Acquire(ctx, runtimeDir, retry) | |||
| 95 | + | }, | |||
| 96 | + | log: log, | |||
| 97 | + | } | |||
| 98 | + | } | |||
| 99 | + | ||||
| 100 | + | func runWithLifecycleOwner(ctx context.Context, runtimeDir, stateDir string, start ownedDaemonStart) error { | |||
| 101 | + | return runWithLifecycleOwnerDeps(ctx, runtimeDir, stateDir, start, lifecycleStartupDependencies(nil)) | |||
| 102 | + | } | |||
| 103 | + | ||||
| 104 | + | func runWithLifecycleOwnerDeps(ctx context.Context, runtimeDir, stateDir string, start ownedDaemonStart, deps lifecycleStartupDeps) (retErr error) { | |||
| 105 | + | log := deps.log | |||
| 106 | + | if log == nil { | |||
| 107 | + | log = slog.Default() | |||
| 108 | + | } | |||
| 109 | + | if err := deps.ensurePrivate(runtimeDir); err != nil { | |||
| 110 | + | return fmt.Errorf("secure runtime directory: %w", err) | |||
| 111 | + | } | |||
| 112 | + | if err := deps.ensurePrivate(stateDir); err != nil { | |||
| 113 | + | return fmt.Errorf("secure state directory: %w", err) | |||
| 114 | + | } | |||
| 115 | + | lockPath := lifecycle.Path(runtimeDir) | |||
| 116 | + | log.Info("lifecycle_owner_wait", "path", lockPath) | |||
| 117 | + | owner, err := deps.acquire(ctx, runtimeDir, lifecycleAcquireRetry) | |||
| 118 | + | if err != nil { | |||
| 119 | + | log.Warn("lifecycle_owner_wait_failed", "path", lockPath, "reason_code", "acquire-failed") | |||
| 120 | + | return fmt.Errorf("acquire lifecycle ownership: %w", err) | |||
| 121 | + | } | |||
| 122 | + | log.Info("lifecycle_owner_acquired", "path", lockPath) | |||
| 123 | + | defer func() { | |||
| 124 | + | releaseErr := owner.Release() | |||
| 125 | + | if releaseErr != nil { | |||
| 126 | + | log.Error("lifecycle_owner_release_failed", "path", lockPath, "reason_code", "release-failed") | |||
| 127 | + | } else { | |||
| 128 | + | log.Info("lifecycle_owner_released", "path", lockPath) | |||
| 129 | + | } | |||
| 130 | + | retErr = errors.Join(retErr, releaseErr) | |||
| 131 | + | }() | |||
| 132 | + | if start == nil { | |||
| 133 | + | return errors.New("nil owned daemon startup") | |||
| 134 | + | } | |||
| 135 | + | return start(ctx) | |||
| 136 | + | } | |||
| 137 | + | ||||
| 138 | + | // runDaemon runs the daemon in the foreground (the hidden --daemon path, | |||
| 139 | + | // entered by an auto-spawned child) while holding exclusive lifecycle | |||
| 140 | + | // ownership through complete teardown. | |||
| 141 | + | func runDaemon() (retErr error) { | |||
| 142 | + | ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM, syscall.SIGINT) | |||
| 143 | + | defer stop() | |||
| 144 | + | log, logCloser, err := configureLogging(logging.Daemon, true) | |||
| 145 | + | if err != nil { | |||
| 146 | + | return err | |||
| 147 | + | } | |||
| 148 | + | defer func() { retErr = errors.Join(retErr, logCloser.Close()) }() | |||
| 149 | + | return runWithLifecycleOwnerDeps(ctx, ipc.SocketDir(), platform.StateDir(), func(ctx context.Context) error { | |||
| 150 | + | return runDaemonOwnedWithLogger(ctx, log) | |||
| 151 | + | }, lifecycleStartupDependencies(log)) | |||
| 152 | + | } | |||
| 153 | + | ||||
| 154 | + | func logCatalogueRecovery(log *slog.Logger, records []domain.CatalogueRecord, recoveryMode string) { | |||
| 155 | + | log.Info("catalogue_validated", "records", len(records), "recovery", recoveryMode) | |||
| 156 | + | } | |||
| 157 | + | ||||
| 158 | + | func logStartupRecoveryCounts(log *slog.Logger, records []domain.CatalogueRecord, restoring int) { | |||
| 159 | + | healthy, fresh, broken := 0, 0, 0 | |||
| 160 | + | for _, record := range records { | |||
| 161 | + | switch { | |||
| 162 | + | case record.DegradedReason != "": | |||
| 163 | + | broken++ | |||
| 164 | + | case record.Committed == nil: | |||
| 165 | + | fresh++ | |||
| 166 | + | default: | |||
| 167 | + | healthy++ | |||
| 168 | + | } | |||
| 169 | + | } | |||
| 170 | + | log.Info("daemon_startup_complete", "healthy", healthy, "fresh", fresh, "restoring", restoring, "broken", broken) | |||
| 171 | + | } | |||
| 172 | + | ||||
| 173 | + | func runDaemonOwned(ctx context.Context) (retErr error) { | |||
| 174 | + | log, logCloser, err := configureLogging(logging.Daemon, true) | |||
| 175 | + | if err != nil { | |||
| 176 | + | return err | |||
| 177 | + | } | |||
| 178 | + | defer func() { retErr = errors.Join(retErr, logCloser.Close()) }() | |||
| 179 | + | return runDaemonOwnedWithLogger(ctx, log) | |||
| 180 | + | } | |||
| 181 | + | ||||
| 182 | + | func runDaemonOwnedWithLogger(ctx context.Context, log *slog.Logger) (retErr error) { | |||
| 183 | + | clk := clock.New() | |||
| 184 | + | observer, observerCloser, err := newPerformanceTrace(clk) | |||
| 185 | + | if err != nil { | |||
| 186 | + | return fmt.Errorf("performance trace: %w", err) | |||
| 187 | + | } | |||
| 188 | + | if observerCloser != nil { | |||
| 189 | + | defer func() { retErr = errors.Join(retErr, observerCloser.Close()) }() | |||
| 190 | + | } | |||
| 191 | + | if addr := os.Getenv("VEV_PPROF_ADDR"); addr != "" { | |||
| 192 | + | if !pprofAddrIsLoopback(addr) { | |||
| 193 | + | log.Warn("pprof bound to non-loopback address; /debug/pprof is unauthenticated", "addr", addr) | |||
| 194 | + | } | |||
| 195 | + | go func() { | |||
| 196 | + | if err := http.ListenAndServe(addr, nil); err != nil && !errors.Is(err, http.ErrServerClosed) { | |||
| 197 | + | log.Error("pprof server exited", "err", err) | |||
| 198 | + | } | |||
| 199 | + | }() | |||
| 200 | + | log.Info("pprof enabled", "addr", addr) | |||
| 201 | + | } | |||
| 202 | + | ||||
| 203 | + | var daemonOpts []daemon.Option | |||
| 204 | + | developmentTempOpt, err := developmentTempDirOption(safedir.EnsurePrivate) | |||
| 205 | + | if err != nil { | |||
| 206 | + | return err | |||
| 207 | + | } | |||
| 208 | + | if developmentTempOpt != nil { | |||
| 209 | + | daemonOpts = append(daemonOpts, developmentTempOpt) | |||
| 210 | + | } | |||
| 211 | + | if observer != nil { | |||
| 212 | + | daemonOpts = append(daemonOpts, daemon.WithRuntimeObserver(observer)) | |||
| 213 | + | } | |||
| 214 | + | configPath := platform.ConfigPath() | |||
| 215 | + | cfg := loadConfigOrDefaults(log, configPath) | |||
| 216 | + | daemonOpts = append(daemonOpts, daemon.WithConfig(cfg)) | |||
| 217 | + | daemonOpts = append(daemonOpts, daemon.WithBarScriptCommandRunner(shellcmd.New())) | |||
| 218 | + | daemonOpts = append(daemonOpts, daemon.WithProcessInspector(platform.NewProcessInspector()), daemon.WithDirOrHome(platform.DirOrHome)) | |||
| 219 | + | snapshotRepository := snapshotadapter.NewRepositoryWithLogger(snapshotDir(), log) | |||
| 220 | + | daemonOpts = append(daemonOpts, daemon.WithSnapshotRepository(snapshotRepository)) | |||
| 221 | + | noticeStore := noticefile.New(platform.StateDir()) | |||
| 222 | + | daemonOpts = append(daemonOpts, daemon.WithNoticeStore(noticeStore)) | |||
| 223 | + | stateDir := platform.StateDir() | |||
| 224 | + | storePath := persist.StorePath(stateDir) | |||
| 225 | + | opened, err := openCatalogue(stateDir) | |||
| 226 | + | if err != nil { | |||
| 227 | + | log.Error("catalogue_validation_failed", "path", storePath, "reason_code", "open-failed") | |||
| 228 | + | if errors.Is(err, persist.ErrCatalogueUnreadable) { | |||
| 229 | + | return unreadableCatalogueError(stateDir) | |||
| 230 | + | } | |||
| 231 | + | return fmt.Errorf("open durable session state %s: %w", storePath, err) | |||
| 232 | + | } | |||
| 233 | + | recoveryMode := "current" | |||
| 234 | + | if opened.NewInstall { | |||
| 235 | + | recoveryMode = "new-install" | |||
| 236 | + | } | |||
| 237 | + | coordinator := recovery.NewCoordinator(opened.Catalogue, snapshotRepository, rand.Reader) | |||
| 238 | + | if opened.Migration.Performed { | |||
| 239 | + | recoveryMode = "catalogue-migrated" | |||
| 240 | + | log.Info("catalogue_migrated", "source_formats", opened.Migration.SourceFormats, "target_format", opened.Migration.TargetFormat, "records", opened.Migration.RecordCount, "backup", opened.Migration.BackupPath) | |||
| 241 | + | } | |||
| 242 | + | logCatalogueRecovery(log, opened.Records, recoveryMode) | |||
| 243 | + | daemonOpts = append(daemonOpts, daemon.WithRecoveryCoordinator(coordinator)) | |||
| 244 | + | daemonOpts = append(daemonOpts, daemon.WithSnapshotGarbageCollection()) | |||
| 245 | + | log.Info("session persistence enabled", "path", storePath) | |||
| 246 | + | daemonOpts = append(daemonOpts, daemon.WithCatalogue(opened.Catalogue, opened.Records)) | |||
| 247 | + | ||||
| 248 | + | // Construct the catalogue-backed expected-session registry before socket | |||
| 249 | + | // publication. Phase 3 snapshot restoration remains asynchronous in Serve. | |||
| 250 | + | identity, err := daemonidentity.LoadOrCreate(stateDir) | |||
| 251 | + | if err != nil { | |||
| 252 | + | return fmt.Errorf("establish daemon identity: %w", err) | |||
| 253 | + | } | |||
| 254 | + | incarnation, err := daemonidentity.NewIncarnation() | |||
| 255 | + | if err != nil { | |||
| 256 | + | return fmt.Errorf("establish daemon incarnation: %w", err) | |||
| 257 | + | } | |||
| 258 | + | binding, err := daemonmux.NewServerBindings(identity, incarnation, []daemonmux.ServerPolicyAdmission{ | |||
| 259 | + | {Policy: localDaemonPolicy(), Origin: ports.SessionOriginLocal}, | |||
| 260 | + | {Policy: remoteBrokerPolicy(hostTransportQUIC), Origin: ports.SessionOriginRemote}, | |||
| 261 | + | {Policy: remoteBrokerPolicy(hostTransportSSH), Origin: ports.SessionOriginRemote}, | |||
| 262 | + | }) | |||
| 263 | + | if err != nil { | |||
| 264 | + | return fmt.Errorf("establish daemon mux authority: %w", err) | |||
| 265 | + | } | |||
| 266 | + | aggregate := daemonmux.NewAggregateListener() | |||
| 267 | + | muxSupervisor, err := daemonmux.NewServerSupervisor(aggregate, binding, daemonmux.DefaultMuxCeilings(), 0) | |||
| 268 | + | if err != nil { | |||
| 269 | + | return err | |||
| 270 | + | } | |||
| 271 | + | defer func() { retErr = errors.Join(retErr, muxSupervisor.Close()) }() | |||
| 272 | + | muxListener, err := ipc.ListenMux(daemonmux.SocketPath(ipc.SocketDir())) | |||
| 273 | + | if err != nil { | |||
| 274 | + | return fmt.Errorf("listen daemon mux: %w", err) | |||
| 275 | + | } | |||
| 276 | + | defer func() { retErr = errors.Join(retErr, muxListener.Close()) }() | |||
| 277 | + | go func() { | |||
| 278 | + | for { | |||
| 279 | + | raw, acceptErr := muxListener.Accept() | |||
| 280 | + | if acceptErr != nil { | |||
| 281 | + | return | |||
| 282 | + | } | |||
| 283 | + | go func() { _ = muxSupervisor.Adopt(ctx, raw) }() | |||
| 284 | + | } | |||
| 285 | + | }() | |||
| 286 | + | ||||
| 287 | + | // The catalogue-backed registry exists before the socket is published. | |||
| 288 | + | // Snapshot GC runs in the background once restoration finishes, so it never | |||
| 289 | + | // delays socket publication. | |||
| 290 | + | d := daemon.New(pty.NewFactory(), clk, log, daemonOpts...) | |||
| 291 | + | ln, err := listenDaemon(ipc.SocketDir(), observer) | |||
| 292 | + | if err != nil { | |||
| 293 | + | closeErr := opened.Catalogue.Close() | |||
| 294 | + | log.Error("daemon listen failed", "socket_dir", ipc.SocketDir(), "err", err) | |||
| 295 | + | return errors.Join(fmt.Errorf("daemon listen: %w", err), closeErr) | |||
| 296 | + | } | |||
| 297 | + | defer func() { _ = ln.Close() }() | |||
| 298 | + | log.Info("daemon starting", "socket", ln.Addr()) | |||
| 299 | + | ||||
| 300 | + | watchCtx, stopWatch := context.WithCancel(ctx) | |||
| 301 | + | defer stopWatch() | |||
| 302 | + | go func() { | |||
| 303 | + | if err := config.Watch(watchCtx, clk, configPath, func(cfg domain.Config, warnings []domain.Warning) { | |||
| 304 | + | logConfigWarnings(log, warnings) | |||
| 305 | + | d.ApplyConfig(cfg) | |||
| 306 | + | }); err != nil && !errors.Is(err, context.Canceled) { | |||
| 307 | + | log.Warn("config watcher stopped", "path", configPath, "err", err) | |||
| 308 | + | } | |||
| 309 | + | }() | |||
| 310 | + | if err := aggregate.Register(sessionwire.NewServerListener(ln)); err != nil { | |||
| 311 | + | return fmt.Errorf("register local session listener: %w", err) | |||
| 312 | + | } | |||
| 313 | + | if err := d.Serve(ctx, aggregate); err != nil { | |||
| 314 | + | log.Error("daemon exited", "err", err) | |||
| 315 | + | return err | |||
| 316 | + | } | |||
| 317 | + | log.Info("daemon exited cleanly") | |||
| 318 | + | return nil | |||
| 319 | + | } | |||
M internal/app/kill_all_linux.go +5 -5
| @@ -62,11 +62,11 @@ type killAllReport struct { | |||||
| 62 | // The daemon checkpoints named sessions on SIGTERM; numbered sessions close. | 62 | // The daemon checkpoints named sessions on SIGTERM; numbered sessions close. | ||
| 63 | func runKillAll(ctx context.Context, out io.Writer) error { | 63 | func runKillAll(ctx context.Context, out io.Writer) error { | ||
| 64 | if os.Getenv("VEV") != "" { | 64 | if os.Getenv("VEV") != "" { | ||
| 65 | - | return errors.New("vev: `kill --all` stops the vev you are running in; run it from a terminal outside vev") | 65 | + | return errors.New("`kill --all` stops the vev you are running in; run it from a terminal outside vev") |
| 66 | } | 66 | } | ||
| 67 | exe, err := os.Executable() | 67 | exe, err := os.Executable() | ||
| 68 | if err != nil { | 68 | if err != nil { | ||
| 69 | - | return fmt.Errorf("vev: resolve executable: %w", err) | 69 | + | return fmt.Errorf("resolve executable: %w", err) |
| 70 | } | 70 | } | ||
| 71 | scope := killAllScope{ | 71 | scope := killAllScope{ | ||
| 72 | procRoot: "/proc", | 72 | procRoot: "/proc", | ||
| @@ -108,7 +108,7 @@ func stopEverything(ctx context.Context, scope killAllScope, timeout time.Durati | |||||
| 108 | return report | 108 | return report | ||
| 109 | } | 109 | } | ||
| 110 | if round == killAllRounds { | 110 | if round == killAllRounds { | ||
| 111 | - | report.errs = append(report.errs, fmt.Errorf("vev: %d vev processes kept starting again; run `vev kill --all` once more", len(procs))) | 111 | + | report.errs = append(report.errs, fmt.Errorf("%d vev processes kept starting again; run `vev kill --all` once more", len(procs))) |
| 112 | return report | 112 | return report | ||
| 113 | } | 113 | } | ||
| 114 | // Clients first, so none of them can start a broker or daemon from | 114 | // Clients first, so none of them can start a broker or daemon from | ||
| @@ -144,7 +144,7 @@ func installedPath(exe string) string { | |||||
| 144 | func findVevProcesses(scope killAllScope) ([]vevProcess, error) { | 144 | func findVevProcesses(scope killAllScope) ([]vevProcess, error) { | ||
| 145 | entries, err := os.ReadDir(scope.procRoot) | 145 | entries, err := os.ReadDir(scope.procRoot) | ||
| 146 | if err != nil { | 146 | if err != nil { | ||
| 147 | - | return nil, fmt.Errorf("vev: list processes: %w", err) | 147 | + | return nil, fmt.Errorf("list processes: %w", err) |
| 148 | } | 148 | } | ||
| 149 | var procs []vevProcess | 149 | var procs []vevProcess | ||
| 150 | for _, entry := range entries { | 150 | for _, entry := range entries { | ||
| @@ -239,7 +239,7 @@ func stopWave(ctx context.Context, procs []vevProcess, timeout time.Duration, re | |||||
| 239 | } | 239 | } | ||
| 240 | _ = syscall.Kill(p.pid, syscall.SIGKILL) | 240 | _ = syscall.Kill(p.pid, syscall.SIGKILL) | ||
| 241 | if err := waitProcessExit(context.WithoutCancel(ctx), p.pid, time.Second); err != nil { | 241 | if err := waitProcessExit(context.WithoutCancel(ctx), p.pid, time.Second); err != nil { | ||
| 242 | - | report.errs = append(report.errs, fmt.Errorf("vev: %s (pid %d) did not stop: %w", p.role, p.pid, err)) | 242 | + | report.errs = append(report.errs, fmt.Errorf("%s (pid %d) did not stop: %w", p.role, p.pid, err)) |
| 243 | survivors = append(survivors, p) | 243 | survivors = append(survivors, p) | ||
| 244 | continue | 244 | continue | ||
| 245 | } | 245 | } | ||
M internal/app/kill_all_other.go +1 -1
| @@ -9,5 +9,5 @@ import ( | |||||
| 9 | ) | 9 | ) | ||
| 10 | 10 | ||||
| 11 | func runKillAll(context.Context, io.Writer) error { | 11 | func runKillAll(context.Context, io.Writer) error { | ||
| 12 | - | return errors.New("vev: `kill --all` requires Linux /proc") | 12 | + | return errors.New("`kill --all` requires Linux /proc") |
| 13 | } | 13 | } | ||
M internal/app/local_route_probe_test_helpers_test.go +2 -2
| @@ -20,11 +20,11 @@ import ( | |||||
| 20 | // does in production. | 20 | // does in production. | ||
| 21 | func newLocalRouteProbe(binding brokerconfig.LocalBinding, epoch ports.BrokerEpoch, dial localCarrierDialer, ceilings daemonmux.MuxCeilings) (*broker.LocalDaemonProbe, error) { | 21 | func newLocalRouteProbe(binding brokerconfig.LocalBinding, epoch ports.BrokerEpoch, dial localCarrierDialer, ceilings daemonmux.MuxCeilings) (*broker.LocalDaemonProbe, error) { | ||
| 22 | if dial == nil { | 22 | if dial == nil { | ||
| 23 | - | return nil, errors.New("vev: broker local probe requires a dialer") | 23 | + | return nil, errors.New("broker local probe requires a dialer") |
| 24 | } | 24 | } | ||
| 25 | connector, err := daemonmux.NewEndpointConnector(daemonmux.RawCarrierDialer(dial), ceilings) | 25 | connector, err := daemonmux.NewEndpointConnector(daemonmux.RawCarrierDialer(dial), ceilings) | ||
| 26 | if err != nil { | 26 | if err != nil { | ||
| 27 | - | return nil, fmt.Errorf("vev: broker local probe: %w", err) | 27 | + | return nil, fmt.Errorf("broker local probe: %w", err) |
| 28 | } | 28 | } | ||
| 29 | return newLocalRouteProbeWithConnector(binding, epoch, connector) | 29 | return newLocalRouteProbeWithConnector(binding, epoch, connector) | ||
| 30 | } | 30 | } | ||
M internal/app/remote_hosts.go +6 -6
| @@ -82,7 +82,7 @@ func runHostCommand(ctx context.Context, cmd command, deps remoteHostDeps) error | |||||
| 82 | _, err = service.AddHost(ctx, cmd.hostTarget, policy) | 82 | _, err = service.AddHost(ctx, cmd.hostTarget, policy) | ||
| 83 | if isHostConflict(err) { | 83 | if isHostConflict(err) { | ||
| 84 | if stored := storedHostTransport(service.Snapshot(), cmd.hostTarget); stored != "" && stored != cmd.hostTransport { | 84 | if stored := storedHostTransport(service.Snapshot(), cmd.hostTarget); stored != "" && stored != cmd.hostTransport { | ||
| 85 | - | return fmt.Errorf("vev: host %q is already registered with transport %q; run `vev host rm %s` first, then add it again: %w", cmd.hostTarget, stored, cmd.hostTarget, err) | 85 | + | return fmt.Errorf("host %q is already registered with transport %q; run `vev host rm %s` first, then add it again: %w", cmd.hostTarget, stored, cmd.hostTarget, err) |
| 86 | } | 86 | } | ||
| 87 | } | 87 | } | ||
| 88 | return err | 88 | return err | ||
| @@ -98,14 +98,14 @@ func runHostCommand(ctx context.Context, cmd command, deps remoteHostDeps) error | |||||
| 98 | } | 98 | } | ||
| 99 | } | 99 | } | ||
| 100 | if registration.Endpoint == "" { | 100 | if registration.Endpoint == "" { | ||
| 101 | - | return fmt.Errorf("vev: unknown host %q", cmd.hostTarget) | 101 | + | return fmt.Errorf("unknown host %q", cmd.hostTarget) |
| 102 | } | 102 | } | ||
| 103 | removed, err := service.RemoveHost(ctx, registration) | 103 | removed, err := service.RemoveHost(ctx, registration) | ||
| 104 | if err != nil { | 104 | if err != nil { | ||
| 105 | return err | 105 | return err | ||
| 106 | } | 106 | } | ||
| 107 | if !removed { | 107 | if !removed { | ||
| 108 | - | return fmt.Errorf("vev: unknown host %q", cmd.hostTarget) | 108 | + | return fmt.Errorf("unknown host %q", cmd.hostTarget) |
| 109 | } | 109 | } | ||
| 110 | return nil | 110 | return nil | ||
| 111 | case hostActionList: | 111 | case hostActionList: | ||
| @@ -179,7 +179,7 @@ func listDisconnectedHosts(w io.Writer) error { | |||||
| 179 | 179 | ||||
| 180 | func printBrokerHosts(w io.Writer, snapshot ports.BrokerSnapshot) error { | 180 | func printBrokerHosts(w io.Writer, snapshot ports.BrokerSnapshot) error { | ||
| 181 | if err := snapshot.Validate(); err != nil { | 181 | if err := snapshot.Validate(); err != nil { | ||
| 182 | - | return fmt.Errorf("vev: reading broker catalogue: %w", err) | 182 | + | return fmt.Errorf("reading broker catalogue: %w", err) |
| 183 | } | 183 | } | ||
| 184 | var endpoints []string | 184 | var endpoints []string | ||
| 185 | for _, daemon := range snapshot.Daemons { | 185 | for _, daemon := range snapshot.Daemons { | ||
| @@ -213,7 +213,7 @@ func runBrokerSnapshotList(cmd command, snapshot ports.BrokerSnapshot, stdout io | |||||
| 213 | 213 | ||||
| 214 | func renderBrokerSnapshotList(cmd command, snapshot ports.BrokerSnapshot, stdout io.Writer, now time.Time) error { | 214 | func renderBrokerSnapshotList(cmd command, snapshot ports.BrokerSnapshot, stdout io.Writer, now time.Time) error { | ||
| 215 | if err := snapshot.Validate(); err != nil { | 215 | if err := snapshot.Validate(); err != nil { | ||
| 216 | - | return fmt.Errorf("vev: reading broker catalogue: %w", err) | 216 | + | return fmt.Errorf("reading broker catalogue: %w", err) |
| 217 | } | 217 | } | ||
| 218 | var sessions []protocol.SessionInfo | 218 | var sessions []protocol.SessionInfo | ||
| 219 | var statuses []string | 219 | var statuses []string | ||
| @@ -241,7 +241,7 @@ func renderBrokerSnapshotList(cmd command, snapshot ports.BrokerSnapshot, stdout | |||||
| 241 | } | 241 | } | ||
| 242 | } | 242 | } | ||
| 243 | if !found { | 243 | if !found { | ||
| 244 | - | return fmt.Errorf("vev: unknown host %q", cmd.listHost) | 244 | + | return fmt.Errorf("unknown host %q", cmd.listHost) |
| 245 | } | 245 | } | ||
| 246 | for _, status := range statuses { | 246 | for _, status := range statuses { | ||
| 247 | if _, err := fmt.Fprintln(stdout, status); err != nil { | 247 | if _, err := fmt.Fprintln(stdout, status); err != nil { | ||
M internal/app/run.go +9 -1051
| @@ -6,145 +6,24 @@ package app | |||||
| 6 | 6 | ||||
| 7 | import ( | 7 | import ( | ||
| 8 | "context" | 8 | "context" | ||
| 9 | - | "crypto/rand" | |||
| 10 | "errors" | 9 | "errors" | ||
| 11 | "fmt" | 10 | "fmt" | ||
| 12 | "io" | 11 | "io" | ||
| 13 | "log/slog" | 12 | "log/slog" | ||
| 14 | - | "net" | |||
| 15 | - | "net/http" | |||
| 16 | _ "net/http/pprof" | 13 | _ "net/http/pprof" | ||
| 17 | "os" | 14 | "os" | ||
| 18 | - | "os/signal" | |||
| 19 | "path/filepath" | 15 | "path/filepath" | ||
| 20 | - | "strconv" | |||
| 21 | - | "strings" | |||
| 22 | - | "sync" | |||
| 23 | - | "syscall" | |||
| 24 | - | "text/tabwriter" | |||
| 25 | "time" | 16 | "time" | ||
| 26 | 17 | ||||
| 27 | "github.com/bnema/vev/internal/adapters/clock" | 18 | "github.com/bnema/vev/internal/adapters/clock" | ||
| 28 | "github.com/bnema/vev/internal/adapters/config" | 19 | "github.com/bnema/vev/internal/adapters/config" | ||
| 29 | - | "github.com/bnema/vev/internal/adapters/daemonidentity" | |||
| 30 | - | "github.com/bnema/vev/internal/adapters/daemonmux" | |||
| 31 | - | "github.com/bnema/vev/internal/adapters/ipc" | |||
| 32 | - | "github.com/bnema/vev/internal/adapters/lifecycle" | |||
| 33 | - | "github.com/bnema/vev/internal/adapters/noticefile" | |||
| 34 | - | "github.com/bnema/vev/internal/adapters/observability" | |||
| 35 | - | "github.com/bnema/vev/internal/adapters/pty" | |||
| 36 | - | "github.com/bnema/vev/internal/adapters/sessionwire" | |||
| 37 | - | "github.com/bnema/vev/internal/adapters/shellcmd" | |||
| 38 | - | snapshotadapter "github.com/bnema/vev/internal/adapters/snapshot" | |||
| 39 | "github.com/bnema/vev/internal/domain" | 20 | "github.com/bnema/vev/internal/domain" | ||
| 40 | "github.com/bnema/vev/internal/logging" | 21 | "github.com/bnema/vev/internal/logging" | ||
| 41 | - | "github.com/bnema/vev/internal/persist" | |||
| 42 | "github.com/bnema/vev/internal/platform" | 22 | "github.com/bnema/vev/internal/platform" | ||
| 43 | - | "github.com/bnema/vev/internal/ports" | |||
| 44 | "github.com/bnema/vev/internal/protocol" | 23 | "github.com/bnema/vev/internal/protocol" | ||
| 45 | - | "github.com/bnema/vev/internal/protocol/wire" | |||
| 46 | "github.com/bnema/vev/internal/usecase/client" | 24 | "github.com/bnema/vev/internal/usecase/client" | ||
| 47 | - | "github.com/bnema/vev/internal/usecase/daemon" | |||
| 48 | - | "github.com/bnema/vev/internal/usecase/recovery" | |||
| 49 | - | "github.com/bnema/vev/pkg/safedir" | |||
| 50 | ) | 25 | ) | ||
| 51 | 26 | ||||
| 52 | - | // cmdKind identifies which sub-command the CLI parsed. | |||
| 53 | - | type cmdKind int | |||
| 54 | - | ||||
| 55 | - | const ( | |||
| 56 | - | kindAttach cmdKind = iota // ephemeral/new/attach — distinguished by intent | |||
| 57 | - | kindList | |||
| 58 | - | kindHost | |||
| 59 | - | kindKill | |||
| 60 | - | kindCmd | |||
| 61 | - | kindEnv | |||
| 62 | - | kindDaemon | |||
| 63 | - | kindDaemonLauncher | |||
| 64 | - | kindUIDriver | |||
| 65 | - | kindUIRemoteCleanup | |||
| 66 | - | kindDaemonStop | |||
| 67 | - | kindWebDaemon | |||
| 68 | - | kindWebRenew | |||
| 69 | - | kindWebServe | |||
| 70 | - | kindBrokerMuxStdio | |||
| 71 | - | kindBrokerMuxQUICBootstrap | |||
| 72 | - | kindBrokerMuxQUICProxy | |||
| 73 | - | kindProductionBrokerServe | |||
| 74 | - | kindProductionBrokerLauncher | |||
| 75 | - | kindBrokerReady | |||
| 76 | - | kindHelp | |||
| 77 | - | kindVersion | |||
| 78 | - | ) | |||
| 79 | - | ||||
| 80 | - | // command is the parsed CLI invocation: what to do, plus the attach intent | |||
| 81 | - | // and session name where relevant. | |||
| 82 | - | type command struct { | |||
| 83 | - | kind cmdKind | |||
| 84 | - | intent uint8 | |||
| 85 | - | name string | |||
| 86 | - | remoteTarget string | |||
| 87 | - | listHost string | |||
| 88 | - | listAll bool | |||
| 89 | - | hostAction string | |||
| 90 | - | hostTarget string | |||
| 91 | - | // hostTransport is the `host add` carriage: hostTransportQUIC or hostTransportSSH. | |||
| 92 | - | hostTransport string | |||
| 93 | - | killAll bool | |||
| 94 | - | killSessions bool | |||
| 95 | - | cmd cmdInvocation | |||
| 96 | - | env envInvocation | |||
| 97 | - | brokerServe brokerServeOptions | |||
| 98 | - | brokerMux brokerMuxOptions | |||
| 99 | - | brokerReady brokerReadyOptions | |||
| 100 | - | uiDriver uiDriverOptions | |||
| 101 | - | uiObserve bool | |||
| 102 | - | uiControl bool | |||
| 103 | - | uiSocket string | |||
| 104 | - | web webOptions | |||
| 105 | - | } | |||
| 106 | - | ||||
| 107 | - | // usageError is a user-facing argument error; the app prints it (with usage) | |||
| 108 | - | // rather than a stack of wrapped internals. | |||
| 109 | - | type usageError struct{ msg string } | |||
| 110 | - | ||||
| 111 | - | func (e *usageError) Error() string { return "vev: " + e.msg } | |||
| 112 | - | ||||
| 113 | - | func usagef(format string, args ...any) error { | |||
| 114 | - | return &usageError{msg: fmt.Sprintf(format, args...)} | |||
| 115 | - | } | |||
| 116 | - | ||||
| 117 | - | const usageText = `vev — a terminal multiplexer | |||
| 118 | - | ||||
| 119 | - | usage: | |||
| 120 | - | vev attach to (or create) an ephemeral session | |||
| 121 | - | vev new <name> create and attach to a named session | |||
| 122 | - | vev attach <name> attach to an existing session (alias: a) | |||
| 123 | - | vev attach user@host[:session] | |||
| 124 | - | attach through SSH to a remote vev daemon | |||
| 125 | - | vev ls list local sessions | |||
| 126 | - | vev ls <host> list sessions on a known remote host | |||
| 127 | - | vev ls --all list local and remote sessions | |||
| 128 | - | vev host add [--transport quic|ssh] <host> | |||
| 129 | - | add a pinned remote host (default transport: quic) | |||
| 130 | - | vev host rm <host> remove a pinned remote host | |||
| 131 | - | vev host list list known remote hosts | |||
| 132 | - | vev kill <name> kill a session | |||
| 133 | - | vev kill --sessions kill all sessions (vev keeps running) | |||
| 134 | - | vev kill --all stop everything: every vev window, the broker, and the | |||
| 135 | - | daemon (named sessions come back on the next start) | |||
| 136 | - | vev cmd <command> run a control command (vev cmd --help) | |||
| 137 | - | vev env <fish|sh> print shell code that refreshes desktop variables | |||
| 138 | - | vev --ui-observe expose passive observation for this interactive client | |||
| 139 | - | (optional: --ui-socket PATH) | |||
| 140 | - | vev --ui-control expose observation and input control for this client | |||
| 141 | - | (optional: --ui-socket PATH) | |||
| 142 | - | vev --web-daemon start the private web terminal or print its current link | |||
| 143 | - | vev --web-renew-token revoke web access and print a fresh link | |||
| 144 | - | Web options: --web-listen IP:PORT --web-origin http(s)://HOST[:PORT] | |||
| 145 | - | vev --help show this help | |||
| 146 | - | vev --version show version` | |||
| 147 | - | ||||
| 148 | // Build metadata. Defaults describe a plain `go build`; releases overwrite | 27 | // Build metadata. Defaults describe a plain `go build`; releases overwrite | ||
| 149 | // them via -ldflags "-X github.com/bnema/vev/internal/app.version=..." (and | 28 | // them via -ldflags "-X github.com/bnema/vev/internal/app.version=..." (and | ||
| 150 | // .commit / .date) — see .goreleaser.yaml. | 29 | // .commit / .date) — see .goreleaser.yaml. | ||
| @@ -152,11 +31,6 @@ var ( | |||||
| 152 | version = "0.1.0-dev" | 31 | version = "0.1.0-dev" | ||
| 153 | commit = "none" | 32 | commit = "none" | ||
| 154 | date = "unknown" | 33 | date = "unknown" | ||
| 155 | - | ||||
| 156 | - | openCatalogue = persist.OpenOrCreate | |||
| 157 | - | listenDaemon = func(dir string, observer ports.SerializedRuntimeObserver) (wire.Listener, error) { | |||
| 158 | - | return ipc.Listen(dir, ipc.WithRuntimeObserver(observer)) | |||
| 159 | - | } | |||
| 160 | ) | 34 | ) | ||
| 161 | 35 | ||||
| 162 | // versionLine renders the --version output. | 36 | // versionLine renders the --version output. | ||
| @@ -177,188 +51,6 @@ func Run(args []string) error { | |||||
| 177 | return dispatch(context.Background(), cmd) | 51 | return dispatch(context.Background(), cmd) | ||
| 178 | } | 52 | } | ||
| 179 | 53 | ||||
| 180 | - | // parseArgs turns the raw argv tail into a command. It is deliberately a | |||
| 181 | - | // pure function so the full dispatch table can be unit-tested without any | |||
| 182 | - | // I/O. | |||
| 183 | - | func parseArgs(args []string) (command, error) { | |||
| 184 | - | if len(args) == 0 { | |||
| 185 | - | return command{kind: kindAttach, intent: protocol.IntentEphemeral}, nil | |||
| 186 | - | } | |||
| 187 | - | if args[0] == "--ui-driver" { | |||
| 188 | - | options, err := parseUIDriverArgs(args[1:]) | |||
| 189 | - | if err != nil { | |||
| 190 | - | return command{}, err | |||
| 191 | - | } | |||
| 192 | - | return command{kind: kindUIDriver, uiDriver: options}, nil | |||
| 193 | - | } | |||
| 194 | - | ||||
| 195 | - | var uiObserve, uiControl bool | |||
| 196 | - | var uiSocket string | |||
| 197 | - | for len(args) > 0 { | |||
| 198 | - | switch args[0] { | |||
| 199 | - | case "--ui-observe": | |||
| 200 | - | uiObserve = true | |||
| 201 | - | case "--ui-control": | |||
| 202 | - | uiControl = true | |||
| 203 | - | case "--ui-socket": | |||
| 204 | - | if len(args) < 2 || args[1] == "" { | |||
| 205 | - | return command{}, usagef("`--ui-socket` requires a path") | |||
| 206 | - | } | |||
| 207 | - | uiSocket = args[1] | |||
| 208 | - | args = args[2:] | |||
| 209 | - | continue | |||
| 210 | - | default: | |||
| 211 | - | goto parsedUIFlags | |||
| 212 | - | } | |||
| 213 | - | args = args[1:] | |||
| 214 | - | } | |||
| 215 | - | parsedUIFlags: | |||
| 216 | - | interactive, err := parseInteractiveUIFlags(nil, uiObserve, uiControl, uiSocket) | |||
| 217 | - | if err != nil { | |||
| 218 | - | return command{}, err | |||
| 219 | - | } | |||
| 220 | - | uiObserve, uiControl, uiSocket = interactive.observe, interactive.control, interactive.socket | |||
| 221 | - | if len(args) == 0 { | |||
| 222 | - | return command{kind: kindAttach, intent: protocol.IntentEphemeral, uiObserve: uiObserve, uiControl: uiControl, uiSocket: uiSocket}, nil | |||
| 223 | - | } | |||
| 224 | - | if (uiObserve || uiControl || uiSocket != "") && args[0] != "new" && args[0] != "attach" && args[0] != "a" { | |||
| 225 | - | return command{}, usagef("UI flags are only valid for an attach command") | |||
| 226 | - | } | |||
| 227 | - | ||||
| 228 | - | switch args[0] { | |||
| 229 | - | case "--web-daemon", "--web-serve": | |||
| 230 | - | return parseWebArgs(args) | |||
| 231 | - | case "--web-renew-token": | |||
| 232 | - | if len(args) != 1 { | |||
| 233 | - | return command{}, usagef("`%s` does not accept arguments", args[0]) | |||
| 234 | - | } | |||
| 235 | - | return command{kind: kindWebRenew}, nil | |||
| 236 | - | case "--daemon": | |||
| 237 | - | return command{kind: kindDaemon}, nil | |||
| 238 | - | case "--daemon-launcher": | |||
| 239 | - | return command{kind: kindDaemonLauncher}, nil | |||
| 240 | - | case productionBrokerServeCommand: | |||
| 241 | - | return parseProductionBrokerServeArgs(args[1:]) | |||
| 242 | - | case productionBrokerLauncherCommand: | |||
| 243 | - | return parseProductionBrokerLauncherArgs(args[1:]) | |||
| 244 | - | case brokerReadyCommand: | |||
| 245 | - | options, err := parseBrokerReadyArgs(args[1:]) | |||
| 246 | - | return command{kind: kindBrokerReady, brokerReady: options}, err | |||
| 247 | - | case brokerMuxStdioCommand: | |||
| 248 | - | return parseBrokerMuxArgs(brokerMuxStdioCommand, kindBrokerMuxStdio, args[1:]) | |||
| 249 | - | case brokerMuxQUICBootstrapCommand: | |||
| 250 | - | return parseBrokerMuxArgs(brokerMuxQUICBootstrapCommand, kindBrokerMuxQUICBootstrap, args[1:]) | |||
| 251 | - | case brokerMuxQUICProxyCommand: | |||
| 252 | - | return parseBrokerMuxArgs(brokerMuxQUICProxyCommand, kindBrokerMuxQUICProxy, args[1:]) | |||
| 253 | - | case uiRemoteCleanupCommand: | |||
| 254 | - | if len(args) != 1 { | |||
| 255 | - | return command{}, usagef("`%s` does not accept arguments", uiRemoteCleanupCommand) | |||
| 256 | - | } | |||
| 257 | - | return command{kind: kindUIRemoteCleanup}, nil | |||
| 258 | - | case daemonStopCommand: | |||
| 259 | - | if len(args) != 1 { | |||
| 260 | - | return command{}, usagef("`%s` does not accept arguments", daemonStopCommand) | |||
| 261 | - | } | |||
| 262 | - | return command{kind: kindDaemonStop}, nil | |||
| 263 | - | case "new": | |||
| 264 | - | if len(args) < 2 || args[1] == "" { | |||
| 265 | - | return command{}, usagef("`new` requires a session name") | |||
| 266 | - | } | |||
| 267 | - | name := args[1] | |||
| 268 | - | var remoteTarget string | |||
| 269 | - | optionStart := 2 | |||
| 270 | - | if len(args) > 2 && !strings.HasPrefix(args[2], "--") { | |||
| 271 | - | remoteTarget = args[2] | |||
| 272 | - | if err := domain.ValidateRemoteHostTarget(remoteTarget); err != nil { | |||
| 273 | - | return command{}, err | |||
| 274 | - | } | |||
| 275 | - | optionStart = 3 | |||
| 276 | - | } | |||
| 277 | - | interactive, err := parseInteractiveUIFlags(args[optionStart:], uiObserve, uiControl, uiSocket) | |||
| 278 | - | if err != nil { | |||
| 279 | - | return command{}, err | |||
| 280 | - | } | |||
| 281 | - | if err := domain.ValidateSessionName(name); err != nil { | |||
| 282 | - | return command{}, err | |||
| 283 | - | } | |||
| 284 | - | return command{kind: kindAttach, intent: protocol.IntentNew, name: name, remoteTarget: remoteTarget, uiObserve: interactive.observe, uiControl: interactive.control, uiSocket: interactive.socket}, nil | |||
| 285 | - | case "attach", "a": | |||
| 286 | - | if len(args) < 2 || args[1] == "" { | |||
| 287 | - | return command{}, usagef("`attach` requires a session name") | |||
| 288 | - | } | |||
| 289 | - | interactive, err := parseInteractiveUIFlags(args[2:], uiObserve, uiControl, uiSocket) | |||
| 290 | - | if err != nil { | |||
| 291 | - | return command{}, err | |||
| 292 | - | } | |||
| 293 | - | cmd := command{kind: kindAttach, intent: protocol.IntentAttach, name: args[1]} | |||
| 294 | - | if target, session, ok := parseRemoteAttachTarget(args[1]); ok { | |||
| 295 | - | if err := domain.ValidateRemoteHostTarget(target); err != nil { | |||
| 296 | - | return command{}, err | |||
| 297 | - | } | |||
| 298 | - | if session != "" { | |||
| 299 | - | if err := domain.ValidateSessionName(session); err != nil { | |||
| 300 | - | return command{}, err | |||
| 301 | - | } | |||
| 302 | - | } | |||
| 303 | - | cmd.remoteTarget = target | |||
| 304 | - | cmd.name = session | |||
| 305 | - | if session == "" { | |||
| 306 | - | cmd.intent = protocol.IntentEphemeral | |||
| 307 | - | } | |||
| 308 | - | } | |||
| 309 | - | cmd.uiObserve = interactive.observe | |||
| 310 | - | cmd.uiControl = interactive.control | |||
| 311 | - | cmd.uiSocket = interactive.socket | |||
| 312 | - | return cmd, nil | |||
| 313 | - | case "ls", "list": | |||
| 314 | - | return parseListArgs(args[1:]) | |||
| 315 | - | case "host": | |||
| 316 | - | return parseHostArgs(args[1:]) | |||
| 317 | - | case "env": | |||
| 318 | - | invocation, err := parseEnvArgs(args[1:]) | |||
| 319 | - | if err != nil { | |||
| 320 | - | return command{}, err | |||
| 321 | - | } | |||
| 322 | - | return command{kind: kindEnv, env: invocation}, nil | |||
| 323 | - | case "cmd": | |||
| 324 | - | invocation, err := parseCmdArgs(args[1:]) | |||
| 325 | - | if err != nil { | |||
| 326 | - | return command{}, err | |||
| 327 | - | } | |||
| 328 | - | return command{kind: kindCmd, cmd: invocation}, nil | |||
| 329 | - | case "kill": | |||
| 330 | - | if len(args) < 2 || args[1] == "" { | |||
| 331 | - | return command{}, usagef("`kill` requires a session name, --sessions, or --all") | |||
| 332 | - | } | |||
| 333 | - | if args[1] == "--" { | |||
| 334 | - | if len(args) != 3 || args[2] == "" { | |||
| 335 | - | return command{}, usagef("`kill --` requires a session name") | |||
| 336 | - | } | |||
| 337 | - | return command{kind: kindKill, name: args[2]}, nil | |||
| 338 | - | } | |||
| 339 | - | if len(args) > 2 { | |||
| 340 | - | return command{}, usagef("`kill` accepts exactly one session name, --sessions, or --all") | |||
| 341 | - | } | |||
| 342 | - | switch args[1] { | |||
| 343 | - | case "--sessions": | |||
| 344 | - | return command{kind: kindKill, killSessions: true}, nil | |||
| 345 | - | case "--all": | |||
| 346 | - | return command{kind: kindKill, killAll: true}, nil | |||
| 347 | - | default: | |||
| 348 | - | if strings.HasPrefix(args[1], "-") { | |||
| 349 | - | return command{}, usagef("unknown flag %q for `kill`; use `kill -- NAME` for a dashed session name", args[1]) | |||
| 350 | - | } | |||
| 351 | - | return command{kind: kindKill, name: args[1]}, nil | |||
| 352 | - | } | |||
| 353 | - | case "-h", "--help", "help": | |||
| 354 | - | return command{kind: kindHelp}, nil | |||
| 355 | - | case "--version", "version": | |||
| 356 | - | return command{kind: kindVersion}, nil | |||
| 357 | - | default: | |||
| 358 | - | return command{}, usagef("unknown command %q", args[0]) | |||
| 359 | - | } | |||
| 360 | - | } | |||
| 361 | - | ||||
| 362 | // dispatch runs the parsed command. | 54 | // dispatch runs the parsed command. | ||
| 363 | func dispatch(ctx context.Context, cmd command) error { | 55 | func dispatch(ctx context.Context, cmd command) error { | ||
| 364 | switch cmd.kind { | 56 | switch cmd.kind { | ||
| @@ -410,208 +102,12 @@ func dispatch(ctx context.Context, cmd command) error { | |||||
| 410 | case kindEnv: | 102 | case kindEnv: | ||
| 411 | return runEnv(ctx, cmd.env) | 103 | return runEnv(ctx, cmd.env) | ||
| 412 | case kindAttach: | 104 | case kindAttach: | ||
| 413 | - | return runAttachWithOptions(ctx, cmd.intent, cmd.name, cmd.remoteTarget, interactiveUIOptions{observe: cmd.uiObserve, control: cmd.uiControl, socket: cmd.uiSocket}) | 105 | + | return runAttachWithOptions(ctx, cmd.intent, cmd.name, cmd.remoteTarget, cmd.ui) |
| 414 | default: | 106 | default: | ||
| 415 | return usagef("unhandled command") | 107 | return usagef("unhandled command") | ||
| 416 | } | 108 | } | ||
| 417 | } | 109 | } | ||
| 418 | 110 | ||||
| 419 | - | func parseListArgs(args []string) (command, error) { | |||
| 420 | - | if len(args) == 0 { | |||
| 421 | - | return command{kind: kindList}, nil | |||
| 422 | - | } | |||
| 423 | - | if len(args) > 1 { | |||
| 424 | - | return command{}, usagef("`ls` accepts at most one host or --all") | |||
| 425 | - | } | |||
| 426 | - | switch args[0] { | |||
| 427 | - | case "--all": | |||
| 428 | - | return command{kind: kindList, listAll: true}, nil | |||
| 429 | - | default: | |||
| 430 | - | if strings.HasPrefix(args[0], "-") { | |||
| 431 | - | return command{}, usagef("unknown flag %q for `ls`", args[0]) | |||
| 432 | - | } | |||
| 433 | - | if err := domain.ValidateRemoteHostTarget(args[0]); err != nil { | |||
| 434 | - | return command{}, err | |||
| 435 | - | } | |||
| 436 | - | return command{kind: kindList, listHost: args[0]}, nil | |||
| 437 | - | } | |||
| 438 | - | } | |||
| 439 | - | ||||
| 440 | - | func parseHostArgs(args []string) (command, error) { | |||
| 441 | - | if len(args) == 0 { | |||
| 442 | - | return command{}, usagef("`host` requires add, rm, or list") | |||
| 443 | - | } | |||
| 444 | - | switch args[0] { | |||
| 445 | - | case hostActionAdd: | |||
| 446 | - | return parseHostAddArgs(args[1:]) | |||
| 447 | - | case hostActionRm: | |||
| 448 | - | if len(args) < 2 || args[1] == "" { | |||
| 449 | - | return command{}, usagef("`host rm` requires a host target") | |||
| 450 | - | } | |||
| 451 | - | if strings.HasPrefix(args[1], "-") { | |||
| 452 | - | return command{}, usagef("unknown flag %q for `host rm`", args[1]) | |||
| 453 | - | } | |||
| 454 | - | if len(args) > 2 { | |||
| 455 | - | return command{}, usagef("`host rm` accepts exactly one host target") | |||
| 456 | - | } | |||
| 457 | - | if err := domain.ValidateRemoteHostTarget(args[1]); err != nil { | |||
| 458 | - | return command{}, err | |||
| 459 | - | } | |||
| 460 | - | return command{kind: kindHost, hostAction: hostActionRm, hostTarget: args[1]}, nil | |||
| 461 | - | case hostActionList: | |||
| 462 | - | if len(args) > 1 { | |||
| 463 | - | if strings.HasPrefix(args[1], "-") { | |||
| 464 | - | return command{}, usagef("unknown flag %q for `host list`", args[1]) | |||
| 465 | - | } | |||
| 466 | - | return command{}, usagef("`host list` accepts no arguments") | |||
| 467 | - | } | |||
| 468 | - | return command{kind: kindHost, hostAction: hostActionList}, nil | |||
| 469 | - | default: | |||
| 470 | - | return command{}, usagef("unknown host action %q", args[0]) | |||
| 471 | - | } | |||
| 472 | - | } | |||
| 473 | - | ||||
| 474 | - | // parseHostAddArgs parses `host add [--transport quic|ssh] <host>`. | |||
| 475 | - | func parseHostAddArgs(args []string) (command, error) { | |||
| 476 | - | transport, target := "", "" | |||
| 477 | - | for i := 0; i < len(args); i++ { | |||
| 478 | - | arg := args[i] | |||
| 479 | - | switch { | |||
| 480 | - | case arg == "--transport" || strings.HasPrefix(arg, "--transport="): | |||
| 481 | - | if transport != "" { | |||
| 482 | - | return command{}, usagef("`host add` accepts --transport once") | |||
| 483 | - | } | |||
| 484 | - | value, hasValue := strings.CutPrefix(arg, "--transport=") | |||
| 485 | - | if !hasValue { | |||
| 486 | - | if i+1 >= len(args) { | |||
| 487 | - | return command{}, usagef("`--transport` requires a value (%s or %s)", hostTransportQUIC, hostTransportSSH) | |||
| 488 | - | } | |||
| 489 | - | i++ | |||
| 490 | - | value = args[i] | |||
| 491 | - | } | |||
| 492 | - | switch value { | |||
| 493 | - | case hostTransportQUIC, hostTransportSSH: | |||
| 494 | - | transport = value | |||
| 495 | - | default: | |||
| 496 | - | return command{}, usagef("invalid transport %q (want %q or %q)", value, hostTransportQUIC, hostTransportSSH) | |||
| 497 | - | } | |||
| 498 | - | case strings.HasPrefix(arg, "-"): | |||
| 499 | - | return command{}, usagef("unknown flag %q for `host add`", arg) | |||
| 500 | - | case target != "": | |||
| 501 | - | return command{}, usagef("`host add` accepts exactly one host target") | |||
| 502 | - | default: | |||
| 503 | - | target = arg | |||
| 504 | - | } | |||
| 505 | - | } | |||
| 506 | - | if target == "" { | |||
| 507 | - | return command{}, usagef("`host add` requires a host target") | |||
| 508 | - | } | |||
| 509 | - | if err := domain.ValidateRemoteHostTarget(target); err != nil { | |||
| 510 | - | return command{}, err | |||
| 511 | - | } | |||
| 512 | - | if transport == "" { | |||
| 513 | - | transport = hostTransportQUIC | |||
| 514 | - | } | |||
| 515 | - | return command{kind: kindHost, hostAction: hostActionAdd, hostTarget: target, hostTransport: transport}, nil | |||
| 516 | - | } | |||
| 517 | - | ||||
| 518 | - | // performanceTrace creates one serialized timestamp owner for this process. | |||
| 519 | - | // An empty trace environment leaves all production behavior and wire bytes | |||
| 520 | - | // unchanged. | |||
| 521 | - | // Composition-root factory seams keep observer propagation testable without | |||
| 522 | - | // opening real transports. | |||
| 523 | - | var connectBroker = connectProductionBroker | |||
| 524 | - | var connectDaemonStopBroker = connectExistingBroker | |||
| 525 | - | ||||
| 526 | - | var newPerformanceTrace = performanceTrace | |||
| 527 | - | ||||
| 528 | - | func performanceTrace(clk ports.Clock) (ports.SerializedRuntimeObserver, io.Closer, error) { | |||
| 529 | - | return performanceTraceWithFactories(clk, observability.NewJSONL, ports.NewRuntimeCorrelationObserver) | |||
| 530 | - | } | |||
| 531 | - | ||||
| 532 | - | // performanceTraceWithFactories keeps setup rollback behavior directly | |||
| 533 | - | // testable without requiring an operating-system file close failure. | |||
| 534 | - | func performanceTraceWithFactories( | |||
| 535 | - | clk ports.Clock, | |||
| 536 | - | newSink func(string, ports.Clock, string) (ports.RuntimeObserver, io.Closer, error), | |||
| 537 | - | newCorrelation func(ports.RuntimeObserver, ports.RuntimeCorrelationInputs) (ports.RuntimeObserver, error), | |||
| 538 | - | ) (ports.SerializedRuntimeObserver, io.Closer, error) { | |||
| 539 | - | path, processID := os.Getenv("VEV_PERF_TRACE"), os.Getenv("VEV_PERF_PROCESS_ID") | |||
| 540 | - | if path == "" { | |||
| 541 | - | return nil, nil, nil | |||
| 542 | - | } | |||
| 543 | - | observer, closer, err := newSink(path, clk, processID) | |||
| 544 | - | if err != nil { | |||
| 545 | - | return nil, nil, err | |||
| 546 | - | } | |||
| 547 | - | // The harness supplies these manifest fields to every launched role. Keep a | |||
| 548 | - | // valid standalone trace for operators that set only the original trace | |||
| 549 | - | // variables, while ensuring harness traces match their process mapping. | |||
| 550 | - | inputs := ports.RuntimeCorrelationInputs{Scenario: os.Getenv("VEV_PERF_SCENARIO"), Run: 1} | |||
| 551 | - | if inputs.Scenario == "" { | |||
| 552 | - | inputs.Scenario = "runtime" | |||
| 553 | - | } | |||
| 554 | - | if rawRun := os.Getenv("VEV_PERF_RUN"); rawRun != "" { | |||
| 555 | - | inputs.Run, err = strconv.ParseUint(rawRun, 10, 64) | |||
| 556 | - | if err != nil { | |||
| 557 | - | setupErr := fmt.Errorf("invalid VEV_PERF_RUN %q: %w", rawRun, err) | |||
| 558 | - | return nil, nil, closeTraceAfterSetupFailure(closer, setupErr) | |||
| 559 | - | } | |||
| 560 | - | if inputs.Run == 0 { | |||
| 561 | - | setupErr := fmt.Errorf("invalid VEV_PERF_RUN %q", rawRun) | |||
| 562 | - | return nil, nil, closeTraceAfterSetupFailure(closer, setupErr) | |||
| 563 | - | } | |||
| 564 | - | } | |||
| 565 | - | observer, err = newCorrelation(observer, inputs) | |||
| 566 | - | if err != nil { | |||
| 567 | - | setupErr := fmt.Errorf("configure runtime trace correlation: %w", err) | |||
| 568 | - | return nil, nil, closeTraceAfterSetupFailure(closer, setupErr) | |||
| 569 | - | } | |||
| 570 | - | reporter := observability.NewSerialized(observer, runtimeTraceQueueDepth) | |||
| 571 | - | return reporter, &runtimeTraceCloser{reporter: reporter, closer: closer}, nil | |||
| 572 | - | } | |||
| 573 | - | ||||
| 574 | - | func closeTraceAfterSetupFailure(closer io.Closer, setupErr error) error { | |||
| 575 | - | if closer == nil { | |||
| 576 | - | return setupErr | |||
| 577 | - | } | |||
| 578 | - | if closeErr := closer.Close(); closeErr != nil { | |||
| 579 | - | return errors.Join(setupErr, fmt.Errorf("close performance trace after setup failure: %w", closeErr)) | |||
| 580 | - | } | |||
| 581 | - | return setupErr | |||
| 582 | - | } | |||
| 583 | - | ||||
| 584 | - | // runtimeTraceQueueDepth bounds all process-local trace producer handoffs. | |||
| 585 | - | // A full queue emits the serialized diagnostic gap rather than delaying a | |||
| 586 | - | // terminal, transport, or ACK progress path. | |||
| 587 | - | const runtimeTraceQueueDepth = 256 | |||
| 588 | - | ||||
| 589 | - | // runtimeTraceCloser owns the two-stage trace shutdown: drain the reporter | |||
| 590 | - | // before closing the concrete timestamp/file owner. sync.Once makes every | |||
| 591 | - | // deferred and explicit close path safe without closing shared workers twice. | |||
| 592 | - | type runtimeTraceCloser struct { | |||
| 593 | - | reporter ports.SerializedRuntimeObserver | |||
| 594 | - | closer io.Closer | |||
| 595 | - | once sync.Once | |||
| 596 | - | err error | |||
| 597 | - | } | |||
| 598 | - | ||||
| 599 | - | func (c *runtimeTraceCloser) Close() error { | |||
| 600 | - | if c == nil { | |||
| 601 | - | return nil | |||
| 602 | - | } | |||
| 603 | - | c.once.Do(func() { | |||
| 604 | - | if c.reporter != nil { | |||
| 605 | - | c.reporter.Flush() | |||
| 606 | - | c.reporter.Close() | |||
| 607 | - | } | |||
| 608 | - | if c.closer != nil { | |||
| 609 | - | c.err = c.closer.Close() | |||
| 610 | - | } | |||
| 611 | - | }) | |||
| 612 | - | return c.err | |||
| 613 | - | } | |||
| 614 | - | ||||
| 615 | func configureLogging(component logging.Component, rotateAtRuntime bool) (*slog.Logger, io.Closer, error) { | 111 | func configureLogging(component logging.Component, rotateAtRuntime bool) (*slog.Logger, io.Closer, error) { | ||
| 616 | return logging.Setup(logging.Config{ | 112 | return logging.Setup(logging.Config{ | ||
| 617 | Dir: platform.StateDir(), | 113 | Dir: platform.StateDir(), | ||
| @@ -646,327 +142,6 @@ func loadConfigOrDefaults(log *slog.Logger, path string) domain.Config { | |||||
| 646 | return cfg | 142 | return cfg | ||
| 647 | } | 143 | } | ||
| 648 | 144 | ||||
| 649 | - | func snapshotDir() string { | |||
| 650 | - | return filepath.Join(platform.StateDir(), "snapshots") | |||
| 651 | - | } | |||
| 652 | - | ||||
| 653 | - | func developmentTempDirOption(ensurePrivate func(string) error) (daemon.Option, error) { | |||
| 654 | - | dir, active := platform.DevelopmentEnvironmentTempDir() | |||
| 655 | - | if !active { | |||
| 656 | - | return nil, nil | |||
| 657 | - | } | |||
| 658 | - | if err := ensurePrivate(dir); err != nil { | |||
| 659 | - | return nil, fmt.Errorf("vev: secure development temp directory: %w", err) | |||
| 660 | - | } | |||
| 661 | - | return daemon.WithTempDir(dir), nil | |||
| 662 | - | } | |||
| 663 | - | ||||
| 664 | - | func pprofAddrIsLoopback(addr string) bool { | |||
| 665 | - | host, _, err := net.SplitHostPort(addr) | |||
| 666 | - | if err != nil || host == "" { | |||
| 667 | - | return false | |||
| 668 | - | } | |||
| 669 | - | if strings.EqualFold(host, "localhost") { | |||
| 670 | - | return true | |||
| 671 | - | } | |||
| 672 | - | ip := net.ParseIP(host) | |||
| 673 | - | return ip != nil && ip.IsLoopback() | |||
| 674 | - | } | |||
| 675 | - | ||||
| 676 | - | type ownedDaemonStart func(context.Context) error | |||
| 677 | - | ||||
| 678 | - | type lifecycleOwnership interface { | |||
| 679 | - | Release() error | |||
| 680 | - | } | |||
| 681 | - | ||||
| 682 | - | type lifecycleStartupDeps struct { | |||
| 683 | - | ensurePrivate func(string) error | |||
| 684 | - | acquire func(context.Context, string, time.Duration) (lifecycleOwnership, error) | |||
| 685 | - | log *slog.Logger | |||
| 686 | - | } | |||
| 687 | - | ||||
| 688 | - | const lifecycleAcquireRetry = 25 * time.Millisecond | |||
| 689 | - | ||||
| 690 | - | func lifecycleStartupDependencies(log *slog.Logger) lifecycleStartupDeps { | |||
| 691 | - | return lifecycleStartupDeps{ | |||
| 692 | - | ensurePrivate: safedir.EnsurePrivate, | |||
| 693 | - | acquire: func(ctx context.Context, runtimeDir string, retry time.Duration) (lifecycleOwnership, error) { | |||
| 694 | - | return lifecycle.Acquire(ctx, runtimeDir, retry) | |||
| 695 | - | }, | |||
| 696 | - | log: log, | |||
| 697 | - | } | |||
| 698 | - | } | |||
| 699 | - | ||||
| 700 | - | func runWithLifecycleOwner(ctx context.Context, runtimeDir, stateDir string, start ownedDaemonStart) error { | |||
| 701 | - | return runWithLifecycleOwnerDeps(ctx, runtimeDir, stateDir, start, lifecycleStartupDependencies(nil)) | |||
| 702 | - | } | |||
| 703 | - | ||||
| 704 | - | func runWithLifecycleOwnerDeps(ctx context.Context, runtimeDir, stateDir string, start ownedDaemonStart, deps lifecycleStartupDeps) (retErr error) { | |||
| 705 | - | log := deps.log | |||
| 706 | - | if log == nil { | |||
| 707 | - | log = slog.Default() | |||
| 708 | - | } | |||
| 709 | - | if err := deps.ensurePrivate(runtimeDir); err != nil { | |||
| 710 | - | return fmt.Errorf("vev: secure runtime directory: %w", err) | |||
| 711 | - | } | |||
| 712 | - | if err := deps.ensurePrivate(stateDir); err != nil { | |||
| 713 | - | return fmt.Errorf("vev: secure state directory: %w", err) | |||
| 714 | - | } | |||
| 715 | - | lockPath := lifecycle.Path(runtimeDir) | |||
| 716 | - | log.Info("lifecycle_owner_wait", "path", lockPath) | |||
| 717 | - | owner, err := deps.acquire(ctx, runtimeDir, lifecycleAcquireRetry) | |||
| 718 | - | if err != nil { | |||
| 719 | - | log.Warn("lifecycle_owner_wait_failed", "path", lockPath, "reason_code", "acquire-failed") | |||
| 720 | - | return fmt.Errorf("vev: acquire lifecycle ownership: %w", err) | |||
| 721 | - | } | |||
| 722 | - | log.Info("lifecycle_owner_acquired", "path", lockPath) | |||
| 723 | - | defer func() { | |||
| 724 | - | releaseErr := owner.Release() | |||
| 725 | - | if releaseErr != nil { | |||
| 726 | - | log.Error("lifecycle_owner_release_failed", "path", lockPath, "reason_code", "release-failed") | |||
| 727 | - | } else { | |||
| 728 | - | log.Info("lifecycle_owner_released", "path", lockPath) | |||
| 729 | - | } | |||
| 730 | - | retErr = errors.Join(retErr, releaseErr) | |||
| 731 | - | }() | |||
| 732 | - | if start == nil { | |||
| 733 | - | return errors.New("vev: nil owned daemon startup") | |||
| 734 | - | } | |||
| 735 | - | return start(ctx) | |||
| 736 | - | } | |||
| 737 | - | ||||
| 738 | - | // runDaemon runs the daemon in the foreground (the hidden --daemon path, | |||
| 739 | - | // entered by an auto-spawned child) while holding exclusive lifecycle | |||
| 740 | - | // ownership through complete teardown. | |||
| 741 | - | func runDaemon() (retErr error) { | |||
| 742 | - | ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM, syscall.SIGINT) | |||
| 743 | - | defer stop() | |||
| 744 | - | log, logCloser, err := configureLogging(logging.Daemon, true) | |||
| 745 | - | if err != nil { | |||
| 746 | - | return err | |||
| 747 | - | } | |||
| 748 | - | defer func() { retErr = errors.Join(retErr, logCloser.Close()) }() | |||
| 749 | - | return runWithLifecycleOwnerDeps(ctx, ipc.SocketDir(), platform.StateDir(), func(ctx context.Context) error { | |||
| 750 | - | return runDaemonOwnedWithLogger(ctx, log) | |||
| 751 | - | }, lifecycleStartupDependencies(log)) | |||
| 752 | - | } | |||
| 753 | - | ||||
| 754 | - | func logCatalogueRecovery(log *slog.Logger, records []domain.CatalogueRecord, recoveryMode string) { | |||
| 755 | - | log.Info("catalogue_validated", "records", len(records), "recovery", recoveryMode) | |||
| 756 | - | } | |||
| 757 | - | ||||
| 758 | - | func logStartupRecoveryCounts(log *slog.Logger, records []domain.CatalogueRecord, restoring int) { | |||
| 759 | - | healthy, fresh, broken := 0, 0, 0 | |||
| 760 | - | for _, record := range records { | |||
| 761 | - | switch { | |||
| 762 | - | case record.DegradedReason != "": | |||
| 763 | - | broken++ | |||
| 764 | - | case record.Committed == nil: | |||
| 765 | - | fresh++ | |||
| 766 | - | default: | |||
| 767 | - | healthy++ | |||
| 768 | - | } | |||
| 769 | - | } | |||
| 770 | - | log.Info("daemon_startup_complete", "healthy", healthy, "fresh", fresh, "restoring", restoring, "broken", broken) | |||
| 771 | - | } | |||
| 772 | - | ||||
| 773 | - | func runDaemonOwned(ctx context.Context) (retErr error) { | |||
| 774 | - | log, logCloser, err := configureLogging(logging.Daemon, true) | |||
| 775 | - | if err != nil { | |||
| 776 | - | return err | |||
| 777 | - | } | |||
| 778 | - | defer func() { retErr = errors.Join(retErr, logCloser.Close()) }() | |||
| 779 | - | return runDaemonOwnedWithLogger(ctx, log) | |||
| 780 | - | } | |||
| 781 | - | ||||
| 782 | - | func runDaemonOwnedWithLogger(ctx context.Context, log *slog.Logger) (retErr error) { | |||
| 783 | - | clk := clock.New() | |||
| 784 | - | observer, observerCloser, err := newPerformanceTrace(clk) | |||
| 785 | - | if err != nil { | |||
| 786 | - | return fmt.Errorf("vev: performance trace: %w", err) | |||
| 787 | - | } | |||
| 788 | - | if observerCloser != nil { | |||
| 789 | - | defer func() { retErr = errors.Join(retErr, observerCloser.Close()) }() | |||
| 790 | - | } | |||
| 791 | - | if addr := os.Getenv("VEV_PPROF_ADDR"); addr != "" { | |||
| 792 | - | if !pprofAddrIsLoopback(addr) { | |||
| 793 | - | log.Warn("pprof bound to non-loopback address; /debug/pprof is unauthenticated", "addr", addr) | |||
| 794 | - | } | |||
| 795 | - | go func() { | |||
| 796 | - | if err := http.ListenAndServe(addr, nil); err != nil && !errors.Is(err, http.ErrServerClosed) { | |||
| 797 | - | log.Error("pprof server exited", "err", err) | |||
| 798 | - | } | |||
| 799 | - | }() | |||
| 800 | - | log.Info("pprof enabled", "addr", addr) | |||
| 801 | - | } | |||
| 802 | - | ||||
| 803 | - | var daemonOpts []daemon.Option | |||
| 804 | - | developmentTempOpt, err := developmentTempDirOption(safedir.EnsurePrivate) | |||
| 805 | - | if err != nil { | |||
| 806 | - | return err | |||
| 807 | - | } | |||
| 808 | - | if developmentTempOpt != nil { | |||
| 809 | - | daemonOpts = append(daemonOpts, developmentTempOpt) | |||
| 810 | - | } | |||
| 811 | - | if observer != nil { | |||
| 812 | - | daemonOpts = append(daemonOpts, daemon.WithRuntimeObserver(observer)) | |||
| 813 | - | } | |||
| 814 | - | configPath := platform.ConfigPath() | |||
| 815 | - | cfg := loadConfigOrDefaults(log, configPath) | |||
| 816 | - | daemonOpts = append(daemonOpts, daemon.WithConfig(cfg)) | |||
| 817 | - | daemonOpts = append(daemonOpts, daemon.WithBarScriptCommandRunner(shellcmd.New())) | |||
| 818 | - | daemonOpts = append(daemonOpts, daemon.WithProcessInspector(platform.NewProcessInspector()), daemon.WithDirOrHome(platform.DirOrHome)) | |||
| 819 | - | snapshotRepository := snapshotadapter.NewRepositoryWithLogger(snapshotDir(), log) | |||
| 820 | - | daemonOpts = append(daemonOpts, daemon.WithSnapshotRepository(snapshotRepository)) | |||
| 821 | - | noticeStore := noticefile.New(platform.StateDir()) | |||
| 822 | - | daemonOpts = append(daemonOpts, daemon.WithNoticeStore(noticeStore)) | |||
| 823 | - | stateDir := platform.StateDir() | |||
| 824 | - | storePath := persist.StorePath(stateDir) | |||
| 825 | - | opened, err := openCatalogue(stateDir) | |||
| 826 | - | if err != nil { | |||
| 827 | - | log.Error("catalogue_validation_failed", "path", storePath, "reason_code", "open-failed") | |||
| 828 | - | if errors.Is(err, persist.ErrCatalogueUnreadable) { | |||
| 829 | - | return unreadableCatalogueError(stateDir) | |||
| 830 | - | } | |||
| 831 | - | return fmt.Errorf("vev: open durable session state %s: %w", storePath, err) | |||
| 832 | - | } | |||
| 833 | - | recoveryMode := "current" | |||
| 834 | - | if opened.NewInstall { | |||
| 835 | - | recoveryMode = "new-install" | |||
| 836 | - | } | |||
| 837 | - | coordinator := recovery.NewCoordinator(opened.Catalogue, snapshotRepository, rand.Reader) | |||
| 838 | - | if opened.Migration.Performed { | |||
| 839 | - | recoveryMode = "catalogue-migrated" | |||
| 840 | - | log.Info("catalogue_migrated", "source_formats", opened.Migration.SourceFormats, "target_format", opened.Migration.TargetFormat, "records", opened.Migration.RecordCount, "backup", opened.Migration.BackupPath) | |||
| 841 | - | } | |||
| 842 | - | logCatalogueRecovery(log, opened.Records, recoveryMode) | |||
| 843 | - | daemonOpts = append(daemonOpts, daemon.WithRecoveryCoordinator(coordinator)) | |||
| 844 | - | daemonOpts = append(daemonOpts, daemon.WithSnapshotGarbageCollection()) | |||
| 845 | - | log.Info("session persistence enabled", "path", storePath) | |||
| 846 | - | daemonOpts = append(daemonOpts, daemon.WithCatalogue(opened.Catalogue, opened.Records)) | |||
| 847 | - | ||||
| 848 | - | // Construct the catalogue-backed expected-session registry before socket | |||
| 849 | - | // publication. Phase 3 snapshot restoration remains asynchronous in Serve. | |||
| 850 | - | identity, err := daemonidentity.LoadOrCreate(stateDir) | |||
| 851 | - | if err != nil { | |||
| 852 | - | return fmt.Errorf("vev: establish daemon identity: %w", err) | |||
| 853 | - | } | |||
| 854 | - | incarnation, err := daemonidentity.NewIncarnation() | |||
| 855 | - | if err != nil { | |||
| 856 | - | return fmt.Errorf("vev: establish daemon incarnation: %w", err) | |||
| 857 | - | } | |||
| 858 | - | binding, err := daemonmux.NewServerBindings(identity, incarnation, []daemonmux.ServerPolicyAdmission{ | |||
| 859 | - | {Policy: localDaemonPolicy(), Origin: ports.SessionOriginLocal}, | |||
| 860 | - | {Policy: remoteBrokerPolicy(hostTransportQUIC), Origin: ports.SessionOriginRemote}, | |||
| 861 | - | {Policy: remoteBrokerPolicy(hostTransportSSH), Origin: ports.SessionOriginRemote}, | |||
| 862 | - | }) | |||
| 863 | - | if err != nil { | |||
| 864 | - | return fmt.Errorf("vev: establish daemon mux authority: %w", err) | |||
| 865 | - | } | |||
| 866 | - | aggregate := daemonmux.NewAggregateListener() | |||
| 867 | - | muxSupervisor, err := daemonmux.NewServerSupervisor(aggregate, binding, daemonmux.DefaultMuxCeilings(), 0) | |||
| 868 | - | if err != nil { | |||
| 869 | - | return err | |||
| 870 | - | } | |||
| 871 | - | defer func() { retErr = errors.Join(retErr, muxSupervisor.Close()) }() | |||
| 872 | - | muxListener, err := ipc.ListenMux(daemonmux.SocketPath(ipc.SocketDir())) | |||
| 873 | - | if err != nil { | |||
| 874 | - | return fmt.Errorf("vev: listen daemon mux: %w", err) | |||
| 875 | - | } | |||
| 876 | - | defer func() { retErr = errors.Join(retErr, muxListener.Close()) }() | |||
| 877 | - | go func() { | |||
| 878 | - | for { | |||
| 879 | - | raw, acceptErr := muxListener.Accept() | |||
| 880 | - | if acceptErr != nil { | |||
| 881 | - | return | |||
| 882 | - | } | |||
| 883 | - | go func() { _ = muxSupervisor.Adopt(ctx, raw) }() | |||
| 884 | - | } | |||
| 885 | - | }() | |||
| 886 | - | ||||
| 887 | - | // The catalogue-backed registry exists before the socket is published. | |||
| 888 | - | // Snapshot GC runs in the background once restoration finishes, so it never | |||
| 889 | - | // delays socket publication. | |||
| 890 | - | d := daemon.New(pty.NewFactory(), clk, log, daemonOpts...) | |||
| 891 | - | ln, err := listenDaemon(ipc.SocketDir(), observer) | |||
| 892 | - | if err != nil { | |||
| 893 | - | closeErr := opened.Catalogue.Close() | |||
| 894 | - | log.Error("daemon listen failed", "socket_dir", ipc.SocketDir(), "err", err) | |||
| 895 | - | return errors.Join(fmt.Errorf("vev: daemon listen: %w", err), closeErr) | |||
| 896 | - | } | |||
| 897 | - | defer func() { _ = ln.Close() }() | |||
| 898 | - | log.Info("daemon starting", "socket", ln.Addr()) | |||
| 899 | - | ||||
| 900 | - | watchCtx, stopWatch := context.WithCancel(ctx) | |||
| 901 | - | defer stopWatch() | |||
| 902 | - | go func() { | |||
| 903 | - | if err := config.Watch(watchCtx, clk, configPath, func(cfg domain.Config, warnings []domain.Warning) { | |||
| 904 | - | logConfigWarnings(log, warnings) | |||
| 905 | - | d.ApplyConfig(cfg) | |||
| 906 | - | }); err != nil && !errors.Is(err, context.Canceled) { | |||
| 907 | - | log.Warn("config watcher stopped", "path", configPath, "err", err) | |||
| 908 | - | } | |||
| 909 | - | }() | |||
| 910 | - | if err := aggregate.Register(sessionwire.NewServerListener(ln)); err != nil { | |||
| 911 | - | return fmt.Errorf("vev: register local session listener: %w", err) | |||
| 912 | - | } | |||
| 913 | - | if err := d.Serve(ctx, aggregate); err != nil { | |||
| 914 | - | log.Error("daemon exited", "err", err) | |||
| 915 | - | return err | |||
| 916 | - | } | |||
| 917 | - | log.Info("daemon exited cleanly") | |||
| 918 | - | return nil | |||
| 919 | - | } | |||
| 920 | - | ||||
| 921 | - | // runAttach dials (auto-spawning the daemon if needed) and runs the client | |||
| 922 | - | // attach loop. Logging goes to the shared file: the client must never write | |||
| 923 | - | // to the console while the terminal is raw. | |||
| 924 | - | func runAttach(ctx context.Context, intent uint8, name, remoteTarget string) (retErr error) { | |||
| 925 | - | if os.Getenv("VEV") != "" { | |||
| 926 | - | if remoteTarget == "" && intent == protocol.IntentNew { | |||
| 927 | - | return createDetachedTerminalSession(ctx, name) | |||
| 928 | - | } | |||
| 929 | - | return errors.New("vev: sessions should be nested with care; unset VEV to force") | |||
| 930 | - | } | |||
| 931 | - | ctx, stop := clientSignalContext(ctx) | |||
| 932 | - | defer stop() | |||
| 933 | - | log, logCloser, err := configureLogging(logging.Client, false) | |||
| 934 | - | if err != nil { | |||
| 935 | - | return err | |||
| 936 | - | } | |||
| 937 | - | defer func() { _ = logCloser.Close() }() | |||
| 938 | - | clk := clock.New() | |||
| 939 | - | observer, observerCloser, err := newPerformanceTrace(clk) | |||
| 940 | - | if err != nil { | |||
| 941 | - | return fmt.Errorf("vev: performance trace: %w", err) | |||
| 942 | - | } | |||
| 943 | - | if observerCloser != nil { | |||
| 944 | - | defer func() { retErr = errors.Join(retErr, observerCloser.Close()) }() | |||
| 945 | - | } | |||
| 946 | - | if observer != nil { | |||
| 947 | - | observer.ObserveRuntime(ports.NewRuntimeMark("client", ports.RuntimeTransportDiagnostic, 0, true)) | |||
| 948 | - | } | |||
| 949 | - | terminal := terminalForAttach() | |||
| 950 | - | intent, preconnected, err := resolveAttachCreationIntent(ctx, intent, name, remoteTarget, terminal) | |||
| 951 | - | if err != nil { | |||
| 952 | - | return err | |||
| 953 | - | } | |||
| 954 | - | navigation, resolver, err := terminalBrokerNavigation(intent, name, remoteTarget) | |||
| 955 | - | if err != nil { | |||
| 956 | - | if preconnected != nil { | |||
| 957 | - | _ = preconnected.Close() | |||
| 958 | - | } | |||
| 959 | - | return err | |||
| 960 | - | } | |||
| 961 | - | callbacks := terminalBrokerCallbacks() | |||
| 962 | - | return runBrokerClient(ctx, brokerClientConfig{ | |||
| 963 | - | Logger: log, Connector: withPreconnected(newProductionBrokerConnector(), preconnected), Terminal: terminal, Clock: clk, | |||
| 964 | - | InitialNavigation: navigation, ResolveInitialNavigation: resolver, | |||
| 965 | - | AttachmentEnvironment: outerTerminalAttachmentEnvironment(log), SessionEnvironment: terminalSessionEnvironment(), | |||
| 966 | - | OnState: callbacks.OnState, OnLifecycle: callbacks.OnLifecycle, OnFailure: callbacks.OnFailure, | |||
| 967 | - | }) | |||
| 968 | - | } | |||
| 969 | - | ||||
| 970 | const ( | 145 | const ( | ||
| 971 | uiRemoteCleanupCommand = "_ui-cleanup" | 146 | uiRemoteCleanupCommand = "_ui-cleanup" | ||
| 972 | // daemonStopCommand is the hidden, environment-scoped graceful stop of | 147 | // daemonStopCommand is the hidden, environment-scoped graceful stop of | ||
| @@ -977,42 +152,15 @@ const ( | |||||
| 977 | 152 | ||||
| 978 | const daemonStopTimeout = 2 * time.Second | 153 | const daemonStopTimeout = 2 * time.Second | ||
| 979 | 154 | ||||
| 980 | - | var ( | 155 | + | var errDaemonNotRunning = errors.New("no daemon running") |
| 981 | - | errDaemonNotRunning = errors.New("vev: no daemon running") | |||
| 982 | - | errKillOutcomeUnknown = errors.New("vev: kill outcome unknown") | |||
| 983 | - | ) | |||
| 984 | - | ||||
| 985 | - | func createDetachedLocalSession(ctx context.Context, name string) error { | |||
| 986 | - | service, err := connectBroker(ctx) | |||
| 987 | - | if err != nil { | |||
| 988 | - | return fmt.Errorf("%w: %v", errDaemonUnreachable, err) | |||
| 989 | - | } | |||
| 990 | - | defer func() { _ = service.Close() }() | |||
| 991 | - | operations, err := client.NewBrokerOperations(service, clock.New()) | |||
| 992 | - | if err != nil { | |||
| 993 | - | return err | |||
| 994 | - | } | |||
| 995 | - | identity, _ := parseVEVEnv(os.Getenv("VEV")) | |||
| 996 | - | result, err := operations.Command(ctx, localBrokerOperationRoute(service.Snapshot()), protocol.CommandRequest{ | |||
| 997 | - | Version: protocol.Version, Slug: "new-session", Args: []string{name}, TargetSession: identity.session, | |||
| 998 | - | }) | |||
| 999 | - | if err != nil { | |||
| 1000 | - | return err | |||
| 1001 | - | } | |||
| 1002 | - | return renderDetachedCreationResult(result) | |||
| 1003 | - | } | |||
| 1004 | - | ||||
| 1005 | - | func renderDetachedCreationResult(result protocol.CommandResult) error { | |||
| 1006 | - | return renderCommandResult(io.Discard, result) | |||
| 1007 | - | } | |||
| 1008 | 156 | ||||
| 1009 | func runUIRemoteCleanup(ctx context.Context) error { | 157 | func runUIRemoteCleanup(ctx context.Context) error { | ||
| 1010 | root := os.Getenv("VEV_ENV_ROOT") | 158 | root := os.Getenv("VEV_ENV_ROOT") | ||
| 1011 | if !filepath.IsAbs(root) || filepath.Clean(root) == string(filepath.Separator) { | 159 | if !filepath.IsAbs(root) || filepath.Clean(root) == string(filepath.Separator) { | ||
| 1012 | - | return errors.New("vev: remote UI cleanup requires a private absolute launch root") | 160 | + | return errors.New("remote UI cleanup requires a private absolute launch root") |
| 1013 | } | 161 | } | ||
| 1014 | if runtimeDir := os.Getenv("XDG_RUNTIME_DIR"); runtimeDir == "" || runtimeDir != filepath.Join(root, "runtime") { | 162 | if runtimeDir := os.Getenv("XDG_RUNTIME_DIR"); runtimeDir == "" || runtimeDir != filepath.Join(root, "runtime") { | ||
| 1015 | - | return errors.New("vev: remote UI cleanup requires the launch runtime directory") | 163 | + | return errors.New("remote UI cleanup requires the launch runtime directory") |
| 1016 | } | 164 | } | ||
| 1017 | if err := requestDaemonStop(ctx); err != nil && !errors.Is(err, errDaemonNotRunning) { | 165 | if err := requestDaemonStop(ctx); err != nil && !errors.Is(err, errDaemonNotRunning) { | ||
| 1018 | return err | 166 | return err | ||
| @@ -1020,6 +168,9 @@ func runUIRemoteCleanup(ctx context.Context) error { | |||||
| 1020 | return nil | 168 | return nil | ||
| 1021 | } | 169 | } | ||
| 1022 | 170 | ||||
| 171 | + | // connectDaemonStopBroker is the broker connection seam for requestDaemonStop. | |||
| 172 | + | var connectDaemonStopBroker = connectExistingBroker | |||
| 173 | + | ||||
| 1023 | func requestDaemonStop(ctx context.Context) error { | 174 | func requestDaemonStop(ctx context.Context) error { | ||
| 1024 | ctx, cancel := context.WithTimeout(ctx, daemonStopTimeout) | 175 | ctx, cancel := context.WithTimeout(ctx, daemonStopTimeout) | ||
| 1025 | defer cancel() | 176 | defer cancel() | ||
| @@ -1046,128 +197,6 @@ func unreachableBrokerError(err error) error { | |||||
| 1046 | return &exitCoded{code: 3, err: fmt.Errorf("%w: %v", errDaemonUnreachable, err)} | 197 | return &exitCoded{code: 3, err: fmt.Errorf("%w: %v", errDaemonUnreachable, err)} | ||
| 1047 | } | 198 | } | ||
| 1048 | 199 | ||||
| 1049 | - | func brokerKillResultError(result protocol.KillResult) error { | |||
| 1050 | - | switch result.Outcome { | |||
| 1051 | - | case protocol.KillSucceeded: | |||
| 1052 | - | return nil | |||
| 1053 | - | case protocol.KillFailed: | |||
| 1054 | - | if result.Text == "" { | |||
| 1055 | - | result.Text = "kill failed" | |||
| 1056 | - | } | |||
| 1057 | - | return errors.New(result.Text) | |||
| 1058 | - | default: | |||
| 1059 | - | if result.Text == "" { | |||
| 1060 | - | result.Text = "daemon did not report a final outcome" | |||
| 1061 | - | } | |||
| 1062 | - | return &exitCoded{code: 3, err: fmt.Errorf("%w: %s", errKillOutcomeUnknown, result.Text)} | |||
| 1063 | - | } | |||
| 1064 | - | } | |||
| 1065 | - | ||||
| 1066 | - | // gracefulTeardownWaiter is implemented by carriages (QUIC) whose Close defers | |||
| 1067 | - | // a bounded connection close past its return. The broker mux QUIC proxy owns its | |||
| 1068 | - | // process, so it must wait for that close before returning: exiting first | |||
| 1069 | - | // would discard the final synchronous envelope Close already handed to the | |||
| 1070 | - | // stream. | |||
| 1071 | - | type gracefulTeardownWaiter interface { | |||
| 1072 | - | WaitGracefulTeardown(ctx context.Context) error | |||
| 1073 | - | } | |||
| 1074 | - | ||||
| 1075 | - | // proxyTeardownTimeout bounds the proxy's wait for a deferred graceful close, | |||
| 1076 | - | // leaving headroom beyond the adapter's own graceful window. A carriage that | |||
| 1077 | - | // does not defer its close is not waited on. | |||
| 1078 | - | const proxyTeardownTimeout = 2 * time.Second | |||
| 1079 | - | ||||
| 1080 | - | func waitGracefulTeardown(transport wire.Transport) error { | |||
| 1081 | - | waiter, ok := transport.(gracefulTeardownWaiter) | |||
| 1082 | - | if !ok { | |||
| 1083 | - | return nil | |||
| 1084 | - | } | |||
| 1085 | - | ctx, cancel := context.WithTimeout(context.Background(), proxyTeardownTimeout) | |||
| 1086 | - | defer cancel() | |||
| 1087 | - | return waiter.WaitGracefulTeardown(ctx) | |||
| 1088 | - | } | |||
| 1089 | - | ||||
| 1090 | - | // runList reads existing state without starting either background process. | |||
| 1091 | - | func runList(ctx context.Context, cmd command) error { | |||
| 1092 | - | service, err := connectListBroker(ctx) | |||
| 1093 | - | if errors.Is(err, errBrokerAbsent) { | |||
| 1094 | - | return runListWithoutBroker(ctx, cmd, os.Stdout) | |||
| 1095 | - | } | |||
| 1096 | - | if err != nil { | |||
| 1097 | - | return unreachableBrokerError(err) | |||
| 1098 | - | } | |||
| 1099 | - | defer func() { _ = service.Close() }() | |||
| 1100 | - | if cmd.listAll || cmd.listHost != "" { | |||
| 1101 | - | return runBrokerSnapshotList(cmd, service.Snapshot(), os.Stdout) | |||
| 1102 | - | } | |||
| 1103 | - | operations, err := client.NewBrokerOperations(service, clock.New()) | |||
| 1104 | - | if err != nil { | |||
| 1105 | - | return err | |||
| 1106 | - | } | |||
| 1107 | - | sessions, err := operations.List(ctx, localBrokerOperationRoute(service.Snapshot())) | |||
| 1108 | - | if err != nil { | |||
| 1109 | - | var brokerErr ports.BrokerError | |||
| 1110 | - | if errors.As(err, &brokerErr) && brokerErr.Code == ports.BrokerErrorNoDaemon { | |||
| 1111 | - | printSessions(os.Stdout, nil) | |||
| 1112 | - | return nil | |||
| 1113 | - | } | |||
| 1114 | - | return err | |||
| 1115 | - | } | |||
| 1116 | - | printSessions(os.Stdout, sessions) | |||
| 1117 | - | return nil | |||
| 1118 | - | } | |||
| 1119 | - | ||||
| 1120 | - | var connectListBroker = connectExistingBroker | |||
| 1121 | - | var dialListDaemon = ipc.DialContext | |||
| 1122 | - | ||||
| 1123 | - | // errNoLocalDaemon reports that no local daemon socket answers; nothing was | |||
| 1124 | - | // started to find out. | |||
| 1125 | - | var errNoLocalDaemon = errors.New("vev: no local daemon") | |||
| 1126 | - | ||||
| 1127 | - | // exchangeLocalDaemon sends one request directly to an existing local daemon | |||
| 1128 | - | // and returns its reply. It is the broker-less path of read and stop | |||
| 1129 | - | // commands: the broker being absent is the normal idle state, and these | |||
| 1130 | - | // commands must stay true without starting it. It never spawns a daemon. | |||
| 1131 | - | func exchangeLocalDaemon(ctx context.Context, request protocol.ClientMessage) (protocol.ServerMessage, error) { | |||
| 1132 | - | transport, err := dialListDaemon(ctx, ipc.SocketDir()) | |||
| 1133 | - | if err != nil { | |||
| 1134 | - | if backendAbsent(err) { | |||
| 1135 | - | return nil, errNoLocalDaemon | |||
| 1136 | - | } | |||
| 1137 | - | return nil, err | |||
| 1138 | - | } | |||
| 1139 | - | connection := sessionwire.NewClientConnection(transport) | |||
| 1140 | - | defer func() { _ = connection.Close() }() | |||
| 1141 | - | if err := connection.SendClient(request); err != nil { | |||
| 1142 | - | return nil, err | |||
| 1143 | - | } | |||
| 1144 | - | return connection.ReceiveServer() | |||
| 1145 | - | } | |||
| 1146 | - | ||||
| 1147 | - | func runListWithoutBroker(ctx context.Context, cmd command, out io.Writer) error { | |||
| 1148 | - | if cmd.listHost != "" { | |||
| 1149 | - | _, err := fmt.Fprintf(out, "%s: not connected (no broker running)\n", cmd.listHost) | |||
| 1150 | - | return err | |||
| 1151 | - | } | |||
| 1152 | - | reply, err := exchangeLocalDaemon(ctx, protocol.List{}) | |||
| 1153 | - | switch { | |||
| 1154 | - | case errors.Is(err, errNoLocalDaemon): | |||
| 1155 | - | printSessions(out, nil) | |||
| 1156 | - | case err != nil: | |||
| 1157 | - | return fmt.Errorf("vev: list local sessions: %w", err) | |||
| 1158 | - | default: | |||
| 1159 | - | sessions, ok := reply.(protocol.Sessions) | |||
| 1160 | - | if !ok { | |||
| 1161 | - | return fmt.Errorf("vev: unexpected local list reply %T", reply) | |||
| 1162 | - | } | |||
| 1163 | - | printSessions(out, sessions.Sessions) | |||
| 1164 | - | } | |||
| 1165 | - | if cmd.listAll { | |||
| 1166 | - | return listDisconnectedHosts(out) | |||
| 1167 | - | } | |||
| 1168 | - | return nil | |||
| 1169 | - | } | |||
| 1170 | - | ||||
| 1171 | // stopDaemonWithoutBroker asks an existing local daemon to stop directly. A | 200 | // stopDaemonWithoutBroker asks an existing local daemon to stop directly. A | ||
| 1172 | // daemon routinely outlives the idle broker, so "no broker" never means "no | 201 | // daemon routinely outlives the idle broker, so "no broker" never means "no | ||
| 1173 | // daemon". | 202 | // daemon". | ||
| @@ -1177,82 +206,11 @@ func stopDaemonWithoutBroker(ctx context.Context) error { | |||||
| 1177 | return errDaemonNotRunning | 206 | return errDaemonNotRunning | ||
| 1178 | } | 207 | } | ||
| 1179 | if err != nil { | 208 | if err != nil { | ||
| 1180 | - | return fmt.Errorf("vev: stop local daemon: %w", err) | 209 | + | return fmt.Errorf("stop local daemon: %w", err) |
| 1181 | } | 210 | } | ||
| 1182 | result, ok := reply.(protocol.KillResult) | 211 | result, ok := reply.(protocol.KillResult) | ||
| 1183 | if !ok { | 212 | if !ok { | ||
| 1184 | - | return fmt.Errorf("vev: unexpected daemon stop reply %T", reply) | 213 | + | return fmt.Errorf("unexpected daemon stop reply %T", reply) |
| 1185 | } | 214 | } | ||
| 1186 | return brokerKillResultError(result) | 215 | return brokerKillResultError(result) | ||
| 1187 | } | 216 | } | ||
| 1188 | - | ||||
| 1189 | - | // printSessions renders a session table (or a friendly note when empty). | |||
| 1190 | - | func printSessions(w io.Writer, sessions []protocol.SessionInfo) { | |||
| 1191 | - | if len(sessions) == 0 { | |||
| 1192 | - | _, _ = fmt.Fprintln(w, "no sessions") | |||
| 1193 | - | return | |||
| 1194 | - | } | |||
| 1195 | - | tw := tabwriter.NewWriter(w, 0, 4, 2, ' ', 0) | |||
| 1196 | - | _, _ = fmt.Fprintln(tw, "NAME\tSTATE\tTABS\tATTACHED") | |||
| 1197 | - | for _, s := range sessions { | |||
| 1198 | - | state := "up" | |||
| 1199 | - | tabs := fmt.Sprintf("%d", s.Tabs) | |||
| 1200 | - | attached := "no" | |||
| 1201 | - | switch s.State { | |||
| 1202 | - | case protocol.SessionDown: | |||
| 1203 | - | state = "down" | |||
| 1204 | - | tabs = "-" | |||
| 1205 | - | case protocol.SessionBroken: | |||
| 1206 | - | state = "broken" | |||
| 1207 | - | tabs = "-" | |||
| 1208 | - | default: | |||
| 1209 | - | if s.Ephemeral { | |||
| 1210 | - | state = "temporary" | |||
| 1211 | - | } | |||
| 1212 | - | } | |||
| 1213 | - | if s.Attached { | |||
| 1214 | - | attached = "yes" | |||
| 1215 | - | } | |||
| 1216 | - | _, _ = fmt.Fprintf(tw, "%s\t%s\t%s\t%s\n", s.Name, state, tabs, attached) | |||
| 1217 | - | } | |||
| 1218 | - | _ = tw.Flush() | |||
| 1219 | - | } | |||
| 1220 | - | ||||
| 1221 | - | func unreadableCatalogueError(stateDir string) error { | |||
| 1222 | - | return fmt.Errorf("%w: vev: durable session state at %s cannot be read and was left untouched.\n"+ | |||
| 1223 | - | "vev does not erase it automatically. To start fresh, remove it:\n"+ | |||
| 1224 | - | " rm -rf %s", persist.ErrCatalogueUnreadable, stateDir, stateDir) | |||
| 1225 | - | } | |||
| 1226 | - | ||||
| 1227 | - | // runKill deletes one session or, with sessions set, every session through the | |||
| 1228 | - | // broker, starting it if needed. | |||
| 1229 | - | func runKill(ctx context.Context, name string, sessions bool) error { | |||
| 1230 | - | service, err := connectBroker(ctx) | |||
| 1231 | - | if err != nil { | |||
| 1232 | - | return unreachableBrokerError(err) | |||
| 1233 | - | } | |||
| 1234 | - | defer func() { _ = service.Close() }() | |||
| 1235 | - | operations, err := client.NewBrokerOperations(service, clock.New()) | |||
| 1236 | - | if err != nil { | |||
| 1237 | - | return err | |||
| 1238 | - | } | |||
| 1239 | - | route := localBrokerOperationRoute(service.Snapshot()) | |||
| 1240 | - | var result protocol.KillResult | |||
| 1241 | - | if sessions { | |||
| 1242 | - | result, err = operations.KillAll(ctx, route) | |||
| 1243 | - | } else { | |||
| 1244 | - | result, err = operations.Kill(ctx, route, name) | |||
| 1245 | - | } | |||
| 1246 | - | if err != nil { | |||
| 1247 | - | return err | |||
| 1248 | - | } | |||
| 1249 | - | if err := brokerKillResultError(result); err != nil { | |||
| 1250 | - | return err | |||
| 1251 | - | } | |||
| 1252 | - | if sessions { | |||
| 1253 | - | fmt.Println("killed all sessions") | |||
| 1254 | - | } else { | |||
| 1255 | - | fmt.Printf("killed %s\n", name) | |||
| 1256 | - | } | |||
| 1257 | - | return nil | |||
| 1258 | - | } | |||
M internal/app/run_broker_test.go +1 -1
| @@ -474,7 +474,7 @@ func TestCreateDetachedLocalSessionUsesBrokerOperations(t *testing.T) { | |||||
| 474 | // not reappear in its body as a fallback. | 474 | // not reappear in its body as a fallback. | ||
| 475 | func TestDetachedCreationProductionSourceHasNoDaemonBypass(t *testing.T) { | 475 | func TestDetachedCreationProductionSourceHasNoDaemonBypass(t *testing.T) { | ||
| 476 | sources := loadAppSources(t, false) | 476 | sources := loadAppSources(t, false) | ||
| 477 | - | body := sources["run.go"] | 477 | + | body := sources["sessions.go"] |
| 478 | start := strings.Index(body, "func createDetachedLocalSession") | 478 | start := strings.Index(body, "func createDetachedLocalSession") | ||
| 479 | require.NotEqual(t, -1, start) | 479 | require.NotEqual(t, -1, start) | ||
| 480 | end := strings.Index(body[start:], "\nfunc renderDetachedCreationResult") | 480 | end := strings.Index(body[start:], "\nfunc renderDetachedCreationResult") | ||
M internal/app/run_test.go +7 -0
| @@ -462,6 +462,13 @@ func TestParseArgs(t *testing.T) { | |||||
| 462 | {name: "kill removed broker flag", args: []string{"kill", "--broker"}, wantErr: true}, | 462 | {name: "kill removed broker flag", args: []string{"kill", "--broker"}, wantErr: true}, | ||
| 463 | {name: "kill without name", args: []string{"kill"}, wantErr: true}, | 463 | {name: "kill without name", args: []string{"kill"}, wantErr: true}, | ||
| 464 | {name: "kill terminator without name", args: []string{"kill", "--"}, wantErr: true}, | 464 | {name: "kill terminator without name", args: []string{"kill", "--"}, wantErr: true}, | ||
| 465 | + | {name: "kill terminator empty name", args: []string{"kill", "--", ""}, wantErr: true}, | |||
| 466 | + | {name: "leading ui socket without value", args: []string{"--ui-socket"}, wantErr: true}, | |||
| 467 | + | {name: "leading ui flag before ls", args: []string{"--ui-observe", "ls"}, wantErr: true}, | |||
| 468 | + | {name: "leading ui flag before ui-driver", args: []string{"--ui-observe", "--ui-driver"}, wantErr: true}, | |||
| 469 | + | {name: "leading ui flag only", args: []string{"--ui-observe"}, wantKind: kindAttach, wantIntent: protocol.IntentEphemeral}, | |||
| 470 | + | {name: "new remote with ui flag", args: []string{"new", "work", "user@example.com", "--ui-control"}, wantKind: kindAttach, wantIntent: protocol.IntentNew, wantName: "work", wantRemote: "user@example.com"}, | |||
| 471 | + | {name: "web renew rejects args", args: []string{"--web-renew-token", "x"}, wantErr: true}, | |||
| 465 | {name: "kill all rejects extra arg", args: []string{"kill", "--all", "extra"}, wantErr: true}, | 472 | {name: "kill all rejects extra arg", args: []string{"kill", "--all", "extra"}, wantErr: true}, | ||
| 466 | {name: "kill extra arg", args: []string{"kill", "work", "extra"}, wantErr: true}, | 473 | {name: "kill extra arg", args: []string{"kill", "work", "extra"}, wantErr: true}, | ||
| 467 | {name: "daemon", args: []string{"--daemon"}, wantKind: kindDaemon}, | 474 | {name: "daemon", args: []string{"--daemon"}, wantKind: kindDaemon}, | ||
A internal/app/sessions.go +217 -0
| @@ -0,0 +1,217 @@ | |||||
| 1 | + | package app | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "errors" | |||
| 6 | + | "fmt" | |||
| 7 | + | "io" | |||
| 8 | + | "os" | |||
| 9 | + | "text/tabwriter" | |||
| 10 | + | ||||
| 11 | + | "github.com/bnema/vev/internal/adapters/clock" | |||
| 12 | + | "github.com/bnema/vev/internal/adapters/ipc" | |||
| 13 | + | "github.com/bnema/vev/internal/adapters/sessionwire" | |||
| 14 | + | "github.com/bnema/vev/internal/persist" | |||
| 15 | + | "github.com/bnema/vev/internal/ports" | |||
| 16 | + | "github.com/bnema/vev/internal/protocol" | |||
| 17 | + | "github.com/bnema/vev/internal/usecase/client" | |||
| 18 | + | ) | |||
| 19 | + | ||||
| 20 | + | // Broker connection seams replaced by tests. | |||
| 21 | + | var ( | |||
| 22 | + | connectBroker = connectProductionBroker | |||
| 23 | + | connectListBroker = connectExistingBroker | |||
| 24 | + | dialListDaemon = ipc.DialContext | |||
| 25 | + | ) | |||
| 26 | + | ||||
| 27 | + | var errKillOutcomeUnknown = errors.New("kill outcome unknown") | |||
| 28 | + | ||||
| 29 | + | // errNoLocalDaemon reports that no local daemon socket answers; nothing was | |||
| 30 | + | // started to find out. | |||
| 31 | + | var errNoLocalDaemon = errors.New("no local daemon") | |||
| 32 | + | ||||
| 33 | + | func createDetachedLocalSession(ctx context.Context, name string) error { | |||
| 34 | + | service, err := connectBroker(ctx) | |||
| 35 | + | if err != nil { | |||
| 36 | + | return fmt.Errorf("%w: %v", errDaemonUnreachable, err) | |||
| 37 | + | } | |||
| 38 | + | defer func() { _ = service.Close() }() | |||
| 39 | + | operations, err := client.NewBrokerOperations(service, clock.New()) | |||
| 40 | + | if err != nil { | |||
| 41 | + | return err | |||
| 42 | + | } | |||
| 43 | + | identity, _ := parseVEVEnv(os.Getenv("VEV")) | |||
| 44 | + | result, err := operations.Command(ctx, localBrokerOperationRoute(service.Snapshot()), protocol.CommandRequest{ | |||
| 45 | + | Version: protocol.Version, Slug: "new-session", Args: []string{name}, TargetSession: identity.session, | |||
| 46 | + | }) | |||
| 47 | + | if err != nil { | |||
| 48 | + | return err | |||
| 49 | + | } | |||
| 50 | + | return renderDetachedCreationResult(result) | |||
| 51 | + | } | |||
| 52 | + | ||||
| 53 | + | func renderDetachedCreationResult(result protocol.CommandResult) error { | |||
| 54 | + | return renderCommandResult(io.Discard, result) | |||
| 55 | + | } | |||
| 56 | + | ||||
| 57 | + | func brokerKillResultError(result protocol.KillResult) error { | |||
| 58 | + | switch result.Outcome { | |||
| 59 | + | case protocol.KillSucceeded: | |||
| 60 | + | return nil | |||
| 61 | + | case protocol.KillFailed: | |||
| 62 | + | if result.Text == "" { | |||
| 63 | + | result.Text = "kill failed" | |||
| 64 | + | } | |||
| 65 | + | return errors.New(result.Text) | |||
| 66 | + | default: | |||
| 67 | + | if result.Text == "" { | |||
| 68 | + | result.Text = "daemon did not report a final outcome" | |||
| 69 | + | } | |||
| 70 | + | return &exitCoded{code: 3, err: fmt.Errorf("%w: %s", errKillOutcomeUnknown, result.Text)} | |||
| 71 | + | } | |||
| 72 | + | } | |||
| 73 | + | ||||
| 74 | + | // runList reads existing state without starting either background process. | |||
| 75 | + | func runList(ctx context.Context, cmd command) error { | |||
| 76 | + | service, err := connectListBroker(ctx) | |||
| 77 | + | if errors.Is(err, errBrokerAbsent) { | |||
| 78 | + | return runListWithoutBroker(ctx, cmd, os.Stdout) | |||
| 79 | + | } | |||
| 80 | + | if err != nil { | |||
| 81 | + | return unreachableBrokerError(err) | |||
| 82 | + | } | |||
| 83 | + | defer func() { _ = service.Close() }() | |||
| 84 | + | if cmd.listAll || cmd.listHost != "" { | |||
| 85 | + | return runBrokerSnapshotList(cmd, service.Snapshot(), os.Stdout) | |||
| 86 | + | } | |||
| 87 | + | operations, err := client.NewBrokerOperations(service, clock.New()) | |||
| 88 | + | if err != nil { | |||
| 89 | + | return err | |||
| 90 | + | } | |||
| 91 | + | sessions, err := operations.List(ctx, localBrokerOperationRoute(service.Snapshot())) | |||
| 92 | + | if err != nil { | |||
| 93 | + | var brokerErr ports.BrokerError | |||
| 94 | + | if errors.As(err, &brokerErr) && brokerErr.Code == ports.BrokerErrorNoDaemon { | |||
| 95 | + | printSessions(os.Stdout, nil) | |||
| 96 | + | return nil | |||
| 97 | + | } | |||
| 98 | + | return err | |||
| 99 | + | } | |||
| 100 | + | printSessions(os.Stdout, sessions) | |||
| 101 | + | return nil | |||
| 102 | + | } | |||
| 103 | + | ||||
| 104 | + | // exchangeLocalDaemon sends one request directly to an existing local daemon | |||
| 105 | + | // and returns its reply. It is the broker-less path of read and stop | |||
| 106 | + | // commands: the broker being absent is the normal idle state, and these | |||
| 107 | + | // commands must stay true without starting it. It never spawns a daemon. | |||
| 108 | + | func exchangeLocalDaemon(ctx context.Context, request protocol.ClientMessage) (protocol.ServerMessage, error) { | |||
| 109 | + | transport, err := dialListDaemon(ctx, ipc.SocketDir()) | |||
| 110 | + | if err != nil { | |||
| 111 | + | if backendAbsent(err) { | |||
| 112 | + | return nil, errNoLocalDaemon | |||
| 113 | + | } | |||
| 114 | + | return nil, err | |||
| 115 | + | } | |||
| 116 | + | connection := sessionwire.NewClientConnection(transport) | |||
| 117 | + | defer func() { _ = connection.Close() }() | |||
| 118 | + | if err := connection.SendClient(request); err != nil { | |||
| 119 | + | return nil, err | |||
| 120 | + | } | |||
| 121 | + | return connection.ReceiveServer() | |||
| 122 | + | } | |||
| 123 | + | ||||
| 124 | + | func runListWithoutBroker(ctx context.Context, cmd command, out io.Writer) error { | |||
| 125 | + | if cmd.listHost != "" { | |||
| 126 | + | _, err := fmt.Fprintf(out, "%s: not connected (no broker running)\n", cmd.listHost) | |||
| 127 | + | return err | |||
| 128 | + | } | |||
| 129 | + | reply, err := exchangeLocalDaemon(ctx, protocol.List{}) | |||
| 130 | + | switch { | |||
| 131 | + | case errors.Is(err, errNoLocalDaemon): | |||
| 132 | + | printSessions(out, nil) | |||
| 133 | + | case err != nil: | |||
| 134 | + | return fmt.Errorf("list local sessions: %w", err) | |||
| 135 | + | default: | |||
| 136 | + | sessions, ok := reply.(protocol.Sessions) | |||
| 137 | + | if !ok { | |||
| 138 | + | return fmt.Errorf("unexpected local list reply %T", reply) | |||
| 139 | + | } | |||
| 140 | + | printSessions(out, sessions.Sessions) | |||
| 141 | + | } | |||
| 142 | + | if cmd.listAll { | |||
| 143 | + | return listDisconnectedHosts(out) | |||
| 144 | + | } | |||
| 145 | + | return nil | |||
| 146 | + | } | |||
| 147 | + | ||||
| 148 | + | // printSessions renders a session table (or a friendly note when empty). | |||
| 149 | + | func printSessions(w io.Writer, sessions []protocol.SessionInfo) { | |||
| 150 | + | if len(sessions) == 0 { | |||
| 151 | + | _, _ = fmt.Fprintln(w, "no sessions") | |||
| 152 | + | return | |||
| 153 | + | } | |||
| 154 | + | tw := tabwriter.NewWriter(w, 0, 4, 2, ' ', 0) | |||
| 155 | + | _, _ = fmt.Fprintln(tw, "NAME\tSTATE\tTABS\tATTACHED") | |||
| 156 | + | for _, s := range sessions { | |||
| 157 | + | state := "up" | |||
| 158 | + | tabs := fmt.Sprintf("%d", s.Tabs) | |||
| 159 | + | attached := "no" | |||
| 160 | + | switch s.State { | |||
| 161 | + | case protocol.SessionDown: | |||
| 162 | + | state = "down" | |||
| 163 | + | tabs = "-" | |||
| 164 | + | case protocol.SessionBroken: | |||
| 165 | + | state = "broken" | |||
| 166 | + | tabs = "-" | |||
| 167 | + | default: | |||
| 168 | + | if s.Ephemeral { | |||
| 169 | + | state = "temporary" | |||
| 170 | + | } | |||
| 171 | + | } | |||
| 172 | + | if s.Attached { | |||
| 173 | + | attached = "yes" | |||
| 174 | + | } | |||
| 175 | + | _, _ = fmt.Fprintf(tw, "%s\t%s\t%s\t%s\n", s.Name, state, tabs, attached) | |||
| 176 | + | } | |||
| 177 | + | _ = tw.Flush() | |||
| 178 | + | } | |||
| 179 | + | ||||
| 180 | + | func unreadableCatalogueError(stateDir string) error { | |||
| 181 | + | return fmt.Errorf("%w: durable session state at %s cannot be read and was left untouched.\n"+ | |||
| 182 | + | "vev does not erase it automatically. To start fresh, remove it:\n"+ | |||
| 183 | + | " rm -rf %s", persist.ErrCatalogueUnreadable, stateDir, stateDir) | |||
| 184 | + | } | |||
| 185 | + | ||||
| 186 | + | // runKill deletes one session or, with sessions set, every session through the | |||
| 187 | + | // broker, starting it if needed. | |||
| 188 | + | func runKill(ctx context.Context, name string, sessions bool) error { | |||
| 189 | + | service, err := connectBroker(ctx) | |||
| 190 | + | if err != nil { | |||
| 191 | + | return unreachableBrokerError(err) | |||
| 192 | + | } | |||
| 193 | + | defer func() { _ = service.Close() }() | |||
| 194 | + | operations, err := client.NewBrokerOperations(service, clock.New()) | |||
| 195 | + | if err != nil { | |||
| 196 | + | return err | |||
| 197 | + | } | |||
| 198 | + | route := localBrokerOperationRoute(service.Snapshot()) | |||
| 199 | + | var result protocol.KillResult | |||
| 200 | + | if sessions { | |||
| 201 | + | result, err = operations.KillAll(ctx, route) | |||
| 202 | + | } else { | |||
| 203 | + | result, err = operations.Kill(ctx, route, name) | |||
| 204 | + | } | |||
| 205 | + | if err != nil { | |||
| 206 | + | return err | |||
| 207 | + | } | |||
| 208 | + | if err := brokerKillResultError(result); err != nil { | |||
| 209 | + | return err | |||
| 210 | + | } | |||
| 211 | + | if sessions { | |||
| 212 | + | fmt.Println("killed all sessions") | |||
| 213 | + | } else { | |||
| 214 | + | fmt.Printf("killed %s\n", name) | |||
| 215 | + | } | |||
| 216 | + | return nil | |||
| 217 | + | } | |||
M internal/app/spawn.go +5 -5
| @@ -23,12 +23,12 @@ import ( | |||||
| 23 | 23 | ||||
| 24 | // ErrDaemonUnreachable is returned when the daemon socket never becomes | 24 | // ErrDaemonUnreachable is returned when the daemon socket never becomes | ||
| 25 | // dialable within the retry budget, despite a spawn attempt. | 25 | // dialable within the retry budget, despite a spawn attempt. | ||
| 26 | - | var ErrDaemonUnreachable = errors.New("vev: daemon did not become reachable") | 26 | + | var ErrDaemonUnreachable = errors.New("daemon did not become reachable") |
| 27 | 27 | ||||
| 28 | // ErrLifecycleHeldByOther reports that another process kept the daemon | 28 | // ErrLifecycleHeldByOther reports that another process kept the daemon | ||
| 29 | // lifecycle lock for the whole start budget without publishing the carriage, | 29 | // lifecycle lock for the whole start budget without publishing the carriage, | ||
| 30 | // for example a vev daemon from an older build. Spawning cannot recover this. | 30 | // for example a vev daemon from an older build. Spawning cannot recover this. | ||
| 31 | - | var ErrLifecycleHeldByOther = errors.New("vev: a vev daemon holds the lifecycle lock but did not answer within the start budget; if it is from an older build, run `vev kill --all` and retry") | 31 | + | var ErrLifecycleHeldByOther = errors.New("a vev daemon holds the lifecycle lock but did not answer within the start budget; if it is from an older build, run `vev kill --all` and retry") |
| 32 | 32 | ||||
| 33 | // spawnLockName is the mkdir-based lock directory guarding daemon spawn, so | 33 | // spawnLockName is the mkdir-based lock directory guarding daemon spawn, so | ||
| 34 | // concurrent first-clients elect a single spawner instead of racing to | 34 | // concurrent first-clients elect a single spawner instead of racing to | ||
| @@ -195,7 +195,7 @@ func ensureTargetWithLifecycle[T any](ctx context.Context, lockDir string, dial | |||||
| 195 | return target, nil | 195 | return target, nil | ||
| 196 | } | 196 | } | ||
| 197 | if err := owner.Release(); err != nil { | 197 | if err := owner.Release(); err != nil { | ||
| 198 | - | return zero, fmt.Errorf("vev: release lifecycle spawn probe: %w", err) | 198 | + | return zero, fmt.Errorf("release lifecycle spawn probe: %w", err) |
| 199 | } | 199 | } | ||
| 200 | return ensureTarget(ctx, lockDir, dial, spawn, cfg) | 200 | return ensureTarget(ctx, lockDir, dial, spawn, cfg) | ||
| 201 | } | 201 | } | ||
| @@ -216,7 +216,7 @@ func ensureTarget[T any](ctx context.Context, lockDir string, dial func(ctx cont | |||||
| 216 | 216 | ||||
| 217 | release, acquired, err := acquireSpawnLock(lockDir) | 217 | release, acquired, err := acquireSpawnLock(lockDir) | ||
| 218 | if err != nil { | 218 | if err != nil { | ||
| 219 | - | return zero, fmt.Errorf("vev: acquiring spawn lock: %w", err) | 219 | + | return zero, fmt.Errorf("acquiring spawn lock: %w", err) |
| 220 | } | 220 | } | ||
| 221 | if acquired { | 221 | if acquired { | ||
| 222 | // We won the election: spawn, and hold the lock until the socket is | 222 | // We won the election: spawn, and hold the lock until the socket is | ||
| @@ -229,7 +229,7 @@ func ensureTarget[T any](ctx context.Context, lockDir string, dial func(ctx cont | |||||
| 229 | } | 229 | } | ||
| 230 | if err := spawn(); err != nil { | 230 | if err := spawn(); err != nil { | ||
| 231 | slog.Error("daemon spawn failed", "err", err) | 231 | slog.Error("daemon spawn failed", "err", err) | ||
| 232 | - | return zero, fmt.Errorf("vev: spawning daemon: %w", err) | 232 | + | return zero, fmt.Errorf("spawning daemon: %w", err) |
| 233 | } | 233 | } | ||
| 234 | } else { | 234 | } else { | ||
| 235 | slog.Debug("waiting for daemon spawned by another process", "socket_dir", lockDir) | 235 | slog.Debug("waiting for daemon spawned by another process", "socket_dir", lockDir) | ||
A internal/app/trace.go +107 -0
| @@ -0,0 +1,107 @@ | |||||
| 1 | + | package app | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "errors" | |||
| 5 | + | "fmt" | |||
| 6 | + | "io" | |||
| 7 | + | "os" | |||
| 8 | + | "strconv" | |||
| 9 | + | "sync" | |||
| 10 | + | ||||
| 11 | + | "github.com/bnema/vev/internal/adapters/observability" | |||
| 12 | + | "github.com/bnema/vev/internal/ports" | |||
| 13 | + | ) | |||
| 14 | + | ||||
| 15 | + | // newPerformanceTrace is the trace factory seam; tests replace it to observe | |||
| 16 | + | // propagation without opening real trace files. | |||
| 17 | + | var newPerformanceTrace = performanceTrace | |||
| 18 | + | ||||
| 19 | + | // performanceTrace creates one serialized timestamp owner for this process. | |||
| 20 | + | // An empty trace environment leaves all production behavior and wire bytes | |||
| 21 | + | // unchanged. | |||
| 22 | + | func performanceTrace(clk ports.Clock) (ports.SerializedRuntimeObserver, io.Closer, error) { | |||
| 23 | + | return performanceTraceWithFactories(clk, observability.NewJSONL, ports.NewRuntimeCorrelationObserver) | |||
| 24 | + | } | |||
| 25 | + | ||||
| 26 | + | // performanceTraceWithFactories keeps setup rollback behavior directly | |||
| 27 | + | // testable without requiring an operating-system file close failure. | |||
| 28 | + | func performanceTraceWithFactories( | |||
| 29 | + | clk ports.Clock, | |||
| 30 | + | newSink func(string, ports.Clock, string) (ports.RuntimeObserver, io.Closer, error), | |||
| 31 | + | newCorrelation func(ports.RuntimeObserver, ports.RuntimeCorrelationInputs) (ports.RuntimeObserver, error), | |||
| 32 | + | ) (ports.SerializedRuntimeObserver, io.Closer, error) { | |||
| 33 | + | path, processID := os.Getenv("VEV_PERF_TRACE"), os.Getenv("VEV_PERF_PROCESS_ID") | |||
| 34 | + | if path == "" { | |||
| 35 | + | return nil, nil, nil | |||
| 36 | + | } | |||
| 37 | + | observer, closer, err := newSink(path, clk, processID) | |||
| 38 | + | if err != nil { | |||
| 39 | + | return nil, nil, err | |||
| 40 | + | } | |||
| 41 | + | // The harness supplies these manifest fields to every launched role. Keep a | |||
| 42 | + | // valid standalone trace for operators that set only the original trace | |||
| 43 | + | // variables, while ensuring harness traces match their process mapping. | |||
| 44 | + | inputs := ports.RuntimeCorrelationInputs{Scenario: os.Getenv("VEV_PERF_SCENARIO"), Run: 1} | |||
| 45 | + | if inputs.Scenario == "" { | |||
| 46 | + | inputs.Scenario = "runtime" | |||
| 47 | + | } | |||
| 48 | + | if rawRun := os.Getenv("VEV_PERF_RUN"); rawRun != "" { | |||
| 49 | + | inputs.Run, err = strconv.ParseUint(rawRun, 10, 64) | |||
| 50 | + | if err != nil { | |||
| 51 | + | setupErr := fmt.Errorf("invalid VEV_PERF_RUN %q: %w", rawRun, err) | |||
| 52 | + | return nil, nil, closeTraceAfterSetupFailure(closer, setupErr) | |||
| 53 | + | } | |||
| 54 | + | if inputs.Run == 0 { | |||
| 55 | + | setupErr := fmt.Errorf("invalid VEV_PERF_RUN %q", rawRun) | |||
| 56 | + | return nil, nil, closeTraceAfterSetupFailure(closer, setupErr) | |||
| 57 | + | } | |||
| 58 | + | } | |||
| 59 | + | observer, err = newCorrelation(observer, inputs) | |||
| 60 | + | if err != nil { | |||
| 61 | + | setupErr := fmt.Errorf("configure runtime trace correlation: %w", err) | |||
| 62 | + | return nil, nil, closeTraceAfterSetupFailure(closer, setupErr) | |||
| 63 | + | } | |||
| 64 | + | reporter := observability.NewSerialized(observer, runtimeTraceQueueDepth) | |||
| 65 | + | return reporter, &runtimeTraceCloser{reporter: reporter, closer: closer}, nil | |||
| 66 | + | } | |||
| 67 | + | ||||
| 68 | + | func closeTraceAfterSetupFailure(closer io.Closer, setupErr error) error { | |||
| 69 | + | if closer == nil { | |||
| 70 | + | return setupErr | |||
| 71 | + | } | |||
| 72 | + | if closeErr := closer.Close(); closeErr != nil { | |||
| 73 | + | return errors.Join(setupErr, fmt.Errorf("close performance trace after setup failure: %w", closeErr)) | |||
| 74 | + | } | |||
| 75 | + | return setupErr | |||
| 76 | + | } | |||
| 77 | + | ||||
| 78 | + | // runtimeTraceQueueDepth bounds all process-local trace producer handoffs. | |||
| 79 | + | // A full queue emits the serialized diagnostic gap rather than delaying a | |||
| 80 | + | // terminal, transport, or ACK progress path. | |||
| 81 | + | const runtimeTraceQueueDepth = 256 | |||
| 82 | + | ||||
| 83 | + | // runtimeTraceCloser owns the two-stage trace shutdown: drain the reporter | |||
| 84 | + | // before closing the concrete timestamp/file owner. sync.Once makes every | |||
| 85 | + | // deferred and explicit close path safe without closing shared workers twice. | |||
| 86 | + | type runtimeTraceCloser struct { | |||
| 87 | + | reporter ports.SerializedRuntimeObserver | |||
| 88 | + | closer io.Closer | |||
| 89 | + | once sync.Once | |||
| 90 | + | err error | |||
| 91 | + | } | |||
| 92 | + | ||||
| 93 | + | func (c *runtimeTraceCloser) Close() error { | |||
| 94 | + | if c == nil { | |||
| 95 | + | return nil | |||
| 96 | + | } | |||
| 97 | + | c.once.Do(func() { | |||
| 98 | + | if c.reporter != nil { | |||
| 99 | + | c.reporter.Flush() | |||
| 100 | + | c.reporter.Close() | |||
| 101 | + | } | |||
| 102 | + | if c.closer != nil { | |||
| 103 | + | c.err = c.closer.Close() | |||
| 104 | + | } | |||
| 105 | + | }) | |||
| 106 | + | return c.err | |||
| 107 | + | } | |||
M internal/app/ui_driver.go +10 -103
| @@ -13,8 +13,6 @@ import ( | |||||
| 13 | "time" | 13 | "time" | ||
| 14 | 14 | ||||
| 15 | "github.com/bnema/vev/internal/adapters/clock" | 15 | "github.com/bnema/vev/internal/adapters/clock" | ||
| 16 | - | "github.com/bnema/vev/internal/adapters/ipc" | |||
| 17 | - | "github.com/bnema/vev/internal/adapters/term" | |||
| 18 | "github.com/bnema/vev/internal/adapters/uidriver" | 16 | "github.com/bnema/vev/internal/adapters/uidriver" | ||
| 19 | "github.com/bnema/vev/internal/adapters/uiterm" | 17 | "github.com/bnema/vev/internal/adapters/uiterm" | ||
| 20 | "github.com/bnema/vev/internal/domain" | 18 | "github.com/bnema/vev/internal/domain" | ||
| @@ -50,8 +48,12 @@ type interactiveUIOptions struct { | |||||
| 50 | socket string | 48 | socket string | ||
| 51 | } | 49 | } | ||
| 52 | 50 | ||||
| 53 | - | func parseInteractiveUIFlags(args []string, observe, control bool, socket string) (interactiveUIOptions, error) { | 51 | + | func (o interactiveUIOptions) enabled() bool { |
| 54 | - | options := interactiveUIOptions{observe: observe, control: control, socket: socket} | 52 | + | return o.observe || o.control || o.socket != "" |
| 53 | + | } | |||
| 54 | + | ||||
| 55 | + | // parseInteractiveUIFlags applies the UI flags in args on top of options. | |||
| 56 | + | func parseInteractiveUIFlags(args []string, options interactiveUIOptions) (interactiveUIOptions, error) { | |||
| 55 | for len(args) > 0 { | 57 | for len(args) > 0 { | ||
| 56 | switch args[0] { | 58 | switch args[0] { | ||
| 57 | case "--ui-observe": | 59 | case "--ui-observe": | ||
| @@ -235,7 +237,7 @@ func runHeadlessUIDriver(ctx context.Context, options uiDriverOptions) error { | |||||
| 235 | defer func() { _ = logCloser.Close() }() | 237 | defer func() { _ = logCloser.Close() }() | ||
| 236 | terminal, err := uiterm.New(ctx, domain.Geometry{Size: domain.Size{Cols: options.cols, Rows: options.rows}}, "") | 238 | terminal, err := uiterm.New(ctx, domain.Geometry{Size: domain.Size{Cols: options.cols, Rows: options.rows}}, "") | ||
| 237 | if err != nil { | 239 | if err != nil { | ||
| 238 | - | return fmt.Errorf("vev: create headless terminal: %w", err) | 240 | + | return fmt.Errorf("create headless terminal: %w", err) |
| 239 | } | 241 | } | ||
| 240 | defer terminal.Close() | 242 | defer terminal.Close() | ||
| 241 | ui := client.NewUI(terminal, clk) | 243 | ui := client.NewUI(terminal, clk) | ||
| @@ -270,10 +272,10 @@ func runHeadlessUIDriver(ctx context.Context, options uiDriverOptions) error { | |||||
| 270 | // daemon, and every session stay alive for the other clients. | 272 | // daemon, and every session stay alive for the other clients. | ||
| 271 | func runUIDriverClient(ctx context.Context, cfg brokerClientConfig, stream io.ReadWriteCloser) error { | 273 | func runUIDriverClient(ctx context.Context, cfg brokerClientConfig, stream io.ReadWriteCloser) error { | ||
| 272 | if cfg.UI == nil || cfg.Terminal == nil || cfg.Clock == nil { | 274 | if cfg.UI == nil || cfg.Terminal == nil || cfg.Clock == nil { | ||
| 273 | - | return errors.New("vev: UI driver requires a UI, a terminal, and a clock") | 275 | + | return errors.New("UI driver requires a UI, a terminal, and a clock") |
| 274 | } | 276 | } | ||
| 275 | if stream == nil { | 277 | if stream == nil { | ||
| 276 | - | return errors.New("vev: UI driver requires a stream") | 278 | + | return errors.New("UI driver requires a stream") |
| 277 | } | 279 | } | ||
| 278 | runCtx, cancel := context.WithCancel(ctx) | 280 | runCtx, cancel := context.WithCancel(ctx) | ||
| 279 | defer cancel() | 281 | defer cancel() | ||
| @@ -325,7 +327,7 @@ func waitForUIDriverPublication(ctx context.Context, ui *client.UI) (ports.UISna | |||||
| 325 | }) | 327 | }) | ||
| 326 | if err != nil { | 328 | if err != nil { | ||
| 327 | if errors.Is(err, context.DeadlineExceeded) && ctx.Err() == nil { | 329 | if errors.Is(err, context.DeadlineExceeded) && ctx.Err() == nil { | ||
| 328 | - | return ports.UISnapshot{}, fmt.Errorf("vev: UI driver published no initial state: %w", err) | 330 | + | return ports.UISnapshot{}, fmt.Errorf("UI driver published no initial state: %w", err) |
| 329 | } | 331 | } | ||
| 330 | return ports.UISnapshot{}, err | 332 | return ports.UISnapshot{}, err | ||
| 331 | } | 333 | } | ||
| @@ -414,98 +416,3 @@ func (s *stdioStream) Close() error { | |||||
| 414 | }) | 416 | }) | ||
| 415 | return closeErr | 417 | return closeErr | ||
| 416 | } | 418 | } | ||
| 417 | - | ||||
| 418 | - | // runAttachWithOptions is the composition path for ordinary interactive | |||
| 419 | - | // clients. Observation is opt-in; the default path remains term.New(). | |||
| 420 | - | // | |||
| 421 | - | // The observed path composes the physical terminal, one VT mirror, the UI | |||
| 422 | - | // service over that mirror, and the same broker connector and initial-navigation | |||
| 423 | - | // translation the ordinary terminal attach uses, then delegates the whole run to | |||
| 424 | - | // the shared runBrokerClient. It owns no daemon dialer, no remote carriage, and | |||
| 425 | - | // no session: mirroring observes bytes at the serialized terminal writer and the | |||
| 426 | - | // broker owns every transport. | |||
| 427 | - | func runAttachWithOptions(ctx context.Context, intent uint8, name, remoteTarget string, options interactiveUIOptions) (retErr error) { | |||
| 428 | - | if !options.observe && !options.control && options.socket == "" { | |||
| 429 | - | return runAttach(ctx, intent, name, remoteTarget) | |||
| 430 | - | } | |||
| 431 | - | if options.control { | |||
| 432 | - | options.observe = true | |||
| 433 | - | } | |||
| 434 | - | ctx, stop := clientSignalContext(ctx) | |||
| 435 | - | defer stop() | |||
| 436 | - | log, logCloser, err := configureLogging(logging.Client, false) | |||
| 437 | - | if err != nil { | |||
| 438 | - | return err | |||
| 439 | - | } | |||
| 440 | - | defer func() { _ = logCloser.Close() }() | |||
| 441 | - | clk := clock.New() | |||
| 442 | - | // The process trace is still created and joined here so an operator's trace | |||
| 443 | - | // file keeps its exact lifecycle, including a close failure joined into the | |||
| 444 | - | // run's result. Client-side transport spans are no longer emitted: the | |||
| 445 | - | // broker owns carriage, so the observed composition keeps no dialer seam. | |||
| 446 | - | _, observerCloser, err := newPerformanceTrace(clk) | |||
| 447 | - | if err != nil { | |||
| 448 | - | return fmt.Errorf("vev: performance trace: %w", err) | |||
| 449 | - | } | |||
| 450 | - | if observerCloser != nil { | |||
| 451 | - | defer func() { retErr = errors.Join(retErr, observerCloser.Close()) }() | |||
| 452 | - | } | |||
| 453 | - | physical := term.NewWithFilesAndObservation(os.Stdin, os.Stdout, nil) | |||
| 454 | - | geometry, err := physical.Geometry() | |||
| 455 | - | if err != nil { | |||
| 456 | - | return fmt.Errorf("vev: reading terminal geometry: %w", err) | |||
| 457 | - | } | |||
| 458 | - | mirror, err := uiterm.NewMirror(ctx, geometry, "") | |||
| 459 | - | if err != nil { | |||
| 460 | - | return fmt.Errorf("vev: create UI mirror: %w", err) | |||
| 461 | - | } | |||
| 462 | - | defer mirror.Close() | |||
| 463 | - | physical = term.NewWithFilesAndObservation(os.Stdin, os.Stdout, mirror) | |||
| 464 | - | ui := client.NewUI(mirror, clk) | |||
| 465 | - | var endpoint *uidriver.UnixEndpoint | |||
| 466 | - | if options.observe { | |||
| 467 | - | server := uidriver.New(ui, clk) | |||
| 468 | - | socketPath := options.socket | |||
| 469 | - | if socketPath == "" { | |||
| 470 | - | socketPath = uidriver.DefaultSocketPath(ipc.SocketDir(), ui.Handle()) | |||
| 471 | - | } | |||
| 472 | - | endpoint, err = uidriver.ListenUnix(socketPath, server, func() uidriver.Ready { | |||
| 473 | - | ready := uidriver.Ready{Attachment: ui.Handle(), Control: options.control, Status: ports.UIStatusPicker} | |||
| 474 | - | if snapshot, snapshotErr := ui.Capture(ui.Handle()); snapshotErr == nil { | |||
| 475 | - | ready = uidriverReady(ui, snapshot, options.control) | |||
| 476 | - | } | |||
| 477 | - | return ready | |||
| 478 | - | }) | |||
| 479 | - | if err != nil { | |||
| 480 | - | return err | |||
| 481 | - | } | |||
| 482 | - | fmt.Fprintln(os.Stderr, socketPath) | |||
| 483 | - | defer func() { _ = endpoint.Close() }() | |||
| 484 | - | } | |||
| 485 | - | terminal := observedTerminal{Terminal: physical, UIOutputTransaction: mirror} | |||
| 486 | - | intent, preconnected, err := resolveAttachCreationIntent(ctx, intent, name, remoteTarget, terminal) | |||
| 487 | - | if err != nil { | |||
| 488 | - | return err | |||
| 489 | - | } | |||
| 490 | - | navigation, resolver, err := terminalBrokerNavigation(intent, name, remoteTarget) | |||
| 491 | - | if err != nil { | |||
| 492 | - | if preconnected != nil { | |||
| 493 | - | _ = preconnected.Close() | |||
| 494 | - | } | |||
| 495 | - | return err | |||
| 496 | - | } | |||
| 497 | - | sessionEnv := terminalSessionEnvironment() | |||
| 498 | - | attachmentEnv := configuredTerminalAttachmentEnvironment(log) | |||
| 499 | - | attachmentEnv.Cwd = sessionEnv.Cwd | |||
| 500 | - | return runBrokerClient(ctx, brokerClientConfig{ | |||
| 501 | - | Logger: log, | |||
| 502 | - | Connector: withPreconnected(newProductionBrokerConnector(), preconnected), | |||
| 503 | - | Terminal: terminal, | |||
| 504 | - | Clock: clk, | |||
| 505 | - | UI: ui, | |||
| 506 | - | InitialNavigation: navigation, | |||
| 507 | - | ResolveInitialNavigation: resolver, | |||
| 508 | - | AttachmentEnvironment: attachmentEnv, | |||
| 509 | - | SessionEnvironment: sessionEnv, | |||
| 510 | - | }) | |||
| 511 | - | } | |||
M internal/app/ui_driver_test.go +5 -5
| @@ -85,8 +85,8 @@ func TestParseArgsUIOptionsAreAttachOnly(t *testing.T) { | |||||
| 85 | command, err := parseArgs([]string{"--ui-control", "new", "work"}) | 85 | command, err := parseArgs([]string{"--ui-control", "new", "work"}) | ||
| 86 | require.NoError(t, err) | 86 | require.NoError(t, err) | ||
| 87 | require.Equal(t, kindAttach, command.kind) | 87 | require.Equal(t, kindAttach, command.kind) | ||
| 88 | - | require.True(t, command.uiControl) | 88 | + | require.True(t, command.ui.control) |
| 89 | - | require.False(t, command.uiObserve) | 89 | + | require.False(t, command.ui.observe) |
| 90 | driver, err := parseArgs([]string{"--ui-driver", "--session", "work"}) | 90 | driver, err := parseArgs([]string{"--ui-driver", "--session", "work"}) | ||
| 91 | require.NoError(t, err) | 91 | require.NoError(t, err) | ||
| 92 | require.Equal(t, kindUIDriver, driver.kind) | 92 | require.Equal(t, kindUIDriver, driver.kind) | ||
| @@ -101,8 +101,8 @@ func TestParseArgsUIOptionsAreAttachOnly(t *testing.T) { | |||||
| 101 | require.Error(t, err) | 101 | require.Error(t, err) | ||
| 102 | trailing, err := parseArgs([]string{"attach", "work", "--ui-observe", "--ui-socket", "/tmp/ui.sock"}) | 102 | trailing, err := parseArgs([]string{"attach", "work", "--ui-observe", "--ui-socket", "/tmp/ui.sock"}) | ||
| 103 | require.NoError(t, err) | 103 | require.NoError(t, err) | ||
| 104 | - | require.True(t, trailing.uiObserve) | 104 | + | require.True(t, trailing.ui.observe) |
| 105 | - | require.Equal(t, "/tmp/ui.sock", trailing.uiSocket) | 105 | + | require.Equal(t, "/tmp/ui.sock", trailing.ui.socket) |
| 106 | _, err = parseArgs([]string{"--ui-observe", "ls"}) | 106 | _, err = parseArgs([]string{"--ui-observe", "ls"}) | ||
| 107 | require.Error(t, err) | 107 | require.Error(t, err) | ||
| 108 | _, err = parseArgs([]string{"--ui-socket", "/tmp/ui.sock"}) | 108 | _, err = parseArgs([]string{"--ui-socket", "/tmp/ui.sock"}) | ||
| @@ -115,7 +115,7 @@ func TestParseArgsUIOptionsAreAttachOnly(t *testing.T) { | |||||
| 115 | // client over the process' production connector. | 115 | // client over the process' production connector. | ||
| 116 | func TestInteractiveObservedCompositionUsesTheBrokerConnector(t *testing.T) { | 116 | func TestInteractiveObservedCompositionUsesTheBrokerConnector(t *testing.T) { | ||
| 117 | sources := loadAppSources(t, false) | 117 | sources := loadAppSources(t, false) | ||
| 118 | - | body := sources["ui_driver.go"] | 118 | + | body := sources["ui_driver.go"] + sources["attach.go"] |
| 119 | require.Contains(t, body, "newProductionBrokerConnector()") | 119 | require.Contains(t, body, "newProductionBrokerConnector()") | ||
| 120 | for _, forbidden := range []string{ | 120 | for _, forbidden := range []string{ | ||
| 121 | "runAttachWithDeps", "localDaemonDialer", "remoteDialerFactory", "launchConfig", | 121 | "runAttachWithDeps", "localDaemonDialer", "remoteDialerFactory", "launchConfig", | ||
M internal/app/web_control.go +4 -4
| @@ -70,10 +70,10 @@ func webControlRequest(ctx context.Context, renew bool) (webAccess, error) { | |||||
| 70 | defer conn.Close() | 70 | defer conn.Close() | ||
| 71 | unixConn, ok := conn.(*net.UnixConn) | 71 | unixConn, ok := conn.(*net.UnixConn) | ||
| 72 | if !ok { | 72 | if !ok { | ||
| 73 | - | return webAccess{}, errors.New("vev: unsafe web control peer") | 73 | + | return webAccess{}, errors.New("unsafe web control peer") |
| 74 | } | 74 | } | ||
| 75 | if !sameWebUID(unixConn) { | 75 | if !sameWebUID(unixConn) { | ||
| 76 | - | return webAccess{}, errors.New("vev: unsafe web control peer") | 76 | + | return webAccess{}, errors.New("unsafe web control peer") |
| 77 | } | 77 | } | ||
| 78 | deadline, _ := ctx.Deadline() | 78 | deadline, _ := ctx.Deadline() | ||
| 79 | _ = conn.SetDeadline(deadline) | 79 | _ = conn.SetDeadline(deadline) | ||
| @@ -90,12 +90,12 @@ func webControlRequest(ctx context.Context, renew bool) (webAccess, error) { | |||||
| 90 | } | 90 | } | ||
| 91 | var access webAccess | 91 | var access webAccess | ||
| 92 | if len(data) > 4096 || json.Unmarshal(data, &access) != nil { | 92 | if len(data) > 4096 || json.Unmarshal(data, &access) != nil { | ||
| 93 | - | return webAccess{}, errors.New("vev: invalid web control response") | 93 | + | return webAccess{}, errors.New("invalid web control response") |
| 94 | } | 94 | } | ||
| 95 | decoded, err := base64.RawURLEncoding.DecodeString(access.Token) | 95 | decoded, err := base64.RawURLEncoding.DecodeString(access.Token) | ||
| 96 | settings, settingsErr := webterm.ParseSettings(access.Settings.Listen, access.Settings.Origin) | 96 | settings, settingsErr := webterm.ParseSettings(access.Settings.Listen, access.Settings.Origin) | ||
| 97 | if err != nil || len(decoded) != 32 || settingsErr != nil || settings != access.Settings { | 97 | if err != nil || len(decoded) != 32 || settingsErr != nil || settings != access.Settings { | ||
| 98 | - | return webAccess{}, errors.New("vev: invalid web control response") | 98 | + | return webAccess{}, errors.New("invalid web control response") |
| 99 | } | 99 | } | ||
| 100 | return access, nil | 100 | return access, nil | ||
| 101 | } | 101 | } | ||
M internal/app/web_control_darwin.go +4 -4
| @@ -44,7 +44,7 @@ func sameWebUID(conn *net.UnixConn) bool { | |||||
| 44 | func bindWebControl() (*net.UnixListener, error) { | 44 | func bindWebControl() (*net.UnixListener, error) { | ||
| 45 | path := webControlAddress() | 45 | path := webControlAddress() | ||
| 46 | if err := safedir.EnsurePrivate(filepath.Dir(path)); err != nil { | 46 | if err := safedir.EnsurePrivate(filepath.Dir(path)); err != nil { | ||
| 47 | - | return nil, fmt.Errorf("vev: securing web control directory: %w", err) | 47 | + | return nil, fmt.Errorf("securing web control directory: %w", err) |
| 48 | } | 48 | } | ||
| 49 | listener, err := net.ListenUnix("unix", &net.UnixAddr{Name: path, Net: "unix"}) | 49 | listener, err := net.ListenUnix("unix", &net.UnixAddr{Name: path, Net: "unix"}) | ||
| 50 | if err != nil { | 50 | if err != nil { | ||
| @@ -55,10 +55,10 @@ func bindWebControl() (*net.UnixListener, error) { | |||||
| 55 | probeConn, probeErr := net.DialTimeout("unix", path, time.Second) | 55 | probeConn, probeErr := net.DialTimeout("unix", path, time.Second) | ||
| 56 | if probeErr == nil { | 56 | if probeErr == nil { | ||
| 57 | _ = probeConn.Close() | 57 | _ = probeConn.Close() | ||
| 58 | - | return nil, errors.New("vev: web gateway control is already bound") | 58 | + | return nil, errors.New("web gateway control is already bound") |
| 59 | } | 59 | } | ||
| 60 | if !errors.Is(probeErr, syscall.ECONNREFUSED) && !errors.Is(probeErr, syscall.ENOENT) { | 60 | if !errors.Is(probeErr, syscall.ECONNREFUSED) && !errors.Is(probeErr, syscall.ENOENT) { | ||
| 61 | - | return nil, fmt.Errorf("vev: probing web control socket: %w", probeErr) | 61 | + | return nil, fmt.Errorf("probing web control socket: %w", probeErr) |
| 62 | } | 62 | } | ||
| 63 | if rmErr := os.Remove(path); rmErr != nil && !os.IsNotExist(rmErr) { | 63 | if rmErr := os.Remove(path); rmErr != nil && !os.IsNotExist(rmErr) { | ||
| 64 | return nil, rmErr | 64 | return nil, rmErr | ||
| @@ -74,7 +74,7 @@ func bindWebControl() (*net.UnixListener, error) { | |||||
| 74 | func secureWebControlSocket(listener *net.UnixListener, path string) error { | 74 | func secureWebControlSocket(listener *net.UnixListener, path string) error { | ||
| 75 | if err := os.Chmod(path, 0o600); err != nil { | 75 | if err := os.Chmod(path, 0o600); err != nil { | ||
| 76 | _ = listener.Close() | 76 | _ = listener.Close() | ||
| 77 | - | return fmt.Errorf("vev: securing web control socket: %w", err) | 77 | + | return fmt.Errorf("securing web control socket: %w", err) |
| 78 | } | 78 | } | ||
| 79 | return nil | 79 | return nil | ||
| 80 | } | 80 | } | ||
M internal/app/web_daemon.go +12 -12
| @@ -47,7 +47,7 @@ func printWebLink(access webAccess) { | |||||
| 47 | func renewWebToken(ctx context.Context) error { | 47 | func renewWebToken(ctx context.Context) error { | ||
| 48 | token, err := webControlRequest(ctx, true) | 48 | token, err := webControlRequest(ctx, true) | ||
| 49 | if err != nil { | 49 | if err != nil { | ||
| 50 | - | return fmt.Errorf("vev: renewing web token: %w", err) | 50 | + | return fmt.Errorf("renewing web token: %w", err) |
| 51 | } | 51 | } | ||
| 52 | printWebLink(token) | 52 | printWebLink(token) | ||
| 53 | return nil | 53 | return nil | ||
| @@ -71,7 +71,7 @@ func launchWebDaemon(ctx context.Context, options webOptions) error { | |||||
| 71 | defer client.CloseIdleConnections() | 71 | defer client.CloseIdleConnections() | ||
| 72 | if token, err := webControlRequest(ctx, false); err == nil { | 72 | if token, err := webControlRequest(ctx, false); err == nil { | ||
| 73 | if token.Settings != settings { | 73 | if token.Settings != settings { | ||
| 74 | - | return errors.New("vev: web gateway is running with different settings; stop and restart it to apply web.listen/web.origin") | 74 | + | return errors.New("web gateway is running with different settings; stop and restart it to apply web.listen/web.origin") |
| 75 | } | 75 | } | ||
| 76 | readyCtx, cancel := context.WithTimeout(ctx, webStartupTimeout) | 76 | readyCtx, cancel := context.WithTimeout(ctx, webStartupTimeout) | ||
| 77 | defer cancel() | 77 | defer cancel() | ||
| @@ -80,7 +80,7 @@ func launchWebDaemon(ctx context.Context, options webOptions) error { | |||||
| 80 | for !webReachable(readyCtx, client, token, settings) { | 80 | for !webReachable(readyCtx, client, token, settings) { | ||
| 81 | select { | 81 | select { | ||
| 82 | case <-readyCtx.Done(): | 82 | case <-readyCtx.Done(): | ||
| 83 | - | return fmt.Errorf("vev: existing web gateway not ready: %w", readyCtx.Err()) | 83 | + | return fmt.Errorf("existing web gateway not ready: %w", readyCtx.Err()) |
| 84 | case <-ticker.C: | 84 | case <-ticker.C: | ||
| 85 | } | 85 | } | ||
| 86 | } | 86 | } | ||
| @@ -89,12 +89,12 @@ func launchWebDaemon(ctx context.Context, options webOptions) error { | |||||
| 89 | } | 89 | } | ||
| 90 | listener, err := listenWeb(settings.Listen) | 90 | listener, err := listenWeb(settings.Listen) | ||
| 91 | if err != nil { | 91 | if err != nil { | ||
| 92 | - | return fmt.Errorf("vev: web port unavailable: %w", err) | 92 | + | return fmt.Errorf("web port unavailable: %w", err) |
| 93 | } | 93 | } | ||
| 94 | defer listener.Close() | 94 | defer listener.Close() | ||
| 95 | inherited, ok := listener.(*net.TCPListener) | 95 | inherited, ok := listener.(*net.TCPListener) | ||
| 96 | if !ok { | 96 | if !ok { | ||
| 97 | - | return fmt.Errorf("vev: web listener is not TCP") | 97 | + | return fmt.Errorf("web listener is not TCP") |
| 98 | } | 98 | } | ||
| 99 | inheritedFile, err := inherited.File() | 99 | inheritedFile, err := inherited.File() | ||
| 100 | if err != nil { | 100 | if err != nil { | ||
| @@ -134,13 +134,13 @@ func launchWebDaemon(ctx context.Context, options webOptions) error { | |||||
| 134 | select { | 134 | select { | ||
| 135 | case err := <-done: | 135 | case err := <-done: | ||
| 136 | if err != nil { | 136 | if err != nil { | ||
| 137 | - | return fmt.Errorf("vev: web process exited before readiness: %w", err) | 137 | + | return fmt.Errorf("web process exited before readiness: %w", err) |
| 138 | } | 138 | } | ||
| 139 | - | return errors.New("vev: web process exited before readiness") | 139 | + | return errors.New("web process exited before readiness") |
| 140 | case <-readyCtx.Done(): | 140 | case <-readyCtx.Done(): | ||
| 141 | _ = child.Process.Kill() | 141 | _ = child.Process.Kill() | ||
| 142 | <-done | 142 | <-done | ||
| 143 | - | return fmt.Errorf("vev: web startup: %w", readyCtx.Err()) | 143 | + | return fmt.Errorf("web startup: %w", readyCtx.Err()) |
| 144 | case <-ticker.C: | 144 | case <-ticker.C: | ||
| 145 | } | 145 | } | ||
| 146 | } | 146 | } | ||
| @@ -225,7 +225,7 @@ func listenWeb(address string) (net.Listener, error) { | |||||
| 225 | func runWebDaemon(parent context.Context, options webOptions) error { | 225 | func runWebDaemon(parent context.Context, options webOptions) error { | ||
| 226 | // The launcher passes resolved settings; do not reload a changing config. | 226 | // The launcher passes resolved settings; do not reload a changing config. | ||
| 227 | if options.listen == "" || options.origin == "" { | 227 | if options.listen == "" || options.origin == "" { | ||
| 228 | - | return errors.New("vev: web server requires its launcher settings") | 228 | + | return errors.New("web server requires its launcher settings") |
| 229 | } | 229 | } | ||
| 230 | settings, err := webterm.ParseSettings(options.listen, options.origin) | 230 | settings, err := webterm.ParseSettings(options.listen, options.origin) | ||
| 231 | if err != nil { | 231 | if err != nil { | ||
| @@ -235,16 +235,16 @@ func runWebDaemon(parent context.Context, options webOptions) error { | |||||
| 235 | defer cancel() | 235 | defer cancel() | ||
| 236 | inherited := os.NewFile(3, "web-listener") | 236 | inherited := os.NewFile(3, "web-listener") | ||
| 237 | if inherited == nil { | 237 | if inherited == nil { | ||
| 238 | - | return errors.New("vev: web server requires its launcher") | 238 | + | return errors.New("web server requires its launcher") |
| 239 | } | 239 | } | ||
| 240 | listener, err := net.FileListener(inherited) | 240 | listener, err := net.FileListener(inherited) | ||
| 241 | _ = inherited.Close() | 241 | _ = inherited.Close() | ||
| 242 | if err != nil { | 242 | if err != nil { | ||
| 243 | - | return fmt.Errorf("vev: inherited web listener: %w", err) | 243 | + | return fmt.Errorf("inherited web listener: %w", err) |
| 244 | } | 244 | } | ||
| 245 | defer listener.Close() | 245 | defer listener.Close() | ||
| 246 | if listener.Addr().String() != settings.Listen { | 246 | if listener.Addr().String() != settings.Listen { | ||
| 247 | - | return errors.New("vev: inherited web listener does not match web.listen") | 247 | + | return errors.New("inherited web listener does not match web.listen") |
| 248 | } | 248 | } | ||
| 249 | token, err := webterm.NewToken() | 249 | token, err := webterm.NewToken() | ||
| 250 | if err != nil { | 250 | if err != nil { | ||
M internal/app/web_options.go +1 -1
| @@ -47,7 +47,7 @@ func parseWebArgs(args []string) (command, error) { | |||||
| 47 | func loadWebSettings(options webOptions) (webterm.Settings, []domain.Warning, error) { | 47 | func loadWebSettings(options webOptions) (webterm.Settings, []domain.Warning, error) { | ||
| 48 | cfg, warnings, err := config.Load(platform.ConfigPath()) | 48 | cfg, warnings, err := config.Load(platform.ConfigPath()) | ||
| 49 | if err != nil { | 49 | if err != nil { | ||
| 50 | - | return webterm.Settings{}, nil, fmt.Errorf("vev: loading web configuration: %w", err) | 50 | + | return webterm.Settings{}, nil, fmt.Errorf("loading web configuration: %w", err) |
| 51 | } | 51 | } | ||
| 52 | if options.listen != "" { | 52 | if options.listen != "" { | ||
| 53 | cfg.WebListen = options.listen | 53 | cfg.WebListen = options.listen | ||
M internal/platform/dir.go +4 -4
| @@ -17,18 +17,18 @@ func ActivateDevelopmentEnvironment() error { | |||||
| 17 | return nil | 17 | return nil | ||
| 18 | } | 18 | } | ||
| 19 | if !validDevelopmentEnvironmentName(name) { | 19 | if !validDevelopmentEnvironmentName(name) { | ||
| 20 | - | return fmt.Errorf("vev: invalid VEV_ENV %q (want one safe segment matching [A-Za-z0-9][A-Za-z0-9._-]{0,63})", name) | 20 | + | return fmt.Errorf("invalid VEV_ENV %q (want one safe segment matching [A-Za-z0-9][A-Za-z0-9._-]{0,63})", name) |
| 21 | } | 21 | } | ||
| 22 | 22 | ||||
| 23 | base := os.Getenv(devEnvironmentRootEnv) | 23 | base := os.Getenv(devEnvironmentRootEnv) | ||
| 24 | if base == "" { | 24 | if base == "" { | ||
| 25 | absolute, err := filepath.Abs(".dev") | 25 | absolute, err := filepath.Abs(".dev") | ||
| 26 | if err != nil { | 26 | if err != nil { | ||
| 27 | - | return fmt.Errorf("vev: resolve VEV_ENV %q root: %w", name, err) | 27 | + | return fmt.Errorf("resolve VEV_ENV %q root: %w", name, err) |
| 28 | } | 28 | } | ||
| 29 | base = absolute | 29 | base = absolute | ||
| 30 | } else if !filepath.IsAbs(base) { | 30 | } else if !filepath.IsAbs(base) { | ||
| 31 | - | return fmt.Errorf("vev: invalid %s %q (want an absolute path)", devEnvironmentRootEnv, base) | 31 | + | return fmt.Errorf("invalid %s %q (want an absolute path)", devEnvironmentRootEnv, base) |
| 32 | } | 32 | } | ||
| 33 | root := filepath.Join(base, name) | 33 | root := filepath.Join(base, name) | ||
| 34 | 34 | ||||
| @@ -56,7 +56,7 @@ func ActivateDevelopmentEnvironment() error { | |||||
| 56 | _ = os.Unsetenv(rollbackKey) | 56 | _ = os.Unsetenv(rollbackKey) | ||
| 57 | } | 57 | } | ||
| 58 | } | 58 | } | ||
| 59 | - | return fmt.Errorf("vev: activate VEV_ENV %q: %w", name, err) | 59 | + | return fmt.Errorf("activate VEV_ENV %q: %w", name, err) |
| 60 | } | 60 | } | ||
| 61 | } | 61 | } | ||
| 62 | return nil | 62 | return nil | ||
M internal/ports/broker.go +3 -3
| @@ -638,9 +638,9 @@ type BrokerError struct { | |||||
| 638 | 638 | ||||
| 639 | func (e BrokerError) Error() string { | 639 | func (e BrokerError) Error() string { | ||
| 640 | if e.Text == "" { | 640 | if e.Text == "" { | ||
| 641 | - | return "vev: broker " + e.Code.String() | 641 | + | return "broker " + e.Code.String() |
| 642 | } | 642 | } | ||
| 643 | - | return "vev: broker " + e.Code.String() + ": " + e.Text | 643 | + | return "broker " + e.Code.String() + ": " + e.Text |
| 644 | } | 644 | } | ||
| 645 | 645 | ||||
| 646 | func (e BrokerError) Unwrap() error { return e.Cause } | 646 | func (e BrokerError) Unwrap() error { return e.Cause } | ||
| @@ -670,7 +670,7 @@ type BrokerStreamLost struct { | |||||
| 670 | } | 670 | } | ||
| 671 | 671 | ||||
| 672 | // Error and Unwrap retain exact stream scope while allowing generic lifecycle handling. | 672 | // Error and Unwrap retain exact stream scope while allowing generic lifecycle handling. | ||
| 673 | - | func (e BrokerStreamLost) Error() string { return "vev: broker attachment_lost" } | 673 | + | func (e BrokerStreamLost) Error() string { return "broker attachment_lost" } |
| 674 | func (e BrokerStreamLost) Unwrap() error { | 674 | func (e BrokerStreamLost) Unwrap() error { | ||
| 675 | return BrokerError{Code: BrokerErrorAttachmentLost, Cause: e.Err} | 675 | return BrokerError{Code: BrokerErrorAttachmentLost, Cause: e.Err} | ||
| 676 | } | 676 | } | ||
M internal/ports/broker_pool.go +1 -1
| @@ -35,7 +35,7 @@ type BrokerLogicalConnection interface { | |||||
| 35 | // ID; callers must use a fresh ID. A connection ID is never reused in an epoch. | 35 | // ID; callers must use a fresh ID. A connection ID is never reused in an epoch. | ||
| 36 | type BrokerAdmissionError string | 36 | type BrokerAdmissionError string | ||
| 37 | 37 | ||||
| 38 | - | func (e BrokerAdmissionError) Error() string { return "vev: broker admission: " + string(e) } | 38 | + | func (e BrokerAdmissionError) Error() string { return "broker admission: " + string(e) } |
| 39 | 39 | ||||
| 40 | const ( | 40 | const ( | ||
| 41 | BrokerAdmissionLimit BrokerAdmissionError = "limit" | 41 | BrokerAdmissionLimit BrokerAdmissionError = "limit" | ||
M internal/ports/broker_pool_test.go +1 -1
| @@ -59,7 +59,7 @@ func TestBrokerStreamLostErrorChain(t *testing.T) { | |||||
| 59 | cause := errors.New("transport diagnostic") | 59 | cause := errors.New("transport diagnostic") | ||
| 60 | loss := BrokerStreamLost{Epoch: 1, Connection: BrokerConnectionID{1}, Stream: 1, Cause: domain.RemoteFailureTrust, Err: cause} | 60 | loss := BrokerStreamLost{Epoch: 1, Connection: BrokerConnectionID{1}, Stream: 1, Cause: domain.RemoteFailureTrust, Err: cause} | ||
| 61 | require.NoError(t, loss.Validate()) | 61 | require.NoError(t, loss.Validate()) | ||
| 62 | - | require.Equal(t, "vev: broker attachment_lost", loss.Error()) | 62 | + | require.Equal(t, "broker attachment_lost", loss.Error()) |
| 63 | require.ErrorIs(t, loss, cause) | 63 | require.ErrorIs(t, loss, cause) | ||
| 64 | var typed BrokerError | 64 | var typed BrokerError | ||
| 65 | require.ErrorAs(t, loss, &typed) | 65 | require.ErrorAs(t, loss, &typed) | ||
M internal/protocol/preview.go +1 -1
| @@ -49,7 +49,7 @@ const ( | |||||
| 49 | RemotePreviewWatchMaxInterval = time.Second | 49 | RemotePreviewWatchMaxInterval = time.Second | ||
| 50 | ) | 50 | ) | ||
| 51 | 51 | ||||
| 52 | - | var ErrInvalidRemotePreviewWatch = errors.New("vev: invalid remote preview watch") | 52 | + | var ErrInvalidRemotePreviewWatch = errors.New("invalid remote preview watch") |
| 53 | 53 | ||||
| 54 | // ValidateRemotePreviewWatch accepts only a valid request and a whole | 54 | // ValidateRemotePreviewWatch accepts only a valid request and a whole | ||
| 55 | // millisecond interval inside the watch bounds. | 55 | // millisecond interval inside the watch bounds. | ||
M internal/usecase/broker/daemon_start.go +1 -1
| @@ -70,6 +70,6 @@ func AuthorizeDaemonLaunch(target ports.BrokerDialTarget) error { | |||||
| 70 | func daemonStartRefused(reason string) error { | 70 | func daemonStartRefused(reason string) error { | ||
| 71 | return ports.BrokerError{ | 71 | return ports.BrokerError{ | ||
| 72 | Code: ports.BrokerErrorConflictingPolicy, | 72 | Code: ports.BrokerErrorConflictingPolicy, | ||
| 73 | - | Cause: fmt.Errorf("vev: refusing to start the daemon: %s", reason), | 73 | + | Cause: fmt.Errorf("refusing to start the daemon: %s", reason), |
| 74 | } | 74 | } | ||
| 75 | } | 75 | } | ||
M internal/usecase/broker/observation.go +18 -18
| @@ -81,7 +81,7 @@ func (p *RemoteProbe) SharePool(pool *Pool) { p.shared.pool.Store(pool) } | |||||
| 81 | 81 | ||||
| 82 | func (p *RemoteProbe) Probe(ctx context.Context, registration domain.RemoteRegistration) (ports.BrokerDaemonObservation, error) { | 82 | func (p *RemoteProbe) Probe(ctx context.Context, registration domain.RemoteRegistration) (ports.BrokerDaemonObservation, error) { | ||
| 83 | if p == nil || p.Epoch == 0 || p.Routes == nil || p.Binder == nil || p.Connector == nil { | 83 | if p == nil || p.Epoch == 0 || p.Routes == nil || p.Binder == nil || p.Connector == nil { | ||
| 84 | - | return ports.BrokerDaemonObservation{}, errors.New("vev: incomplete broker remote probe") | 84 | + | return ports.BrokerDaemonObservation{}, errors.New("incomplete broker remote probe") |
| 85 | } | 85 | } | ||
| 86 | request, err := p.request(ctx, registration) | 86 | request, err := p.request(ctx, registration) | ||
| 87 | if err != nil { | 87 | if err != nil { | ||
| @@ -117,11 +117,11 @@ func (p *RemoteProbe) Probe(ctx context.Context, registration domain.RemoteRegis | |||||
| 117 | } | 117 | } | ||
| 118 | }() | 118 | }() | ||
| 119 | if (endpoint.ExpectedIdentity.Bound && physical.Identity() != endpoint.ExpectedIdentity.Identity) || physical.Policy() != endpoint.Policy { | 119 | if (endpoint.ExpectedIdentity.Bound && physical.Identity() != endpoint.ExpectedIdentity.Identity) || physical.Policy() != endpoint.Policy { | ||
| 120 | - | return ports.BrokerDaemonObservation{}, errors.New("vev: remote probe authenticated binding mismatch") | 120 | + | return ports.BrokerDaemonObservation{}, errors.New("remote probe authenticated binding mismatch") |
| 121 | } | 121 | } | ||
| 122 | identity, err := p.Binder.BindAuthenticatedIdentity(ctx, ports.BrokerIdentityBindingRequest{Fence: endpoint.Fence, Policy: endpoint.Policy, Identity: physical.Identity()}) | 122 | identity, err := p.Binder.BindAuthenticatedIdentity(ctx, ports.BrokerIdentityBindingRequest{Fence: endpoint.Fence, Policy: endpoint.Policy, Identity: physical.Identity()}) | ||
| 123 | if err != nil || identity != physical.Identity() { | 123 | if err != nil || identity != physical.Identity() { | ||
| 124 | - | return ports.BrokerDaemonObservation{}, errors.New("vev: remote probe could not commit authenticated binding") | 124 | + | return ports.BrokerDaemonObservation{}, errors.New("remote probe could not commit authenticated binding") |
| 125 | } | 125 | } | ||
| 126 | observation, err := observeDaemonCatalogue(ctx, physical, request, p.Codec) | 126 | observation, err := observeDaemonCatalogue(ctx, physical, request, p.Codec) | ||
| 127 | if err == nil && ctx.Err() == nil { | 127 | if err == nil && ctx.Err() == nil { | ||
| @@ -132,7 +132,7 @@ func (p *RemoteProbe) Probe(ctx context.Context, registration domain.RemoteRegis | |||||
| 132 | 132 | ||||
| 133 | func (p *RemoteProbe) request(ctx context.Context, registration domain.RemoteRegistration) (ports.BrokerOpenStreamRequest, error) { | 133 | func (p *RemoteProbe) request(ctx context.Context, registration domain.RemoteRegistration) (ports.BrokerOpenStreamRequest, error) { | ||
| 134 | if p.Hosts == nil { | 134 | if p.Hosts == nil { | ||
| 135 | - | return ports.BrokerOpenStreamRequest{}, errors.New("vev: remote probe has no policy authority") | 135 | + | return ports.BrokerOpenStreamRequest{}, errors.New("remote probe has no policy authority") |
| 136 | } | 136 | } | ||
| 137 | record, ok, err := p.Hosts.LookupHost(ctx, registration.Endpoint) | 137 | record, ok, err := p.Hosts.LookupHost(ctx, registration.Endpoint) | ||
| 138 | if err != nil { | 138 | if err != nil { | ||
| @@ -175,22 +175,22 @@ var _ LocalProbe = (*LocalDaemonProbe)(nil) | |||||
| 175 | // probing must never spawn a process. | 175 | // probing must never spawn a process. | ||
| 176 | func NewLocalDaemonProbe(epoch ports.BrokerEpoch, address string, policy ports.BrokerPolicy, connector ports.BrokerEndpointConnector, codec ports.SessionCodec, loadIdentity LocalIdentityLoader) (*LocalDaemonProbe, error) { | 176 | func NewLocalDaemonProbe(epoch ports.BrokerEpoch, address string, policy ports.BrokerPolicy, connector ports.BrokerEndpointConnector, codec ports.SessionCodec, loadIdentity LocalIdentityLoader) (*LocalDaemonProbe, error) { | ||
| 177 | if epoch == 0 { | 177 | if epoch == 0 { | ||
| 178 | - | return nil, errors.New("vev: broker local probe requires a broker epoch") | 178 | + | return nil, errors.New("broker local probe requires a broker epoch") |
| 179 | } | 179 | } | ||
| 180 | if nilDependency(connector) { | 180 | if nilDependency(connector) { | ||
| 181 | - | return nil, errors.New("vev: broker local probe requires a connector") | 181 | + | return nil, errors.New("broker local probe requires a connector") |
| 182 | } | 182 | } | ||
| 183 | if nilDependency(codec) { | 183 | if nilDependency(codec) { | ||
| 184 | - | return nil, errors.New("vev: broker local probe requires a codec") | 184 | + | return nil, errors.New("broker local probe requires a codec") |
| 185 | } | 185 | } | ||
| 186 | if loadIdentity == nil { | 186 | if loadIdentity == nil { | ||
| 187 | - | return nil, errors.New("vev: broker local probe requires an identity loader") | 187 | + | return nil, errors.New("broker local probe requires an identity loader") |
| 188 | } | 188 | } | ||
| 189 | if address == "" { | 189 | if address == "" { | ||
| 190 | - | return nil, errors.New("vev: broker local probe requires an address") | 190 | + | return nil, errors.New("broker local probe requires an address") |
| 191 | } | 191 | } | ||
| 192 | if err := policy.Validate(); err != nil { | 192 | if err := policy.Validate(); err != nil { | ||
| 193 | - | return nil, fmt.Errorf("vev: broker local probe: %w", err) | 193 | + | return nil, fmt.Errorf("broker local probe: %w", err) |
| 194 | } | 194 | } | ||
| 195 | return &LocalDaemonProbe{epoch: epoch, address: address, policy: policy, loadIdentity: loadIdentity, connector: connector, codec: codec}, nil | 195 | return &LocalDaemonProbe{epoch: epoch, address: address, policy: policy, loadIdentity: loadIdentity, connector: connector, codec: codec}, nil | ||
| 196 | } | 196 | } | ||
| @@ -207,7 +207,7 @@ func (p *LocalDaemonProbe) SharePool(pool *Pool) { p.shared.pool.Store(pool) } | |||||
| 207 | func (p *LocalDaemonProbe) ProbeLocal(ctx context.Context) (ports.BrokerDaemonObservation, error) { | 207 | func (p *LocalDaemonProbe) ProbeLocal(ctx context.Context) (ports.BrokerDaemonObservation, error) { | ||
| 208 | unreachable := ports.BrokerDaemonObservation{Availability: domain.RemoteAvailabilityUnreachable} | 208 | unreachable := ports.BrokerDaemonObservation{Availability: domain.RemoteAvailabilityUnreachable} | ||
| 209 | if p == nil || p.connector == nil { | 209 | if p == nil || p.connector == nil { | ||
| 210 | - | return unreachable, errors.New("vev: broker local probe is nil") | 210 | + | return unreachable, errors.New("broker local probe is nil") |
| 211 | } | 211 | } | ||
| 212 | identity, err := p.loadIdentity() | 212 | identity, err := p.loadIdentity() | ||
| 213 | if err != nil { | 213 | if err != nil { | ||
| @@ -222,7 +222,7 @@ func (p *LocalDaemonProbe) ProbeLocal(ctx context.Context) (ports.BrokerDaemonOb | |||||
| 222 | ExpectedIdentity: ports.BrokerExpectedIdentity{Identity: identity, Bound: true}, | 222 | ExpectedIdentity: ports.BrokerExpectedIdentity{Identity: identity, Bound: true}, | ||
| 223 | } | 223 | } | ||
| 224 | if err := endpoint.Validate(); err != nil { | 224 | if err := endpoint.Validate(); err != nil { | ||
| 225 | - | return unreachable, fmt.Errorf("vev: broker local probe: %w", err) | 225 | + | return unreachable, fmt.Errorf("broker local probe: %w", err) |
| 226 | } | 226 | } | ||
| 227 | request, err := p.request() | 227 | request, err := p.request() | ||
| 228 | if err != nil { | 228 | if err != nil { | ||
| @@ -243,7 +243,7 @@ func (p *LocalDaemonProbe) ProbeLocal(ctx context.Context) (ports.BrokerDaemonOb | |||||
| 243 | // though the connector already checked it, so a future connector change can | 243 | // though the connector already checked it, so a future connector change can | ||
| 244 | // never weaken what the probe publishes. | 244 | // never weaken what the probe publishes. | ||
| 245 | if physical.Identity() != endpoint.ExpectedIdentity.Identity || !physical.Policy().Compatible(endpoint.Policy) { | 245 | if physical.Identity() != endpoint.ExpectedIdentity.Identity || !physical.Policy().Compatible(endpoint.Policy) { | ||
| 246 | - | return unreachable, errors.New("vev: broker local probe authenticated binding mismatch") | 246 | + | return unreachable, errors.New("broker local probe authenticated binding mismatch") |
| 247 | } | 247 | } | ||
| 248 | observation, err := observeDaemonCatalogue(ctx, physical, request, p.codec) | 248 | observation, err := observeDaemonCatalogue(ctx, physical, request, p.codec) | ||
| 249 | if err != nil { | 249 | if err != nil { | ||
| @@ -294,7 +294,7 @@ func newObservationRequest(epoch ports.BrokerEpoch, policy ports.BrokerPolicy) ( | |||||
| 294 | // the caller receives an error and no observation it could publish. | 294 | // the caller receives an error and no observation it could publish. | ||
| 295 | func observeDaemonCatalogue(ctx context.Context, physical ports.BrokerPhysicalConnection, request ports.BrokerOpenStreamRequest, codec ports.SessionCodec) (ports.BrokerDaemonObservation, error) { | 295 | func observeDaemonCatalogue(ctx context.Context, physical ports.BrokerPhysicalConnection, request ports.BrokerOpenStreamRequest, codec ports.SessionCodec) (ports.BrokerDaemonObservation, error) { | ||
| 296 | if physical == nil || codec == nil || request.Purpose != ports.BrokerStreamObservation { | 296 | if physical == nil || codec == nil || request.Purpose != ports.BrokerStreamObservation { | ||
| 297 | - | return ports.BrokerDaemonObservation{}, errors.New("vev: invalid daemon observation request") | 297 | + | return ports.BrokerDaemonObservation{}, errors.New("invalid daemon observation request") |
| 298 | } | 298 | } | ||
| 299 | logical, err := physical.OpenStream(ctx, request) | 299 | logical, err := physical.OpenStream(ctx, request) | ||
| 300 | if err != nil { | 300 | if err != nil { | ||
| @@ -305,7 +305,7 @@ func observeDaemonCatalogue(ctx context.Context, physical ports.BrokerPhysicalCo | |||||
| 305 | return ports.BrokerDaemonObservation{}, err | 305 | return ports.BrokerDaemonObservation{}, err | ||
| 306 | } | 306 | } | ||
| 307 | if logical == nil { | 307 | if logical == nil { | ||
| 308 | - | return ports.BrokerDaemonObservation{}, errors.New("vev: daemon observation opened no logical stream") | 308 | + | return ports.BrokerDaemonObservation{}, errors.New("daemon observation opened no logical stream") |
| 309 | } | 309 | } | ||
| 310 | defer func() { _ = logical.Close() }() | 310 | defer func() { _ = logical.Close() }() | ||
| 311 | commandID, err := randomNonzeroUint64() | 311 | commandID, err := randomNonzeroUint64() | ||
| @@ -318,14 +318,14 @@ func observeDaemonCatalogue(ctx context.Context, physical ports.BrokerPhysicalCo | |||||
| 318 | } | 318 | } | ||
| 319 | result, ok := reply.(protocol.CommandResult) | 319 | result, ok := reply.(protocol.CommandResult) | ||
| 320 | if !ok || result.RequestID != commandID || !result.Valid() { | 320 | if !ok || result.RequestID != commandID || !result.Valid() { | ||
| 321 | - | return ports.BrokerDaemonObservation{}, errors.New("vev: invalid daemon observation result") | 321 | + | return ports.BrokerDaemonObservation{}, errors.New("invalid daemon observation result") |
| 322 | } | 322 | } | ||
| 323 | if result.Outcome != protocol.CommandSucceeded { | 323 | if result.Outcome != protocol.CommandSucceeded { | ||
| 324 | - | return ports.BrokerDaemonObservation{}, fmt.Errorf("vev: daemon observation failed: %s", result.Text) | 324 | + | return ports.BrokerDaemonObservation{}, fmt.Errorf("daemon observation failed: %s", result.Text) |
| 325 | } | 325 | } | ||
| 326 | var catalog catalogue.RemoteCatalog | 326 | var catalog catalogue.RemoteCatalog | ||
| 327 | if err := json.Unmarshal([]byte(result.Output), &catalog); err != nil { | 327 | if err := json.Unmarshal([]byte(result.Output), &catalog); err != nil { | ||
| 328 | - | return ports.BrokerDaemonObservation{}, fmt.Errorf("vev: decode daemon observation: %w", err) | 328 | + | return ports.BrokerDaemonObservation{}, fmt.Errorf("decode daemon observation: %w", err) |
| 329 | } | 329 | } | ||
| 330 | if err := catalogue.ValidateRemoteCatalog(catalog); err != nil { | 330 | if err := catalogue.ValidateRemoteCatalog(catalog); err != nil { | ||
| 331 | return ports.BrokerDaemonObservation{}, err | 331 | return ports.BrokerDaemonObservation{}, err | ||
M internal/usecase/client/attachment_input.go +3 -3
| @@ -355,16 +355,16 @@ func (in *attachmentInput) apply(ctx context.Context, actions []paletteGeneratio | |||||
| 355 | switch action.kind { | 355 | switch action.kind { | ||
| 356 | case paletteActionPublishCleared: | 356 | case paletteActionPublishCleared: | ||
| 357 | if err := in.send(ctx, action.theme); err != nil { | 357 | if err := in.send(ctx, action.theme); err != nil { | ||
| 358 | - | return fmt.Errorf("vev: publishing theme: %w", err) | 358 | + | return fmt.Errorf("publishing theme: %w", err) |
| 359 | } | 359 | } | ||
| 360 | case paletteActionPublishFinal: | 360 | case paletteActionPublishFinal: | ||
| 361 | in.themes.update(func(current *protocol.Theme) { *current = action.theme }) | 361 | in.themes.update(func(current *protocol.Theme) { *current = action.theme }) | ||
| 362 | if err := in.send(ctx, action.theme); err != nil { | 362 | if err := in.send(ctx, action.theme); err != nil { | ||
| 363 | - | return fmt.Errorf("vev: publishing theme: %w", err) | 363 | + | return fmt.Errorf("publishing theme: %w", err) |
| 364 | } | 364 | } | ||
| 365 | case paletteActionWriteDrain, paletteActionWriteBatch: | 365 | case paletteActionWriteDrain, paletteActionWriteBatch: | ||
| 366 | if err := in.query.writeTerminalQuery([]byte(action.bytes)); err != nil { | 366 | if err := in.query.writeTerminalQuery([]byte(action.bytes)); err != nil { | ||
| 367 | - | return fmt.Errorf("vev: writing palette query: %w", err) | 367 | + | return fmt.Errorf("writing palette query: %w", err) |
| 368 | } | 368 | } | ||
| 369 | case paletteActionArmDrainDeadline: | 369 | case paletteActionArmDrainDeadline: | ||
| 370 | in.drain.arm(in.clock, action.id, action.deadline) | 370 | in.drain.arm(in.clock, action.id, action.deadline) | ||
M internal/usecase/client/attachment_session.go +5 -5
| @@ -188,7 +188,7 @@ func (w *sessionAttachmentWorker) Run(ctx context.Context, fg AttachmentForegrou | |||||
| 188 | case protocol.ErrorMsg: | 188 | case protocol.ErrorMsg: | ||
| 189 | return AttachmentEvent{Token: token, Kind: AttachmentEventFailed, Err: &ProtocolError{Code: message.Code, Text: message.Text}} | 189 | return AttachmentEvent{Token: token, Kind: AttachmentEventFailed, Err: &ProtocolError{Code: message.Code, Text: message.Text}} | ||
| 190 | default: | 190 | default: | ||
| 191 | - | return AttachmentEvent{Token: token, Kind: AttachmentEventFailed, Err: fmt.Errorf("vev: unexpected %T before welcome", welcome)} | 191 | + | return AttachmentEvent{Token: token, Kind: AttachmentEventFailed, Err: fmt.Errorf("unexpected %T before welcome", welcome)} |
| 192 | } | 192 | } | ||
| 193 | 193 | ||||
| 194 | state, event := w.awaitInitialPublication(ctx, fg, stream, token, incoming) | 194 | state, event := w.awaitInitialPublication(ctx, fg, stream, token, incoming) | ||
| @@ -228,7 +228,7 @@ func (w *sessionAttachmentWorker) awaitInitialPublication(ctx context.Context, f | |||||
| 228 | // shape rule, a public generation of zero. Attached is published only after | 228 | // shape rule, a public generation of zero. Attached is published only after | ||
| 229 | // that write, flush, and commit succeeded. | 229 | // that write, flush, and commit succeeded. | ||
| 230 | if err := fg.Output(state.uiContext(ports.UIContext{Generation: attachmentActionableGeneration(fg, token)}, ports.UIStatusConnecting), typed.Data); err != nil { | 230 | if err := fg.Output(state.uiContext(ports.UIContext{Generation: attachmentActionableGeneration(fg, token)}, ports.UIStatusConnecting), typed.Data); err != nil { | ||
| 231 | - | event := AttachmentEvent{Token: token, Kind: AttachmentEventFailed, Err: fmt.Errorf("vev: publishing initial output: %w", err)} | 231 | + | event := AttachmentEvent{Token: token, Kind: AttachmentEventFailed, Err: fmt.Errorf("publishing initial output: %w", err)} |
| 232 | return state, &event | 232 | return state, &event | ||
| 233 | } | 233 | } | ||
| 234 | if err := w.send(ctx, fg, stream, protocol.Ack{Epoch: state.epoch, State: state.state}); err != nil { | 234 | if err := w.send(ctx, fg, stream, protocol.Ack{Epoch: state.epoch, State: state.state}); err != nil { | ||
| @@ -256,7 +256,7 @@ func (w *sessionAttachmentWorker) awaitInitialPublication(ctx context.Context, f | |||||
| 256 | // writes no bytes: the screen it commits is exactly the frame that just | 256 | // writes no bytes: the screen it commits is exactly the frame that just | ||
| 257 | // drained. | 257 | // drained. | ||
| 258 | if err := fg.PublishAttached(state.uiContext(ports.UIContext{Generation: attachmentActionableGeneration(fg, token)}, ports.UIStatusAttached)); err != nil { | 258 | if err := fg.PublishAttached(state.uiContext(ports.UIContext{Generation: attachmentActionableGeneration(fg, token)}, ports.UIStatusAttached)); err != nil { | ||
| 259 | - | event := AttachmentEvent{Token: token, Kind: AttachmentEventFailed, Err: fmt.Errorf("vev: publishing attached presentation: %w", err)} | 259 | + | event := AttachmentEvent{Token: token, Kind: AttachmentEventFailed, Err: fmt.Errorf("publishing attached presentation: %w", err)} |
| 260 | return state, &event | 260 | return state, &event | ||
| 261 | } | 261 | } | ||
| 262 | return state, nil | 262 | return state, nil | ||
| @@ -419,7 +419,7 @@ func (w *sessionAttachmentWorker) pumpAttached(ctx context.Context, fg Attachmen | |||||
| 419 | } | 419 | } | ||
| 420 | w.noteCommitted(fg, state.context) | 420 | w.noteCommitted(fg, state.context) | ||
| 421 | if err := fg.Output(state.uiContext(ports.UIContext{Generation: attachmentActionableGeneration(fg, token)}, ports.UIStatusAttached), typed.Data); err != nil { | 421 | if err := fg.Output(state.uiContext(ports.UIContext{Generation: attachmentActionableGeneration(fg, token)}, ports.UIStatusAttached), typed.Data); err != nil { | ||
| 422 | - | return AttachmentEvent{Token: token, Kind: AttachmentEventFailed, Err: fmt.Errorf("vev: publishing output: %w", err)} | 422 | + | return AttachmentEvent{Token: token, Kind: AttachmentEventFailed, Err: fmt.Errorf("publishing output: %w", err)} |
| 423 | } | 423 | } | ||
| 424 | if err := w.send(ctx, fg, stream, protocol.Ack{Epoch: state.epoch, State: state.state}); err != nil { | 424 | if err := w.send(ctx, fg, stream, protocol.Ack{Epoch: state.epoch, State: state.state}); err != nil { | ||
| 425 | return w.settle(ctx, fg, stream, token, err) | 425 | return w.settle(ctx, fg, stream, token, err) | ||
| @@ -438,7 +438,7 @@ func (w *sessionAttachmentWorker) pumpAttached(ctx context.Context, fg Attachmen | |||||
| 438 | state = next | 438 | state = next | ||
| 439 | w.noteCommitted(fg, state.context) | 439 | w.noteCommitted(fg, state.context) | ||
| 440 | if err := fg.Output(state.uiContext(ports.UIContext{Generation: attachmentActionableGeneration(fg, token)}, ports.UIStatusAttached), nil); err != nil { | 440 | if err := fg.Output(state.uiContext(ports.UIContext{Generation: attachmentActionableGeneration(fg, token)}, ports.UIStatusAttached), nil); err != nil { | ||
| 441 | - | return AttachmentEvent{Token: token, Kind: AttachmentEventFailed, Err: fmt.Errorf("vev: publishing view update: %w", err)} | 441 | + | return AttachmentEvent{Token: token, Kind: AttachmentEventFailed, Err: fmt.Errorf("publishing view update: %w", err)} |
| 442 | } | 442 | } | ||
| 443 | case protocol.UIReceipt: | 443 | case protocol.UIReceipt: | ||
| 444 | attachmentUIReceipt(fg, typed) | 444 | attachmentUIReceipt(fg, typed) | ||
M internal/usecase/client/broker_operations.go +6 -6
| @@ -86,10 +86,10 @@ type BrokerOperationRoute struct { | |||||
| 86 | // destination fence. It performs no I/O and consults no snapshot. | 86 | // destination fence. It performs no I/O and consults no snapshot. | ||
| 87 | func (r BrokerOperationRoute) Validate() error { | 87 | func (r BrokerOperationRoute) Validate() error { | ||
| 88 | if r.Epoch == 0 { | 88 | if r.Epoch == 0 { | ||
| 89 | - | return errors.New("vev: broker operation route carries no epoch") | 89 | + | return errors.New("broker operation route carries no epoch") |
| 90 | } | 90 | } | ||
| 91 | if err := r.Destination.Validate(); err != nil { | 91 | if err := r.Destination.Validate(); err != nil { | ||
| 92 | - | return fmt.Errorf("vev: broker operation route destination: %w", err) | 92 | + | return fmt.Errorf("broker operation route destination: %w", err) |
| 93 | } | 93 | } | ||
| 94 | return nil | 94 | return nil | ||
| 95 | } | 95 | } | ||
| @@ -108,10 +108,10 @@ type BrokerOperations struct { | |||||
| 108 | // missing dependency; the caller keeps ownership of the service lifetime. | 108 | // missing dependency; the caller keeps ownership of the service lifetime. | ||
| 109 | func NewBrokerOperations(service ports.BrokerNavigator, clock ports.Clock) (*BrokerOperations, error) { | 109 | func NewBrokerOperations(service ports.BrokerNavigator, clock ports.Clock) (*BrokerOperations, error) { | ||
| 110 | if supervisorNil(service) { | 110 | if supervisorNil(service) { | ||
| 111 | - | return nil, errors.New("vev: broker operations require a broker service") | 111 | + | return nil, errors.New("broker operations require a broker service") |
| 112 | } | 112 | } | ||
| 113 | if supervisorNil(clock) { | 113 | if supervisorNil(clock) { | ||
| 114 | - | return nil, errors.New("vev: broker operations require a clock") | 114 | + | return nil, errors.New("broker operations require a clock") |
| 115 | } | 115 | } | ||
| 116 | return &BrokerOperations{service: service, clock: clock}, nil | 116 | return &BrokerOperations{service: service, clock: clock}, nil | ||
| 117 | } | 117 | } | ||
| @@ -136,14 +136,14 @@ func (o *BrokerOperations) list(ctx context.Context, route BrokerOperationRoute, | |||||
| 136 | reply, err := o.exchange(ctx, route, mode, protocol.List{}) | 136 | reply, err := o.exchange(ctx, route, mode, protocol.List{}) | ||
| 137 | if err != nil { | 137 | if err != nil { | ||
| 138 | if errors.Is(err, errBrokerOperationLost) { | 138 | if errors.Is(err, errBrokerOperationLost) { | ||
| 139 | - | return nil, fmt.Errorf("vev: reading session list: %w", err) | 139 | + | return nil, fmt.Errorf("reading session list: %w", err) |
| 140 | } | 140 | } | ||
| 141 | return nil, err | 141 | return nil, err | ||
| 142 | } | 142 | } | ||
| 143 | sessions, ok := reply.(protocol.Sessions) | 143 | sessions, ok := reply.(protocol.Sessions) | ||
| 144 | if !ok { | 144 | if !ok { | ||
| 145 | logBrokerOperationCause("list reply", fmt.Errorf("unexpected reply %T", reply)) | 145 | logBrokerOperationCause("list reply", fmt.Errorf("unexpected reply %T", reply)) | ||
| 146 | - | return nil, fmt.Errorf("vev: unexpected reply %T to list", reply) | 146 | + | return nil, fmt.Errorf("unexpected reply %T to list", reply) |
| 147 | } | 147 | } | ||
| 148 | return sessions.Sessions, nil | 148 | return sessions.Sessions, nil | ||
| 149 | } | 149 | } | ||
M internal/usecase/client/initial_navigation.go +14 -14
| @@ -99,54 +99,54 @@ func (n InitialNavigation) Validate() error { | |||||
| 99 | switch n.Kind { | 99 | switch n.Kind { | ||
| 100 | case InitialNavigationPicker: | 100 | case InitialNavigationPicker: | ||
| 101 | if n.Destination != (ports.BrokerEndpointFence{}) { | 101 | if n.Destination != (ports.BrokerEndpointFence{}) { | ||
| 102 | - | return errors.New("vev: picker navigation carries a destination") | 102 | + | return errors.New("picker navigation carries a destination") |
| 103 | } | 103 | } | ||
| 104 | if n.Epoch != 0 { | 104 | if n.Epoch != 0 { | ||
| 105 | - | return errors.New("vev: picker navigation carries an epoch") | 105 | + | return errors.New("picker navigation carries an epoch") |
| 106 | } | 106 | } | ||
| 107 | if n.Name != "" { | 107 | if n.Name != "" { | ||
| 108 | - | return errors.New("vev: picker navigation carries a session name") | 108 | + | return errors.New("picker navigation carries a session name") |
| 109 | } | 109 | } | ||
| 110 | if n.Target != (protocol.ExactSessionTarget{}) { | 110 | if n.Target != (protocol.ExactSessionTarget{}) { | ||
| 111 | - | return errors.New("vev: picker navigation carries an exact target") | 111 | + | return errors.New("picker navigation carries an exact target") |
| 112 | } | 112 | } | ||
| 113 | return nil | 113 | return nil | ||
| 114 | case InitialNavigationCreateEphemeral, InitialNavigationCreateNamed, InitialNavigationAttachExact, InitialNavigationAttachNamed: | 114 | case InitialNavigationCreateEphemeral, InitialNavigationCreateNamed, InitialNavigationAttachExact, InitialNavigationAttachNamed: | ||
| 115 | default: | 115 | default: | ||
| 116 | - | return fmt.Errorf("vev: unknown initial navigation kind %d", uint8(n.Kind)) | 116 | + | return fmt.Errorf("unknown initial navigation kind %d", uint8(n.Kind)) |
| 117 | } | 117 | } | ||
| 118 | if err := n.Destination.Validate(); err != nil { | 118 | if err := n.Destination.Validate(); err != nil { | ||
| 119 | - | return fmt.Errorf("vev: initial navigation destination: %w", err) | 119 | + | return fmt.Errorf("initial navigation destination: %w", err) |
| 120 | } | 120 | } | ||
| 121 | if !n.Destination.Local && n.Epoch == 0 { | 121 | if !n.Destination.Local && n.Epoch == 0 { | ||
| 122 | // A remote destination cannot be decided before the connection: it | 122 | // A remote destination cannot be decided before the connection: it | ||
| 123 | // requires the epoch of the snapshot that resolved its registration. | 123 | // requires the epoch of the snapshot that resolved its registration. | ||
| 124 | - | return errors.New("vev: remote initial navigation carries no epoch") | 124 | + | return errors.New("remote initial navigation carries no epoch") |
| 125 | } | 125 | } | ||
| 126 | switch n.Kind { | 126 | switch n.Kind { | ||
| 127 | case InitialNavigationCreateEphemeral: | 127 | case InitialNavigationCreateEphemeral: | ||
| 128 | if n.Name != "" { | 128 | if n.Name != "" { | ||
| 129 | - | return errors.New("vev: ephemeral creation navigation carries a session name") | 129 | + | return errors.New("ephemeral creation navigation carries a session name") |
| 130 | } | 130 | } | ||
| 131 | if n.Target != (protocol.ExactSessionTarget{}) { | 131 | if n.Target != (protocol.ExactSessionTarget{}) { | ||
| 132 | - | return errors.New("vev: ephemeral creation navigation carries an exact target") | 132 | + | return errors.New("ephemeral creation navigation carries an exact target") |
| 133 | } | 133 | } | ||
| 134 | case InitialNavigationCreateNamed, InitialNavigationAttachNamed: | 134 | case InitialNavigationCreateNamed, InitialNavigationAttachNamed: | ||
| 135 | if err := domain.ValidateSessionName(n.Name); err != nil { | 135 | if err := domain.ValidateSessionName(n.Name); err != nil { | ||
| 136 | - | return fmt.Errorf("vev: initial navigation session name: %w", err) | 136 | + | return fmt.Errorf("initial navigation session name: %w", err) |
| 137 | } | 137 | } | ||
| 138 | if n.Target != (protocol.ExactSessionTarget{}) { | 138 | if n.Target != (protocol.ExactSessionTarget{}) { | ||
| 139 | - | return errors.New("vev: named navigation carries an exact target") | 139 | + | return errors.New("named navigation carries an exact target") |
| 140 | } | 140 | } | ||
| 141 | case InitialNavigationAttachExact: | 141 | case InitialNavigationAttachExact: | ||
| 142 | if n.Epoch == 0 { | 142 | if n.Epoch == 0 { | ||
| 143 | - | return errors.New("vev: exact attach navigation carries no epoch") | 143 | + | return errors.New("exact attach navigation carries no epoch") |
| 144 | } | 144 | } | ||
| 145 | if n.Name != "" { | 145 | if n.Name != "" { | ||
| 146 | - | return errors.New("vev: exact attach navigation carries a session name") | 146 | + | return errors.New("exact attach navigation carries a session name") |
| 147 | } | 147 | } | ||
| 148 | if err := n.Target.Validate(); err != nil { | 148 | if err := n.Target.Validate(); err != nil { | ||
| 149 | - | return fmt.Errorf("vev: initial navigation target: %w", err) | 149 | + | return fmt.Errorf("initial navigation target: %w", err) |
| 150 | } | 150 | } | ||
| 151 | } | 151 | } | ||
| 152 | return nil | 152 | return nil | ||
M internal/usecase/client/move_picker_overlay.go +1 -1
| @@ -291,7 +291,7 @@ func (p *attachmentMovePicker) paint(state outputApplyState) error { | |||||
| 291 | } | 291 | } | ||
| 292 | uiContext := state.uiContext(ports.UIContext{Generation: attachmentActionableGeneration(p.fg, p.fg.Token())}, ports.UIStatusAttached) | 292 | uiContext := state.uiContext(ports.UIContext{Generation: attachmentActionableGeneration(p.fg, p.fg.Token())}, ports.UIStatusAttached) | ||
| 293 | if err := p.overlay.overlayOutput(uiContext, frame); err != nil { | 293 | if err := p.overlay.overlayOutput(uiContext, frame); err != nil { | ||
| 294 | - | return fmt.Errorf("vev: publishing move picker: %w", err) | 294 | + | return fmt.Errorf("publishing move picker: %w", err) |
| 295 | } | 295 | } | ||
| 296 | return nil | 296 | return nil | ||
| 297 | } | 297 | } | ||
M internal/usecase/client/picker_catalogue.go +2 -2
| @@ -120,9 +120,9 @@ func (e pickerCatalogueError) noticeText() string { | |||||
| 120 | 120 | ||||
| 121 | func (e pickerCatalogueError) Error() string { | 121 | func (e pickerCatalogueError) Error() string { | ||
| 122 | if e.Text == "" { | 122 | if e.Text == "" { | ||
| 123 | - | return "vev: picker selection " + e.Code.String() | 123 | + | return "picker selection " + e.Code.String() |
| 124 | } | 124 | } | ||
| 125 | - | return "vev: picker selection " + e.Code.String() + ": " + e.Text | 125 | + | return "picker selection " + e.Code.String() + ": " + e.Text |
| 126 | } | 126 | } | ||
| 127 | 127 | ||||
| 128 | // pickerSelectionKind names one closed admission variant a resolved selection | 128 | // pickerSelectionKind names one closed admission variant a resolved selection | ||
M internal/usecase/client/runtime_primitives.go +2 -2
| @@ -16,9 +16,9 @@ type ProtocolError struct { | |||||
| 16 | 16 | ||||
| 17 | func (e *ProtocolError) Error() string { | 17 | func (e *ProtocolError) Error() string { | ||
| 18 | if e.Text == "" { | 18 | if e.Text == "" { | ||
| 19 | - | return fmt.Sprintf("vev: daemon rejected attach (code %d)", e.Code) | 19 | + | return fmt.Sprintf("daemon rejected attach (code %d)", e.Code) |
| 20 | } | 20 | } | ||
| 21 | - | return fmt.Sprintf("vev: %s", e.Text) | 21 | + | return e.Text |
| 22 | } | 22 | } | ||
| 23 | 23 | ||||
| 24 | func newClientID() [16]byte { | 24 | func newClientID() [16]byte { | ||
M internal/usecase/client/supervisor.go +7 -7
| @@ -478,13 +478,13 @@ func NewSupervisor(cfg SupervisorConfig) (*Supervisor, error) { | |||||
| 478 | } | 478 | } | ||
| 479 | cfg.SessionEnvironment = cfg.SessionEnvironment.Clone() | 479 | cfg.SessionEnvironment = cfg.SessionEnvironment.Clone() | ||
| 480 | if supervisorNil(cfg.Connector) { | 480 | if supervisorNil(cfg.Connector) { | ||
| 481 | - | return nil, errors.New("vev: supervisor requires a broker connector") | 481 | + | return nil, errors.New("supervisor requires a broker connector") |
| 482 | } | 482 | } | ||
| 483 | if supervisorNil(cfg.Terminal) { | 483 | if supervisorNil(cfg.Terminal) { | ||
| 484 | - | return nil, errors.New("vev: supervisor requires a terminal") | 484 | + | return nil, errors.New("supervisor requires a terminal") |
| 485 | } | 485 | } | ||
| 486 | if supervisorNil(cfg.Clock) { | 486 | if supervisorNil(cfg.Clock) { | ||
| 487 | - | return nil, errors.New("vev: supervisor requires a clock") | 487 | + | return nil, errors.New("supervisor requires a clock") |
| 488 | } | 488 | } | ||
| 489 | if cfg.Render == nil { | 489 | if cfg.Render == nil { | ||
| 490 | cfg.Render = func(State) {} | 490 | cfg.Render = func(State) {} | ||
| @@ -499,10 +499,10 @@ func NewSupervisor(cfg SupervisorConfig) (*Supervisor, error) { | |||||
| 499 | } | 499 | } | ||
| 500 | if cfg.ResolveInitialNavigation != nil { | 500 | if cfg.ResolveInitialNavigation != nil { | ||
| 501 | if cfg.InitialNavigation != (InitialNavigation{}) { | 501 | if cfg.InitialNavigation != (InitialNavigation{}) { | ||
| 502 | - | return nil, errors.New("vev: supervisor accepts either an initial navigation or a resolver, not both") | 502 | + | return nil, errors.New("supervisor accepts either an initial navigation or a resolver, not both") |
| 503 | } | 503 | } | ||
| 504 | } else if err := cfg.InitialNavigation.Validate(); err != nil { | 504 | } else if err := cfg.InitialNavigation.Validate(); err != nil { | ||
| 505 | - | return nil, fmt.Errorf("vev: supervisor initial navigation: %w", err) | 505 | + | return nil, fmt.Errorf("supervisor initial navigation: %w", err) |
| 506 | } | 506 | } | ||
| 507 | logger := cfg.Logger | 507 | logger := cfg.Logger | ||
| 508 | if logger == nil { | 508 | if logger == nil { | ||
| @@ -558,7 +558,7 @@ func (s *Supervisor) State() State { | |||||
| 558 | func (s *Supervisor) Run(ctx context.Context) (retErr error) { | 558 | func (s *Supervisor) Run(ctx context.Context) (retErr error) { | ||
| 559 | restore, err := s.cfg.Terminal.EnterRaw() | 559 | restore, err := s.cfg.Terminal.EnterRaw() | ||
| 560 | if err != nil { | 560 | if err != nil { | ||
| 561 | - | return fmt.Errorf("vev: entering raw mode: %w", err) | 561 | + | return fmt.Errorf("entering raw mode: %w", err) |
| 562 | } | 562 | } | ||
| 563 | if restore == nil { | 563 | if restore == nil { | ||
| 564 | restore = func() error { return nil } | 564 | restore = func() error { return nil } | ||
| @@ -566,7 +566,7 @@ func (s *Supervisor) Run(ctx context.Context) (retErr error) { | |||||
| 566 | restoreTerminal := sync.OnceValue(restore) | 566 | restoreTerminal := sync.OnceValue(restore) | ||
| 567 | defer func() { | 567 | defer func() { | ||
| 568 | if rerr := restoreTerminal(); rerr != nil && retErr == nil { | 568 | if rerr := restoreTerminal(); rerr != nil && retErr == nil { | ||
| 569 | - | retErr = fmt.Errorf("vev: restoring terminal: %w", rerr) | 569 | + | retErr = fmt.Errorf("restoring terminal: %w", rerr) |
| 570 | } | 570 | } | ||
| 571 | }() | 571 | }() | ||
| 572 | 572 | ||||
M internal/usecase/client/supervisor_attach.go +3 -3
| @@ -337,7 +337,7 @@ func (s *Supervisor) newAttachmentWorker(request ports.BrokerOpenStreamRequest, | |||||
| 337 | var err error | 337 | var err error | ||
| 338 | geometry, err = s.cfg.Terminal.Geometry() | 338 | geometry, err = s.cfg.Terminal.Geometry() | ||
| 339 | if err != nil { | 339 | if err != nil { | ||
| 340 | - | return nil, fmt.Errorf("vev: reading terminal geometry: %w", err) | 340 | + | return nil, fmt.Errorf("reading terminal geometry: %w", err) |
| 341 | } | 341 | } | ||
| 342 | } | 342 | } | ||
| 343 | environment := s.cfg.AttachmentEnvironment | 343 | environment := s.cfg.AttachmentEnvironment | ||
| @@ -380,7 +380,7 @@ func (s *Supervisor) takeInitialNavigation(snapshot ports.BrokerSnapshot) (Initi | |||||
| 380 | // publication consumes the intent and reports a bounded refusal instead of | 380 | // publication consumes the intent and reports a bounded refusal instead of | ||
| 381 | // handing an unvalidated authority to the resolver or the request. | 381 | // handing an unvalidated authority to the resolver or the request. | ||
| 382 | if err := snapshot.Validate(); err != nil { | 382 | if err := snapshot.Validate(); err != nil { | ||
| 383 | - | return InitialNavigation{}, true, fmt.Errorf("vev: initial navigation snapshot: %w", err) | 383 | + | return InitialNavigation{}, true, fmt.Errorf("initial navigation snapshot: %w", err) |
| 384 | } | 384 | } | ||
| 385 | s.mu.Lock() | 385 | s.mu.Lock() | ||
| 386 | if s.navigationConsumed { | 386 | if s.navigationConsumed { | ||
| @@ -421,7 +421,7 @@ func (s *Supervisor) takeInitialNavigation(snapshot ports.BrokerSnapshot) (Initi | |||||
| 421 | // not carry yet (for example attach-or-create before the host's first | 421 | // not carry yet (for example attach-or-create before the host's first | ||
| 422 | // observation). The supervisor keeps the intent armed and resolves it again on | 422 | // observation). The supervisor keeps the intent armed and resolves it again on | ||
| 423 | // later publications, bounded by initialNavigationObservationBudget. | 423 | // later publications, bounded by initialNavigationObservationBudget. | ||
| 424 | - | var ErrInitialNavigationNotObserved = errors.New("vev: initial navigation target is not observed yet") | 424 | + | var ErrInitialNavigationNotObserved = errors.New("initial navigation target is not observed yet") |
| 425 | 425 | ||||
| 426 | // InitialNavigationNotObserved is the resolver's ErrInitialNavigationNotObserved | 426 | // InitialNavigationNotObserved is the resolver's ErrInitialNavigationNotObserved | ||
| 427 | // naming the configured endpoint whose fresh observation it needs; the | 427 | // naming the configured endpoint whose fresh observation it needs; the | ||
M internal/usecase/daemon/daemon.go +112 -103
| @@ -167,6 +167,118 @@ type Daemon struct { | |||||
| 167 | 167 | ||||
| 168 | paletteRecentMu sync.Mutex | 168 | paletteRecentMu sync.Mutex | ||
| 169 | paletteRecent []string | 169 | paletteRecent []string | ||
| 170 | + | // daemonTestHooks holds deterministic test seams; production leaves every | |||
| 171 | + | // hook nil. | |||
| 172 | + | daemonTestHooks | |||
| 173 | + | ptys ports.PTYFactory | |||
| 174 | + | clock ports.Clock | |||
| 175 | + | log *slog.Logger | |||
| 176 | + | runtimeObserver ports.RuntimeObserver | |||
| 177 | + | baseEnv []string | |||
| 178 | + | shell string | |||
| 179 | + | shellArgs []string | |||
| 180 | + | shellOverride bool | |||
| 181 | + | persistEnabled bool | |||
| 182 | + | catalogue ports.Catalogue | |||
| 183 | + | catalogueRecords []domain.CatalogueRecord | |||
| 184 | + | catalogueRecordsProvided bool | |||
| 185 | + | // snapshotRepository is the sole checkpoint storage contract. | |||
| 186 | + | snapshotRepository ports.SnapshotRepository | |||
| 187 | + | recovery *recoveryusecase.Coordinator | |||
| 188 | + | snapshotGarbageCollection bool | |||
| 189 | + | maintenanceWorkerCancel context.CancelFunc | |||
| 190 | + | maintenanceWorkerDone chan struct{} | |||
| 191 | + | // restoreWorkerDone is the restoration goroutine's ownership signal. Startup | |||
| 192 | + | // restoration reconciles durable checkpoints, so it is a durable writer and | |||
| 193 | + | // is guarded by snapshotWorkerMu with the other two. | |||
| 194 | + | restoreWorkerDone chan struct{} | |||
| 195 | + | snapsEnabled bool | |||
| 196 | + | noticeStore ports.NoticeStore | |||
| 197 | + | snapshotJobs chan *snapshotCapture | |||
| 198 | + | // snapshotAdmitted contains every capture accepted by either worker queue, | |||
| 199 | + | // including captures buffered in snapshotJobs. Guarded by snapshotWorkerMu. | |||
| 200 | + | snapshotAdmitted map[*snapshotCapture]struct{} | |||
| 201 | + | // snapshotWake wakes the repository scheduler when a session becomes dirty | |||
| 202 | + | // or an attempt completes. It is never closed and producers only send | |||
| 203 | + | // non-blockingly. | |||
| 204 | + | snapshotWake chan struct{} | |||
| 205 | + | snapshotWorkerMu sync.Mutex | |||
| 206 | + | snapshotWorkerID uint64 | |||
| 207 | + | snapshotWorkerCtx context.Context | |||
| 208 | + | snapshotWorkerCancel context.CancelFunc | |||
| 209 | + | snapshotWorkerDone chan struct{} | |||
| 210 | + | snapshotWorkerFlush chan struct{} | |||
| 211 | + | snapshotWorkerFinalWake chan struct{} | |||
| 212 | + | // snapshotFinalJobs coalesces terminal captures by session when the bounded | |||
| 213 | + | // regular queue is full. It retains at most snapshotFinalQueueCapacity named | |||
| 214 | + | // sessions, each with only its newest terminal state while the worker blocks. | |||
| 215 | + | snapshotFinalJobs map[*session]*snapshotCapture | |||
| 216 | + | snapshotFinalOrder []*session | |||
| 217 | + | snapshotWorkerClosing bool | |||
| 218 | + | snapshotWorkerInFlight *snapshotCapture | |||
| 219 | + | // snapshotNoticeMu guards the active global persistence failure signature. | |||
| 220 | + | // It is separate from snapshotWorkerMu so notice routing cannot block a | |||
| 221 | + | // producer or a repository worker. | |||
| 222 | + | snapshotNoticeMu sync.Mutex | |||
| 223 | + | snapshotActiveFailureSignature string | |||
| 224 | + | shutdownNoticeMu sync.Mutex | |||
| 225 | + | shutdownNoticedSessions map[string]struct{} | |||
| 226 | + | restoreDone chan struct{} | |||
| 227 | + | restoreOnce sync.Once | |||
| 228 | + | procCwd func(int) (string, error) | |||
| 229 | + | procComm func(int) (string, error) | |||
| 230 | + | procArgv func(int) ([]string, error) | |||
| 231 | + | procGroupArgv func(int, int) ([]string, error) | |||
| 232 | + | dirOrHome func(string) string | |||
| 233 | + | bindings atomic.Pointer[keys.Bindings] | |||
| 234 | + | codeOverrides atomic.Pointer[map[string]string] | |||
| 235 | + | restoreProcessAllowlist atomic.Pointer[map[string]struct{}] | |||
| 236 | + | floatingConfig atomic.Pointer[domain.FloatingConfig] | |||
| 237 | + | copyConfig atomic.Pointer[domain.CopyConfig] | |||
| 238 | + | paletteConfig atomic.Pointer[domain.PaletteConfig] | |||
| 239 | + | navConfig atomic.Pointer[domain.NavConfig] | |||
| 240 | + | tabsConfig atomic.Pointer[domain.TabsConfig] | |||
| 241 | + | ephemeralConfig atomic.Pointer[domain.EphemeralConfig] | |||
| 242 | + | scrollbackConfig atomic.Pointer[domain.ScrollbackConfig] | |||
| 243 | + | themeConfig atomic.Pointer[themeConfigSnapshot] | |||
| 244 | + | barScripts *barScriptState | |||
| 245 | + | notices *noticeCenter | |||
| 246 | + | resumeParkGrace time.Duration | |||
| 247 | + | suspendedSafetyExpiry time.Duration | |||
| 248 | + | // tempDir overrides os.TempDir() for clipboard-image-transfer writes | |||
| 249 | + | // (see clipboard.go); empty means use os.TempDir(). | |||
| 250 | + | tempDir string | |||
| 251 | + | ||||
| 252 | + | serveCtx context.Context | |||
| 253 | + | serveCancel context.CancelFunc | |||
| 254 | + | ||||
| 255 | + | // hardCtx force-closes connection transports on shutdown, but only after | |||
| 256 | + | // shutdownAll has delivered graceful Detached notices — keeping it separate | |||
| 257 | + | // from serveCtx so a parent-context cancel never races the notice. | |||
| 258 | + | hardCtx context.Context | |||
| 259 | + | hardCancel context.CancelFunc | |||
| 260 | + | ||||
| 261 | + | // done is closed exactly once by an explicit daemon stop (shutdownAll) to | |||
| 262 | + | // break Serve's accept loop. It is deliberately not closed by final session | |||
| 263 | + | // removal or by a KillAll purge: an empty registry and a purged registry | |||
| 264 | + | // both keep the daemon serving. | |||
| 265 | + | done chan struct{} | |||
| 266 | + | doneOnce sync.Once | |||
| 267 | + | ||||
| 268 | + | // sessWg owns attention animation, bar-script polling, CWD sampling, | |||
| 269 | + | // snapshot save/restore, and floating-session launch goroutines. | |||
| 270 | + | sessWg sync.WaitGroup | |||
| 271 | + | connWg sync.WaitGroup // per-connection handler goroutines | |||
| 272 | + | // attachmentCleanupWg owns replacement transport closes and retired render | |||
| 273 | + | // worker joins. Only connection handlers add work; Serve waits for those | |||
| 274 | + | // handlers before joining this group, so no Add races its terminal Wait. | |||
| 275 | + | attachmentCleanupWg sync.WaitGroup | |||
| 276 | + | } | |||
| 277 | + | ||||
| 278 | + | // daemonTestHooks are deterministic seams that let tests pause or observe the | |||
| 279 | + | // daemon inside narrow concurrency windows. Production never sets them, and no | |||
| 280 | + | // hook may perform external work while locks are held. | |||
| 281 | + | type daemonTestHooks struct { | |||
| 170 | // beforeRecentSessionHandoff is a deterministic test seam for the narrow | 282 | // beforeRecentSessionHandoff is a deterministic test seam for the narrow | ||
| 171 | // interval between JRS validation and its committed hand-off. | 283 | // interval between JRS validation and its committed hand-off. | ||
| 172 | beforeRecentSessionHandoff func() | 284 | beforeRecentSessionHandoff func() | ||
| @@ -284,109 +396,6 @@ type Daemon struct { | |||||
| 284 | // and before it freezes the attachment effect gate, so tests can win | 396 | // and before it freezes the attachment effect gate, so tests can win | ||
| 285 | // activation, disconnect, or replacement first. It is a test-only seam. | 397 | // activation, disconnect, or replacement first. It is a test-only seam. | ||
| 286 | beforeSuspendedExpiryFreeze func(*attachedClient) | 398 | beforeSuspendedExpiryFreeze func(*attachedClient) | ||
| 287 | - | ptys ports.PTYFactory | |||
| 288 | - | clock ports.Clock | |||
| 289 | - | log *slog.Logger | |||
| 290 | - | runtimeObserver ports.RuntimeObserver | |||
| 291 | - | baseEnv []string | |||
| 292 | - | shell string | |||
| 293 | - | shellArgs []string | |||
| 294 | - | shellOverride bool | |||
| 295 | - | persistEnabled bool | |||
| 296 | - | catalogue ports.Catalogue | |||
| 297 | - | catalogueRecords []domain.CatalogueRecord | |||
| 298 | - | catalogueRecordsProvided bool | |||
| 299 | - | // snapshotRepository is the sole checkpoint storage contract. | |||
| 300 | - | snapshotRepository ports.SnapshotRepository | |||
| 301 | - | recovery *recoveryusecase.Coordinator | |||
| 302 | - | snapshotGarbageCollection bool | |||
| 303 | - | maintenanceWorkerCancel context.CancelFunc | |||
| 304 | - | maintenanceWorkerDone chan struct{} | |||
| 305 | - | // restoreWorkerDone is the restoration goroutine's ownership signal. Startup | |||
| 306 | - | // restoration reconciles durable checkpoints, so it is a durable writer and | |||
| 307 | - | // is guarded by snapshotWorkerMu with the other two. | |||
| 308 | - | restoreWorkerDone chan struct{} | |||
| 309 | - | snapsEnabled bool | |||
| 310 | - | noticeStore ports.NoticeStore | |||
| 311 | - | snapshotJobs chan *snapshotCapture | |||
| 312 | - | // snapshotAdmitted contains every capture accepted by either worker queue, | |||
| 313 | - | // including captures buffered in snapshotJobs. Guarded by snapshotWorkerMu. | |||
| 314 | - | snapshotAdmitted map[*snapshotCapture]struct{} | |||
| 315 | - | // snapshotWake wakes the repository scheduler when a session becomes dirty | |||
| 316 | - | // or an attempt completes. It is never closed and producers only send | |||
| 317 | - | // non-blockingly. | |||
| 318 | - | snapshotWake chan struct{} | |||
| 319 | - | snapshotWorkerMu sync.Mutex | |||
| 320 | - | snapshotWorkerID uint64 | |||
| 321 | - | snapshotWorkerCtx context.Context | |||
| 322 | - | snapshotWorkerCancel context.CancelFunc | |||
| 323 | - | snapshotWorkerDone chan struct{} | |||
| 324 | - | snapshotWorkerFlush chan struct{} | |||
| 325 | - | snapshotWorkerFinalWake chan struct{} | |||
| 326 | - | // snapshotFinalJobs coalesces terminal captures by session when the bounded | |||
| 327 | - | // regular queue is full. It retains at most snapshotFinalQueueCapacity named | |||
| 328 | - | // sessions, each with only its newest terminal state while the worker blocks. | |||
| 329 | - | snapshotFinalJobs map[*session]*snapshotCapture | |||
| 330 | - | snapshotFinalOrder []*session | |||
| 331 | - | snapshotWorkerClosing bool | |||
| 332 | - | snapshotWorkerInFlight *snapshotCapture | |||
| 333 | - | // snapshotNoticeMu guards the active global persistence failure signature. | |||
| 334 | - | // It is separate from snapshotWorkerMu so notice routing cannot block a | |||
| 335 | - | // producer or a repository worker. | |||
| 336 | - | snapshotNoticeMu sync.Mutex | |||
| 337 | - | snapshotActiveFailureSignature string | |||
| 338 | - | shutdownNoticeMu sync.Mutex | |||
| 339 | - | shutdownNoticedSessions map[string]struct{} | |||
| 340 | - | restoreDone chan struct{} | |||
| 341 | - | restoreOnce sync.Once | |||
| 342 | - | procCwd func(int) (string, error) | |||
| 343 | - | procComm func(int) (string, error) | |||
| 344 | - | procArgv func(int) ([]string, error) | |||
| 345 | - | procGroupArgv func(int, int) ([]string, error) | |||
| 346 | - | dirOrHome func(string) string | |||
| 347 | - | bindings atomic.Pointer[keys.Bindings] | |||
| 348 | - | codeOverrides atomic.Pointer[map[string]string] | |||
| 349 | - | restoreProcessAllowlist atomic.Pointer[map[string]struct{}] | |||
| 350 | - | floatingConfig atomic.Pointer[domain.FloatingConfig] | |||
| 351 | - | copyConfig atomic.Pointer[domain.CopyConfig] | |||
| 352 | - | paletteConfig atomic.Pointer[domain.PaletteConfig] | |||
| 353 | - | navConfig atomic.Pointer[domain.NavConfig] | |||
| 354 | - | tabsConfig atomic.Pointer[domain.TabsConfig] | |||
| 355 | - | ephemeralConfig atomic.Pointer[domain.EphemeralConfig] | |||
| 356 | - | scrollbackConfig atomic.Pointer[domain.ScrollbackConfig] | |||
| 357 | - | themeConfig atomic.Pointer[themeConfigSnapshot] | |||
| 358 | - | barScripts *barScriptState | |||
| 359 | - | notices *noticeCenter | |||
| 360 | - | resumeParkGrace time.Duration | |||
| 361 | - | suspendedSafetyExpiry time.Duration | |||
| 362 | - | // tempDir overrides os.TempDir() for clipboard-image-transfer writes | |||
| 363 | - | // (see clipboard.go); empty means use os.TempDir(). | |||
| 364 | - | tempDir string | |||
| 365 | - | ||||
| 366 | - | serveCtx context.Context | |||
| 367 | - | serveCancel context.CancelFunc | |||
| 368 | - | ||||
| 369 | - | // hardCtx force-closes connection transports on shutdown, but only after | |||
| 370 | - | // shutdownAll has delivered graceful Detached notices — keeping it separate | |||
| 371 | - | // from serveCtx so a parent-context cancel never races the notice. | |||
| 372 | - | hardCtx context.Context | |||
| 373 | - | hardCancel context.CancelFunc | |||
| 374 | - | ||||
| 375 | - | // done is closed exactly once by an explicit daemon stop (shutdownAll) to | |||
| 376 | - | // break Serve's accept loop. It is deliberately not closed by final session | |||
| 377 | - | // removal or by a KillAll purge: an empty registry and a purged registry | |||
| 378 | - | // both keep the daemon serving. | |||
| 379 | - | done chan struct{} | |||
| 380 | - | doneOnce sync.Once | |||
| 381 | - | ||||
| 382 | - | // sessWg owns attention animation, bar-script polling, CWD sampling, | |||
| 383 | - | // snapshot save/restore, and floating-session launch goroutines. | |||
| 384 | - | sessWg sync.WaitGroup | |||
| 385 | - | connWg sync.WaitGroup // per-connection handler goroutines | |||
| 386 | - | // attachmentCleanupWg owns replacement transport closes and retired render | |||
| 387 | - | // worker joins. Only connection handlers add work; Serve waits for those | |||
| 388 | - | // handlers before joining this group, so no Add races its terminal Wait. | |||
| 389 | - | attachmentCleanupWg sync.WaitGroup | |||
| 390 | } | 399 | } | ||
| 391 | 400 | ||||
| 392 | type parkedAttachment struct { | 401 | type parkedAttachment struct { | ||
M internal/usecase/daemon/notify_test.go +1 -1
| @@ -721,7 +721,7 @@ func TestNotifyGlobalQueuesWhenUnattached(t *testing.T) { | |||||
| 721 | } | 721 | } | ||
| 722 | 722 | ||||
| 723 | // TestNotifyGlobalPersistDisabledDrainsAtFirstAttach guards the store-open | 723 | // TestNotifyGlobalPersistDisabledDrainsAtFirstAttach guards the store-open | ||
| 724 | - | // failure contract (Task 13): app/run.go queues this exact code and message | 724 | + | // failure contract: the app daemon composition queues this exact code and message |
| 725 | // via NotifyGlobal before any client has attached, and the first attach must | 725 | // via NotifyGlobal before any client has attached, and the first attach must | ||
| 726 | // see it via firstPaint's pending-queue drain. | 726 | // see it via firstPaint's pending-queue drain. | ||
| 727 | func TestNotifyGlobalPersistDisabledDrainsAtFirstAttach(t *testing.T) { | 727 | func TestNotifyGlobalPersistDisabledDrainsAtFirstAttach(t *testing.T) { | ||
M main.go +3 -9
| @@ -3,7 +3,6 @@ package main | |||||
| 3 | import ( | 3 | import ( | ||
| 4 | "fmt" | 4 | "fmt" | ||
| 5 | "os" | 5 | "os" | ||
| 6 | - | "strings" | |||
| 7 | 6 | ||||
| 8 | "github.com/bnema/vev/internal/app" | 7 | "github.com/bnema/vev/internal/app" | ||
| 9 | ) | 8 | ) | ||
| @@ -15,13 +14,8 @@ func main() { | |||||
| 15 | } | 14 | } | ||
| 16 | } | 15 | } | ||
| 17 | 16 | ||||
| 18 | - | // cliError adds the sole user-facing CLI prefix. Errors may already include | 17 | + | // cliError adds the sole user-facing CLI prefix; internal errors never carry |
| 19 | - | // it when they originate from a runtime component, so remove any such prefix | 18 | + | // it. |
| 20 | - | // before rendering. | |||
| 21 | func cliError(err error) string { | 19 | func cliError(err error) string { | ||
| 22 | - | message := err.Error() | 20 | + | return "vev: " + err.Error() |
| 23 | - | for strings.HasPrefix(message, "vev:") { | |||
| 24 | - | message = strings.TrimSpace(strings.TrimPrefix(message, "vev:")) | |||
| 25 | - | } | |||
| 26 | - | return "vev: " + message | |||
| 27 | } | 21 | } | ||
M main_test.go +3 -17
| @@ -2,6 +2,7 @@ package main | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "errors" | 4 | "errors" | ||
| 5 | + | "fmt" | |||
| 5 | "testing" | 6 | "testing" | ||
| 6 | 7 | ||||
| 7 | "github.com/bnema/vev/internal/app" | 8 | "github.com/bnema/vev/internal/app" | ||
| @@ -24,25 +25,10 @@ func TestCLIError(t *testing.T) { | |||||
| 24 | want: "vev: invalid session name: must match [A-Za-z0-9][A-Za-z0-9._-]{0,63}", | 25 | want: "vev: invalid session name: must match [A-Za-z0-9][A-Za-z0-9._-]{0,63}", | ||
| 25 | }, | 26 | }, | ||
| 26 | { | 27 | { | ||
| 27 | - | name: "daemon error", | 28 | + | name: "wrapped runtime error", |
| 28 | - | err: errors.New("vev: daemon listen: address already in use"), | 29 | + | err: fmt.Errorf("daemon listen: %w", errors.New("address already in use")), |
| 29 | want: "vev: daemon listen: address already in use", | 30 | want: "vev: daemon listen: address already in use", | ||
| 30 | }, | 31 | }, | ||
| 31 | - | { | |||
| 32 | - | name: "session error", | |||
| 33 | - | err: errors.New("vev: no such session: work"), | |||
| 34 | - | want: "vev: no such session: work", | |||
| 35 | - | }, | |||
| 36 | - | { | |||
| 37 | - | name: "repeated prefix", | |||
| 38 | - | err: errors.New("vev: vev: message"), | |||
| 39 | - | want: "vev: message", | |||
| 40 | - | }, | |||
| 41 | - | { | |||
| 42 | - | name: "prefix whitespace", | |||
| 43 | - | err: errors.New("vev:\t message"), | |||
| 44 | - | want: "vev: message", | |||
| 45 | - | }, | |||
| 46 | } | 32 | } | ||
| 47 | 33 | ||||
| 48 | for _, tt := range tests { | 34 | for _, tt := range tests { | ||