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
5 changes: 3 additions & 2 deletions apps/server/src/provider/managedRuntime/ManagedNode.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -169,11 +169,12 @@ describe.skipIf(process.platform === "win32")("makeManagedNode", () => {
Effect.gen(function* () {
const toolsDir = yield* tempToolsDir;
const pidFile = path.join(toolsDir, "node.pid");
// A `node` that hangs, like one waiting on something it will never get.
// A `node` that hangs, like one waiting on something it will never
// get, and ignores being asked to stop: it has to be killed outright.
const hanging = makeFixture([
{
name: "node/bin/node",
data: Buffer.from(`#!/bin/sh\necho $$ > "${pidFile}"\nexec sleep 60\n`),
data: Buffer.from(`#!/bin/sh\ntrap '' TERM\necho $$ > "${pidFile}"\nexec sleep 60\n`),
mode: 0o755,
},
{ name: "node/lib/node_modules/npm/bin/npm-cli.js", data: Buffer.from("// npm\n") },
Expand Down
33 changes: 30 additions & 3 deletions apps/server/src/provider/managedRuntime/ManagedNode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,8 @@ export interface ManagedNode {
const LABEL = "Node.js";
const ARCHIVE_FILE = "a";
const VALIDATE_TIMEOUT_MS = 30_000;
/** How long a check asked to stop gets before it is killed outright. */
const STOP_GRACE_MS = 2_000;

const NodeMarker = Schema.Struct({
releaseId: Schema.String.check(Schema.isPattern(MANAGED_RELEASE_ID_PATTERN)),
Expand All @@ -252,20 +254,45 @@ const layoutFor = (platform: NodeJS.Platform) =>
bin: ["bin"],
};

/** Runs a program to its end. Aborting kills it, and this settles only once it has exited. */
/**
* Runs a program to its end. Aborting (or the timeout) asks it to stop and
* kills it outright after STOP_GRACE_MS. Settles only once it has exited:
* execFile answers an abort the moment it sends the stop, while the program
* may still be running from a folder that is about to be moved or deleted.
*/
const runOnce = (
program: string,
args: ReadonlyArray<string>,
env: NodeJS.ProcessEnv,
signal: AbortSignal,
) =>
new Promise<string>((resolve, reject) => {
execFile(
let answer: { readonly error: Error | null; readonly stdout: string } | undefined;
let exited = false;
let forceKill: ReturnType<typeof setTimeout> | undefined;
const settle = () => {
// A program that never started (no pid) has no exit to wait for.
if (answer === undefined || (!exited && child.pid !== undefined)) return;
clearTimeout(forceKill);
if (answer.error) reject(answer.error);
else resolve(answer.stdout.trim());
};
const child = execFile(
program,
[...args],
{ env, signal, timeout: VALIDATE_TIMEOUT_MS, windowsHide: true },
(error, stdout) => (error ? reject(error) : resolve(String(stdout).trim())),
(error, stdout) => {
answer = { error, stdout: String(stdout) };
if (error && !exited) {
forceKill = setTimeout(() => child.kill("SIGKILL"), STOP_GRACE_MS);
}
settle();
},
);
child.once("exit", () => {
exited = true;
settle();
});
});

/** Builds the manager for one pinned release. Cheap; holds no resources. */
Expand Down