Skip to content

Commit 591c22f

Browse files
committed
Linux service: enable lingering on install so it survives reboot
Realistic VM testing surfaced two gaps in the systemd --user backend: - The unit was enabled but lingering was never turned on, so after a reboot the daemon only came up once the user logged in (not at boot). install now runs `loginctl enable-linger`; uninstall disables it; status flags when lingering is off. Proven in an Ubuntu 24.04 VM: after a real reboot the service is active ~4s after boot (started by the user manager at boot, not by a login). - `systemctl --user` needs XDG_RUNTIME_DIR to reach the user bus; supply it when the caller's environment lacks it so install is robust from any shell.
1 parent 0f9306a commit 591c22f

1 file changed

Lines changed: 48 additions & 16 deletions

File tree

apps/cli/src/service.ts

Lines changed: 48 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,9 @@ import { resolveExecutorDataDir } from "./local-server-manifest";
1616
// `<executor> daemon run --foreground --port <p>`, bind loopback, write
1717
// `server.json`, and get restarted on crash but not on a clean stop.
1818
//
19-
// macOS is the fully-built, e2e-proven path. Linux is implemented best-effort
20-
// (systemctl is usually present but unverified here). Windows is a guided
21-
// scaffold that prints the exact commands rather than claiming registration we
22-
// cannot yet verify.
19+
// macOS (launchd) and Linux (systemd --user + lingering) are both reboot-
20+
// survival verified in real VMs. Windows is a guided scaffold that prints the
21+
// exact commands rather than claiming registration we cannot yet verify.
2322
// ---------------------------------------------------------------------------
2423

