src / worker / launcher.ts
src / worker / launcher.ts
import { spawn } from "child_process";
import fs from "fs";
import path from "path";
import { markBackgroundRequestQueued, markBackgroundRequestFailed } from "../core-bundle.mjs";
import { getConfiguredCustomConfigsPath } from "../services/customConfigsLoader.js";
import {
JOBS_DIR,
JOB_FILE_SUFFIX,
PLUGIN_ROOT,
WORKER_LOG_FILE,
WORKER_SCRIPT,
readLiveWorkerPid,
type WorkerJob,
} from "./workerShared.js";
/** process.env keys the render pipeline reads; toolsProvider exports them from the plugin config. */
const WORKER_ENV_KEYS = [
"PREVIEW_IN_CHAT",
"EMBED_PNG_METADATA",
"HTTP_SERVER_PORT",
"DRAW_THINGS_HOST",
"DRAW_THINGS_HTTP_PORT",
"DRAW_THINGS_GRPC_PORT",
] as const;
const WORKER_START_TIMEOUT_MS = 10_000;
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
/**
* Starts the worker as an orphan of LM Studio's process tree. LM Studio kills the plugin host and
* its direct child processes shortly after the tool call ends; `bash` is the direct child and exits
* right away, the worker is adopted by launchd and survives.
*/
async function startWorker(): Promise<void> {
fs.mkdirSync(JOBS_DIR, { recursive: true });
const child = spawn(
"bash",
["-c", 'nohup "$0" "$1" > /dev/null 2>&1 &', process.execPath, WORKER_SCRIPT],
{ detached: true, stdio: "ignore", cwd: PLUGIN_ROOT }
);
await new Promise<void>((resolve, reject) => {
child.once("error", reject);
child.once("spawn", () => resolve());
});
child.unref();
const deadline = Date.now() + WORKER_START_TIMEOUT_MS;
while (Date.now() < deadline) {
if (readLiveWorkerPid() !== null) return;
await sleep(100);
}
throw new Error(
`Background worker did not start within ${WORKER_START_TIMEOUT_MS / 1000}s (see ${WORKER_LOG_FILE}).`
);
}
/**
* Hands a render job to the background worker. The "queued" record is written first, so an
* immediate `request_progress` call always finds it. The job file is written before the liveness
* check, so a worker that is just shutting down still sees it in its final scan.
*/
export async function submitWorkerJob(params: {
requestId: string;
chatId: string;
workingDir: string;
args: Record<string, unknown>;
}): Promise<void> {
const env: Record<string, string> = {};
for (const key of WORKER_ENV_KEYS) {
const value = process.env[key];
if (typeof value === "string") env[key] = value;
}
const job: WorkerJob = {
requestId: params.requestId,
chatId: params.chatId,
workingDir: params.workingDir,
tool: "generate_image",
args: params.args,
env,
customConfigsPath: getConfiguredCustomConfigsPath(),
submittedAt: new Date().toISOString(),
};
await markBackgroundRequestQueued(params.workingDir, params.requestId, "generate_image");
const jobPath = path.join(JOBS_DIR, `${Date.now()}-${params.requestId}${JOB_FILE_SUFFIX}`);
try {
fs.mkdirSync(JOBS_DIR, { recursive: true });
const tmp = path.join(JOBS_DIR, `.${path.basename(jobPath)}.tmp`);
fs.writeFileSync(tmp, JSON.stringify(job), "utf8");
fs.renameSync(tmp, jobPath);
if (readLiveWorkerPid() === null) await startWorker();
} catch (e) {
const message = String((e as any)?.message || e);
// Withdraw the job so it cannot render later for a request that was already reported as failed.
// If a worker claimed it in the meantime, the file is gone and its real result replaces this entry.
fs.rmSync(jobPath, { force: true });
await markBackgroundRequestFailed(
params.workingDir,
params.requestId,
`Could not hand the job to the background worker: ${message}`,
true
);
throw e;
}
}
import { spawn } from "child_process";
import fs from "fs";
import path from "path";
import { markBackgroundRequestQueued, markBackgroundRequestFailed } from "../core-bundle.mjs";
import { getConfiguredCustomConfigsPath } from "../services/customConfigsLoader.js";
import {
JOBS_DIR,
JOB_FILE_SUFFIX,
PLUGIN_ROOT,
WORKER_LOG_FILE,
WORKER_SCRIPT,
readLiveWorkerPid,
type WorkerJob,
} from "./workerShared.js";
/** process.env keys the render pipeline reads; toolsProvider exports them from the plugin config. */
const WORKER_ENV_KEYS = [
"PREVIEW_IN_CHAT",
"EMBED_PNG_METADATA",
"HTTP_SERVER_PORT",
"DRAW_THINGS_HOST",
"DRAW_THINGS_HTTP_PORT",
"DRAW_THINGS_GRPC_PORT",
] as const;
const WORKER_START_TIMEOUT_MS = 10_000;
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
/**
* Starts the worker as an orphan of LM Studio's process tree. LM Studio kills the plugin host and
* its direct child processes shortly after the tool call ends; `bash` is the direct child and exits
* right away, the worker is adopted by launchd and survives.
*/
async function startWorker(): Promise<void> {
fs.mkdirSync(JOBS_DIR, { recursive: true });
const child = spawn(
"bash",
["-c", 'nohup "$0" "$1" > /dev/null 2>&1 &', process.execPath, WORKER_SCRIPT],
{ detached: true, stdio: "ignore", cwd: PLUGIN_ROOT }
);
await new Promise<void>((resolve, reject) => {
child.once("error", reject);
child.once("spawn", () => resolve());
});
child.unref();
const deadline = Date.now() + WORKER_START_TIMEOUT_MS;
while (Date.now() < deadline) {
if (readLiveWorkerPid() !== null) return;
await sleep(100);
}
throw new Error(
`Background worker did not start within ${WORKER_START_TIMEOUT_MS / 1000}s (see ${WORKER_LOG_FILE}).`
);
}
/**
* Hands a render job to the background worker. The "queued" record is written first, so an
* immediate `request_progress` call always finds it. The job file is written before the liveness
* check, so a worker that is just shutting down still sees it in its final scan.
*/
export async function submitWorkerJob(params: {
requestId: string;
chatId: string;
workingDir: string;
args: Record<string, unknown>;
}): Promise<void> {
const env: Record<string, string> = {};
for (const key of WORKER_ENV_KEYS) {
const value = process.env[key];
if (typeof value === "string") env[key] = value;
}
const job: WorkerJob = {
requestId: params.requestId,
chatId: params.chatId,
workingDir: params.workingDir,
tool: "generate_image",
args: params.args,
env,
customConfigsPath: getConfiguredCustomConfigsPath(),
submittedAt: new Date().toISOString(),
};
await markBackgroundRequestQueued(params.workingDir, params.requestId, "generate_image");
const jobPath = path.join(JOBS_DIR, `${Date.now()}-${params.requestId}${JOB_FILE_SUFFIX}`);
try {
fs.mkdirSync(JOBS_DIR, { recursive: true });
const tmp = path.join(JOBS_DIR, `.${path.basename(jobPath)}.tmp`);
fs.writeFileSync(tmp, JSON.stringify(job), "utf8");
fs.renameSync(tmp, jobPath);
if (readLiveWorkerPid() === null) await startWorker();
} catch (e) {
const message = String((e as any)?.message || e);
// Withdraw the job so it cannot render later for a request that was already reported as failed.
// If a worker claimed it in the meantime, the file is gone and its real result replaces this entry.
fs.rmSync(jobPath, { force: true });
await markBackgroundRequestFailed(
params.workingDir,
params.requestId,
`Could not hand the job to the background worker: ${message}`,
true
);
throw e;
}
}