okami.mom/albedo

git clone https://git.nkp.pet/okami.mom/albedo

Aly Raffaufrefactor(cli): extract local daemon lifecycled9c1a13

main
13.3 KiB414 linesraw
1package localdaemon
2
3import (
4	"albedo/cli/internal/daemon"
5	"context"
6	"encoding/json"
7	"errors"
8	"fmt"
9	"io"
10	"net/http"
11	"os"
12	"os/exec"
13	"path/filepath"
14	"strings"
15	"time"
16)
17
18const (
19	pollInterval   = 10 * time.Millisecond
20	startupTimeout = 30 * time.Second
21	// A ready daemon's owner observations may queue behind other work.
22	discoveryProbeTimeout = 5 * time.Second
23)
24
25type DiscoveryKind int
26
27const (
28	Absent DiscoveryKind = iota
29	Stale
30	Running
31)
32
33type Discovery struct {
34	Kind     DiscoveryKind
35	Snapshot daemon.ConnectionSnapshot
36	Server   daemon.ServerInfo
37}
38
39type LocalOptions struct {
40	HomeDir     string
41	ProjectRoot string
42}
43
44type LocalErrorKind string
45
46const (
47	InvalidDiscovery     LocalErrorKind = "invalid_discovery"
48	DiscoveryUnavailable LocalErrorKind = "discovery_unavailable"
49	UnreachableDaemon    LocalErrorKind = "unreachable_daemon"
50	UnhealthyDaemon      LocalErrorKind = "unhealthy_daemon"
51	IncompatibleDaemon   LocalErrorKind = "incompatible_daemon"
52	AuthenticationFailed LocalErrorKind = "authentication_failed"
53	TargetChanged        LocalErrorKind = "target_changed"
54	LauncherLockFailure  LocalErrorKind = "launcher_lock_failure"
55	StartupFailed        LocalErrorKind = "startup_failed"
56	HomeInUse            LocalErrorKind = "home_in_use"
57)
58
59type LocalError struct {
60	Kind    LocalErrorKind
61	HomeDir string
62	Cause   error
63}
64
65func (e *LocalError) Error() string {
66	return fmt.Sprintf("Albedo %s in %s: %v", strings.ReplaceAll(string(e.Kind), "_", " "), e.HomeDir, e.Cause)
67}
68func (e *LocalError) Unwrap() error { return e.Cause }
69
70// Discover distinguishes missing and proven stale records from unsafe failures.
71// A valid live endpoint is returned even when its API contract needs upgrading.
72func Discover(ctx context.Context, homeDir string) (Discovery, error) {
73	if err := ctx.Err(); err != nil {
74		return Discovery{}, err
75	}
76	fail := func(kind LocalErrorKind, cause error) (Discovery, error) {
77		return Discovery{}, &LocalError{Kind: kind, HomeDir: homeDir, Cause: cause}
78	}
79	file, err := os.Open(filepath.Join(homeDir, "daemon.json"))
80	if errors.Is(err, os.ErrNotExist) {
81		return Discovery{Kind: Absent}, nil
82	}
83	if err != nil {
84		return fail(DiscoveryUnavailable, err)
85	}
86	defer file.Close()
87	data, err := io.ReadAll(io.LimitReader(file, 64*1024+1))
88	if err != nil {
89		return fail(DiscoveryUnavailable, err)
90	}
91	if len(data) > 64*1024 {
92		return fail(InvalidDiscovery, errors.New("daemon.json exceeds 64 KiB"))
93	}
94	var snapshot daemon.ConnectionSnapshot
95	if err := json.Unmarshal(data, &snapshot); err != nil {
96		return fail(InvalidDiscovery, err)
97	}
98	if snapshot.Pid <= 0 || snapshot.Port < 1 || snapshot.Port > 65535 || snapshot.Token == "" || snapshot.Version <= 0 {
99		return fail(InvalidDiscovery, errors.New("daemon.json requires a positive PID, valid port, token and protocol version"))
100	}
101	probeCtx, cancel := context.WithTimeout(ctx, discoveryProbeTimeout)
102	connection := daemon.NewConnection(snapshot, nil)
103	server, err := daemon.ProbeServer(probeCtx, connection)
104	connection.HTTPClient().CloseIdleConnections()
105	cancel()
106	if err != nil {
107		if ctx.Err() != nil {
108			return Discovery{}, ctx.Err()
109		}
110		if !processAlive(snapshot.Pid) && endpointRefused(err) {
111			return Discovery{Kind: Stale, Snapshot: snapshot}, nil
112		}
113		if api, ok := errors.AsType[*daemon.APIError](err); ok {
114			if api.StatusCode == http.StatusUnauthorized || api.StatusCode == http.StatusForbidden {
115				return fail(AuthenticationFailed, err)
116			}
117			if api.StatusCode == http.StatusNotFound && snapshot.Version != daemon.ProtocolVersion {
118				return fail(IncompatibleDaemon, &daemon.CompatibilityError{Version: snapshot.Version})
119			}
120			return fail(UnhealthyDaemon, err)
121		}
122		if _, ok := errors.AsType[*daemon.ProtocolError](err); ok {
123			return fail(UnhealthyDaemon, err)
124		}
125		return fail(UnreachableDaemon, err)
126	}
127	if server.Protocol != snapshot.Version || (server.Build != "" && server.Build != snapshot.Build) ||
128		(server.Digest != "" && snapshot.Digest != "" && server.Digest != snapshot.Digest) {
129		return fail(InvalidDiscovery, errors.New("server identity does not match daemon.json"))
130	}
131	if snapshot.InstanceID != "" && snapshot.InstanceID != server.InstanceID {
132		return fail(InvalidDiscovery, errors.New("server identity does not match daemon.json"))
133	}
134	snapshot.InstanceID, snapshot.Digest = server.InstanceID, server.Digest
135	discovery := Discovery{Kind: Running, Snapshot: snapshot, Server: server}
136	return discovery, daemon.CheckCompatible(server)
137}
138
139// Rediscover waits for a verified replacement endpoint without starting one.
140func Rediscover(ctx context.Context, homeDir string) (daemon.ConnectionSnapshot, error) {
141	for range 20 {
142		discovery, err := Discover(ctx, homeDir)
143		if err == nil && discovery.Kind == Running {
144			return discovery.Snapshot, nil
145		}
146		if err != nil {
147			var local *LocalError
148			if !errors.As(err, &local) || (local.Kind != UnreachableDaemon && local.Kind != UnhealthyDaemon) {
149				return daemon.ConnectionSnapshot{}, err
150			}
151			var api *daemon.APIError
152			if errors.As(err, &api) && !api.DaemonRestarting() {
153				return daemon.ConnectionSnapshot{}, err
154			}
155			if _, ok := errors.AsType[*daemon.ProtocolError](err); ok {
156				return daemon.ConnectionSnapshot{}, err
157			}
158		}
159		timer := time.NewTimer(100 * time.Millisecond)
160		select {
161		case <-ctx.Done():
162			timer.Stop()
163			return daemon.ConnectionSnapshot{}, ctx.Err()
164		case <-timer.C:
165		}
166	}
167	return daemon.ConnectionSnapshot{}, &LocalError{Kind: UnreachableDaemon, HomeDir: homeDir, Cause: errors.New("could not rediscover a ready daemon; check whether it is running")}
168}
169
170func resolveDaemonExecutable(path string) (string, error) {
171	if !filepath.IsAbs(path) {
172		return "", fmt.Errorf("ALBEDO_DAEMON must point to an executable using its full path: %q", path)
173	}
174	resolved, err := exec.LookPath(path)
175	if err != nil {
176		return "", fmt.Errorf("cannot run ALBEDO_DAEMON (%q): %w", path, err)
177	}
178	info, err := os.Stat(resolved)
179	if err != nil {
180		return "", err
181	}
182	if info.IsDir() {
183		return "", fmt.Errorf("ALBEDO_DAEMON points to a directory (%q)", path)
184	}
185	return resolved, nil
186}
187
188// Local launch filters provider overrides; daemon bootstrap owns all defaults.
189func daemonCommand(options LocalOptions) (*exec.Cmd, error) {
190	return LocalCommand(context.Background(), options)
191}
192
193// LocalCommand selects the local daemon executable without launching its server.
194func LocalCommand(ctx context.Context, options LocalOptions, args ...string) (*exec.Cmd, error) {
195	executable := os.Getenv("ALBEDO_DAEMON")
196	if executable == "" {
197		if options.ProjectRoot == "" {
198			return nil, errors.New("set ALBEDO_DAEMON to its full executable path or ALBEDO_ROOT to a source checkout")
199		}
200		root, err := filepath.Abs(options.ProjectRoot)
201		if err != nil {
202			return nil, err
203		}
204		if _, err := exec.LookPath("gleam"); err != nil {
205			return nil, fmt.Errorf("source daemon requires gleam: %w", err)
206		}
207		executable = filepath.Join(root, "priv", "bin", "albedo-daemon")
208	}
209	executable, err := resolveDaemonExecutable(executable)
210	if err != nil {
211		return nil, err
212	}
213	cmd := exec.CommandContext(ctx, executable, args...)
214	for _, entry := range os.Environ() {
215		key, _, _ := strings.Cut(entry, "=")
216		switch key {
217		case "ALBEDO_API_KEY", "ALBEDO_MODEL", "ALBEDO_BASE_URL", "ALBEDO_PROTOCOL", "ALBEDO_HOME":
218		default:
219			cmd.Env = append(cmd.Env, entry)
220		}
221	}
222	cmd.Env = append(cmd.Env, "ALBEDO_HOME="+options.HomeDir)
223	return cmd, nil
224}
225
226func waitForPoll(ctx context.Context) error {
227	timer := time.NewTimer(pollInterval)
228	defer timer.Stop()
229	select {
230	case <-ctx.Done():
231		return ctx.Err()
232	case <-timer.C:
233		return nil
234	}
235}
236
237func acquireLauncher(ctx context.Context, homeDir string) (*os.File, error) {
238	// The launcher must create its lock directory before the daemon can start.
239	if err := os.MkdirAll(homeDir, 0700); err != nil {
240		return nil, err
241	}
242	file, err := os.OpenFile(filepath.Join(homeDir, "launcher.lock"), os.O_CREATE|os.O_RDWR, 0600)
243	if err != nil {
244		return nil, err
245	}
246	for {
247		if err := ctx.Err(); err != nil {
248			file.Close()
249			return nil, err
250		}
251		acquired, err := tryLauncherLock(file)
252		if err != nil {
253			file.Close()
254			return nil, err
255		}
256		if acquired {
257			return file, nil
258		}
259		if err := waitForPoll(ctx); err != nil {
260			file.Close()
261			return nil, err
262		}
263	}
264}
265
266// Launch serializes local startup without weakening the daemon's home lock.
267func Launch(parent context.Context, options LocalOptions) (*daemon.Connection, error) {
268	return launchLocal(parent, options, nil)
269}
270
271// Upgrade stops only the exact authenticated protocol 3 daemon approved by the caller.
272func Upgrade(parent context.Context, options LocalOptions, approved daemon.ConnectionSnapshot) (*daemon.Connection, error) {
273	return launchLocal(parent, options, &approved)
274}
275
276func launchLocal(parent context.Context, options LocalOptions, approved *daemon.ConnectionSnapshot) (*daemon.Connection, error) {
277	ctx, cancel := context.WithTimeout(parent, startupTimeout)
278	defer cancel()
279	if err := ctx.Err(); err != nil {
280		return nil, err
281	}
282	if options.HomeDir == "" {
283		return nil, errors.New("local launch requires HomeDir")
284	}
285	home, err := filepath.Abs(options.HomeDir)
286	if err != nil {
287		return nil, err
288	}
289	options.HomeDir = home
290	// Validate the replacement executable before taking any destructive action.
291	cmd, err := daemonCommand(options)
292	if err != nil {
293		return nil, err
294	}
295	lock, err := acquireLauncher(ctx, home)
296	if err != nil {
297		return nil, &LocalError{Kind: LauncherLockFailure, HomeDir: home, Cause: err}
298	}
299	defer lock.Close() // Closing releases the advisory lock; its inode persists.
300	discovery, discoveryErr := Discover(ctx, home)
301	if approved == nil {
302		if discoveryErr != nil {
303			return nil, discoveryErr
304		}
305		if discovery.Kind == Running {
306			return daemon.Attach(ctx, discovery.Snapshot, func(ctx context.Context) (daemon.ConnectionSnapshot, error) { return Rediscover(ctx, home) })
307		}
308	} else {
309		var compatible *daemon.CompatibilityError
310		if discoveryErr != nil && !errors.As(discoveryErr, &compatible) {
311			return nil, discoveryErr
312		}
313		if discovery.Kind != Running || discovery.Snapshot != *approved {
314			return nil, &LocalError{Kind: TargetChanged, HomeDir: home, Cause: errors.New("the daemon changed after approval; review the current daemon before restarting")}
315		}
316		if discovery.Server.Protocol != daemon.ProtocolVersion {
317			return nil, &daemon.CompatibilityError{Version: discovery.Server.Protocol}
318		}
319		connection := daemon.NewConnection(discovery.Snapshot, nil)
320		stopErr := daemon.StopDaemon(ctx, connection)
321		connection.HTTPClient().CloseIdleConnections()
322		if stopErr != nil {
323			return nil, stopErr
324		}
325		for processAlive(discovery.Snapshot.Pid) {
326			if err := waitForPoll(ctx); err != nil {
327				return nil, err
328			}
329		}
330		current, err := Discover(ctx, home)
331		if err != nil {
332			return nil, err
333		}
334		if current.Kind == Running {
335			return nil, &LocalError{Kind: TargetChanged, HomeDir: home, Cause: errors.New("another daemon started during shutdown")}
336		}
337	}
338	return startLocal(ctx, cmd, options)
339}
340
341func startLocal(ctx context.Context, cmd *exec.Cmd, options LocalOptions) (*daemon.Connection, error) {
342	logPath := filepath.Join(options.HomeDir, "daemon.log")
343	logFile, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0600)
344	if err != nil {
345		return nil, err
346	}
347	logStart, _ := logFile.Seek(0, io.SeekEnd)
348	cmd.Stdout, cmd.Stderr = logFile, logFile
349	detach(cmd)
350	passFileLimit()
351	if err := ctx.Err(); err != nil {
352		logFile.Close()
353		return nil, err
354	}
355	if err := cmd.Start(); err != nil {
356		logFile.Close()
357		return nil, err
358	}
359	logFile.Close()
360	exited := make(chan error, 1)
361	go func() { exited <- cmd.Wait() }()
362	contended := false
363	for {
364		discovery, err := Discover(ctx, options.HomeDir)
365		if err == nil && discovery.Kind == Running {
366			return daemon.Attach(ctx, discovery.Snapshot, func(ctx context.Context) (daemon.ConnectionSnapshot, error) { return Rediscover(ctx, options.HomeDir) })
367		}
368		if err != nil && ctx.Err() == nil {
369			var local *LocalError
370			var api *daemon.APIError
371			unreachable := errors.As(err, &local) && local.Kind == UnreachableDaemon
372			starting := errors.As(err, &api) && api.DaemonRestarting()
373			if !unreachable && !starting {
374				return nil, err
375			}
376		}
377		select {
378		case waitErr := <-exited:
379			exited = nil
380			var exit *exec.ExitError
381			if errors.As(waitErr, &exit) && exit.ExitCode() == 75 {
382				contended = true
383			} else {
384				return nil, &LocalError{Kind: StartupFailed, HomeDir: options.HomeDir, Cause: startupExitError(waitErr, logPath, logStart, options.ProjectRoot)}
385			}
386		case <-ctx.Done():
387			if contended && errors.Is(ctx.Err(), context.DeadlineExceeded) {
388				return nil, &LocalError{Kind: HomeInUse, HomeDir: options.HomeDir, Cause: fmt.Errorf("home is held by another daemon or maintenance command; wait for it to finish and retry: %w", ctx.Err())}
389			}
390			return nil, ctx.Err()
391		case <-time.After(pollInterval):
392		}
393	}
394}
395
396func startupExitError(waitErr error, logPath string, logStart int64, projectRoot string) error {
397	message := "daemon stopped before it finished starting"
398	if file, err := os.Open(logPath); err == nil {
399		defer file.Close()
400		if _, err := file.Seek(logStart, io.SeekStart); err == nil {
401			output, _ := io.ReadAll(io.LimitReader(file, 4096))
402			if tail := strings.TrimSpace(string(output)); tail != "" {
403				message += ":\n" + tail
404			}
405		}
406	}
407	if projectRoot != "" {
408		message += fmt.Sprintf("\nsource checkout: %s; check ALBEDO_ROOT", projectRoot)
409	}
410	if waitErr != nil {
411		return fmt.Errorf("%s: %w", message, waitErr)
412	}
413	return errors.New(message)
414}