feat(FN-5189): complete Step 3 — migrate supervised spawns
Fusion-Task-Id: FN-5189 Fusion-Task-Lineage: 4caa3f0a-af81-4c60-88e8-de229ed72e08
This commit is contained in:
committed by
gsxdsm
parent
87ea63c7c3
commit
818db4a714
@@ -33,6 +33,7 @@ function openInBrowser(url: string): void {
|
||||
args = [url];
|
||||
}
|
||||
try {
|
||||
// process-supervisor-allowlist: user-facing browser opener must outlive the TUI process
|
||||
const child = spawn(cmd, args, { detached: true, stdio: "ignore" });
|
||||
child.unref();
|
||||
} catch {
|
||||
|
||||
@@ -14,6 +14,8 @@ type ShutdownReason =
|
||||
| { kind: "test"; label: string };
|
||||
|
||||
export interface SuperviseSpawnOptions extends Omit<SpawnOptions, "detached"> {
|
||||
/** Override spawn for tests or alternate process factories. */
|
||||
spawnImpl?: typeof spawn;
|
||||
/**
|
||||
* Grace period between SIGTERM and SIGKILL when the supervisor tears a child
|
||||
* down because the parent is exiting or a lifetime limit expires.
|
||||
@@ -246,11 +248,12 @@ export function superviseSpawn(
|
||||
const {
|
||||
killGraceMs = DEFAULT_KILL_GRACE_MS,
|
||||
maxLifetimeMs = DEFAULT_MAX_LIFETIME_MS,
|
||||
spawnImpl = spawn,
|
||||
...spawnOptions
|
||||
} = options;
|
||||
|
||||
const processGroup = usesProcessGroup();
|
||||
const child = spawn(command, [...args], {
|
||||
const child = spawnImpl(command, [...args], {
|
||||
...spawnOptions,
|
||||
detached: processGroup,
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { superviseSpawn } from "./process-supervisor.js";
|
||||
|
||||
export interface RunCommandOptions {
|
||||
cwd?: string;
|
||||
@@ -48,27 +48,18 @@ export function runCommandAsync(
|
||||
let bufferExceeded = false;
|
||||
let timedOut = false;
|
||||
let forceKillTimer: NodeJS.Timeout | null = null;
|
||||
const useProcessGroup = process.platform !== "win32";
|
||||
|
||||
const child = spawn(command, {
|
||||
const supervised = superviseSpawn(command, [], {
|
||||
cwd: options.cwd,
|
||||
env: options.env,
|
||||
detached: useProcessGroup,
|
||||
shell: true,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
maxLifetimeMs: (options.timeoutMs ?? 0) > 0 ? options.timeoutMs! + FORCE_KILL_DELAY_MS + 1_000 : undefined,
|
||||
});
|
||||
const child = supervised.child;
|
||||
|
||||
const signalProcessGroup = (signal: NodeJS.Signals): void => {
|
||||
if (!child.pid) return;
|
||||
try {
|
||||
if (useProcessGroup) {
|
||||
process.kill(-child.pid, signal);
|
||||
} else {
|
||||
child.kill(signal);
|
||||
}
|
||||
} catch {
|
||||
// The command may already have exited and cleaned up its process group.
|
||||
}
|
||||
supervised.kill(signal);
|
||||
};
|
||||
|
||||
const scheduleForceKill = (delayMs: number): void => {
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
|
||||
import { spawn } from "node:child_process";
|
||||
import { createServer } from "node:http";
|
||||
import { superviseSpawn } from "@fusion/core";
|
||||
import { readFile, rm, stat, mkdtemp } from "node:fs/promises";
|
||||
import { existsSync } from "node:fs";
|
||||
import os from "node:os";
|
||||
@@ -469,7 +470,7 @@ async function findBrowserExecutable() {
|
||||
|
||||
async function launchBrowser(executable) {
|
||||
const userDataDir = await mkdtemp(path.join(os.tmpdir(), "fusion-dashboard-browser-smoke-"));
|
||||
const browser = spawn(executable, [
|
||||
const supervised = superviseSpawn(executable, [
|
||||
"--headless=new",
|
||||
"--disable-gpu",
|
||||
"--disable-dev-shm-usage",
|
||||
@@ -480,7 +481,9 @@ async function launchBrowser(executable) {
|
||||
"about:blank",
|
||||
], {
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
maxLifetimeMs: 60_000,
|
||||
});
|
||||
const browser = supervised.child;
|
||||
|
||||
try {
|
||||
const wsUrl = await new Promise((resolve, reject) => {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { EventEmitter } from "node:events";
|
||||
import { spawn, type ChildProcess } from "node:child_process";
|
||||
import type { ChildProcess } from "node:child_process";
|
||||
import type { Readable } from "node:stream";
|
||||
import { superviseSpawn } from "@fusion/core";
|
||||
import type { DevServerState, DevServerStore } from "./dev-server-store.js";
|
||||
import {
|
||||
detectPortFromLogLine,
|
||||
@@ -40,8 +41,8 @@ function killManagedProcess(child: ChildProcess, signal: NodeJS.Signals): void {
|
||||
|
||||
if (process.platform !== "win32") {
|
||||
try {
|
||||
// Detached POSIX children become their own process group leaders, so
|
||||
// signaling the negative PID tears down the shell wrapper and its child.
|
||||
// Supervised POSIX children remain process-group leaders, so a negative
|
||||
// PID still tears down the shell wrapper and its descendants.
|
||||
process.kill(-child.pid, signal);
|
||||
return;
|
||||
} catch {
|
||||
@@ -132,12 +133,13 @@ export class DevServerProcessManager extends EventEmitter {
|
||||
detectedPort: undefined,
|
||||
});
|
||||
|
||||
const child = spawn(safeCommand, [], {
|
||||
const supervised = superviseSpawn(safeCommand, [], {
|
||||
cwd: safeCwd,
|
||||
detached: process.platform !== "win32",
|
||||
shell: true,
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
maxLifetimeMs: 24 * 60 * 60 * 1_000,
|
||||
});
|
||||
const child = supervised.child;
|
||||
|
||||
this.childProcess = child;
|
||||
this.closePromise = new Promise<DevServerState>((resolve) => {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { EventEmitter } from "node:events";
|
||||
import { exec, execFile, spawn, type ChildProcess } from "node:child_process";
|
||||
import type { Readable } from "node:stream";
|
||||
import { promisify } from "node:util";
|
||||
import { superviseSpawn } from "@fusion/core";
|
||||
import { remoteTunnelLog } from "../logger.js";
|
||||
import {
|
||||
getTunnelProviderAdapter,
|
||||
@@ -306,13 +307,15 @@ export class TunnelProcessManager extends EventEmitter implements TunnelManager
|
||||
|
||||
this.emitLog("info", "manager", `Starting ${provider} tunnel: ${command.redactedPreview}`);
|
||||
|
||||
const child = this.spawnImpl(command.command, command.args, {
|
||||
const supervised = superviseSpawn(command.command, command.args, {
|
||||
cwd: command.cwd,
|
||||
env: command.env,
|
||||
detached: process.platform !== "win32",
|
||||
shell: false,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
maxLifetimeMs: 24 * 60 * 60 * 1_000,
|
||||
spawnImpl: this.spawnImpl,
|
||||
});
|
||||
const child = supervised.child;
|
||||
|
||||
this.processHandle = {
|
||||
provider,
|
||||
|
||||
@@ -146,7 +146,7 @@ describe("NativeSandboxBackend.runStreaming", () => {
|
||||
child.emit("close", null, "SIGTERM");
|
||||
|
||||
await expect(promise).resolves.toMatchObject({ outcome: "timeout", timeoutMs: 100 });
|
||||
expect(spawnMock).toHaveBeenCalledWith("sleep", expect.objectContaining({ detached: false }));
|
||||
expect(spawnMock).toHaveBeenCalledWith("sleep", [], expect.objectContaining({ detached: false }));
|
||||
});
|
||||
|
||||
it("returns spawn-error", async () => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { exec, spawn } from "node:child_process";
|
||||
import { exec } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import { superviseSpawn } from "@fusion/core";
|
||||
|
||||
import type {
|
||||
SandboxBackend,
|
||||
@@ -84,18 +85,18 @@ export class NativeSandboxBackend implements SandboxBackend {
|
||||
}
|
||||
|
||||
return await new Promise((resolve) => {
|
||||
const useProcessGroup = process.platform !== "win32";
|
||||
const child = spawn(command, {
|
||||
const supervised = superviseSpawn(command, [], {
|
||||
cwd: options.cwd,
|
||||
shell: true,
|
||||
detached: useProcessGroup,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
env: {
|
||||
...process.env,
|
||||
COREPACK_ENABLE_DOWNLOAD_PROMPT: "0",
|
||||
...(options.env ?? {}),
|
||||
},
|
||||
maxLifetimeMs: options.timeout + 6_000,
|
||||
});
|
||||
const child = supervised.child;
|
||||
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
@@ -106,16 +107,7 @@ export class NativeSandboxBackend implements SandboxBackend {
|
||||
let settled = false;
|
||||
|
||||
const killTree = (sig: NodeJS.Signals) => {
|
||||
if (child.pid === undefined) return;
|
||||
try {
|
||||
if (useProcessGroup) {
|
||||
process.kill(-child.pid, sig);
|
||||
} else {
|
||||
child.kill(sig);
|
||||
}
|
||||
} catch {
|
||||
// group may already be gone
|
||||
}
|
||||
supervised.kill(sig);
|
||||
};
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
|
||||
Reference in New Issue
Block a user