Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
136 changes: 114 additions & 22 deletions packages/core/src/services/sleepInhibitor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { EventEmitter } from 'node:events';
import type { ChildProcess, SpawnOptions } from 'node:child_process';
import { describe, expect, it, vi } from 'vitest';
import { acquireSleepInhibitor, SleepInhibitor } from './sleepInhibitor.js';
import { PassThrough } from 'node:stream';

function createChild(pid: number | undefined = 4242): ChildProcess {
const child = new EventEmitter() as ChildProcess;
Expand All @@ -26,13 +27,49 @@ function createChild(pid: number | undefined = 4242): ChildProcess {
return child;
}

function createHelpChild(output: string): ChildProcess {
const child = new EventEmitter() as ChildProcess;
const stdout = new PassThrough();
const stderr = new PassThrough();
Object.defineProperty(child, 'stdout', { value: stdout });
Object.defineProperty(child, 'stderr', { value: stderr });
Object.defineProperty(child, 'pid', { value: 9999 });
Object.defineProperty(child, 'killed', { value: false });
child.kill = vi.fn(() => true);
queueMicrotask(() => {
stdout.write(output);
stdout.end();
child.emit('close', 0, null);
});
return child;
}

function createErrorChild(): ChildProcess {
const child = new EventEmitter() as ChildProcess;
Object.defineProperty(child, 'pid', { value: undefined });
Object.defineProperty(child, 'killed', { value: false });
child.kill = vi.fn(() => true);
queueMicrotask(() => child.emit('error', new Error('ENOENT')));
return child;
}

function createHarness(
platform: NodeJS.Platform = 'linux',
env: NodeJS.ProcessEnv = {},
noAskPasswordSupported: boolean | null = true,
) {
const children: ChildProcess[] = [];
const helpOutput = noAskPasswordSupported
? 'systemd-inhibit [OPTIONS...] COMMAND ...\n\n --no-ask-password Do not attempt interactive authorization\n'
: 'systemd-inhibit [OPTIONS...] COMMAND ...\n\n --what=WHAT Operations to inhibit\n';
const spawn = vi.fn(
(_command: string, _args: string[], _options?: SpawnOptions) => {
(command: string, args: string[], _options?: SpawnOptions) => {
if (command === 'systemd-inhibit' && args[0] === '--help') {
if (noAskPasswordSupported === null) {
return createErrorChild();
}
return createHelpChild(helpOutput);
}
const child = createChild();
children.push(child);
return child;
Expand All @@ -47,16 +84,23 @@ function createHarness(
}

describe('SleepInhibitor', () => {
it('starts systemd-inhibit on linux and stops it after the final release', () => {
it('starts systemd-inhibit on linux and stops it after the final release', async () => {
const { children, inhibitor, spawn } = createHarness('linux');

const first = inhibitor.acquire('working');
const second = inhibitor.acquire('working again');

// Initially only the --help probe is spawned
expect(spawn).toHaveBeenCalledTimes(1);
expect(spawn.mock.calls[0]![0]).toBe('systemd-inhibit');
expect(spawn.mock.calls[0]![1]).toEqual(['--help']);

// After probe completes, the real inhibitor spawns
await vi.waitFor(() => expect(spawn).toHaveBeenCalledTimes(2));
expect(spawn).toHaveBeenCalledWith(
'systemd-inhibit',
[
'--no-ask-password',
'--what=sleep',
'--who=Qwen Code',
'--why=working',
Expand Down Expand Up @@ -103,14 +147,15 @@ describe('SleepInhibitor', () => {
expect(inhibitor.getActiveCount()).toBe(0);
});

it('starts systemd-inhibit for SSH sessions with a display server', () => {
it('starts systemd-inhibit for SSH sessions with a display server', async () => {
const { inhibitor, spawn } = createHarness('linux', {
SSH_TTY: '/dev/pts/3',
DISPLAY: ':10',
});

const handle = inhibitor.acquire('forwarded display work');

await vi.waitFor(() => expect(spawn).toHaveBeenCalledTimes(2));
expect(spawn).toHaveBeenCalledWith(
'systemd-inhibit',
expect.arrayContaining(['--what=sleep']),
Expand All @@ -119,11 +164,12 @@ describe('SleepInhibitor', () => {
handle.release();
});

it('starts systemd-inhibit for local headless Linux sessions', () => {
it('starts systemd-inhibit for local headless Linux sessions', async () => {
const { inhibitor, spawn } = createHarness('linux');

const handle = inhibitor.acquire('local headless work');

await vi.waitFor(() => expect(spawn).toHaveBeenCalledTimes(2));
expect(spawn).toHaveBeenCalledWith(
'systemd-inhibit',
expect.arrayContaining(['--what=sleep']),
Expand All @@ -132,14 +178,15 @@ describe('SleepInhibitor', () => {
handle.release();
});

it('forwards a curated environment instead of an empty env', () => {
it('forwards a curated environment instead of an empty env', async () => {
const { inhibitor, spawn } = createHarness('linux', {
DBUS_SESSION_BUS_ADDRESS: 'unix:path=/run/user/1000/bus',
SOME_UNRELATED_SECRET: 'secret',
});

const handle = inhibitor.acquire();
const env = spawn.mock.calls[0]![2]!.env as NodeJS.ProcessEnv;
await vi.waitFor(() => expect(spawn).toHaveBeenCalledTimes(2));
const env = spawn.mock.calls[1]![2]!.env as NodeJS.ProcessEnv;
// D-Bus address required by systemd-inhibit must be forwarded.
expect(env['DBUS_SESSION_BUS_ADDRESS']).toBe(
'unix:path=/run/user/1000/bus',
Expand Down Expand Up @@ -177,10 +224,11 @@ describe('SleepInhibitor', () => {
handle.release();
});

it('ignores duplicate releases', () => {
it('ignores duplicate releases', async () => {
const { children, inhibitor } = createHarness('linux');

const handle = inhibitor.acquire();
await vi.waitFor(() => expect(inhibitor.isRunning()).toBe(true));
handle.release();
handle.release();

Expand Down Expand Up @@ -209,10 +257,11 @@ describe('SleepInhibitor', () => {
expect(inhibitor.getActiveCount()).toBe(0);
});

it('handles async error events from the spawned child', () => {
it('handles async error events from the spawned child', async () => {
const { children, inhibitor, logger } = createHarness('linux');

const handle = inhibitor.acquire();
await vi.waitFor(() => expect(children).toHaveLength(1));
children[0]!.emit('error', new Error('EPERM'));

expect(inhibitor.isRunning()).toBe(false);
Expand All @@ -222,10 +271,11 @@ describe('SleepInhibitor', () => {
handle.release();
});

it('restarts after an unexpected exit when acquired again', () => {
it('restarts after an unexpected exit when acquired again', async () => {
const { children, inhibitor, logger, spawn } = createHarness('linux');

const first = inhibitor.acquire('initial work');
await vi.waitFor(() => expect(children).toHaveLength(1));
children[0]!.emit('exit', 1, null);

expect(inhibitor.isRunning()).toBe(false);
Expand All @@ -234,7 +284,7 @@ describe('SleepInhibitor', () => {
);

const second = inhibitor.acquire('more work');
expect(spawn).toHaveBeenCalledTimes(2);
await vi.waitFor(() => expect(spawn).toHaveBeenCalledTimes(3));
expect(inhibitor.isRunning()).toBe(true);

first.release();
Expand All @@ -255,13 +305,13 @@ describe('SleepInhibitor', () => {
expect(() => missingGetter.release()).not.toThrow();
});

it('dispose kills the active child, resets state, and is idempotent', () => {
it('dispose kills the active child, resets state, and is idempotent', async () => {
const { children, inhibitor } = createHarness('linux');

inhibitor.acquire('work');
inhibitor.acquire('more work');
await vi.waitFor(() => expect(inhibitor.isRunning()).toBe(true));
expect(inhibitor.getActiveCount()).toBe(2);
expect(inhibitor.isRunning()).toBe(true);

inhibitor.dispose();
expect(children[0]!.kill).toHaveBeenCalledTimes(1);
Expand All @@ -273,9 +323,12 @@ describe('SleepInhibitor', () => {
expect(children[0]!.kill).toHaveBeenCalledTimes(1);
});

it('does not propagate when child.kill() throws during release', () => {
it('does not propagate when child.kill() throws during release', async () => {
const children: ChildProcess[] = [];
const spawn = vi.fn(() => {
const spawn = vi.fn((command: string, args: string[]) => {
if (command === 'systemd-inhibit' && args[0] === '--help') {
return createHelpChild('--no-ask-password');
}
const child = new EventEmitter() as ChildProcess;
Object.defineProperty(child, 'killed', { get: () => false });
Object.defineProperty(child, 'pid', { value: 4242 });
Expand All @@ -294,25 +347,29 @@ describe('SleepInhibitor', () => {
});

const handle = inhibitor.acquire();
await vi.waitFor(() => expect(children).toHaveLength(1));
expect(() => handle.release()).not.toThrow();
expect(logger.warn).toHaveBeenCalledWith(
'Failed to stop sleep inhibitor: ESRCH',
);
expect(inhibitor.getActiveCount()).toBe(0);
});

it('does not kill a child whose spawn failed (no pid)', () => {
it('does not kill a child whose spawn failed (no pid)', async () => {
// Mimics the container sandbox: `systemd-inhibit` is absent, so the spawn
// rejects with ENOENT on the next tick and the child never gets a pid. If
// `stop()` (here via the synchronous release before the error event fires)
// called `kill()` on this pidless child, the kill would target the
// caller's own process group and deliver SIGTERM to this process, aborting
// the run. Releasing must therefore be a no-op for a pidless child.
const children: ChildProcess[] = [];
const spawn = vi.fn(() => {
// Pidless child: spawn returned but the process never started (ENOENT).
const spawn = vi.fn((command: string, args: string[]) => {
if (command === 'systemd-inhibit' && args[0] === '--help') {
return createErrorChild();
}
const child = new EventEmitter() as ChildProcess;
Object.defineProperty(child, 'killed', { get: () => false });
// Pidless child: spawn returned but the process never started (ENOENT).
Object.defineProperty(child, 'pid', { value: undefined });
child.kill = vi.fn(() => true);
children.push(child);
Expand All @@ -327,7 +384,7 @@ describe('SleepInhibitor', () => {
});

const handle = inhibitor.acquire('executing tool');
expect(spawn).toHaveBeenCalledTimes(1);
await vi.waitFor(() => expect(spawn).toHaveBeenCalledTimes(2));

handle.release();

Expand All @@ -336,14 +393,15 @@ describe('SleepInhibitor', () => {
expect(inhibitor.isRunning()).toBe(false);
});

it('ignores a late error event from an already-replaced child', () => {
it('ignores a late error event from an already-replaced child', async () => {
const { children, inhibitor, logger, spawn } = createHarness('linux');

const first = inhibitor.acquire();
await vi.waitFor(() => expect(children).toHaveLength(1));
// First child exits, so this.child is cleared and a re-acquire respawns.
children[0]!.emit('exit', 0, null);
const second = inhibitor.acquire();
expect(spawn).toHaveBeenCalledTimes(2);
await vi.waitFor(() => expect(spawn).toHaveBeenCalledTimes(3));
expect(inhibitor.isRunning()).toBe(true);

logger.debug.mockClear();
Expand Down Expand Up @@ -379,11 +437,12 @@ describe('SleepInhibitor', () => {
second.release();
});

it('sanitizes the systemd-inhibit reason (strips control chars, caps length)', () => {
it('sanitizes the systemd-inhibit reason (strips control chars, caps length)', async () => {
const { inhibitor, spawn } = createHarness('linux');

const handle = inhibitor.acquire(`run\x00 tool\n${'x'.repeat(200)}`);
const args = spawn.mock.calls[0]![1] as string[];
await vi.waitFor(() => expect(spawn).toHaveBeenCalledTimes(2));
const args = spawn.mock.calls[1]![1] as string[];
const why = args.find((arg) => arg.startsWith('--why='))!;

// eslint-disable-next-line no-control-regex
Expand All @@ -392,4 +451,37 @@ describe('SleepInhibitor', () => {

handle.release();
});

it.each([
{
support: true,
expected: true,
label: 'includes --no-ask-password when supported',
},
{
support: false,
expected: false,
label: 'omits --no-ask-password when not supported',
},
{
support: null,
expected: false,
label: 'omits --no-ask-password when unavailable',
},
])('$label', async ({ support, expected }) => {
const { inhibitor, spawn } = createHarness('linux', {}, support);

const handle = inhibitor.acquire('working');
await vi.waitFor(() => expect(spawn).toHaveBeenCalledTimes(2));
const args = spawn.mock.calls[1]![1] as string[];

if (expected) {
expect(args[0]).toBe('--no-ask-password');
} else {
expect(args).not.toContain('--no-ask-password');
}
expect(args).toContain('--what=sleep');

handle.release();
});
});
Loading
Loading