v0.1.0 7deb448

spawn.go RawHistoryFolder

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}

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