feat(FN-4643): complete Step 2 — add native runStreaming backend
Fusion-Task-Id: FN-4643 Fusion-Task-Lineage: edcfb4a1-f714-47e8-a919-50b35b40e6af
This commit is contained in:
@@ -4,7 +4,15 @@ import { promisify } from "node:util";
|
||||
import { detectBwrap } from "./bubblewrap-detect.js";
|
||||
import { policyToBwrapArgs, type BubblewrapPolicy } from "./bubblewrap-policy.js";
|
||||
import { NativeSandboxBackend } from "./native.js";
|
||||
import type { SandboxBackend, SandboxCapabilities, SandboxPolicy, SandboxRunOptions, SandboxRunResult } from "./types.js";
|
||||
import type {
|
||||
SandboxBackend,
|
||||
SandboxCapabilities,
|
||||
SandboxPolicy,
|
||||
SandboxRunOptions,
|
||||
SandboxRunResult,
|
||||
SandboxRunStreamingOptions,
|
||||
SandboxStreamingResult,
|
||||
} from "./types.js";
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
@@ -29,6 +37,7 @@ export class BubblewrapBackend implements SandboxBackend {
|
||||
id: "bubblewrap",
|
||||
supportsNetworkPolicy: true,
|
||||
supportsFilesystemPolicy: true,
|
||||
supportsStreaming: false,
|
||||
platform: ["linux"],
|
||||
};
|
||||
}
|
||||
@@ -81,6 +90,10 @@ export class BubblewrapBackend implements SandboxBackend {
|
||||
return this.runBwrapSpawn(bwrapPath, [...policyArgs, "--", "/bin/sh", "-lc", command], options);
|
||||
}
|
||||
|
||||
async runStreaming(command: string, options: SandboxRunStreamingOptions): Promise<SandboxStreamingResult> {
|
||||
return this.nativeBackend.runStreaming(command, options);
|
||||
}
|
||||
|
||||
async dispose(): Promise<void> {
|
||||
this.useNativeFallback = false;
|
||||
this.pnpmStorePathByCwd.clear();
|
||||
|
||||
@@ -9,6 +9,8 @@ export type {
|
||||
SandboxPolicy,
|
||||
SandboxRunOptions,
|
||||
SandboxRunResult,
|
||||
SandboxRunStreamingOptions,
|
||||
SandboxStreamingResult,
|
||||
} from "./types.js";
|
||||
|
||||
let sandboxBackendOverrideForTests: SandboxBackend | null = null;
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
import { exec } from "node:child_process";
|
||||
import { exec, spawn } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
import type { SandboxBackend, SandboxCapabilities, SandboxPolicy, SandboxRunOptions, SandboxRunResult } from "./types.js";
|
||||
import type {
|
||||
SandboxBackend,
|
||||
SandboxCapabilities,
|
||||
SandboxPolicy,
|
||||
SandboxRunOptions,
|
||||
SandboxRunResult,
|
||||
SandboxRunStreamingOptions,
|
||||
SandboxStreamingResult,
|
||||
} from "./types.js";
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
@@ -11,6 +19,7 @@ export class NativeSandboxBackend implements SandboxBackend {
|
||||
id: "native",
|
||||
supportsNetworkPolicy: false,
|
||||
supportsFilesystemPolicy: false,
|
||||
supportsStreaming: true,
|
||||
platform: "any",
|
||||
};
|
||||
}
|
||||
@@ -64,6 +73,136 @@ export class NativeSandboxBackend implements SandboxBackend {
|
||||
}
|
||||
}
|
||||
|
||||
async runStreaming(command: string, options: SandboxRunStreamingOptions): Promise<SandboxStreamingResult> {
|
||||
if (options.signal?.aborted) {
|
||||
return {
|
||||
outcome: "aborted",
|
||||
phase: "pre-start",
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
};
|
||||
}
|
||||
|
||||
return await new Promise((resolve) => {
|
||||
const useProcessGroup = process.platform !== "win32";
|
||||
const child = spawn(command, {
|
||||
cwd: options.cwd,
|
||||
shell: true,
|
||||
detached: useProcessGroup,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
env: {
|
||||
...process.env,
|
||||
COREPACK_ENABLE_DOWNLOAD_PROMPT: "0",
|
||||
...(options.env ?? {}),
|
||||
},
|
||||
});
|
||||
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
let stdoutOverflow = false;
|
||||
let stderrOverflow = false;
|
||||
let timedOut = false;
|
||||
let aborted = false;
|
||||
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
|
||||
}
|
||||
};
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
timedOut = true;
|
||||
killTree("SIGTERM");
|
||||
setTimeout(() => {
|
||||
if (settled) return;
|
||||
killTree("SIGKILL");
|
||||
}, 5_000).unref();
|
||||
}, options.timeout);
|
||||
timer.unref();
|
||||
|
||||
const onAbort = () => {
|
||||
aborted = true;
|
||||
killTree("SIGTERM");
|
||||
setTimeout(() => {
|
||||
if (settled) return;
|
||||
killTree("SIGKILL");
|
||||
}, 5_000).unref();
|
||||
};
|
||||
options.signal?.addEventListener("abort", onAbort, { once: true });
|
||||
|
||||
child.stdout?.on("data", (chunk: Buffer) => {
|
||||
if (stdoutOverflow) return;
|
||||
if (stdout.length + chunk.length > options.maxBuffer) {
|
||||
stdoutOverflow = true;
|
||||
stdout += chunk.toString("utf-8", 0, options.maxBuffer - stdout.length);
|
||||
return;
|
||||
}
|
||||
stdout += chunk.toString("utf-8");
|
||||
});
|
||||
|
||||
child.stderr?.on("data", (chunk: Buffer) => {
|
||||
if (stderrOverflow) return;
|
||||
if (stderr.length + chunk.length > options.maxBuffer) {
|
||||
stderrOverflow = true;
|
||||
stderr += chunk.toString("utf-8", 0, options.maxBuffer - stderr.length);
|
||||
return;
|
||||
}
|
||||
stderr += chunk.toString("utf-8");
|
||||
});
|
||||
|
||||
const finish = (err: NodeJS.ErrnoException | null, code: number | null, signal: NodeJS.Signals | null) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
options.signal?.removeEventListener("abort", onAbort);
|
||||
|
||||
if (aborted) {
|
||||
resolve({ outcome: "aborted", phase: "mid-flight", stdout, stderr });
|
||||
return;
|
||||
}
|
||||
|
||||
if (timedOut) {
|
||||
resolve({ outcome: "timeout", timeoutMs: options.timeout, stdout, stderr });
|
||||
return;
|
||||
}
|
||||
|
||||
if (err) {
|
||||
resolve({ outcome: "spawn-error", error: err, stdout, stderr });
|
||||
return;
|
||||
}
|
||||
|
||||
if (code === 0) {
|
||||
resolve({
|
||||
outcome: "success",
|
||||
stdout,
|
||||
stderr,
|
||||
bufferOverflow: stdoutOverflow || stderrOverflow,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
resolve({
|
||||
outcome: "non-zero-exit",
|
||||
stdout,
|
||||
stderr,
|
||||
exitCode: code,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
child.on("error", (err) => finish(err, null, null));
|
||||
child.on("close", (code, signal) => finish(null, code, signal));
|
||||
});
|
||||
}
|
||||
|
||||
async dispose(): Promise<void> {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
@@ -11,7 +11,15 @@ import {
|
||||
type SandboxExecPolicy,
|
||||
} from "./sandbox-exec-policy.js";
|
||||
import { NativeSandboxBackend } from "./native.js";
|
||||
import type { SandboxBackend, SandboxCapabilities, SandboxPolicy, SandboxRunOptions, SandboxRunResult } from "./types.js";
|
||||
import type {
|
||||
SandboxBackend,
|
||||
SandboxCapabilities,
|
||||
SandboxPolicy,
|
||||
SandboxRunOptions,
|
||||
SandboxRunResult,
|
||||
SandboxRunStreamingOptions,
|
||||
SandboxStreamingResult,
|
||||
} from "./types.js";
|
||||
import { createLogger } from "../logger.js";
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
@@ -41,6 +49,7 @@ export class SandboxExecBackend implements SandboxBackend {
|
||||
id: "sandbox-exec",
|
||||
supportsNetworkPolicy: true,
|
||||
supportsFilesystemPolicy: true,
|
||||
supportsStreaming: false,
|
||||
platform: ["darwin"],
|
||||
};
|
||||
}
|
||||
@@ -173,6 +182,10 @@ export class SandboxExecBackend implements SandboxBackend {
|
||||
}
|
||||
}
|
||||
|
||||
async runStreaming(command: string, options: SandboxRunStreamingOptions): Promise<SandboxStreamingResult> {
|
||||
return this.nativeBackend.runStreaming(command, options);
|
||||
}
|
||||
|
||||
async dispose(): Promise<void> {
|
||||
this.ctx = null;
|
||||
this.useNativeFallback = false;
|
||||
|
||||
@@ -27,10 +27,26 @@ export interface SandboxRunResult {
|
||||
spawnError?: Error;
|
||||
}
|
||||
|
||||
export interface SandboxRunStreamingOptions {
|
||||
cwd: string;
|
||||
timeout: number;
|
||||
maxBuffer: number;
|
||||
signal?: AbortSignal;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
}
|
||||
|
||||
export type SandboxStreamingResult =
|
||||
| { outcome: "success"; stdout: string; stderr: string; bufferOverflow: boolean }
|
||||
| { outcome: "non-zero-exit"; stdout: string; stderr: string; exitCode: number | null; signal: NodeJS.Signals | null }
|
||||
| { outcome: "timeout"; stdout: string; stderr: string; timeoutMs: number }
|
||||
| { outcome: "aborted"; stdout: string; stderr: string; phase: "pre-start" | "mid-flight" }
|
||||
| { outcome: "spawn-error"; error: Error; stdout: string; stderr: string };
|
||||
|
||||
export interface SandboxCapabilities {
|
||||
id: "native" | "sandbox-exec" | "bubblewrap" | "firejail" | "docker" | "podman" | "custom";
|
||||
supportsNetworkPolicy: boolean;
|
||||
supportsFilesystemPolicy: boolean;
|
||||
supportsStreaming: boolean;
|
||||
platform: NodeJS.Platform[] | "any";
|
||||
}
|
||||
|
||||
@@ -41,6 +57,12 @@ export interface SandboxBackend {
|
||||
prepare(policy: SandboxPolicy): Promise<void>;
|
||||
/** Execute a command in the backend's environment. */
|
||||
run(command: string, options: SandboxRunOptions): Promise<SandboxRunResult>;
|
||||
/**
|
||||
* Execute a spawn-shaped streaming command path.
|
||||
* Implementations must not throw for command-level outcomes (abort/timeout/non-zero);
|
||||
* return a structured result instead.
|
||||
*/
|
||||
runStreaming(command: string, options: SandboxRunStreamingOptions): Promise<SandboxStreamingResult>;
|
||||
/** Best-effort cleanup hook for backend-owned resources. */
|
||||
dispose(): Promise<void>;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user