Files
SnapOtter/packages/ai/src/bridge.ts
T
SnapOtterandGitHub 7a70affac5 fix(ai): enforce the feature gate on the per-request fallback path (#331)
The persistent Python dispatcher rejects scripts whose feature bundle is not
installed, but the per-request fallback (used when the dispatcher is down, e.g.
restarting right after a model repair) spawned scripts directly and bypassed
that gate. Behavior was therefore inconsistent: a gated script would fail under
the dispatcher but run under the fallback -- the "works once after a repair"
symptom from the original report.

- add packages/ai/src/feature-gate.ts: SCRIPT_BUNDLE_MAP + missingBundleForScript,
  mirroring TOOL_BUNDLE_MAP in dispatcher.py, reading the same installed.json and
  failing closed exactly like dispatcher._get_installed_bundles()
- runPerRequest now rejects with "feature_not_installed" (the same message the
  dispatcher path surfaces) when a gated script's bundle is not installed
- unit tests for the gate, plus a drift test pinning the TS map to dispatcher.py

Closes #327
2026-06-22 23:49:41 +08:00

738 lines
23 KiB
TypeScript

import { type ChildProcess, spawn } from "node:child_process";
import { randomUUID } from "node:crypto";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { context, propagation, SpanStatusCode, trace } from "@opentelemetry/api";
import { missingBundleForScript } from "./feature-gate.js";
const __dirname = dirname(fileURLToPath(import.meta.url));
const PYTHON_DIR = resolve(__dirname, "../python");
/**
* Build a minimal environment for spawned Python processes.
* Only passes through variables needed for venv, CUDA, model cache,
* package resolution, and locale -- avoids leaking secrets or
* application config from the parent process.
*/
function buildMinimalEnv(): Record<string, string> {
const env: Record<string, string> = {
PYTHONUNBUFFERED: "1",
LANG: process.env.LANG || "C.UTF-8",
};
const passthrough = [
"PATH",
"HOME",
"VIRTUAL_ENV",
"PYTHONPATH",
"CUDA_VISIBLE_DEVICES",
"LD_LIBRARY_PATH",
// Application-specific vars the sidecar scripts depend on
"DATA_DIR",
"MODELS_PATH",
"U2NET_HOME",
"PROCESSING_TIMEOUT_S",
"DISPATCHER_MAX_REQUESTS",
"PYTHON_VENV_PATH",
"SNAPOTTER_GPU",
"OTEL_EXPORTER_OTLP_ENDPOINT",
"OTEL_EXPORTER_OTLP_PROTOCOL",
"OTEL_EXPORTER_OTLP_HEADERS",
];
for (const key of passthrough) {
if (process.env[key] !== undefined) {
env[key] = process.env[key] as string;
}
}
return env;
}
/** Try venv first, then system python. */
function getPythonPath(): string {
const venvPath = process.env.PYTHON_VENV_PATH || resolve(__dirname, "../../../.venv");
return `${venvPath}/bin/python3`;
}
/**
* Extract a user-friendly error from a Python process error.
*/
function extractPythonError(error: unknown): string {
if (error && typeof error === "object") {
const pErr = error as {
stderr?: string;
stdout?: string;
message?: string;
};
for (const output of [pErr.stdout, pErr.stderr]) {
if (output) {
try {
const parsed = JSON.parse(output.trim());
if (parsed.error) return parsed.error;
} catch {
const trimmed = output.trim();
if (trimmed && !trimmed.startsWith("Traceback")) {
return trimmed;
}
// Extract the last meaningful line from a Python Traceback
if (trimmed) {
const lines = trimmed
.split("\n")
.map((l) => l.trim())
.filter(Boolean);
const lastLine = lines[lines.length - 1];
if (lastLine && lastLine !== "Traceback (most recent call last):") {
return lastLine;
}
}
}
}
}
if (pErr.message) return pErr.message;
// Return empty string for {stdout, stderr} objects with no useful content
// so the caller's fallback message (e.g. exit code) kicks in.
return "";
}
if (error instanceof Error) return error.message;
return String(error);
}
export type ProgressCallback = (percent: number, stage: string) => void;
// ── PythonDispatcher class ─────────────────────────────────────────
interface PendingRequest {
resolve: (result: { stdout: string; stderr: string }) => void;
reject: (err: Error) => void;
onProgress?: ProgressCallback;
stderrLines: string[];
}
// Crash recovery constants
const CRASH_WINDOW_MS = 60_000;
const MAX_CONSECUTIVE_CRASHES = 5;
const BASE_BACKOFF_MS = 1_000;
export interface DispatcherStatus {
running: boolean;
ready: boolean;
failed: boolean;
gpu: boolean;
pid: number | null;
consecutiveCrashes: number;
}
/**
* A single Python dispatcher instance parameterized by profile.
* The "ai" profile loads heavy ML libraries at startup; the "docs"
* profile starts lean with only document-processing scripts allowed.
*/
export class PythonDispatcher {
private profile: "ai" | "docs";
private child: ChildProcess | null = null;
private childReady = false;
private childFailed = false;
private gpuAvail = false;
private pending = new Map<string, PendingRequest>();
private stdoutBuf = "";
private crashes = 0;
private lastCrashTs = 0;
private backoffEnd = 0;
constructor(opts: { profile: "ai" | "docs" }) {
this.profile = opts.profile;
}
private buildEnv(): Record<string, string> {
const env = buildMinimalEnv();
env.DISPATCHER_PROFILE = this.profile;
return env;
}
private recordCrash(): void {
const now = Date.now();
if (now - this.lastCrashTs > CRASH_WINDOW_MS) {
this.crashes = 1;
} else {
this.crashes++;
}
this.lastCrashTs = now;
if (this.crashes >= MAX_CONSECUTIVE_CRASHES) {
console.error(
`[bridge] Dispatcher crashed ${this.crashes} times in ${CRASH_WINDOW_MS / 1000}s, disabling permanently`,
);
this.childFailed = true;
return;
}
const delay = BASE_BACKOFF_MS * 2 ** (this.crashes - 1);
this.backoffEnd = now + delay;
console.warn(
`[bridge] Dispatcher crash #${this.crashes}, backing off ${delay}ms before restart`,
);
}
private startChild(): ChildProcess | null {
if (this.childFailed) return null;
try {
const proc = spawn(getPythonPath(), [resolve(PYTHON_DIR, "dispatcher.py")], {
stdio: ["pipe", "pipe", "pipe"],
env: this.buildEnv(),
});
proc.stdin?.on("error", (err: NodeJS.ErrnoException) => {
if (err.code === "EPIPE" || err.code === "ERR_STREAM_DESTROYED") {
console.error(
`[bridge] Dispatcher stdin pipe broken (${err.code}), rejecting pending requests`,
);
for (const [id, req] of this.pending.entries()) {
req.reject(new Error("Python dispatcher stdin closed unexpectedly"));
this.pending.delete(id);
}
this.recordCrash();
this.child = null;
this.childReady = false;
}
});
let stderrBuf = "";
proc.stderr?.on("data", (chunk: Buffer) => {
stderrBuf += chunk.toString();
const lines = stderrBuf.split("\n");
stderrBuf = lines.pop() ?? "";
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) continue;
try {
const parsed = JSON.parse(trimmed);
// Readiness signal
if (parsed.ready === true) {
this.childReady = true;
this.gpuAvail = parsed.gpu === true;
this.crashes = 0;
console.log(`[bridge] Python dispatcher ready (GPU: ${parsed.gpu === true})`);
continue;
}
// Progress event - route to the currently active request
if (typeof parsed.progress === "number" && typeof parsed.stage === "string") {
// Progress goes to all pending requests (only one should be active at a time
// since Python processes synchronously)
for (const req of this.pending.values()) {
req.onProgress?.(parsed.progress, parsed.stage);
}
}
} catch {
// Not JSON - forward diagnostic messages to Node.js logger,
// collect the rest as error output for pending requests.
if (trimmed.startsWith("[")) {
console.log(`[python] ${trimmed}`);
}
for (const req of this.pending.values()) {
req.stderrLines.push(trimmed);
}
}
}
});
proc.stdout?.on("data", (chunk: Buffer) => {
this.stdoutBuf += chunk.toString();
const lines = this.stdoutBuf.split("\n");
this.stdoutBuf = lines.pop() ?? "";
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) continue;
try {
const response = JSON.parse(trimmed);
const reqId = response.id;
const req = this.pending.get(reqId);
if (req) {
this.pending.delete(reqId);
if (response.exitCode !== 0) {
const errText =
extractPythonError({
stdout: response.stdout,
stderr: req.stderrLines.join("\n"),
}) ||
(response.exitCode === 137
? "Process killed (out of memory) -- try a lighter model or smaller image"
: response.exitCode === 139
? "Process crashed (segmentation fault)"
: `Python script exited with code ${response.exitCode}`);
req.reject(new Error(errText));
} else {
req.resolve({
stdout: response.stdout || "",
stderr: req.stderrLines.join("\n"),
});
}
}
} catch {
// Not a valid response line
}
}
});
proc.on("error", (err: NodeJS.ErrnoException) => {
console.error(`[bridge] Dispatcher error: ${err.message} (code: ${err.code})`);
if (err.code === "ENOENT") {
this.childFailed = true;
} else {
this.recordCrash();
}
for (const [id, req] of this.pending.entries()) {
req.reject(new Error(extractPythonError(err)));
this.pending.delete(id);
}
this.child = null;
this.childReady = false;
});
proc.on("close", (code) => {
for (const [id, req] of this.pending.entries()) {
req.reject(new Error("Python dispatcher exited unexpectedly"));
this.pending.delete(id);
}
if (code !== 0) {
this.recordCrash();
}
this.child = null;
this.childReady = false;
});
return proc;
} catch {
this.childFailed = true;
return null;
}
}
private getChild(): ChildProcess | null {
if (this.childFailed) return null;
if (!this.child || this.child.killed) {
if (Date.now() < this.backoffEnd) return null;
this.child = this.startChild();
}
return this.child;
}
/**
* Send a request to the persistent Python dispatcher.
* Returns null if the dispatcher is unavailable (caller should fall back).
*/
private dispatcherRun(
scriptName: string,
args: string[],
options: { onProgress?: ProgressCallback; timeout?: number } = {},
): Promise<{ stdout: string; stderr: string }> | null {
const proc = this.getChild();
if (!proc || !proc.stdin || !this.childReady) return null;
const id = randomUUID();
const timeout =
options.timeout ??
(process.env.PROCESSING_TIMEOUT_S && parseInt(process.env.PROCESSING_TIMEOUT_S, 10) > 0
? parseInt(process.env.PROCESSING_TIMEOUT_S, 10) * 1000
: 600000);
return new Promise((resolvePromise, rejectPromise) => {
const timer = setTimeout(() => {
this.pending.delete(id);
// Kill the stuck dispatcher so it restarts on the next request instead of
// blocking all subsequent operations behind the timed-out script.
if (this.child && !this.child.killed) {
this.child.kill("SIGTERM");
}
rejectPromise(new Error("Python script timed out"));
}, timeout);
const wrappedResolve = (result: { stdout: string; stderr: string }) => {
clearTimeout(timer);
resolvePromise(result);
};
const wrappedReject = (err: Error) => {
clearTimeout(timer);
rejectPromise(err);
};
this.pending.set(id, {
resolve: wrappedResolve,
reject: wrappedReject,
onProgress: options.onProgress,
stderrLines: [],
});
const msg: Record<string, unknown> = { id, script: scriptName.replace(".py", ""), args };
const otelCarrier: Record<string, string> = {};
propagation.inject(context.active(), otelCarrier);
if (otelCarrier.traceparent) {
msg._otel = {
traceparent: otelCarrier.traceparent,
tracestate: otelCarrier.tracestate,
};
}
const request = JSON.stringify(msg);
try {
proc.stdin!.write(request + "\n");
} catch {
this.pending.delete(id);
clearTimeout(timer);
rejectPromise(new Error("Python dispatcher stdin closed unexpectedly"));
}
});
}
// ── Per-request fallback (original implementation) ──────────────
private runPerRequest(
scriptName: string,
args: string[],
options: {
onProgress?: ProgressCallback;
timeout?: number;
} = {},
): Promise<{ stdout: string; stderr: string }> {
// Mirror the dispatcher's feature gate. The persistent dispatcher rejects
// scripts whose bundle is not installed (in Python); this fallback spawns
// scripts directly, so without the same check it would run them ungated
// when the dispatcher is down (e.g. right after a model repair). Reject
// with the same message the dispatcher path surfaces.
if (missingBundleForScript(scriptName)) {
return Promise.reject(new Error("feature_not_installed"));
}
const scriptPath = resolve(PYTHON_DIR, scriptName);
const timeout =
options.timeout ??
(process.env.PROCESSING_TIMEOUT_S && parseInt(process.env.PROCESSING_TIMEOUT_S, 10) > 0
? parseInt(process.env.PROCESSING_TIMEOUT_S, 10) * 1000
: 600000);
return new Promise((resolvePromise, rejectPromise) => {
const trySpawn = (pythonBin: string, isFallback: boolean) => {
const proc = spawn(pythonBin, [scriptPath, ...args], {
stdio: ["ignore", "pipe", "pipe"],
env: this.buildEnv(),
});
let stdout = "";
const stderrLines: string[] = [];
let stderrBuffer = "";
let timedOut = false;
const timer = setTimeout(() => {
timedOut = true;
proc.kill("SIGTERM");
}, timeout);
proc.stdout.on("data", (chunk: Buffer) => {
stdout += chunk.toString();
});
proc.stderr.on("data", (chunk: Buffer) => {
stderrBuffer += chunk.toString();
const lines = stderrBuffer.split("\n");
stderrBuffer = lines.pop() ?? "";
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) continue;
try {
const parsed = JSON.parse(trimmed);
if (typeof parsed.progress === "number" && typeof parsed.stage === "string") {
options.onProgress?.(parsed.progress, parsed.stage);
continue;
}
} catch {
// Not JSON - collect as regular stderr
}
stderrLines.push(trimmed);
}
});
proc.on("error", (err: NodeJS.ErrnoException) => {
clearTimeout(timer);
if (err.code === "ENOENT" && !isFallback) {
trySpawn("python3", true);
} else {
rejectPromise(new Error(extractPythonError(err)));
}
});
proc.on("close", (code, signal) => {
clearTimeout(timer);
if (stderrBuffer.trim()) {
stderrLines.push(stderrBuffer.trim());
}
if (timedOut) {
rejectPromise(new Error("Python script timed out"));
return;
}
const stderr = stderrLines.join("\n");
if (code !== 0) {
// When the process was killed by a signal, use a clear message
// instead of surfacing unrelated stderr (e.g. CUDA warnings).
const signalMsg =
signal === "SIGKILL" || code === 137
? "Process killed (out of memory) -- try a lighter model or smaller image"
: signal === "SIGSEGV" || code === 139
? "Process crashed (segmentation fault)"
: null;
const errorText =
signalMsg ||
extractPythonError({ stdout: stdout.trim(), stderr }) ||
`Python script exited with code ${code}`;
rejectPromise(new Error(errorText));
return;
}
resolvePromise({ stdout: stdout.trim(), stderr });
});
};
trySpawn(getPythonPath(), false);
});
}
// ── Public API ──────────────────────────────────────────────────
/**
* Whether the Python dispatcher detected a CUDA GPU at startup.
*/
isGpuAvailable(): boolean {
return this.gpuAvail;
}
getStatus(): DispatcherStatus {
return {
running: this.child !== null && !this.child.killed,
ready: this.childReady,
failed: this.childFailed,
gpu: this.gpuAvail,
pid: this.child?.pid ?? null,
consecutiveCrashes: this.crashes,
};
}
/**
* Shut down the persistent dispatcher process.
*/
shutdown(): void {
if (this.child && !this.child.killed) {
this.child.stdin?.end();
this.child.kill("SIGTERM");
this.child = null;
this.childReady = false;
}
}
/**
* Eagerly start the Python dispatcher and wait for its readiness signal.
* Returns the GPU status once ready, or {ready: false} on timeout/failure.
* Safe to call multiple times -- idempotent if the dispatcher is already running.
*/
init(timeoutMs = 30_000): Promise<{ ready: boolean; gpu: boolean }> {
if (this.childReady) {
return Promise.resolve({ ready: true, gpu: this.gpuAvail });
}
if (this.childFailed) {
return Promise.resolve({ ready: false, gpu: false });
}
const proc = this.getChild();
if (!proc) {
return Promise.resolve({ ready: false, gpu: false });
}
return new Promise((resolvePromise) => {
const timer = setTimeout(() => {
clearInterval(poll);
resolvePromise({ ready: false, gpu: false });
}, timeoutMs);
const poll = setInterval(() => {
if (this.childReady) {
clearTimeout(timer);
clearInterval(poll);
resolvePromise({ ready: true, gpu: this.gpuAvail });
} else if (this.childFailed) {
clearTimeout(timer);
clearInterval(poll);
resolvePromise({ ready: false, gpu: false });
}
}, 50);
});
}
/**
* Run a Python script with real-time progress streaming via stderr.
*
* Tries the persistent dispatcher first for warm-start performance.
* Falls back to per-request spawning if the dispatcher is unavailable.
*/
run(
scriptName: string,
args: string[],
options: {
onProgress?: ProgressCallback;
timeout?: number;
} = {},
): Promise<{ stdout: string; stderr: string }> {
const tracer = trace.getTracer("snapotter-sidecar");
const span = trace.getActiveSpan()
? tracer.startSpan("sidecar.execute", {
attributes: {
"sidecar.script": scriptName.replace(".py", ""),
"sidecar.profile": this.profile,
},
})
: null;
const doRun = (): Promise<{ stdout: string; stderr: string }> => {
// Try persistent dispatcher first
const dispatcherPromise = this.dispatcherRun(scriptName, args, options);
if (dispatcherPromise) {
return dispatcherPromise.catch((err: Error) => {
if (
err.message === "Python dispatcher exited unexpectedly" ||
err.message === "Python dispatcher stdin closed unexpectedly"
) {
console.warn(
`[bridge] Dispatcher crashed during ${scriptName}, retrying with per-request process`,
);
return this.runPerRequest(scriptName, args, options).then((result) => ({
...result,
stderr: `${result.stderr}\n[bridge] retried after dispatcher crash`,
}));
}
throw err;
});
}
// Fall back to per-request spawning
return this.runPerRequest(scriptName, args, options);
};
if (!span) return doRun();
return doRun().then(
(result) => {
span.end();
return result;
},
(err) => {
span.setStatus({
code: SpanStatusCode.ERROR,
message: err instanceof Error ? err.message : String(err),
});
span.recordException(err instanceof Error ? err : new Error(String(err)));
span.end();
throw err;
},
);
}
}
// ── Singleton AI dispatcher (preserves existing module-level API) ──
let aiDispatcher: PythonDispatcher | null = null;
function getAiDispatcher(): PythonDispatcher {
if (!aiDispatcher) aiDispatcher = new PythonDispatcher({ profile: "ai" });
return aiDispatcher;
}
/**
* Whether the Python dispatcher detected a CUDA GPU at startup.
*/
export function isGpuAvailable(): boolean {
return getAiDispatcher().isGpuAvailable();
}
export function getDispatcherStatus(): DispatcherStatus {
return getAiDispatcher().getStatus();
}
/**
* Shut down the persistent dispatcher process.
*/
export function shutdownDispatcher(): void {
getAiDispatcher().shutdown();
}
/**
* Eagerly start the Python dispatcher and wait for its readiness signal.
* Returns the GPU status once ready, or {ready: false} on timeout/failure.
* Safe to call multiple times -- idempotent if the dispatcher is already running.
*/
export function initDispatcher(timeoutMs = 30_000): Promise<{ ready: boolean; gpu: boolean }> {
return getAiDispatcher().init(timeoutMs);
}
/**
* Run a Python script with real-time progress streaming via stderr.
*
* Tries the persistent dispatcher first for warm-start performance.
* Falls back to per-request spawning if the dispatcher is unavailable.
*/
export function runPythonWithProgress(
scriptName: string,
args: string[],
options: {
onProgress?: ProgressCallback;
timeout?: number;
} = {},
): Promise<{ stdout: string; stderr: string }> {
return getAiDispatcher().run(scriptName, args, options);
}
// biome-ignore lint/suspicious/noExplicitAny: matches JSON.parse return type
export function parseStdoutJson(stdout: string): any {
const matched = stdout.match(/\{[\s\S]*\}$/);
if (!matched) throw new Error("No JSON response from Python script");
return JSON.parse(matched[0]);
}
// ── Docs-profile dispatcher ──────────────────────────────────────
let docsDispatcher: PythonDispatcher | null = null;
/** Lazy docs-profile dispatcher instance (same protocol, no new IPC). */
export function getDocsDispatcher(): PythonDispatcher {
if (!docsDispatcher) docsDispatcher = new PythonDispatcher({ profile: "docs" });
return docsDispatcher;
}
export async function runDocsScript(
script: string,
args: Record<string, unknown>,
opts?: { timeoutMs?: number },
): Promise<string> {
// Ensure .py extension: the dispatcher protocol strips it for the
// in-process path, while the per-request fallback needs it for the
// file path on disk.
const scriptFile = script.endsWith(".py") ? script : `${script}.py`;
const result = await getDocsDispatcher().run(
scriptFile,
[JSON.stringify(args)],
opts?.timeoutMs ? { timeout: opts.timeoutMs } : undefined,
);
return result.stdout;
}
export async function shutdownDocsDispatcher(): Promise<void> {
docsDispatcher?.shutdown();
docsDispatcher = null;
}