detached 5ac8017

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 UnverifiedB5690EEEBB952194 The 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 the369 // 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 []string375 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 return261 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 return275 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.Clock269 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 bounded341 // no destination was dialed, so the supervisor surfaces it as a bounded
342 // selection-unavailable notice and returns to the picker instead of attaching a342 // 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 the346 // terminalBrokerNavigation translates the parsed terminal CLI intent into the
347 // closed initial-navigation union. An intent that is fully determined before347 // 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 := localDaemonStarter145 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 nil183 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 nil197 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", nil232 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, nil246 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, nil108 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]byte166 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 err211 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 a223 // 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 an228 // The broker is allowed to precede the daemon. Identity is therefore an
229 // optional existing authority here; acquisition binds it after an229 // 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.MembershipImmutable462 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 single458 // 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 err644 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 a72 // 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 dead73 // 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 a76 // errBrokerIncompatible reports that the endpoint exists but is not a
77 // compatible broker (a foreign path, a live endpoint that fails the broker77 // 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 an78 // 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 broker81 // 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 exactly86 // 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 = devNull177 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 = crashLog190 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 = &stderr226 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 nil233 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 private255 // 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 brokerStatusLock264 var held brokerStatusLock
@@ -293,7 +293,7 @@ func ensureBrokerReady(ctx context.Context, req brokerStatusRequest, deps broker
293 return brokerStatusReport{}, err293 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{}, err314 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 elected320 // 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 continue330 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 nil37 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 name54 // 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 := false78 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 nil95 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 nil100 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, nil86 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 may137 // uncorrelated, malformed, or never observed before the deadline. The daemon may
138 // already have committed the command, so the outcome is indeterminate, the138 // 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 indeterminate142 // 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 report108 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 report112 return report
113 }113 }
114 // Clients first, so none of them can start a broker or daemon from114 // 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 []vevProcess149 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 continue244 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 err88 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 err105 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 nil110 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 []string184 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.SessionInfo218 var sessions []protocol.SessionInfo
219 var statuses []string219 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 overwrite27 // Build metadata. Defaults describe a plain `go build`; releases overwrite
149 // them via -ldflags "-X github.com/bnema/vev/internal/app.version=..." (and28 // 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 cfg142 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 of147 // daemonStopCommand is the hidden, environment-scoped graceful stop of
@@ -977,42 +152,15 @@ const (
977 152
978 const daemonStopTimeout = 2 * time.Second153 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 err166 return err
@@ -1020,6 +168,9 @@ func runUIRemoteCleanup(ctx context.Context) error {
1020 return nil168 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. A200 // stopDaemonWithoutBroker asks an existing local daemon to stop directly. A
1172 // daemon routinely outlives the idle broker, so "no broker" never means "no201 // 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 errDaemonNotRunning206 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 becomes24 // 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 daemon28 // 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, so33 // spawnLockName is the mkdir-based lock directory guarding daemon spawn, so
34 // concurrent first-clients elect a single spawner instead of racing to34 // 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, nil195 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 is222 // 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 string48 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{}, err332 return ports.UISnapshot{}, err
331 }333 }
@@ -414,98 +416,3 @@ func (s *stdioStream) Close() error {
414 })416 })
415 return closeErr417 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 webAccess91 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, nil100 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, rmErr64 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 nil79 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 nil53 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 <-done142 <-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.listen53 cfg.WebListen = options.listen
M internal/platform/dir.go +4 -4
@@ -17,18 +17,18 @@ func ActivateDevelopmentEnvironment() error {
17 return nil17 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 = absolute29 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 nil62 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.Text643+ 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 string36 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 BrokerError64 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.Second49 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 whole54 // 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}, nil195 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 can243 // 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{}, err305 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.RemoteCatalog326 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{}, err331 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 after228 // 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, &event232 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 just256 // 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, &event260 return state, &event
261 }261 }
262 return state, nil262 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 = next438 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 nil94 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}, nil116 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, err141 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, nil148 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 nil113 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: it122 // 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 nil152 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 nil296 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.Text125+ return "picker selection " + e.Code.String() + ": " + e.Text
126 }126 }
127 127
128 // pickerSelectionKind names one closed admission variant a resolved selection128 // 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.Logger507 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 error337 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.AttachmentEnvironment343 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 of380 // 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 first421 // 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 on422 // 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 ErrInitialNavigationNotObserved426 // InitialNavigationNotObserved is the resolver's ErrInitialNavigationNotObserved
427 // naming the configured endpoint whose fresh observation it needs; the427 // 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.Mutex168 paletteRecentMu sync.Mutex
169 paletteRecent []string169 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 narrow282 // 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 win396 // 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-open723 // TestNotifyGlobalPersistDisabledDrainsAtFirstAttach guards the store-open
724-// failure contract (Task 13): app/run.go queues this exact code and message724+// failure contract: the app daemon composition queues this exact code and message
725 // via NotifyGlobal before any client has attached, and the first attach must725 // 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 include17+// 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 prefix18+// 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 {

Keyboard shortcuts

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

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

Branches 12

Tags 11

↑↓ moveenter openesc close