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
47 changes: 17 additions & 30 deletions packages/host-kit/src/internal/exec-kill-settle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,8 @@ import { mkdtempForTestSync } from './tmp-dir.fixtures.ts';
const HOLDER_LIFETIME_SECONDS = 3;
const DEADLINE_MS = 400;

function holderFinishedPath(label: string): string {
return path.join(mkdtempForTestSync(`agent-device-exec-${label}-`), 'finished');
function markerPath(label: string, name: 'finished' | 'released'): string {
return path.join(mkdtempForTestSync(`agent-device-exec-${label}-`), name);
}

// The holder announces its pid on the stdout it inherited, so the pid arriving is also the proof
Expand Down Expand Up @@ -109,7 +109,7 @@ function settledRejection<T>(promise: Promise<T>): Promise<{ error: unknown } |
test.runIf(process.platform !== 'win32')(
'a command killed at its deadline settles on the child exit, not on an inherited pipe',
async () => {
const finishedPath = holderFinishedPath('timeout-holder');
const finishedPath = markerPath('timeout-holder', 'finished');
holdModuleClock();
const holder = stdoutAnnouncement();
const killed = runCmdStreaming('/bin/sh', ['-c', pipeHolderShellScript(finishedPath, 'wait')], {
Expand All @@ -136,7 +136,7 @@ test.runIf(process.platform !== 'win32')(
test.runIf(process.platform !== 'win32')(
'a deadline on a detached command kills the descendant that inherited the pipes',
async () => {
const finishedPath = holderFinishedPath('detached-holder');
const finishedPath = markerPath('detached-holder', 'finished');
holdModuleClock();
const holder = stdoutAnnouncement();
const killed = runCmdStreaming('/bin/sh', ['-c', pipeHolderShellScript(finishedPath, 'wait')], {
Expand Down Expand Up @@ -165,7 +165,7 @@ test.runIf(process.platform !== 'win32')(
test.runIf(process.platform !== 'win32')(
'runCmdBackground killed by request cancellation settles on the child exit',
async () => {
const finishedPath = holderFinishedPath('abort-holder');
const finishedPath = markerPath('abort-holder', 'finished');
const controller = new AbortController();
const holder = stdoutAnnouncement();
const { child, wait } = runCmdBackground(
Expand Down Expand Up @@ -213,7 +213,7 @@ async function deadlineAfterChildExit(options: Pick<ExecStreamOptions, 'detached
holderFinished: boolean;
childPid: number;
}> {
const finishedPath = holderFinishedPath('exited-holder');
const finishedPath = markerPath('exited-holder', 'finished');
holdModuleClock();
const holder = stdoutAnnouncement();
const child = directChildExit();
Expand Down Expand Up @@ -397,39 +397,26 @@ test.runIf(process.platform !== 'win32')(
// that helper: it records the release the signal handler performs, and its `sleep` is the
// hold the kill interrupts.

function signalMarkerPaths(label: string): { ready: string; released: string } {
const dir = mkdtempForTestSync(`agent-device-exec-${label}-`);
return { ready: path.join(dir, 'ready'), released: path.join(dir, 'released') };
}

// `ready` is written only once the trap is installed and the hold has started, so a
// signal sent after it appears always reaches the handler.
function holdUntilSignalledShellScript(markers: { ready: string; released: string }): string {
return `trap 'printf released > ${shellQuote(markers.released)}; exit 143' TERM; sleep ${HOLDER_LIFETIME_SECONDS} & printf ready > ${shellQuote(markers.ready)}; wait`;
}

async function waitForTrapReady(readyPath: string, settled: Promise<unknown>): Promise<void> {
let didSettle = false;
void settled.then(() => (didSettle = true));
const deadline = Date.now() + 2_000;
while (!fs.existsSync(readyPath)) {
if (didSettle) throw new Error('the command settled before its shell installed the trap');
if (Date.now() > deadline) throw new Error('the shell never reported its trap installed');
await sleep(10);
}
// `ready` is printed only once the trap is installed and the hold has started, so a signal sent
// after it arrives always reaches the handler.
function holdUntilSignalledShellScript(releasedPath: string): string {
return `trap 'printf released > ${shellQuote(releasedPath)}; exit 143' TERM; sleep ${HOLDER_LIFETIME_SECONDS} & printf ready; wait`;
}

test.runIf(process.platform !== 'win32')(
'a cancelled command with a kill policy is signalled so it can release what it holds',
async () => {
const markers = signalMarkerPaths('graceful-abort');
const releasedPath = markerPath('graceful-abort', 'released');
const controller = new AbortController();
const held = runCmd('/bin/sh', ['-c', holdUntilSignalledShellScript(markers)], {
const trap = stdoutAnnouncement();
const held = runCmdStreaming('/bin/sh', ['-c', holdUntilSignalledShellScript(releasedPath)], {
signal: controller.signal,
kill: { signal: 'SIGTERM', graceMs: 2_000 },
captureOutput: false,
onStdoutChunk: trap.onStdoutChunk,
});
const rejection = settledRejection(held);
await waitForTrapReady(markers.ready, rejection);
await trap.before(rejection);

controller.abort();
const outcome = await rejection;
Expand All @@ -438,7 +425,7 @@ test.runIf(process.platform !== 'win32')(
const details = (outcome.error as { details?: Record<string, unknown> }).details;
assert.equal(details?.reason, 'request_canceled');
assert.equal(
fs.readFileSync(markers.released, 'utf8'),
fs.readFileSync(releasedPath, 'utf8'),
'released',
'the child never saw the signal its handler releases on: it was killed outright',
);
Expand Down
11 changes: 0 additions & 11 deletions scripts/check-affected/run.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -252,17 +252,6 @@ test('runChecks skips CI checks, runs every local check of a fail-open plan, and
);
});

// The related run leaves the unit projects out, so only check:unit can see their failures.
test('a fail-open plan fails on a unit-suite failure', async () => {
const plan = selectChecks({ changedFiles: ['unknown/path.xyz'], packageEntryFiles: [] });
assert.equal(plan.failOpen, true);
const code = await runChecks(plan, { scripts: ALL_SCRIPTS }, ARGS, {
execute: async (command) => (command.includes('check:unit') ? 23 : 0),
cwd: '.',
});
assert.equal(code, 23);
});

async function runFailingOn(script: string, changedFiles: string[]): Promise<number> {
const plan = selectChecks({ changedFiles, packageEntryFiles: [] });
assert.equal(plan.failOpen, false, changedFiles.join(', '));
Expand Down
Loading