mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-14 03:09:44 +02:00
feat(power): keep the machine awake while Codeman runs, lid close included
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>
This commit is contained in:
@@ -0,0 +1,207 @@
|
||||
/**
|
||||
* @fileoverview install.sh's keep-awake follow-up, driven in a real bash where it can be
|
||||
* (laptop detection over a fake sysfs, the settings.json write, the --yes default) and
|
||||
* pinned statically where it cannot (the macOS root helper needs sudo and launchd).
|
||||
*
|
||||
* The rules: never turned on by --yes or a headless run; the setting is written before
|
||||
* the service starts; the root helper runs from a ROOT-OWNED copy, never from the
|
||||
* user-writable install dir; uninstall undoes only what the helper set.
|
||||
*
|
||||
* Port: none.
|
||||
*/
|
||||
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
const INSTALL_SH = fileURLToPath(new URL('../install.sh', import.meta.url));
|
||||
const SOURCE = readFileSync(INSTALL_SH, 'utf-8');
|
||||
|
||||
let dir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), 'install-keep-awake-'));
|
||||
});
|
||||
afterEach(() => {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
/** Source install.sh (library mode) in a real bash with HOME in the temp dir, then run `body`. */
|
||||
function drive(body: string, env: Record<string, string> = {}) {
|
||||
const script = `
|
||||
set -euo pipefail
|
||||
export CODEMAN_INSTALL_SH_LIB=1
|
||||
. "$1"
|
||||
${body}
|
||||
`;
|
||||
const result = spawnSync('bash', ['-c', script, 'bash', INSTALL_SH], {
|
||||
encoding: 'utf-8',
|
||||
timeout: 30_000,
|
||||
input: '',
|
||||
env: { ...process.env, HOME: join(dir, 'home'), ...env },
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
function fakeSupply(name: string, files: Record<string, string>) {
|
||||
const d = join(dir, 'ps', name);
|
||||
mkdirSync(d, { recursive: true });
|
||||
for (const [k, v] of Object.entries(files)) writeFileSync(join(d, k), `${v}\n`);
|
||||
}
|
||||
|
||||
const laptopCheck = `
|
||||
POWER_SUPPLY_ROOT="${'$'}FAKE_PS"; LID_BUTTON_ROOT="${'$'}FAKE_LID"
|
||||
if is_laptop linux; then echo laptop; else echo desktop; fi
|
||||
`;
|
||||
|
||||
describe('is_laptop (Linux)', () => {
|
||||
const run = () => drive(laptopCheck, { FAKE_PS: join(dir, 'ps'), FAKE_LID: join(dir, 'lid') }).stdout.trim();
|
||||
|
||||
it('a machine with no battery and no lid is not a laptop', () => {
|
||||
mkdirSync(join(dir, 'ps'), { recursive: true });
|
||||
fakeSupply('AC', { type: 'Mains', online: '1' });
|
||||
expect(run()).toBe('desktop');
|
||||
});
|
||||
|
||||
it("a wireless mouse's battery does not make a desktop a laptop", () => {
|
||||
fakeSupply('hidpp_battery_0', { type: 'Battery', scope: 'Device', status: 'Discharging' });
|
||||
expect(run()).toBe('desktop');
|
||||
});
|
||||
|
||||
it('a system battery or a lid does', () => {
|
||||
fakeSupply('BAT0', { type: 'Battery', status: 'Charging' });
|
||||
expect(run()).toBe('laptop');
|
||||
rmSync(join(dir, 'ps'), { recursive: true });
|
||||
mkdirSync(join(dir, 'lid', 'LID0'), { recursive: true });
|
||||
expect(run()).toBe('laptop');
|
||||
});
|
||||
|
||||
it('copes with a missing power_supply tree', () => {
|
||||
expect(run()).toBe('desktop');
|
||||
});
|
||||
});
|
||||
|
||||
describe('write_keep_awake_setting', () => {
|
||||
const settings = () => join(dir, 'home', '.codeman', 'settings.json');
|
||||
|
||||
it('creates settings.json with the setting on and AC-only on', () => {
|
||||
const r = drive('write_keep_awake_setting');
|
||||
expect(r.status, r.stderr).toBe(0);
|
||||
expect(JSON.parse(readFileSync(settings(), 'utf-8'))).toEqual({ keepAwakeEnabled: true, keepAwakeAcOnly: true });
|
||||
});
|
||||
|
||||
it('keeps every other key, and an explicit AC-only choice', () => {
|
||||
mkdirSync(join(dir, 'home', '.codeman'), { recursive: true });
|
||||
writeFileSync(settings(), JSON.stringify({ theme: 'dark', keepAwakeAcOnly: false }, null, 2));
|
||||
expect(drive('write_keep_awake_setting').status).toBe(0);
|
||||
expect(JSON.parse(readFileSync(settings(), 'utf-8'))).toEqual({
|
||||
theme: 'dark',
|
||||
keepAwakeAcOnly: false,
|
||||
keepAwakeEnabled: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('leaves a settings file that does not parse untouched', () => {
|
||||
mkdirSync(join(dir, 'home', '.codeman'), { recursive: true });
|
||||
writeFileSync(settings(), '{ not json');
|
||||
const r = drive('write_keep_awake_setting || echo refused');
|
||||
expect(r.stdout).toContain('refused');
|
||||
expect(readFileSync(settings(), 'utf-8')).toBe('{ not json');
|
||||
});
|
||||
|
||||
it('is what keep_awake_enabled_now reads back', () => {
|
||||
const r = drive(
|
||||
'keep_awake_enabled_now && echo before; write_keep_awake_setting >/dev/null 2>&1; keep_awake_enabled_now && echo after'
|
||||
);
|
||||
expect(r.stdout.trim()).toBe('after');
|
||||
});
|
||||
});
|
||||
|
||||
describe('choose_keep_awake', () => {
|
||||
it('--yes on a laptop never turns it on', () => {
|
||||
fakeSupply('BAT0', { type: 'Battery', status: 'Charging' });
|
||||
const r = drive(
|
||||
`POWER_SUPPLY_ROOT="$FAKE_PS"; ASSUME_YES=1
|
||||
choose_keep_awake linux
|
||||
echo "keep=[$KEEP_AWAKE] helper=[$KEEP_AWAKE_LID_HELPER]"`,
|
||||
{ FAKE_PS: join(dir, 'ps') }
|
||||
);
|
||||
expect(r.status, r.stderr).toBe(0);
|
||||
expect(r.stdout).toContain('keep=[] helper=[]');
|
||||
});
|
||||
|
||||
it('asks nothing on a desktop', () => {
|
||||
const r = drive(
|
||||
`POWER_SUPPLY_ROOT="$FAKE_PS"; LID_BUTTON_ROOT="$FAKE_PS"
|
||||
choose_keep_awake linux 2>&1
|
||||
echo "keep=[$KEEP_AWAKE]"`,
|
||||
{ FAKE_PS: join(dir, 'nothing') }
|
||||
);
|
||||
expect(r.stdout.trim()).toBe('keep=[]');
|
||||
});
|
||||
|
||||
it('only notes an existing setting, without re-asking', () => {
|
||||
mkdirSync(join(dir, 'home', '.codeman'), { recursive: true });
|
||||
writeFileSync(join(dir, 'home', '.codeman', 'settings.json'), JSON.stringify({ keepAwakeEnabled: true }, null, 2));
|
||||
const r = drive(`choose_keep_awake linux force 2>&1; echo "keep=[$KEEP_AWAKE]"`);
|
||||
expect(r.stdout).toContain('already on');
|
||||
expect(r.stdout).toContain('keep=[]');
|
||||
});
|
||||
|
||||
it('defaults to no in the prompt itself', () => {
|
||||
const fn = SOURCE.slice(SOURCE.indexOf('choose_keep_awake() {'), SOURCE.indexOf('write_keep_awake_setting() {'));
|
||||
expect(fn).toMatch(/prompt_yes_no "Keep this machine awake[^"]*" "n"/);
|
||||
// The sudo-needing helper is offered only to a person at a terminal.
|
||||
expect(fn).toMatch(/"\$NONINTERACTIVE" != "1" && "\$ASSUME_YES" != "1" \]\] && has_tty/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('install.sh keep-awake wiring', () => {
|
||||
const fn = (name: string, next: string) => SOURCE.slice(SOURCE.indexOf(`${name}() {`), SOURCE.indexOf(next));
|
||||
|
||||
it('asks after question 3 and applies before the service starts', () => {
|
||||
const main = fn('main', '\npreflight_detect() {');
|
||||
const ask = main.indexOf('choose_keep_awake "$os"');
|
||||
const apply = main.indexOf('apply_keep_awake');
|
||||
expect(ask).toBeGreaterThan(main.indexOf('choose_launch_mode "$os"'));
|
||||
expect(ask).toBeLessThan(main.indexOf('install_or_update_repo'));
|
||||
expect(apply).toBeGreaterThan(main.indexOf('run_step "Building Codeman"'));
|
||||
expect(apply).toBeLessThan(main.indexOf('setup_launchd_service'));
|
||||
expect(apply).toBeLessThan(main.indexOf('setup_systemd_service'));
|
||||
});
|
||||
|
||||
it('runs the root helper from a root-owned copy, never from the install dir', () => {
|
||||
const body = fn('install_keep_awake_lid_helper', '\nremove_keep_awake_lid_helper() {');
|
||||
expect(body).toContain('install -m 755 -o root -g wheel "$src" "$script"');
|
||||
expect(body).toContain('local script="$KEEP_AWAKE_HELPER_DIR/keep-awake-macos.sh"');
|
||||
const programArgs = body.slice(body.indexOf('<key>ProgramArguments</key>'), body.indexOf('</array>'));
|
||||
expect(programArgs).toContain('$(xml_escape "$script")');
|
||||
expect(programArgs).not.toContain('INSTALL_DIR');
|
||||
// The request path must match what the server writes (dataPath('keep-awake-lid.pid')).
|
||||
expect(body).toContain('local request="$HOME/.codeman/keep-awake-lid.pid"');
|
||||
});
|
||||
|
||||
it('uninstall removes the helper and undoes only a disablesleep it set', () => {
|
||||
expect(fn('uninstall', '\nusage() {')).toContain('remove_keep_awake_lid_helper');
|
||||
const remove = fn('remove_keep_awake_lid_helper', '\napply_keep_awake() {');
|
||||
const owned = remove.indexOf('keep-awake.owned');
|
||||
expect(owned).toBeGreaterThan(-1);
|
||||
expect(remove.indexOf('pmset -a disablesleep 0')).toBeGreaterThan(owned);
|
||||
});
|
||||
|
||||
it('dispatches the keep-awake subcommand and documents it', () => {
|
||||
expect(SOURCE).toMatch(/update\|uninstall\|tailscale\|name\|status\|cloudflared\|keep-awake\)/);
|
||||
expect(SOURCE).toMatch(/\n {4}keep-awake\) {2}keep_awake_subcommand ;;/);
|
||||
const header = SOURCE.slice(0, SOURCE.indexOf('set -euo pipefail'));
|
||||
expect(header).toContain('install.sh keep-awake');
|
||||
expect(fn('usage', '\nparse_flags() {')).toContain('keep-awake');
|
||||
});
|
||||
|
||||
it('update and keep-awake share one restart path', () => {
|
||||
expect(fn('update', '\nuninstall() {')).toContain('restart_running_service');
|
||||
expect(fn('keep_awake_subcommand', '\nverify_systemd_active() {')).toContain('restart_running_service');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,175 @@
|
||||
/**
|
||||
* @fileoverview Runs the real macOS lid helper (scripts/keep-awake-macos.sh) in bash
|
||||
* against a stub `pmset` that records what it was asked to do. The helper runs as root
|
||||
* from a LaunchDaemon, so the cases that matter are the ones where it must NOT act: a
|
||||
* stale, dead, foreign or symlinked request, and an administrator's own
|
||||
* `disablesleep 1`, which it must never switch off.
|
||||
*
|
||||
* Only the BSD/GNU-portable tools the script uses (find -mmin, ps -o uid=, ls -ln) run
|
||||
* here, so this passes on Linux and macOS alike. Port: none.
|
||||
*/
|
||||
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import {
|
||||
chmodSync,
|
||||
existsSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
symlinkSync,
|
||||
utimesSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
const HELPER = fileURLToPath(new URL('../scripts/keep-awake-macos.sh', import.meta.url));
|
||||
|
||||
let dir: string;
|
||||
let stateDir: string;
|
||||
let request: string;
|
||||
|
||||
/** Stub pmset: `-g` prints the stored SleepDisabled, `-a disablesleep N` stores N and logs it. */
|
||||
function writeStubPmset(initial: '0' | '1' | null) {
|
||||
const value = join(dir, 'sleepdisabled');
|
||||
if (initial !== null) writeFileSync(value, initial);
|
||||
const stub = join(dir, 'pmset');
|
||||
writeFileSync(
|
||||
stub,
|
||||
`#!/bin/bash
|
||||
if [ "$1" = "-g" ]; then
|
||||
echo "System-wide power settings:"
|
||||
[ -f "${value}" ] && printf ' SleepDisabled\\t\\t%s\\n' "$(cat "${value}")"
|
||||
echo "Currently in use:"
|
||||
echo " sleep 1"
|
||||
exit 0
|
||||
fi
|
||||
if [ "$1" = "-a" ] && [ "$2" = "disablesleep" ]; then
|
||||
printf '%s' "$3" > "${value}"
|
||||
echo "disablesleep $3" >> "${join(dir, 'pmset.log')}"
|
||||
exit 0
|
||||
fi
|
||||
exit 1
|
||||
`
|
||||
);
|
||||
chmodSync(stub, 0o755);
|
||||
return stub;
|
||||
}
|
||||
|
||||
function run() {
|
||||
const result = spawnSync('bash', [HELPER, request], {
|
||||
encoding: 'utf-8',
|
||||
env: {
|
||||
...process.env,
|
||||
CODEMAN_KEEPAWAKE_PMSET: join(dir, 'pmset'),
|
||||
CODEMAN_KEEPAWAKE_STATE_DIR: stateDir,
|
||||
},
|
||||
});
|
||||
expect(result.status, result.stderr).toBe(0);
|
||||
}
|
||||
|
||||
const calls = () => (existsSync(join(dir, 'pmset.log')) ? readFileSync(join(dir, 'pmset.log'), 'utf-8') : '');
|
||||
const sleepDisabled = () => readFileSync(join(dir, 'sleepdisabled'), 'utf-8');
|
||||
const owned = () => existsSync(join(stateDir, 'keep-awake.owned'));
|
||||
const requestFromLiveServer = () => writeFileSync(request, `${process.pid}\n`);
|
||||
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), 'keep-awake-helper-'));
|
||||
stateDir = join(dir, 'state');
|
||||
request = join(dir, 'keep-awake-lid.pid');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('keep-awake-macos.sh', () => {
|
||||
it('does nothing without a request', () => {
|
||||
writeStubPmset('0');
|
||||
run();
|
||||
expect(calls()).toBe('');
|
||||
expect(owned()).toBe(false);
|
||||
});
|
||||
|
||||
it('applies disablesleep for a fresh request from a live process, then undoes it when the request goes', () => {
|
||||
writeStubPmset('0');
|
||||
requestFromLiveServer();
|
||||
run();
|
||||
expect(sleepDisabled()).toBe('1');
|
||||
expect(owned()).toBe(true);
|
||||
|
||||
run(); // steady state: no repeated pmset call
|
||||
expect(calls()).toBe('disablesleep 1\n');
|
||||
|
||||
rmSync(request);
|
||||
run();
|
||||
expect(sleepDisabled()).toBe('0');
|
||||
expect(owned()).toBe(false);
|
||||
expect(calls()).toBe('disablesleep 1\ndisablesleep 0\n');
|
||||
});
|
||||
|
||||
it("never switches off an administrator's own disablesleep", () => {
|
||||
writeStubPmset('1');
|
||||
requestFromLiveServer();
|
||||
run();
|
||||
expect(calls()).toBe('');
|
||||
expect(owned()).toBe(false);
|
||||
rmSync(request);
|
||||
run();
|
||||
expect(sleepDisabled()).toBe('1');
|
||||
expect(calls()).toBe('');
|
||||
});
|
||||
|
||||
it('treats an absent SleepDisabled line as 0', () => {
|
||||
writeStubPmset(null);
|
||||
requestFromLiveServer();
|
||||
run();
|
||||
expect(sleepDisabled()).toBe('1');
|
||||
expect(owned()).toBe(true);
|
||||
});
|
||||
|
||||
it('re-applies after a reboot or OS update reset it, while the request stands', () => {
|
||||
writeStubPmset('0');
|
||||
requestFromLiveServer();
|
||||
run();
|
||||
writeFileSync(join(dir, 'sleepdisabled'), '0'); // the reset
|
||||
run();
|
||||
expect(sleepDisabled()).toBe('1');
|
||||
});
|
||||
|
||||
it('ignores a stale request (a crashed or hung server) and releases', () => {
|
||||
writeStubPmset('0');
|
||||
requestFromLiveServer();
|
||||
run();
|
||||
expect(sleepDisabled()).toBe('1');
|
||||
const threeMinutesAgo = (Date.now() - 3 * 60_000) / 1000;
|
||||
utimesSync(request, threeMinutesAgo, threeMinutesAgo);
|
||||
run();
|
||||
expect(sleepDisabled()).toBe('0');
|
||||
expect(owned()).toBe(false);
|
||||
});
|
||||
|
||||
it('ignores a request whose pid is not running', () => {
|
||||
writeStubPmset('0');
|
||||
const gone = spawnSync('true').pid;
|
||||
writeFileSync(request, `${gone}\n`);
|
||||
run();
|
||||
expect(calls()).toBe('');
|
||||
});
|
||||
|
||||
it('ignores garbage and refuses a symlinked request', () => {
|
||||
writeStubPmset('0');
|
||||
writeFileSync(request, 'not-a-pid\n');
|
||||
run();
|
||||
expect(calls()).toBe('');
|
||||
|
||||
rmSync(request);
|
||||
const real = join(dir, 'elsewhere.pid');
|
||||
writeFileSync(real, `${process.pid}\n`);
|
||||
symlinkSync(real, request);
|
||||
run();
|
||||
expect(calls()).toBe('');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,270 @@
|
||||
/**
|
||||
* @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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* @fileoverview Pure decisions behind "Keep this computer awake while Codeman runs"
|
||||
* (src/keep-awake.ts): settings defaults, platform choice, power-source parsing, the
|
||||
* hold/release rule, the lock argv and the failure classification.
|
||||
*
|
||||
* Port: none (pure).
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
LINUX_HELD_MARKER,
|
||||
LINUX_INHIBIT_WHAT,
|
||||
classifyInhibitFailure,
|
||||
isOnAcPowerLinux,
|
||||
isOnAcPowerMac,
|
||||
keepAwakePlatform,
|
||||
linuxInhibitArgs,
|
||||
macCaffeinateArgs,
|
||||
resolveKeepAwakeConfig,
|
||||
shouldHoldLock,
|
||||
} from '../src/keep-awake.js';
|
||||
|
||||
describe('resolveKeepAwakeConfig', () => {
|
||||
it('is off unless explicitly enabled, and AC-only unless explicitly not', () => {
|
||||
expect(resolveKeepAwakeConfig({})).toEqual({ enabled: false, acOnly: true });
|
||||
expect(resolveKeepAwakeConfig({ keepAwakeEnabled: 'yes' })).toEqual({ enabled: false, acOnly: true });
|
||||
expect(resolveKeepAwakeConfig({ keepAwakeEnabled: true })).toEqual({ enabled: true, acOnly: true });
|
||||
expect(resolveKeepAwakeConfig({ keepAwakeEnabled: true, keepAwakeAcOnly: false })).toEqual({
|
||||
enabled: true,
|
||||
acOnly: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('keepAwakePlatform', () => {
|
||||
it('maps darwin and linux, and treats WSL as unsupported', () => {
|
||||
expect(keepAwakePlatform('darwin', '24.0.0')).toBe('macos');
|
||||
expect(keepAwakePlatform('linux', '6.8.0-124-generic')).toBe('linux');
|
||||
expect(keepAwakePlatform('linux', '5.15.153.1-microsoft-standard-WSL2')).toBe('unsupported');
|
||||
expect(keepAwakePlatform('win32', '10.0.22631')).toBe('unsupported');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isOnAcPowerLinux', () => {
|
||||
const battery = (status: string) => ({ type: 'Battery', status });
|
||||
it('a machine with no battery of its own is on external power', () => {
|
||||
expect(isOnAcPowerLinux([])).toBe(true);
|
||||
expect(isOnAcPowerLinux([{ type: 'Battery', status: 'Discharging', scope: 'Device' }])).toBe(true);
|
||||
});
|
||||
it('an online adapter means AC, all adapters offline means battery', () => {
|
||||
expect(isOnAcPowerLinux([{ type: 'Mains', online: true }, battery('Charging')])).toBe(true);
|
||||
expect(isOnAcPowerLinux([{ type: 'USB', online: true }, battery('Unknown')])).toBe(true);
|
||||
expect(isOnAcPowerLinux([{ type: 'Mains', online: false }, battery('Unknown')])).toBe(false);
|
||||
});
|
||||
it('falls back to the battery status when no adapter is listed', () => {
|
||||
expect(isOnAcPowerLinux([battery('Discharging')])).toBe(false);
|
||||
expect(isOnAcPowerLinux([battery('Full')])).toBe(true);
|
||||
expect(isOnAcPowerLinux([battery('Not charging')])).toBe(true);
|
||||
expect(isOnAcPowerLinux([battery('Unknown')])).toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isOnAcPowerMac', () => {
|
||||
it('reads the source from `pmset -g batt`', () => {
|
||||
expect(isOnAcPowerMac("Now drawing from 'AC Power'\n -InternalBattery-0 (id=1)\t100%; charged;")).toBe(true);
|
||||
expect(isOnAcPowerMac("Now drawing from 'Battery Power'\n -InternalBattery-0 (id=1)\t80%;")).toBe(false);
|
||||
expect(isOnAcPowerMac("Now drawing from 'UPS Power'")).toBe(false);
|
||||
expect(isOnAcPowerMac('')).toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('shouldHoldLock', () => {
|
||||
it('holds only when enabled, and on battery only when not AC-only', () => {
|
||||
expect(shouldHoldLock({ enabled: false, acOnly: false }, true)).toBe(false);
|
||||
expect(shouldHoldLock({ enabled: true, acOnly: true }, true)).toBe(true);
|
||||
expect(shouldHoldLock({ enabled: true, acOnly: true }, false)).toBe(false);
|
||||
expect(shouldHoldLock({ enabled: true, acOnly: false }, false)).toBe(true);
|
||||
});
|
||||
it('treats an unknown power source as AC (a desktop without battery reporting)', () => {
|
||||
expect(shouldHoldLock({ enabled: true, acOnly: true }, null)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('lock argv', () => {
|
||||
it('asks logind for the lid lock, not just sleep (LidSwitchIgnoreInhibited=yes ignores sleep)', () => {
|
||||
const args = linuxInhibitArgs();
|
||||
expect(LINUX_INHIBIT_WHAT.split(':')).toContain('handle-lid-switch');
|
||||
expect(args).toContain(`--what=${LINUX_INHIBIT_WHAT}`);
|
||||
expect(args).toContain('--mode=block');
|
||||
});
|
||||
it('ends in a cat on stdin, so the lock dies with the server', () => {
|
||||
const args = linuxInhibitArgs();
|
||||
const script = args[args.length - 1];
|
||||
expect(args.slice(-3, -1)).toEqual(['/bin/sh', '-c']);
|
||||
expect(script).toBe(`echo ${LINUX_HELD_MARKER}; exec cat`);
|
||||
expect(script).not.toMatch(/sleep/);
|
||||
});
|
||||
it('ties caffeinate to the server pid', () => {
|
||||
expect(macCaffeinateArgs(4242)).toEqual(['-i', '-s', '-w', '4242']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('classifyInhibitFailure', () => {
|
||||
it('reads a polkit refusal as denied', () => {
|
||||
expect(classifyInhibitFailure('Failed to inhibit: Access denied\n').state).toBe('denied');
|
||||
expect(classifyInhibitFailure('Interactive authentication required.').state).toBe('denied');
|
||||
});
|
||||
it('reads a missing logind as unavailable', () => {
|
||||
expect(classifyInhibitFailure('Failed to connect to bus: No such file or directory').state).toBe('unavailable');
|
||||
});
|
||||
it('keeps the first line of anything else', () => {
|
||||
const r = classifyInhibitFailure('something odd\nmore');
|
||||
expect(r).toEqual({ state: 'failed', detail: 'something odd' });
|
||||
expect(classifyInhibitFailure('').detail).toBe('the inhibitor exited');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* @fileoverview Keep-awake wiring in system-routes: PUT /api/settings reconciles the sleep
|
||||
* lock from the MERGED settings (a partial body never reads as "turn it off"), a
|
||||
* non-admin's value is dropped in multi-user mode (it is machine state), and
|
||||
* GET /api/system/keep-awake returns the manager's status in the standard envelope.
|
||||
*
|
||||
* Uses app.inject(); the manager is mocked, so no real lock is taken. Port: N/A.
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { createRouteTestHarness, type RouteTestHarness } from './_route-test-utils.js';
|
||||
import { registerSystemRoutes } from '../../src/web/routes/system-routes.js';
|
||||
|
||||
const { store, keepAwake } = vi.hoisted(() => ({
|
||||
store: { settings: {} as Record<string, unknown>, written: [] as Record<string, unknown>[] },
|
||||
keepAwake: {
|
||||
apply: vi.fn(async () => {}),
|
||||
getStatus: vi.fn(() => ({
|
||||
enabled: true,
|
||||
acOnly: true,
|
||||
platform: 'linux',
|
||||
state: 'active',
|
||||
onAc: true,
|
||||
lidHelper: null,
|
||||
detail: null,
|
||||
})),
|
||||
stop: vi.fn(async () => {}),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('node:fs/promises', () => ({
|
||||
default: {
|
||||
readFile: vi.fn(async () => JSON.stringify(store.settings)),
|
||||
writeFile: vi.fn(async (_path: string, data: string) => {
|
||||
store.written.push(JSON.parse(data));
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('node:fs', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('node:fs')>();
|
||||
return { ...actual, existsSync: vi.fn(() => true), mkdirSync: vi.fn(), readdirSync: vi.fn(() => []) };
|
||||
});
|
||||
|
||||
vi.mock('../../src/keep-awake-manager.js', () => ({ keepAwake }));
|
||||
|
||||
describe('keep-awake in system routes', () => {
|
||||
let harness: RouteTestHarness;
|
||||
|
||||
beforeEach(() => {
|
||||
store.settings = {};
|
||||
store.written = [];
|
||||
keepAwake.apply.mockClear();
|
||||
delete process.env.CODEMAN_MULTIUSER;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
delete process.env.CODEMAN_MULTIUSER;
|
||||
await harness?.app.close();
|
||||
});
|
||||
|
||||
it('a partial PUT keeps the persisted keep-awake on', async () => {
|
||||
harness = await createRouteTestHarness(registerSystemRoutes);
|
||||
store.settings = { keepAwakeEnabled: true, keepAwakeAcOnly: false };
|
||||
const res = await harness.app.inject({ method: 'PUT', url: '/api/settings', payload: { showMonitor: true } });
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(keepAwake.apply).toHaveBeenCalledWith({ enabled: true, acOnly: false });
|
||||
});
|
||||
|
||||
it('an explicit PUT turns it on and persists it', async () => {
|
||||
harness = await createRouteTestHarness(registerSystemRoutes);
|
||||
const res = await harness.app.inject({
|
||||
method: 'PUT',
|
||||
url: '/api/settings',
|
||||
payload: { keepAwakeEnabled: true },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(store.written.at(-1)).toMatchObject({ keepAwakeEnabled: true });
|
||||
expect(keepAwake.apply).toHaveBeenCalledWith({ enabled: true, acOnly: true });
|
||||
});
|
||||
|
||||
it('rejects a non-boolean value', async () => {
|
||||
harness = await createRouteTestHarness(registerSystemRoutes);
|
||||
const res = await harness.app.inject({
|
||||
method: 'PUT',
|
||||
url: '/api/settings',
|
||||
payload: { keepAwakeEnabled: 'yes' },
|
||||
});
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(keepAwake.apply).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("drops a non-admin's value in multi-user mode, without failing the rest of the save", async () => {
|
||||
process.env.CODEMAN_MULTIUSER = '1';
|
||||
harness = await createRouteTestHarness(registerSystemRoutes, {
|
||||
authUser: { username: 'bob', role: 'user' },
|
||||
});
|
||||
store.settings = { keepAwakeEnabled: false };
|
||||
const res = await harness.app.inject({
|
||||
method: 'PUT',
|
||||
url: '/api/settings',
|
||||
payload: { keepAwakeEnabled: true, keepAwakeAcOnly: false, showMonitor: true },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const written = store.written.at(-1)!;
|
||||
expect(written.keepAwakeEnabled).toBe(false);
|
||||
expect('keepAwakeAcOnly' in written).toBe(false);
|
||||
expect(written.showMonitor).toBe(true);
|
||||
expect(keepAwake.apply).toHaveBeenCalledWith({ enabled: false, acOnly: true });
|
||||
});
|
||||
|
||||
it('an admin can change it in multi-user mode', async () => {
|
||||
process.env.CODEMAN_MULTIUSER = '1';
|
||||
harness = await createRouteTestHarness(registerSystemRoutes, {
|
||||
authUser: { username: 'root', role: 'admin' },
|
||||
});
|
||||
const res = await harness.app.inject({
|
||||
method: 'PUT',
|
||||
url: '/api/settings',
|
||||
payload: { keepAwakeEnabled: true },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(keepAwake.apply).toHaveBeenCalledWith({ enabled: true, acOnly: true });
|
||||
});
|
||||
|
||||
it('GET /api/system/keep-awake returns the status envelope', async () => {
|
||||
harness = await createRouteTestHarness(registerSystemRoutes);
|
||||
const res = await harness.app.inject({ method: 'GET', url: '/api/system/keep-awake' });
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json()).toEqual({ success: true, data: keepAwake.getStatus() });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user