1package app
2
3import (
4 "bytes"
5 "context"
6 "errors"
7 "fmt"
8 "log/slog"
9 "os"
10 "os/exec"
11 "path/filepath"
12 "syscall"
13 "time"
14
15 "github.com/bnema/vev/internal/adapters/ipc"
16 "github.com/bnema/vev/internal/adapters/lifecycle"
17 "github.com/bnema/vev/internal/platform"
18 "github.com/bnema/vev/internal/ports"
19 "github.com/bnema/vev/pkg/safedir"
20)
21
22// ErrDaemonUnreachable is returned when the daemon socket never becomes
23// dialable within the retry budget, despite a spawn attempt.
24var ErrDaemonUnreachable = errors.New("vev: daemon did not become reachable")
25
26// spawnLockName is the mkdir-based lock directory guarding daemon spawn, so
27// concurrent first-clients elect a single spawner instead of racing to
28// re-exec multiple daemons.
29const spawnLockName = "spawn.lock"
30
31// staleLockAge bounds how long a spawn lock may persist before it is treated
32// as abandoned (spawner crashed mid-flight) and forcibly taken over.
33const staleLockAge = 10 * time.Second
34
35// dialFunc dials the daemon socket in dir. Injected so tests can drive the
36// spawn/backoff logic without a real socket.
37type dialFunc func(ctx context.Context, dir string) (ports.Transport, error)
38
39// spawnFunc launches a detached daemon process. Injected so tests never
40// re-exec a real binary.
41type spawnFunc func() error
42
43type lifecycleProbe interface {
44 TryAcquire(string) (lifecycleOwnership, error)
45}
46
47type osLifecycleProbe struct{}
48
49func (osLifecycleProbe) TryAcquire(runtimeDir string) (lifecycleOwnership, error) {
50 return lifecycle.TryAcquire(runtimeDir)
51}
52
53var daemonLifecycleProbe lifecycleProbe = osLifecycleProbe{}
54
55// backoffConfig parameterises retry-dial timing so tests can shrink it.
56type backoffConfig struct {
57 initial time.Duration
58 max time.Duration
59 total time.Duration
60}
61
62// defaultBackoff is the production retry schedule: 10ms, 20ms, 40ms... capped
63// at 500ms, for roughly 5s total.
64var defaultBackoff = backoffConfig{
65 initial: 10 * time.Millisecond,
66 max: 500 * time.Millisecond,
67 total: 5 * time.Second,
68}
69
70func retryAttempts[T any](ctx context.Context, cfg backoffConfig, attempt func() (T, bool, error)) (T, error) {
71 deadline := time.Now().Add(cfg.total)
72 backoff := cfg.initial
73 if backoff <= 0 {
74 backoff = time.Millisecond
75 }
76 for {
77 if err := ctx.Err(); err != nil {
78 var zero T
79 return zero, err
80 }
81 result, done, err := attempt()
82 if err != nil {
83 var zero T
84 return zero, err
85 }
86 if done {
87 return result, nil
88 }
89 if !time.Now().Before(deadline) {
90 var zero T
91 return zero, ErrDaemonUnreachable
92 }
93 if err := waitBackoff(ctx, backoff); err != nil {
94 var zero T
95 return zero, err
96 }
97 if backoff *= 2; backoff > cfg.max {
98 backoff = cfg.max
99 }
100 }
101}
102
103func waitForLifecycleAvailability(ctx context.Context, runtimeDir string, cfg backoffConfig) (lifecycleOwnership, error) {
104 return retryAttempts(ctx, cfg, func() (lifecycleOwnership, bool, error) {
105 owner, err := daemonLifecycleProbe.TryAcquire(runtimeDir)
106 if err == nil {
107 return owner, true, nil
108 }
109 if !errors.Is(err, lifecycle.ErrBusy) {
110 return nil, false, err
111 }
112 return nil, false, nil
113 })
114}
115
116type daemonOrLifecycle struct {
117 transport ports.Transport
118 owner lifecycleOwnership
119}
120
121func waitForDaemonOrLifecycle(ctx context.Context, dir string, dial dialFunc, cfg backoffConfig) (ports.Transport, lifecycleOwnership, error) {
122 result, err := retryAttempts(ctx, cfg, func() (daemonOrLifecycle, bool, error) {
123 if transport, err := dial(ctx, dir); err == nil {
124 return daemonOrLifecycle{transport: transport}, true, nil
125 }
126 owner, err := daemonLifecycleProbe.TryAcquire(dir)
127 if err == nil {
128 return daemonOrLifecycle{owner: owner}, true, nil
129 }
130 if !errors.Is(err, lifecycle.ErrBusy) {
131 return daemonOrLifecycle{}, false, err
132 }
133 return daemonOrLifecycle{}, false, nil
134 })
135 return result.transport, result.owner, err
136}
137
138func waitBackoff(ctx context.Context, duration time.Duration) error {
139 timer := time.NewTimer(duration)
140 defer timer.Stop()
141 select {
142 case <-ctx.Done():
143 return ctx.Err()
144 case <-timer.C:
145 return nil
146 }
147}
148
149// ensureDaemonWithLifecycle never elects a spawner while another lifecycle
150// owner may still be initializing or tearing down durable state.
151func ensureDaemonWithLifecycle(ctx context.Context, dir string, dial dialFunc, spawn spawnFunc, cfg backoffConfig) (ports.Transport, error) {
152 if err := ctx.Err(); err != nil {
153 return nil, err
154 }
155 if transport, err := dial(ctx, dir); err == nil {
156 return transport, nil
157 }
158 transport, owner, err := waitForDaemonOrLifecycle(ctx, dir, dial, cfg)
159 if err != nil {
160 return nil, err
161 }
162 if transport != nil {
163 return transport, nil
164 }
165 if err := owner.Release(); err != nil {
166 return nil, fmt.Errorf("vev: release lifecycle spawn probe: %w", err)
167 }
168 return ensureDaemon(ctx, dir, dial, spawn, cfg)
169}
170
171// ensureDaemon returns a transport to a running daemon, spawning one if
172// necessary after ensureDaemonWithLifecycle has established lifecycle
173// availability. Tests also exercise this lower-level spawn election directly.
174func ensureDaemon(ctx context.Context, dir string, dial dialFunc, spawn spawnFunc, cfg backoffConfig) (ports.Transport, error) {
175 if err := ctx.Err(); err != nil {
176 return nil, err
177 }
178 if t, err := dial(ctx, dir); err == nil {
179 slog.Debug("daemon already reachable", "socket_dir", dir)
180 return t, nil
181 }
182
183 release, acquired, err := acquireSpawnLock(dir)
184 if err != nil {
185 return nil, fmt.Errorf("vev: acquiring spawn lock: %w", err)
186 }
187 if acquired {
188 // We won the election: spawn, and hold the lock until the socket is
189 // dialable (or spawn fails) so late-arriving clients keep waiting
190 // rather than spawning a second daemon.
191 defer release()
192 slog.Info("spawning daemon", "socket_dir", dir)
193 if err := ctx.Err(); err != nil {
194 return nil, err
195 }
196 if err := spawn(); err != nil {
197 slog.Error("daemon spawn failed", "err", err)
198 return nil, fmt.Errorf("vev: spawning daemon: %w", err)
199 }
200 } else {
201 slog.Debug("waiting for daemon spawned by another process", "socket_dir", dir)
202 }
203
204 return retryDial(ctx, dir, dial, cfg)
205}
206
207// acquireSpawnLock attempts to create the spawn-lock directory. It returns
208// acquired=true with a release closure when it wins, acquired=false (with a
209// no-op release) when another process holds a fresh lock, and takes over a
210// lock older than staleLockAge (crash resilience).
211func acquireSpawnLock(dir string) (release func(), acquired bool, err error) {
212 if err := safedir.EnsurePrivate(dir); err != nil {
213 return nil, false, err
214 }
215 path := filepath.Join(dir, spawnLockName)
216
217 if mkErr := os.Mkdir(path, 0o700); mkErr == nil {
218 return func() { _ = os.Remove(path) }, true, nil
219 } else if !errors.Is(mkErr, os.ErrExist) {
220 return nil, false, mkErr
221 }
222
223 // Lock exists: take it over only if it looks abandoned.
224 if info, statErr := os.Stat(path); statErr == nil && time.Since(info.ModTime()) > staleLockAge {
225 age := time.Since(info.ModTime())
226 slog.Warn("taking over stale daemon spawn lock", "path", path, "age", age)
227 _ = os.Remove(path)
228 if mkErr := os.Mkdir(path, 0o700); mkErr == nil {
229 return func() { _ = os.Remove(path) }, true, nil
230 }
231 }
232
233 // A live peer is spawning; wait for it via retry-dial.
234 return func() {}, false, nil
235}
236
237// retryDial dials repeatedly with exponential backoff until the daemon
238// answers, the context is cancelled, or the total budget is exhausted.
239func retryDial(ctx context.Context, dir string, dial dialFunc, cfg backoffConfig) (ports.Transport, error) {
240 transport, err := retryAttempts(ctx, cfg, func() (ports.Transport, bool, error) {
241 transport, err := dial(ctx, dir)
242 return transport, err == nil, nil
243 })
244 if errors.Is(err, ErrDaemonUnreachable) {
245 slog.Error("daemon did not become reachable before retry budget expired", "socket_dir", dir, "budget", cfg.total)
246 }
247 return transport, err
248}
249
250// realDial is the production dialer.
251func realDial(ctx context.Context, dir string) (ports.Transport, error) {
252 return ipc.DialContext(ctx, dir)
253}
254
255// realSpawn re-execs this binary through a short-lived launcher. Waiting for
256// that launcher to exit ensures the daemon has been re-parented before the
257// client continues, keeping process-tree termination from reaching it.
258func realSpawn() error {
259 exePath, err := selfExePath()
260 if err != nil {
261 return fmt.Errorf("resolving executable path: %w", err)
262 }
263
264 devNull, err := os.OpenFile(os.DevNull, os.O_RDWR, 0)
265 if err != nil {
266 return fmt.Errorf("opening %s: %w", os.DevNull, err)
267 }
268 defer func() { _ = devNull.Close() }()
269
270 var stderr bytes.Buffer
271 cmd := exec.Command(exePath, "--daemon-launcher")
272 cmd.Dir = platform.DirOrHome("")
273 cmd.Stdin = devNull
274 cmd.Stdout = devNull
275 cmd.Stderr = &stderr
276 if err := cmd.Run(); err != nil {
277 if detail := bytes.TrimSpace(stderr.Bytes()); len(detail) > 0 {
278 return fmt.Errorf("%w: %s", err, detail)
279 }
280 return err
281 }
282 slog.Info("daemon process detached")
283 return nil
284}
285
286// runDaemonLauncher is the intermediate half of the double-fork. It starts
287// the long-lived daemon in a new session and exits immediately; realSpawn
288// waits for this exit so the daemon is no longer descended from the client.
289func runDaemonLauncher() error {
290 exePath, err := selfExePath()
291 if err != nil {
292 return fmt.Errorf("resolving executable path: %w", err)
293 }
294
295 devNull, err := os.OpenFile(os.DevNull, os.O_RDWR, 0)
296 if err != nil {
297 return fmt.Errorf("opening %s: %w", os.DevNull, err)
298 }
299 defer func() { _ = devNull.Close() }()
300
301 cmd := exec.Command(exePath, "--daemon")
302 cmd.Dir = platform.DirOrHome("")
303 cmd.Stdin = devNull
304 cmd.Stdout = devNull
305 cmd.Stderr = devNull
306 cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
307 if err := cmd.Start(); err != nil {
308 return err
309 }
310 return cmd.Process.Release()
311}