2524
export const SERVICE_LABEL = "sh.executor.daemon";
@@ -93,9 +92,11 @@ interface CommandResult {
9392
const runCommand = (
9493
cmd: string,
9594
args: ReadonlyArray<string>,
95+
env?: Record<string, string | undefined>,
9696
): Effect.Effect<CommandResult, Error> =>
9797
Effect.callback<CommandResult, Error>((resume) => {
98-
execFile(cmd, [...args], { encoding: "utf8" }, (error, stdout, stderr) => {
98+
const options = env ? { encoding: "utf8" as const, env: { ...process.env, ...env } } : { encoding: "utf8" as const };
99+
execFile(cmd, [...args], options, (error, stdout, stderr) => {
99100
// A string `code` (ENOENT etc.) means the command could not be spawned.
100101
if (error && typeof (error as { code?: unknown }).code === "string") {
101102
resume(
@@ -321,7 +322,7 @@ const makeLaunchdBackend = (): ServiceBackend => {
321322
};
322323

323324
// ---------------------------------------------------------------------------
324-
// Linux — systemd --user (best-effort, unverified on this machine)
325+
// Linux — systemd --user + lingering (reboot-survival verified in an Ubuntu VM)
325326
// ---------------------------------------------------------------------------
326327

327328
const systemdUnitDir = (path: Path.Path): string =>
@@ -384,44 +385,75 @@ const makeSystemdBackend = (): ServiceBackend => {
384385
stderrPath: path.join(logs, "daemon.error.log"),
385386
});
386387
yield* fs.writeFileString(systemdUnitPath(path), unit, { mode: 0o600 });
387-
yield* runCommand("systemctl", ["--user", "daemon-reload"]).pipe(Effect.ignore);
388-
const enable = yield* runCommand("systemctl", ["--user", "enable", "--now", unitName]);
388+
// `systemctl --user` needs XDG_RUNTIME_DIR to reach the user bus. Supply
389+
// it if the caller's environment lacks it (e.g. a non-login shell) so
390+
// install is robust regardless of how it was invoked.
391+
const username = userInfo().username;
392+
const sdEnv = { XDG_RUNTIME_DIR: process.env.XDG_RUNTIME_DIR ?? `/run/user/${currentUid()}` };
393+
yield* runCommand("systemctl", ["--user", "daemon-reload"], sdEnv).pipe(Effect.ignore);
394+
const enable = yield* runCommand(
395+
"systemctl",
396+
["--user", "enable", "--now", unitName],
397+
sdEnv,
398+
);
389399
if (enable.code !== 0) {
390400
return yield* Effect.fail(
391-
new Error(
392-
`systemctl --user enable failed (exit ${enable.code}): ${enable.stderr.trim()}\nNote: 'loginctl enable-linger ${userInfo().username}' may be required for the service to survive logout/reboot.`,
393-
),
401+
new Error(`systemctl --user enable failed (exit ${enable.code}): ${enable.stderr.trim()}`),
394402
);
395403
}
404+
// Enable lingering so the user manager — and this enabled service —
405+
// starts at BOOT, not just on login, so the daemon survives a reboot
406+
// unattended (verified in a real Ubuntu VM via loginctl). Best-effort:
407+
// if the platform needs privilege, the service still works for the
408+
// logged-in case and `service status` flags the missing linger.
409+
yield* runCommand("loginctl", ["enable-linger", username], sdEnv).pipe(Effect.ignore);
396410
}),
397411
uninstall: () =>
398412
Effect.gen(function* () {
399413
const fs = yield* FileSystem.FileSystem;
400414
const path = yield* Path.Path;
401-
yield* runCommand("systemctl", ["--user", "disable", "--now", unitName]).pipe(
415+
const sdEnv = { XDG_RUNTIME_DIR: process.env.XDG_RUNTIME_DIR ?? `/run/user/${currentUid()}` };
416+
yield* runCommand("systemctl", ["--user", "disable", "--now", unitName], sdEnv).pipe(
402417
Effect.ignore,
403418
);
404419
yield* fs.remove(systemdUnitPath(path), { force: true });
405-
yield* runCommand("systemctl", ["--user", "daemon-reload"]).pipe(Effect.ignore);
420+
yield* runCommand("systemctl", ["--user", "daemon-reload"], sdEnv).pipe(Effect.ignore);
421+
yield* runCommand("loginctl", ["disable-linger", userInfo().username], sdEnv).pipe(
422+
Effect.ignore,
423+
);
406424
}),
407425
status: () =>
408426
Effect.gen(function* () {
409427
const fs = yield* FileSystem.FileSystem;
410428
const path = yield* Path.Path;
429+
const sdEnv = { XDG_RUNTIME_DIR: process.env.XDG_RUNTIME_DIR ?? `/run/user/${currentUid()}` };
411430
const registered = yield* fs.exists(systemdUnitPath(path));
412-
const active = yield* runCommand("systemctl", ["--user", "is-active", unitName]);
431+
const active = yield* runCommand("systemctl", ["--user", "is-active", unitName], sdEnv);
413432
const running = active.stdout.trim() === "active";
433+
const linger = yield* runCommand("loginctl", [
434+
"show-user",
435+
userInfo().username,
436+
"-p",
437+
"Linger",
438+
"--value",
439+
]);
440+
const lingerOn = linger.stdout.trim() === "yes";
414441
return {
415442
platform: "linux" as const,
416443
registered,
417444
running,
418445
pid: null,
419-
detail: ["Linux support is best-effort and unverified; report issues."],
446+
detail: lingerOn
447+
? []
448+
: [
449+
"Lingering is off — the daemon won't start until you log in. Run `loginctl enable-linger`.",
450+
],
420451
};
421452
}),
422453
restart: () =>
423454
Effect.gen(function* () {
424-
const result = yield* runCommand("systemctl", ["--user", "restart", unitName]);
455+
const sdEnv = { XDG_RUNTIME_DIR: process.env.XDG_RUNTIME_DIR ?? `/run/user/${currentUid()}` };
456+
const result = yield* runCommand("systemctl", ["--user", "restart", unitName], sdEnv);
425457
if (result.code !== 0) {
426458
return yield* Effect.fail(
427459
new Error(

0 commit comments

Comments
 (0)