mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
fix: format preservation, dispatcher stability, and health reporting
Closes #17, #18, #19, #31, #32, #33, #34 Format preservation (#17, #18, #19): - Add resolveOutputFormat to rotate, resize, text-overlay, watermark-text, border, replace-color, blur-faces, upscale, erase-object, restore-photo - Alpha-aware fallback: border with corner radius/shadow and replace-color with makeTransparent fall back to PNG for non-alpha formats (JPEG) - Python sidecar tools (blur-faces, upscale, erase-object) now convert PNG output back to input format, matching restore-photo/colorize pattern - Upscale and erase-object default to "auto" format detection instead of PNG Dispatcher stability (#31, #32): - Add gc.collect() and torch.cuda.empty_cache() after each dispatcher request - Add configurable max_requests (default 50) for periodic dispatcher restart - Add exponential backoff to dispatcher crash recovery in bridge.ts - Circuit breaker: 5 crashes within 60s permanently disables dispatcher - Reset crash counter on successful dispatcher startup Health & security (#33, #34): - Export getDispatcherStatus() from @snapotter/ai with running/ready/failed/ gpu/pid/consecutiveCrashes fields - Admin health endpoint now includes full dispatcher status - Add pip-audit job to CI workflow for Python dependency scanning
This commit is contained in:
@@ -76,6 +76,19 @@ jobs:
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: pnpm test:ci
|
||||
|
||||
pip-audit:
|
||||
name: Python Dependency Audit
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.11"
|
||||
|
||||
- run: pip install pip-audit
|
||||
- run: pip-audit -r packages/ai/python/requirements.txt
|
||||
|
||||
build:
|
||||
name: Build
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import cors from "@fastify/cors";
|
||||
import rateLimit from "@fastify/rate-limit";
|
||||
import { isGpuAvailable } from "@snapotter/ai";
|
||||
import { getDispatcherStatus, isGpuAvailable } from "@snapotter/ai";
|
||||
import { APP_VERSION } from "@snapotter/shared";
|
||||
import { eq } from "drizzle-orm";
|
||||
import Fastify from "fastify";
|
||||
@@ -222,7 +222,7 @@ app.get("/api/v1/admin/health", async (request, reply) => {
|
||||
storage: { mode: env.STORAGE_MODE, available: "N/A" },
|
||||
database: dbOk ? "ok" : "error",
|
||||
queue: { active: 0, pending: 0 },
|
||||
ai: { gpu: isGpuAvailable() },
|
||||
ai: { gpu: isGpuAvailable(), dispatcher: getDispatcherStatus() },
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { basename, join } from "node:path";
|
||||
import { blurFaces } from "@snapotter/ai";
|
||||
import { getBundleForTool, TOOL_BUNDLE_MAP } from "@snapotter/shared";
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
||||
import sharp from "sharp";
|
||||
import { z } from "zod";
|
||||
import { autoOrient } from "../../lib/auto-orient.js";
|
||||
import { formatZodErrors } from "../../lib/errors.js";
|
||||
@@ -11,6 +12,7 @@ import { isToolInstalled } from "../../lib/feature-status.js";
|
||||
import { validateImageBuffer } from "../../lib/file-validation.js";
|
||||
import { decodeToSharpCompat, needsCliDecode } from "../../lib/format-decoders.js";
|
||||
import { decodeHeic, ensureSharpCompat } from "../../lib/heic-converter.js";
|
||||
import { resolveOutputFormat } from "../../lib/output-format.js";
|
||||
import { createWorkspace } from "../../lib/workspace.js";
|
||||
import { updateSingleFileProgress } from "../progress.js";
|
||||
import { registerToolProcessFn } from "../tool-factory.js";
|
||||
@@ -139,10 +141,19 @@ export function registerBlurFaces(app: FastifyInstance) {
|
||||
onProgress,
|
||||
);
|
||||
|
||||
// Save output
|
||||
const outputFilename = `${filename.replace(/\.[^.]+$/, "")}_blurred.png`;
|
||||
// Resolve output format to match input
|
||||
const outputFormat = await resolveOutputFormat(fileBuffer, filename);
|
||||
let outputBuffer = result.buffer;
|
||||
if (outputFormat.format !== "png") {
|
||||
outputBuffer = await sharp(result.buffer)
|
||||
.toFormat(outputFormat.format, { quality: outputFormat.quality })
|
||||
.toBuffer();
|
||||
}
|
||||
|
||||
const ext = outputFormat.format === "jpeg" ? "jpg" : outputFormat.format;
|
||||
const outputFilename = `${filename.replace(/\.[^.]+$/, "")}_blurred.${ext}`;
|
||||
const outputPath = join(workspacePath, "output", outputFilename);
|
||||
await writeFile(outputPath, result.buffer);
|
||||
await writeFile(outputPath, outputBuffer);
|
||||
|
||||
if (clientJobId) {
|
||||
updateSingleFileProgress({
|
||||
@@ -156,7 +167,7 @@ export function registerBlurFaces(app: FastifyInstance) {
|
||||
jobId,
|
||||
downloadUrl: `/api/v1/download/${jobId}/${encodeURIComponent(outputFilename)}`,
|
||||
originalSize: fileBuffer.length,
|
||||
processedSize: result.buffer.length,
|
||||
processedSize: outputBuffer.length,
|
||||
facesDetected: result.facesDetected,
|
||||
faces: result.faces,
|
||||
...(result.facesDetected === 0 && {
|
||||
@@ -189,8 +200,20 @@ export function registerBlurFaces(app: FastifyInstance) {
|
||||
blurRadius: s.blurRadius ?? 30,
|
||||
sensitivity: s.sensitivity ?? 0.5,
|
||||
});
|
||||
const outputFilename = `${filename.replace(/\.[^.]+$/, "")}_blurred.png`;
|
||||
return { buffer: result.buffer, filename: outputFilename, contentType: "image/png" };
|
||||
const outputFormat = await resolveOutputFormat(inputBuffer, filename);
|
||||
let outputBuffer = result.buffer;
|
||||
if (outputFormat.format !== "png") {
|
||||
outputBuffer = await sharp(result.buffer)
|
||||
.toFormat(outputFormat.format, { quality: outputFormat.quality })
|
||||
.toBuffer();
|
||||
}
|
||||
const ext = outputFormat.format === "jpeg" ? "jpg" : outputFormat.format;
|
||||
const outputFilename = `${filename.replace(/\.[^.]+$/, "")}_blurred.${ext}`;
|
||||
return {
|
||||
buffer: outputBuffer,
|
||||
filename: outputFilename,
|
||||
contentType: outputFormat.contentType,
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import sharp from "sharp";
|
||||
import { z } from "zod";
|
||||
import { resolveOutputFormat } from "../../lib/output-format.js";
|
||||
import { createToolRoute } from "../tool-factory.js";
|
||||
|
||||
const hexColor = z.string().regex(/^#[0-9a-fA-F]{6}$/);
|
||||
@@ -144,9 +145,20 @@ export function registerBorder(app: FastifyInstance) {
|
||||
.toBuffer();
|
||||
}
|
||||
|
||||
const buffer = await sharp(buf).png().toBuffer();
|
||||
const outName = filename.replace(/\.[^.]+$/, ".png");
|
||||
return { buffer, filename: outName, contentType: "image/png" };
|
||||
const outputFormat = await resolveOutputFormat(inputBuffer, filename);
|
||||
const ALPHA_FORMATS = new Set(["png", "webp", "avif", "tiff"]);
|
||||
const needsAlpha = settings.cornerRadius > 0 || settings.shadow;
|
||||
|
||||
if (needsAlpha && !ALPHA_FORMATS.has(outputFormat.format)) {
|
||||
const buffer = await sharp(buf).png().toBuffer();
|
||||
const outName = filename.replace(/\.[^.]+$/, ".png");
|
||||
return { buffer, filename: outName, contentType: "image/png" };
|
||||
}
|
||||
|
||||
const buffer = await sharp(buf)
|
||||
.toFormat(outputFormat.format, { quality: outputFormat.quality })
|
||||
.toBuffer();
|
||||
return { buffer, filename, contentType: outputFormat.contentType };
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import { isToolInstalled } from "../../lib/feature-status.js";
|
||||
import { validateImageBuffer } from "../../lib/file-validation.js";
|
||||
import { decodeToSharpCompat, needsCliDecode } from "../../lib/format-decoders.js";
|
||||
import { decodeHeic, encodeHeic } from "../../lib/heic-converter.js";
|
||||
import { resolveOutputFormat } from "../../lib/output-format.js";
|
||||
import { createWorkspace } from "../../lib/workspace.js";
|
||||
import { updateSingleFileProgress } from "../progress.js";
|
||||
|
||||
@@ -30,8 +31,8 @@ const BROWSER_PREVIEWABLE = new Set(["png", "jpg", "jpeg", "webp", "gif", "avif"
|
||||
|
||||
const settingsSchema = z.object({
|
||||
format: z
|
||||
.enum(["png", "jpg", "jpeg", "webp", "tiff", "gif", "avif", "heic", "heif"])
|
||||
.default("png"),
|
||||
.enum(["auto", "png", "jpg", "jpeg", "webp", "tiff", "gif", "avif", "heic", "heif"])
|
||||
.default("auto"),
|
||||
quality: z.number().int().min(1).max(100).default(95),
|
||||
});
|
||||
|
||||
@@ -122,6 +123,12 @@ export function registerEraseObject(app: FastifyInstance) {
|
||||
format = settingsResult.data.format;
|
||||
quality = settingsResult.data.quality;
|
||||
|
||||
if (format === "auto") {
|
||||
const detected = await resolveOutputFormat(imageBuffer, filename);
|
||||
format = detected.format === "jpeg" ? "jpg" : detected.format;
|
||||
quality = detected.quality;
|
||||
}
|
||||
|
||||
request.log.info(
|
||||
{
|
||||
toolId: "erase-object",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import sharp from "sharp";
|
||||
import { z } from "zod";
|
||||
import { resolveOutputFormat } from "../../lib/output-format.js";
|
||||
import { createToolRoute } from "../tool-factory.js";
|
||||
|
||||
const settingsSchema = z.object({
|
||||
@@ -73,13 +74,21 @@ export function registerReplaceColor(app: FastifyInstance) {
|
||||
}
|
||||
}
|
||||
|
||||
const outputFormat = await resolveOutputFormat(inputBuffer, filename);
|
||||
const ALPHA_FORMATS = new Set(["png", "webp", "avif", "tiff"]);
|
||||
const needsAlpha = settings.makeTransparent;
|
||||
const useFormat =
|
||||
needsAlpha && !ALPHA_FORMATS.has(outputFormat.format)
|
||||
? { format: "png" as const, quality: 100, contentType: "image/png" }
|
||||
: outputFormat;
|
||||
|
||||
const buffer = await sharp(pixels, {
|
||||
raw: { width: info.width, height: info.height, channels: 4 },
|
||||
})
|
||||
.png()
|
||||
.toFormat(useFormat.format, { quality: useFormat.quality })
|
||||
.toBuffer();
|
||||
|
||||
return { buffer, filename, contentType: "image/png" };
|
||||
return { buffer, filename, contentType: useFormat.contentType };
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { resize } from "@snapotter/image-engine";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import sharp from "sharp";
|
||||
import { z } from "zod";
|
||||
import { resolveOutputFormat } from "../../lib/output-format.js";
|
||||
import { createToolRoute } from "../tool-factory.js";
|
||||
|
||||
const settingsSchema = z.object({
|
||||
@@ -17,10 +18,13 @@ export function registerResize(app: FastifyInstance) {
|
||||
toolId: "resize",
|
||||
settingsSchema,
|
||||
process: async (inputBuffer, settings, filename) => {
|
||||
const outputFormat = await resolveOutputFormat(inputBuffer, filename);
|
||||
const image = sharp(inputBuffer);
|
||||
const result = await resize(image, settings);
|
||||
const buffer = await result.toBuffer();
|
||||
return { buffer, filename, contentType: "image/png" };
|
||||
const buffer = await result
|
||||
.toFormat(outputFormat.format, { quality: outputFormat.quality })
|
||||
.toBuffer();
|
||||
return { buffer, filename, contentType: outputFormat.contentType };
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -239,8 +239,20 @@ export function registerRestorePhoto(app: FastifyInstance) {
|
||||
denoiseStrength: s.denoiseStrength,
|
||||
colorize: s.colorize,
|
||||
});
|
||||
const outputFilename = `${filename.replace(/\.[^.]+$/, "")}_restored.png`;
|
||||
return { buffer: result.buffer, filename: outputFilename, contentType: "image/png" };
|
||||
const outputFormat = await resolveOutputFormat(inputBuffer, filename);
|
||||
let outputBuffer = result.buffer;
|
||||
if (outputFormat.format !== "png") {
|
||||
outputBuffer = await sharp(result.buffer)
|
||||
.toFormat(outputFormat.format, { quality: outputFormat.quality })
|
||||
.toBuffer();
|
||||
}
|
||||
const ext = outputFormat.format === "jpeg" ? "jpg" : outputFormat.format;
|
||||
const outputFilename = `${filename.replace(/\.[^.]+$/, "")}_restored.${ext}`;
|
||||
return {
|
||||
buffer: outputBuffer,
|
||||
filename: outputFilename,
|
||||
contentType: outputFormat.contentType,
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { flip, rotate } from "@snapotter/image-engine";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import sharp from "sharp";
|
||||
import { z } from "zod";
|
||||
import { resolveOutputFormat } from "../../lib/output-format.js";
|
||||
import { createToolRoute } from "../tool-factory.js";
|
||||
|
||||
const settingsSchema = z.object({
|
||||
@@ -15,6 +16,7 @@ export function registerRotate(app: FastifyInstance) {
|
||||
toolId: "rotate",
|
||||
settingsSchema,
|
||||
process: async (inputBuffer, settings, filename) => {
|
||||
const outputFormat = await resolveOutputFormat(inputBuffer, filename);
|
||||
let image = sharp(inputBuffer);
|
||||
|
||||
// Apply rotation first
|
||||
@@ -30,8 +32,10 @@ export function registerRotate(app: FastifyInstance) {
|
||||
});
|
||||
}
|
||||
|
||||
const buffer = await image.toBuffer();
|
||||
return { buffer, filename, contentType: "image/png" };
|
||||
const buffer = await image
|
||||
.toFormat(outputFormat.format, { quality: outputFormat.quality })
|
||||
.toBuffer();
|
||||
return { buffer, filename, contentType: outputFormat.contentType };
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import sharp from "sharp";
|
||||
import { z } from "zod";
|
||||
import { resolveOutputFormat } from "../../lib/output-format.js";
|
||||
import { createToolRoute } from "../tool-factory.js";
|
||||
|
||||
const settingsSchema = z.object({
|
||||
@@ -33,6 +34,7 @@ export function registerTextOverlay(app: FastifyInstance) {
|
||||
toolId: "text-overlay",
|
||||
settingsSchema,
|
||||
process: async (inputBuffer, settings, filename) => {
|
||||
const outputFormat = await resolveOutputFormat(inputBuffer, filename);
|
||||
const image = sharp(inputBuffer);
|
||||
const metadata = await image.metadata();
|
||||
const width = metadata.width ?? 800;
|
||||
@@ -79,9 +81,11 @@ export function registerTextOverlay(app: FastifyInstance) {
|
||||
|
||||
const svgBuffer = Buffer.from(svgOverlay);
|
||||
const result = await image.composite([{ input: svgBuffer, top: 0, left: 0 }]);
|
||||
const buffer = await result.toBuffer();
|
||||
const buffer = await result
|
||||
.toFormat(outputFormat.format, { quality: outputFormat.quality })
|
||||
.toBuffer();
|
||||
|
||||
return { buffer, filename, contentType: "image/png" };
|
||||
return { buffer, filename, contentType: outputFormat.contentType };
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import { isToolInstalled } from "../../lib/feature-status.js";
|
||||
import { validateImageBuffer } from "../../lib/file-validation.js";
|
||||
import { decodeToSharpCompat, needsCliDecode } from "../../lib/format-decoders.js";
|
||||
import { decodeHeic, encodeHeic } from "../../lib/heic-converter.js";
|
||||
import { resolveOutputFormat } from "../../lib/output-format.js";
|
||||
import { createWorkspace } from "../../lib/workspace.js";
|
||||
import { updateSingleFileProgress } from "../progress.js";
|
||||
import { registerToolProcessFn } from "../tool-factory.js";
|
||||
@@ -21,7 +22,7 @@ const settingsSchema = z.object({
|
||||
model: z.string().default("auto"),
|
||||
faceEnhance: z.boolean().default(false),
|
||||
denoise: z.union([z.number(), z.string()]).transform(Number).default(0),
|
||||
format: z.string().default("png"),
|
||||
format: z.string().default("auto"),
|
||||
quality: z.union([z.number(), z.string()]).transform(Number).default(95),
|
||||
});
|
||||
|
||||
@@ -99,8 +100,13 @@ export function registerUpscale(app: FastifyInstance) {
|
||||
const model = settings.model;
|
||||
const faceEnhance = settings.faceEnhance;
|
||||
const denoise = settings.denoise;
|
||||
const format = settings.format;
|
||||
let format = settings.format;
|
||||
const outputQuality = settings.quality;
|
||||
|
||||
if (format === "auto") {
|
||||
const detected = await resolveOutputFormat(fileBuffer, filename);
|
||||
format = detected.format === "jpeg" ? "jpg" : detected.format;
|
||||
}
|
||||
request.log.info(
|
||||
{ toolId: "upscale", imageSize: fileBuffer.length, scale, model, format },
|
||||
"Starting upscale",
|
||||
@@ -248,8 +254,20 @@ export function registerUpscale(app: FastifyInstance) {
|
||||
const jobId = randomUUID();
|
||||
const workspacePath = await createWorkspace(jobId);
|
||||
const result = await upscale(orientedBuffer, join(workspacePath, "output"), { scale });
|
||||
const outputFilename = `${filename.replace(/\.[^.]+$/, "")}_${scale}x.png`;
|
||||
return { buffer: result.buffer, filename: outputFilename, contentType: "image/png" };
|
||||
const outputFormat = await resolveOutputFormat(inputBuffer, filename);
|
||||
let outputBuffer = result.buffer;
|
||||
if (outputFormat.format !== "png") {
|
||||
outputBuffer = await sharp(result.buffer)
|
||||
.toFormat(outputFormat.format, { quality: outputFormat.quality })
|
||||
.toBuffer();
|
||||
}
|
||||
const ext = outputFormat.format === "jpeg" ? "jpg" : outputFormat.format;
|
||||
const outputFilename = `${filename.replace(/\.[^.]+$/, "")}_${scale}x.${ext}`;
|
||||
return {
|
||||
buffer: outputBuffer,
|
||||
filename: outputFilename,
|
||||
contentType: outputFormat.contentType,
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import sharp from "sharp";
|
||||
import { z } from "zod";
|
||||
import { resolveOutputFormat } from "../../lib/output-format.js";
|
||||
import { createToolRoute } from "../tool-factory.js";
|
||||
|
||||
const settingsSchema = z.object({
|
||||
@@ -38,6 +39,7 @@ export function registerWatermarkText(app: FastifyInstance) {
|
||||
toolId: "watermark-text",
|
||||
settingsSchema,
|
||||
process: async (inputBuffer, settings, filename) => {
|
||||
const outputFormat = await resolveOutputFormat(inputBuffer, filename);
|
||||
const image = sharp(inputBuffer);
|
||||
const metadata = await image.metadata();
|
||||
const width = metadata.width ?? 800;
|
||||
@@ -103,9 +105,11 @@ export function registerWatermarkText(app: FastifyInstance) {
|
||||
|
||||
const svgBuffer = Buffer.from(svgOverlay);
|
||||
const result = await image.composite([{ input: svgBuffer, top: 0, left: 0 }]);
|
||||
const buffer = await result.toBuffer();
|
||||
const buffer = await result
|
||||
.toFormat(outputFormat.format, { quality: outputFormat.quality })
|
||||
.toBuffer();
|
||||
|
||||
return { buffer, filename, contentType: "image/png" };
|
||||
return { buffer, filename, contentType: outputFormat.contentType };
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ Pre-imports heavy libraries at startup to eliminate cold-start latency.
|
||||
"""
|
||||
import sys
|
||||
import json
|
||||
import gc
|
||||
import io
|
||||
import os
|
||||
import traceback
|
||||
@@ -198,6 +199,20 @@ def _run_script_main(script_name, args):
|
||||
# ── Main loop ───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
MAX_REQUESTS = int(os.environ.get("DISPATCHER_MAX_REQUESTS", "50"))
|
||||
|
||||
|
||||
def _cleanup_after_request():
|
||||
"""Free unreferenced objects and GPU memory after each request."""
|
||||
gc.collect()
|
||||
try:
|
||||
import torch
|
||||
if torch.cuda.is_available():
|
||||
torch.cuda.empty_cache()
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
|
||||
def main():
|
||||
# Signal readiness with GPU status
|
||||
gpu = False
|
||||
@@ -207,7 +222,9 @@ def main():
|
||||
except ImportError as e:
|
||||
print(f"[dispatcher] GPU detection failed: {e}", file=sys.stderr, flush=True)
|
||||
print(json.dumps({"ready": True, "gpu": gpu}), file=sys.stderr, flush=True)
|
||||
print(f"[dispatcher] Ready. GPU: {gpu}. Modules: {list(available_modules.keys())}", file=sys.stderr, flush=True)
|
||||
print(f"[dispatcher] Ready. GPU: {gpu}. Max requests: {MAX_REQUESTS}. Modules: {list(available_modules.keys())}", file=sys.stderr, flush=True)
|
||||
|
||||
request_count = 0
|
||||
|
||||
for line in sys.stdin:
|
||||
line = line.strip()
|
||||
@@ -241,6 +258,14 @@ def main():
|
||||
sys.stdout.write(json.dumps(response) + "\n")
|
||||
sys.stdout.flush()
|
||||
|
||||
_cleanup_after_request()
|
||||
request_count += 1
|
||||
|
||||
if request_count >= MAX_REQUESTS:
|
||||
print(f"[dispatcher] Reached max requests ({MAX_REQUESTS}), shutting down for restart",
|
||||
file=sys.stderr, flush=True)
|
||||
break
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
@@ -74,6 +74,41 @@ let dispatcherGpuAvailable = false;
|
||||
const pendingRequests = new Map<string, PendingRequest>();
|
||||
let stdoutBuffer = "";
|
||||
|
||||
// Crash recovery with exponential backoff
|
||||
// biome-ignore lint/style/useConst: reassigned on crash events
|
||||
let consecutiveCrashes = 0;
|
||||
// biome-ignore lint/style/useConst: reassigned on crash events
|
||||
let lastCrashTime = 0;
|
||||
// biome-ignore lint/style/useConst: reassigned on crash events
|
||||
let backoffUntil = 0;
|
||||
const CRASH_WINDOW_MS = 60_000;
|
||||
const MAX_CONSECUTIVE_CRASHES = 5;
|
||||
const BASE_BACKOFF_MS = 1_000;
|
||||
|
||||
function recordCrash(): void {
|
||||
const now = Date.now();
|
||||
if (now - lastCrashTime > CRASH_WINDOW_MS) {
|
||||
consecutiveCrashes = 1;
|
||||
} else {
|
||||
consecutiveCrashes++;
|
||||
}
|
||||
lastCrashTime = now;
|
||||
|
||||
if (consecutiveCrashes >= MAX_CONSECUTIVE_CRASHES) {
|
||||
console.error(
|
||||
`[bridge] Dispatcher crashed ${consecutiveCrashes} times in ${CRASH_WINDOW_MS / 1000}s, disabling permanently`,
|
||||
);
|
||||
dispatcherFailed = true;
|
||||
return;
|
||||
}
|
||||
|
||||
const delay = BASE_BACKOFF_MS * 2 ** (consecutiveCrashes - 1);
|
||||
backoffUntil = now + delay;
|
||||
console.warn(
|
||||
`[bridge] Dispatcher crash #${consecutiveCrashes}, backing off ${delay}ms before restart`,
|
||||
);
|
||||
}
|
||||
|
||||
function startDispatcher(): ChildProcess | null {
|
||||
if (dispatcherFailed) return null;
|
||||
|
||||
@@ -100,6 +135,7 @@ function startDispatcher(): ChildProcess | null {
|
||||
if (parsed.ready === true) {
|
||||
dispatcherReady = true;
|
||||
dispatcherGpuAvailable = parsed.gpu === true;
|
||||
consecutiveCrashes = 0;
|
||||
console.log(`[bridge] Python dispatcher ready (GPU: ${parsed.gpu === true})`);
|
||||
continue;
|
||||
}
|
||||
@@ -168,10 +204,10 @@ function startDispatcher(): ChildProcess | null {
|
||||
child.on("error", (err: NodeJS.ErrnoException) => {
|
||||
console.error(`[bridge] Dispatcher error: ${err.message} (code: ${err.code})`);
|
||||
if (err.code === "ENOENT") {
|
||||
// Venv python not found - mark as failed, will fall back to per-request
|
||||
dispatcherFailed = true;
|
||||
} else {
|
||||
recordCrash();
|
||||
}
|
||||
// Reject all pending requests
|
||||
for (const [id, req] of pendingRequests.entries()) {
|
||||
req.reject(new Error(extractPythonError(err)));
|
||||
pendingRequests.delete(id);
|
||||
@@ -181,11 +217,11 @@ function startDispatcher(): ChildProcess | null {
|
||||
});
|
||||
|
||||
child.on("close", () => {
|
||||
// Reject all pending requests
|
||||
for (const [id, req] of pendingRequests.entries()) {
|
||||
req.reject(new Error("Python dispatcher exited unexpectedly"));
|
||||
pendingRequests.delete(id);
|
||||
}
|
||||
recordCrash();
|
||||
dispatcher = null;
|
||||
dispatcherReady = false;
|
||||
});
|
||||
@@ -200,6 +236,7 @@ function startDispatcher(): ChildProcess | null {
|
||||
function getDispatcher(): ChildProcess | null {
|
||||
if (dispatcherFailed) return null;
|
||||
if (!dispatcher || dispatcher.killed) {
|
||||
if (Date.now() < backoffUntil) return null;
|
||||
dispatcher = startDispatcher();
|
||||
}
|
||||
return dispatcher;
|
||||
@@ -259,6 +296,26 @@ export function isGpuAvailable(): boolean {
|
||||
return dispatcherGpuAvailable;
|
||||
}
|
||||
|
||||
export interface DispatcherStatus {
|
||||
running: boolean;
|
||||
ready: boolean;
|
||||
failed: boolean;
|
||||
gpu: boolean;
|
||||
pid: number | null;
|
||||
consecutiveCrashes: number;
|
||||
}
|
||||
|
||||
export function getDispatcherStatus(): DispatcherStatus {
|
||||
return {
|
||||
running: dispatcher !== null && !dispatcher.killed,
|
||||
ready: dispatcherReady,
|
||||
failed: dispatcherFailed,
|
||||
gpu: dispatcherGpuAvailable,
|
||||
pid: dispatcher?.pid ?? null,
|
||||
consecutiveCrashes,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Shut down the persistent dispatcher process.
|
||||
*/
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export { removeBackground } from "./background-removal.js";
|
||||
export { isGpuAvailable, shutdownDispatcher } from "./bridge.js";
|
||||
export type { DispatcherStatus } from "./bridge.js";
|
||||
export { getDispatcherStatus, isGpuAvailable, shutdownDispatcher } from "./bridge.js";
|
||||
export { colorize } from "./colorization.js";
|
||||
export type { DetectFacesResult, FaceRegion } from "./face-detection.js";
|
||||
export { blurFaces, detectFaces } from "./face-detection.js";
|
||||
|
||||
Reference in New Issue
Block a user