mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-11 01:39:41 +02:00
A laptop that suspends freezes every agent session until it wakes. The new opt-in setting (App Settings > System > Power, off by default, "Only on AC power" on by default) makes the server hold an OS sleep lock for exactly as long as it runs: - Linux: a logind block lock on handle-lid-switch:sleep:idle through systemd-inhibit. The lid needs the low-level handle-lid-switch lock (LidSwitchIgnoreInhibited=yes ignores a plain sleep lock), and polkit grants it only while someone is logged in to the desktop, so a refusal is reported as "denied" and retried every minute. - macOS: caffeinate -i -s -w <pid>. Lid close needs pmset disablesleep, which an optional root helper (scripts/keep-awake-macos.sh, a LaunchDaemon run from a root-owned copy) applies while the server keeps a fresh request file, undoing only a disablesleep it set itself. The lock dies with the server: systemd-inhibit runs cat on a stdin pipe, so any exit (SIGKILL included, and under the unit's KillMode=process) releases it; caffeinate exits with the pid it watches. The installer asks on laptops (default no, never under --yes), and `install.sh keep-awake` turns it on for an existing install. Status is at GET /api/system/keep-awake; a non-admin's value is dropped in multi-user mode. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
271 lines
9.9 KiB
TypeScript
271 lines
9.9 KiB
TypeScript
/**
|
|
* @fileoverview KeepAwakeManager (src/keep-awake-manager.ts) driven through fake deps: no
|
|
* real lock is ever taken. Pins the lifecycle: a lock is confirmed before it reads as
|
|
* active, a polkit refusal is a retried state, AC-only follows the power source, a
|
|
* release closes the inhibitor's stdin (which is what drops the lock), and the macOS lid
|
|
* request file follows the lock only when the root helper is installed.
|
|
*
|
|
* Port: none.
|
|
*/
|
|
|
|
import { EventEmitter } from 'node:events';
|
|
import type { ChildProcess } from 'node:child_process';
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import { KeepAwakeManager, type KeepAwakeDeps } from '../src/keep-awake-manager.js';
|
|
import {
|
|
LINUX_HELD_MARKER,
|
|
POWER_POLL_MS,
|
|
RETRY_MS,
|
|
linuxInhibitArgs,
|
|
macCaffeinateArgs,
|
|
type PowerSupplyInfo,
|
|
} from '../src/keep-awake.js';
|
|
|
|
class FakeChild extends EventEmitter {
|
|
stdout = new EventEmitter();
|
|
stderr = new EventEmitter();
|
|
/** Like the real `cat`: closing stdin ends the process (asynchronously). */
|
|
stdin = Object.assign(new EventEmitter(), {
|
|
end: vi.fn(() => {
|
|
queueMicrotask(() => {
|
|
if (this.exitCode !== null || this.signalCode !== null) return;
|
|
this.exitCode = 0;
|
|
this.emit('exit', 0, null);
|
|
});
|
|
}),
|
|
});
|
|
exitCode: number | null = null;
|
|
signalCode: string | null = null;
|
|
kill = vi.fn((signal?: string) => {
|
|
this.signalCode = signal ?? 'SIGTERM';
|
|
this.emit('exit', null, this.signalCode);
|
|
return true;
|
|
});
|
|
/** The process announces the lock (systemd-inhibit only execs its child once held). */
|
|
hold() {
|
|
this.stdout.emit('data', Buffer.from(`${LINUX_HELD_MARKER}\n`));
|
|
}
|
|
fail(stderr: string, code = 1) {
|
|
this.stderr.emit('data', Buffer.from(stderr));
|
|
this.exitCode = code;
|
|
this.emit('exit', code, null);
|
|
}
|
|
}
|
|
|
|
const AC: PowerSupplyInfo[] = [
|
|
{ type: 'Mains', online: true },
|
|
{ type: 'Battery', status: 'Charging' },
|
|
];
|
|
const BATTERY: PowerSupplyInfo[] = [
|
|
{ type: 'Mains', online: false },
|
|
{ type: 'Battery', status: 'Discharging' },
|
|
];
|
|
|
|
/** Let queued reconciles (a promise chain) run to completion. */
|
|
const settle = async () => {
|
|
for (let i = 0; i < 10; i++) await new Promise((r) => setImmediate(r));
|
|
};
|
|
|
|
function makeDeps(overrides: Partial<KeepAwakeDeps> = {}) {
|
|
const children: FakeChild[] = [];
|
|
let supplies = AC;
|
|
let batt = "Now drawing from 'AC Power'";
|
|
const deps: KeepAwakeDeps = {
|
|
platform: 'linux',
|
|
serverPid: 4242,
|
|
spawn: vi.fn(() => {
|
|
const c = new FakeChild();
|
|
children.push(c);
|
|
return c as unknown as ChildProcess;
|
|
}),
|
|
readPowerSupplies: vi.fn(async () => supplies),
|
|
readMacBatt: vi.fn(async () => batt),
|
|
lidHelperInstalled: vi.fn(async () => true),
|
|
writeLidRequest: vi.fn(async () => {}),
|
|
removeLidRequest: vi.fn(async () => {}),
|
|
...overrides,
|
|
};
|
|
return {
|
|
deps,
|
|
children,
|
|
setSupplies: (s: PowerSupplyInfo[]) => (supplies = s),
|
|
setBatt: (s: string) => (batt = s),
|
|
};
|
|
}
|
|
|
|
describe('KeepAwakeManager on Linux', () => {
|
|
beforeEach(() => {
|
|
vi.useFakeTimers({ toFake: ['setTimeout', 'setInterval', 'clearTimeout', 'clearInterval'] });
|
|
});
|
|
afterEach(() => {
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it('takes the logind lock and reads active only once the lock is confirmed', async () => {
|
|
const { deps, children } = makeDeps();
|
|
const m = new KeepAwakeManager(deps);
|
|
await m.apply({ enabled: true, acOnly: true });
|
|
expect(deps.spawn).toHaveBeenCalledWith('systemd-inhibit', linuxInhibitArgs());
|
|
expect(m.getStatus().state).toBe('starting');
|
|
children[0].hold();
|
|
expect(m.getStatus()).toMatchObject({ state: 'active', onAc: true, platform: 'linux', lidHelper: null });
|
|
await m.stop();
|
|
});
|
|
|
|
it('reports a polkit refusal as denied and retries it', async () => {
|
|
const { deps, children } = makeDeps();
|
|
const m = new KeepAwakeManager(deps);
|
|
await m.apply({ enabled: true, acOnly: true });
|
|
children[0].fail('Failed to inhibit: Access denied\n');
|
|
expect(m.getStatus().state).toBe('denied');
|
|
expect(deps.spawn).toHaveBeenCalledTimes(1);
|
|
|
|
await vi.advanceTimersByTimeAsync(RETRY_MS);
|
|
await settle();
|
|
expect(deps.spawn).toHaveBeenCalledTimes(2);
|
|
children[1].hold();
|
|
expect(m.getStatus().state).toBe('active');
|
|
await m.stop();
|
|
});
|
|
|
|
it('turning it off closes the inhibitor stdin, which drops the lock, and does not retry', async () => {
|
|
const { deps, children } = makeDeps();
|
|
const m = new KeepAwakeManager(deps);
|
|
await m.apply({ enabled: true, acOnly: false });
|
|
children[0].hold();
|
|
await m.apply({ enabled: false, acOnly: false });
|
|
expect(children[0].stdin.end).toHaveBeenCalled();
|
|
expect(m.getStatus().state).toBe('off');
|
|
// The released child exiting is expected, not a failure to retry.
|
|
await settle();
|
|
await vi.advanceTimersByTimeAsync(RETRY_MS * 2);
|
|
await settle();
|
|
expect(deps.spawn).toHaveBeenCalledTimes(1);
|
|
expect(m.getStatus().state).toBe('off');
|
|
});
|
|
|
|
it('AC-only: no lock on battery, takes it when plugged in, releases on unplug', async () => {
|
|
const { deps, children, setSupplies } = makeDeps();
|
|
setSupplies(BATTERY);
|
|
const m = new KeepAwakeManager(deps);
|
|
await m.apply({ enabled: true, acOnly: true });
|
|
expect(deps.spawn).not.toHaveBeenCalled();
|
|
expect(m.getStatus()).toMatchObject({ state: 'paused-battery', onAc: false });
|
|
|
|
setSupplies(AC);
|
|
await vi.advanceTimersByTimeAsync(POWER_POLL_MS);
|
|
await settle();
|
|
expect(deps.spawn).toHaveBeenCalledTimes(1);
|
|
children[0].hold();
|
|
expect(m.getStatus().state).toBe('active');
|
|
|
|
setSupplies(BATTERY);
|
|
await vi.advanceTimersByTimeAsync(POWER_POLL_MS);
|
|
await settle();
|
|
expect(children[0].stdin.end).toHaveBeenCalled();
|
|
expect(m.getStatus().state).toBe('paused-battery');
|
|
await m.stop();
|
|
});
|
|
|
|
it('without AC-only it never reads the power source', async () => {
|
|
const { deps, setSupplies } = makeDeps();
|
|
setSupplies(BATTERY);
|
|
const m = new KeepAwakeManager(deps);
|
|
await m.apply({ enabled: true, acOnly: false });
|
|
expect(deps.readPowerSupplies).not.toHaveBeenCalled();
|
|
expect(deps.spawn).toHaveBeenCalledTimes(1);
|
|
await m.stop();
|
|
});
|
|
|
|
it('a machine without systemd-inhibit is unavailable, and is not retried', async () => {
|
|
const { deps } = makeDeps({
|
|
spawn: vi.fn(() => {
|
|
throw Object.assign(new Error('spawn systemd-inhibit ENOENT'), { code: 'ENOENT' });
|
|
}),
|
|
});
|
|
const m = new KeepAwakeManager(deps);
|
|
await m.apply({ enabled: true, acOnly: true });
|
|
expect(m.getStatus()).toMatchObject({ state: 'unavailable' });
|
|
expect(m.getStatus().detail).toMatch(/systemd-inhibit is not installed/);
|
|
await vi.advanceTimersByTimeAsync(RETRY_MS * 3);
|
|
await settle();
|
|
expect(deps.spawn).toHaveBeenCalledTimes(1);
|
|
await m.stop();
|
|
});
|
|
|
|
it('stop() releases the lock and leaves nothing scheduled', async () => {
|
|
const { deps, children } = makeDeps();
|
|
const m = new KeepAwakeManager(deps);
|
|
await m.apply({ enabled: true, acOnly: true });
|
|
children[0].hold();
|
|
await m.stop();
|
|
await settle();
|
|
expect(children[0].stdin.end).toHaveBeenCalled();
|
|
expect(children[0].kill).not.toHaveBeenCalled();
|
|
expect(m.getStatus().state).toBe('off');
|
|
expect(vi.getTimerCount()).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe('KeepAwakeManager on macOS', () => {
|
|
beforeEach(() => {
|
|
vi.useFakeTimers({ toFake: ['setTimeout', 'setInterval', 'clearTimeout', 'clearInterval'] });
|
|
});
|
|
afterEach(() => {
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it('runs caffeinate tied to the server pid and keeps the lid request fresh while held', async () => {
|
|
const { deps, children } = makeDeps({ platform: 'macos' });
|
|
const m = new KeepAwakeManager(deps);
|
|
await m.apply({ enabled: true, acOnly: true });
|
|
expect(deps.spawn).toHaveBeenCalledWith('/usr/bin/caffeinate', macCaffeinateArgs(4242));
|
|
children[0].emit('spawn');
|
|
expect(m.getStatus()).toMatchObject({ state: 'active', lidHelper: 'installed', onAc: true });
|
|
expect(deps.writeLidRequest).toHaveBeenCalledWith(4242);
|
|
|
|
await vi.advanceTimersByTimeAsync(30_000);
|
|
expect((deps.writeLidRequest as ReturnType<typeof vi.fn>).mock.calls.length).toBeGreaterThanOrEqual(2);
|
|
|
|
await m.apply({ enabled: false, acOnly: true });
|
|
expect(deps.removeLidRequest).toHaveBeenCalled();
|
|
expect(children[0].kill).toHaveBeenCalled();
|
|
const writes = (deps.writeLidRequest as ReturnType<typeof vi.fn>).mock.calls.length;
|
|
await vi.advanceTimersByTimeAsync(120_000);
|
|
expect((deps.writeLidRequest as ReturnType<typeof vi.fn>).mock.calls.length).toBe(writes);
|
|
});
|
|
|
|
it('writes no lid request when the root helper is not installed', async () => {
|
|
const { deps, children } = makeDeps({ platform: 'macos', lidHelperInstalled: vi.fn(async () => false) });
|
|
const m = new KeepAwakeManager(deps);
|
|
await m.apply({ enabled: true, acOnly: true });
|
|
children[0].emit('spawn');
|
|
expect(m.getStatus()).toMatchObject({ state: 'active', lidHelper: 'missing' });
|
|
expect(deps.writeLidRequest).not.toHaveBeenCalled();
|
|
await m.stop();
|
|
});
|
|
|
|
it('on battery with AC-only, drops both caffeinate and the lid request', async () => {
|
|
const { deps, children, setBatt } = makeDeps({ platform: 'macos' });
|
|
const m = new KeepAwakeManager(deps);
|
|
await m.apply({ enabled: true, acOnly: true });
|
|
children[0].emit('spawn');
|
|
setBatt("Now drawing from 'Battery Power'");
|
|
await vi.advanceTimersByTimeAsync(POWER_POLL_MS);
|
|
await settle();
|
|
expect(m.getStatus().state).toBe('paused-battery');
|
|
expect(children[0].kill).toHaveBeenCalled();
|
|
expect(deps.removeLidRequest).toHaveBeenCalled();
|
|
await m.stop();
|
|
});
|
|
});
|
|
|
|
describe('the process-wide manager under vitest', () => {
|
|
it('is inert: enabling it never spawns a real lock', async () => {
|
|
const m = new KeepAwakeManager();
|
|
await m.apply({ enabled: true, acOnly: false });
|
|
expect(m.getStatus()).toMatchObject({ state: 'unavailable', detail: 'Disabled under the test runner.' });
|
|
await m.stop();
|
|
});
|
|
});
|