From 17edde235111407fd2408229183f1a82cc8a91f6 Mon Sep 17 00:00:00 2001 From: "IM.codes" Date: Sat, 9 May 2026 09:36:46 +0800 Subject: [PATCH] fix(bind-flow): enable systemd user-linger after install MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Without `loginctl enable-linger`, systemd-logind tears down the per-user `systemd --user` instance after the last session ends, and imcodes goes down with it. Result: every server set up via `imcodes bind` "mysteriously" loses its daemon overnight, comes back when the operator next SSHes in (which restarts the user manager and auto-starts the unit), then drops again on the next disconnect. Caught on 212/213/215 (2026-05-09). All three were bound via `imcodes bind`. `setup-flow.ts.installSystemdService` already had the linger call (since 2026-04, line 415); `bind-flow.ts` was missing it — this commit copies the same try/catch pattern over. Pinned with a contract test that scans both source files for the `execSync(... loginctl enable-linger ...)` invocation. Source-content scan is the right tool here: a unit-test mock harness for a single execSync call would be heavier than the bug fix, and the failure mode ("daemon disappears overnight") is exactly the kind of silent regression that future refactors invite if the call gets removed without a tripwire. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/bind/bind-flow.ts | 21 ++++++++ test/util/systemd-linger-contract.test.ts | 62 +++++++++++++++++++++++ 2 files changed, 83 insertions(+) create mode 100644 test/util/systemd-linger-contract.test.ts diff --git a/src/bind/bind-flow.ts b/src/bind/bind-flow.ts index 72116235..c3ff2d72 100644 --- a/src/bind/bind-flow.ts +++ b/src/bind/bind-flow.ts @@ -348,6 +348,27 @@ WantedBy=default.target execSync('systemctl --user daemon-reload', { stdio: 'inherit' }); execSync('systemctl --user enable --now imcodes', { stdio: 'inherit' }); + + // Enable lingering so the service keeps running when the user logs out + // / SSH disconnects. Without this, systemd-logind tears down the + // per-user `systemd --user` instance after the last session ends, and + // imcodes goes down with it. Symptom in the wild: daemon "mysteriously + // disappears" overnight on every server bound via `imcodes bind` — + // exactly the 212/213/215 family of incidents on 2026-05-09. + // + // Best-effort: lingering requires polkit auth on some distros and may + // legitimately fail in rootless containers. Don't gate the rest of the + // bind flow on it — log a hint so the operator can run it themselves. + // `setup-flow.ts.installSystemdService` does the equivalent (line 415). + try { + execSync('loginctl enable-linger', { stdio: 'ignore' }); + console.log('Systemd user-linger enabled (daemon survives logout).'); + } catch { + console.log( + 'Note: could not enable systemd user-linger automatically. The daemon ' + + 'will stop when you log out unless you run: `loginctl enable-linger`', + ); + } console.log(`Systemd user service installed: ${servicePath}`); } diff --git a/test/util/systemd-linger-contract.test.ts b/test/util/systemd-linger-contract.test.ts new file mode 100644 index 00000000..b5aec6e7 --- /dev/null +++ b/test/util/systemd-linger-contract.test.ts @@ -0,0 +1,62 @@ +/** + * Contract test: every code path that installs the imcodes systemd + * `--user` service MUST also call `loginctl enable-linger`. + * + * Why: without lingering, systemd-logind tears down the per-user + * `systemd --user` instance after the last session ends, and any + * `--user` services (including imcodes) go down with it. Symptom in + * the wild: daemon "mysteriously disappears" overnight on every + * server set up by `imcodes bind` — exactly the 212/213/215 family of + * incidents on 2026-05-09 ("怎么又挂了"). + * + * `setup-flow.ts.installSystemdService` had this since 2026-04 (line + * 415); `bind-flow.ts.installSystemdService` was missing it, + * fingerprint-mapping every server installed via `imcodes bind` to + * the same recurring outage. Adding the line is one trivial edit, but + * the FAILURE MODE is "silent until the user is offline for a few + * hours" — exactly the kind of thing that regresses unnoticed if a + * future refactor removes the call. + * + * jsdom-style mock-the-world tests would need a heavy execSync mock + * harness for a single line. A source-content scan catches the + * regression with one regex per file — cheap, deterministic, no + * runtime dependencies on systemctl/loginctl actually existing. + */ +import { describe, expect, it } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +const REPO_ROOT = resolve(__dirname, '..', '..'); + +describe('systemd user-service install paths must enable lingering', () => { + // Both files install the imcodes.service unit at + // ~/.config/systemd/user/imcodes.service. After the install, both + // MUST call `loginctl enable-linger` so the daemon survives logout. + const targets = [ + 'src/setup/setup-flow.ts', + 'src/bind/bind-flow.ts', + ]; + + for (const rel of targets) { + it(`${rel} calls loginctl enable-linger`, () => { + const src = readFileSync(resolve(REPO_ROOT, rel), 'utf8'); + // Must reference loginctl enable-linger somewhere in the file. + // Allow both with and without an explicit user argument + // (`loginctl enable-linger` defaults to the calling user, and + // both call sites today rely on that default). + expect(src).toMatch(/loginctl\s+enable-linger\b/); + // And it must be passed to execSync (so it actually runs at + // install time — not just in a comment). + expect(src).toMatch(/execSync\([^)]*loginctl\s+enable-linger/); + }); + } + + it('the contract test itself names the failure mode (so future readers know why)', () => { + // Self-pin: if someone deletes the rationale comment, the test + // file no longer documents the failure mode and a future reader + // might "simplify" the install flow by removing the linger call. + const self = readFileSync(__filename, 'utf8'); + expect(self).toMatch(/systemd-logind/); + expect(self).toMatch(/lingering/i); + }); +});