fix(test): stop Telegram runtime children before removing their temp root

The runtime test registered its temp-root removal as the first after
hook and each child's kill-and-join hook after it. node:test runs after
hooks in registration order and stops at the first one that throws, so
a timed-out case removed the root while stalled uv/Python children were
still writing under it, and a failed removal skipped every kill hook.

Each case now registers one after hook at test start that kills every
tracked child's process group, awaits every close, and only then removes
the root through useAutoCleanupTempDirTracker. run()/stopChildren()
match the sibling follow-up/checkpoint tests (#163326).
This commit is contained in:
Peter Steinberger 2026-10-02 09:12:28 -07:00
parent 8bb49fa67a
commit 34c45c4b75
No known key found for this signature in database

View file

@ -1,9 +1,9 @@
import assert from "node:assert/strict";
import { spawn, spawnSync } from "node:child_process";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { describe, test } from "node:test";
import { useAutoCleanupTempDirTracker } from "../../../../test/helpers/temp-dir.ts";
import { telegramPythonArgs } from "./telegram-runtime.mjs";
const uv = spawnSync("which", ["uv"], { encoding: "utf8" }).stdout?.trim();
@ -19,8 +19,8 @@ const python = spawnSync(
const TEST_TIMEOUT_MS = 90_000;
// Completion is the child closing its pipes; only the test timeout bounds a stalled
// host. The after hook kills and joins whatever a timed-out body left running.
function run(context, command, args, options) {
// host. The test's after hook kills and joins whatever a timed-out body left running.
function run(context, children, command, args, options) {
context.signal.throwIfAborted();
const child = spawn(command, args, {
...options,
@ -35,12 +35,30 @@ function run(context, command, args, options) {
child.stderr.setEncoding("utf8").on("data", (chunk) => {
stderr += chunk;
});
const closed = new Promise((resolve, reject) => {
child.once("error", reject);
const closed = new Promise((resolve) => {
child.once("close", (status, signal) => resolve({ status, signal, stdout, stderr }));
});
context.after(async () => {
// Kill the group even after the child exits: a descendant can still hold its pipes.
children.add({ child, closed });
return new Promise((resolve, reject) => {
const abort = () => {
const error = new Error(
`Child did not finish before the test ended:\n${stderr.slice(-4000)}`,
{
cause: context.signal.reason,
},
);
context.diagnostic(error.message);
reject(error);
};
context.signal.addEventListener("abort", abort, { once: true });
child.once("error", reject);
closed.then(resolve).finally(() => context.signal.removeEventListener("abort", abort));
});
}
// A descendant can hold the pipes after its parent exits, so always kill the group.
async function stopChildren(children) {
for (const { child } of children) {
if (child.pid) {
try {
process.kill(-child.pid, "SIGKILL");
@ -50,13 +68,8 @@ function run(context, command, args, options) {
}
}
}
await closed.catch(() => {});
});
return new Promise((resolve, reject) => {
const abort = () => reject(context.signal.reason);
context.signal.addEventListener("abort", abort, { once: true });
closed.then(resolve, reject).finally(() => context.signal.removeEventListener("abort", abort));
});
}
await Promise.all([...children].map(({ closed }) => closed));
}
describe("Telegram runtime", { concurrency: true }, () => {
@ -68,8 +81,16 @@ describe("Telegram runtime", { concurrency: true }, () => {
timeout: TEST_TIMEOUT_MS,
},
async (context) => {
const root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "telegram-runtime-")));
context.after(() => fs.rmSync(root, { recursive: true, force: true }));
// One after hook joins every child before removing the root: node:test runs after
// hooks in registration order and skips the rest once one throws.
const children = new Set();
const temporary = useAutoCleanupTempDirTracker((cleanup) =>
context.after(async () => {
await stopChildren(children);
cleanup();
}),
);
const root = temporary.make("telegram-runtime-");
const host = path.join(root, "host");
const state = path.join(root, "state");
const bin = path.join(host, "bin");
@ -77,7 +98,10 @@ describe("Telegram runtime", { concurrency: true }, () => {
// Real uv discovery with no compatible interpreter on PATH. The installed
// executable is real Python 3.12; only its managed-install location is a fixture.
const version = (
await run(context, python, ["-c", "import platform; print(platform.python_version())"])
await run(context, children, python, [
"-c",
"import platform; print(platform.python_version())",
])
).stdout.trim();
const installation = path.join(
managed,
@ -95,6 +119,7 @@ describe("Telegram runtime", { concurrency: true }, () => {
const env = { HOME: host, PATH: bin, UV_OFFLINE: "1", UV_PYTHON_DOWNLOADS: "never" };
const approved = await run(
context,
children,
uv,
[
"python",
@ -118,6 +143,7 @@ spec.loader.exec_module(driver)
// request or native Telegram client. Cache selection, not binary ABI, is under test.
const cache = await run(
context,
children,
python,
[
"-B",
@ -150,6 +176,7 @@ print(p)`,
const userSite = (
await run(
context,
children,
python,
["-I", "-S", "-c", "import site; print(site.getusersitepackages())"],
{ env },
@ -161,7 +188,7 @@ print(p)`,
`from pathlib import Path\nPath(${JSON.stringify(startupWrite)}).write_text("host startup ran")\n`,
);
// Discovery runs synchronous probes, so call it from a child the timeout can kill.
const discovery = await run(context, process.execPath, [
const discovery = await run(context, children, process.execPath, [
"--input-type=module",
"--eval",
`import { createTelegramRuntimeEnvironment } from ${JSON.stringify(new URL("./telegram-runtime.mjs", import.meta.url).href)};
@ -176,6 +203,7 @@ process.stdout.write(JSON.stringify(createTelegramRuntimeEnvironment(${JSON.stri
);
const result = await run(
context,
children,
"/usr/bin/sandbox-exec",
["-f", policy, uv, ...telegramPythonArgs(runtime, script)],
{ env: { ...env, ...runtime } },