263 lines
9.4 KiB
TypeScript
263 lines
9.4 KiB
TypeScript
import { Readable } from "node:stream";
|
|
import { type ChildProcess, Command, runtimeWriteSync } from "./index.ts";
|
|
|
|
/** 本地执行协议。业务类型、重试、权限与超时策略由调用方负责。 */
|
|
export interface WorkerRequest {
|
|
id: string;
|
|
task: string;
|
|
input: unknown;
|
|
}
|
|
export interface WorkerError {
|
|
code: string;
|
|
message: string;
|
|
detail?: Record<string, unknown>;
|
|
}
|
|
export type WorkerReply =
|
|
| { id: string; type: "result"; result: unknown }
|
|
| { id: string; type: "error"; error: WorkerError };
|
|
export type WorkerMessage = WorkerReply | { id: string; type: "progress"; value: unknown };
|
|
|
|
export class WorkerTransportError extends Error {
|
|
constructor(
|
|
readonly kind: "spawn" | "protocol" | "exit" | "size",
|
|
message: string,
|
|
override readonly cause?: unknown,
|
|
) {
|
|
super(message);
|
|
this.name = "WorkerTransportError";
|
|
}
|
|
}
|
|
|
|
const encoder = new TextEncoder();
|
|
export function encodeWorkerMessage(value: WorkerRequest | WorkerMessage, maxBytes: number): Uint8Array {
|
|
const bytes = encoder.encode(`${JSON.stringify(value)}\n`);
|
|
if (bytes.byteLength > maxBytes) throw new WorkerTransportError("size", `worker 消息超过 ${maxBytes} 字节`);
|
|
return bytes;
|
|
}
|
|
|
|
/** 按字节限制一行的大小;UTF-8 与换行可以跨管道分块。EOF 不能代替消息结束符。 */
|
|
export async function* workerLines(
|
|
reader: ReadableStreamDefaultReader<Uint8Array>,
|
|
maxBytes: number,
|
|
): AsyncGenerator<string> {
|
|
const decoder = new TextDecoder("utf-8", { fatal: true });
|
|
let parts: string[] = [];
|
|
let length = 0;
|
|
for (;;) {
|
|
const { done, value } = await reader.read();
|
|
if (done) {
|
|
if (length) throw new WorkerTransportError("protocol", "worker 消息未以换行结束");
|
|
return;
|
|
}
|
|
let start = 0;
|
|
for (let i = 0; i < value.length; i++) {
|
|
if (value[i] !== 10) continue;
|
|
length += i - start + 1;
|
|
if (length > maxBytes) throw new WorkerTransportError("size", `worker 消息超过 ${maxBytes} 字节`);
|
|
parts.push(decoder.decode(value.subarray(start, i), { stream: true }), decoder.decode());
|
|
const line = parts.join("");
|
|
parts = [];
|
|
length = 0;
|
|
start = i + 1;
|
|
if (!line) throw new WorkerTransportError("protocol", "worker 返回空消息");
|
|
yield line;
|
|
}
|
|
length += value.length - start;
|
|
if (length > maxBytes) throw new WorkerTransportError("size", `worker 消息超过 ${maxBytes} 字节`);
|
|
parts.push(decoder.decode(value.subarray(start), { stream: true }));
|
|
}
|
|
}
|
|
|
|
export async function readWorkerRequestText(maxBytes: number): Promise<string> {
|
|
const stream = Readable.toWeb(process.stdin) as unknown as ReadableStream<Uint8Array>;
|
|
const reader = stream.getReader();
|
|
try {
|
|
let text: string | undefined;
|
|
for await (const line of workerLines(reader, maxBytes)) {
|
|
if (text !== undefined) throw new WorkerTransportError("protocol", "一次性 worker 只能接收一个请求");
|
|
text = line;
|
|
}
|
|
if (text === undefined) throw new WorkerTransportError("protocol", "worker 没有收到请求");
|
|
return text;
|
|
} finally {
|
|
reader.releaseLock();
|
|
}
|
|
}
|
|
|
|
export function decodeWorkerRequest(text: string): WorkerRequest {
|
|
const req = JSON.parse(text) as WorkerRequest;
|
|
if (!req || typeof req.id !== "string" || !req.id || typeof req.task !== "string" || !("input" in req))
|
|
throw new WorkerTransportError("protocol", "worker 请求格式无效");
|
|
return req;
|
|
}
|
|
|
|
export function writeWorkerMessage(message: WorkerMessage, maxBytes: number): void {
|
|
const bytes = encodeWorkerMessage(message, maxBytes);
|
|
let offset = 0;
|
|
while (offset < bytes.length) offset += runtimeWriteSync(bytes.subarray(offset));
|
|
}
|
|
|
|
function decodeMessage(line: string, id: string): WorkerMessage {
|
|
const value = JSON.parse(line) as WorkerMessage;
|
|
if (!value || value.id !== id) throw new WorkerTransportError("protocol", "worker 响应不属于当前请求");
|
|
if (value.type === "result" && "result" in value) return value;
|
|
if (value.type === "progress" && "value" in value) return value;
|
|
if (value.type === "error" && typeof value.error?.code === "string" && typeof value.error?.message === "string")
|
|
return value;
|
|
throw new WorkerTransportError("protocol", "worker 响应格式无效");
|
|
}
|
|
|
|
/** 一个一次性进程连接。只管通信与回收,不重试、不决定超时、不安装权限策略。 */
|
|
export class WorkerConnection {
|
|
readonly pid: number;
|
|
private readonly child: ChildProcess;
|
|
private readonly stdout: ReadableStreamDefaultReader<Uint8Array>;
|
|
private readonly stderrReader: ReadableStreamDefaultReader<Uint8Array>;
|
|
private reject: ((error: unknown) => void) | null = null;
|
|
private terminating: unknown;
|
|
private killTimer: ReturnType<typeof setTimeout> | undefined;
|
|
private settled = false;
|
|
private started = false;
|
|
private stderr = "";
|
|
|
|
constructor(command: { cmd: string; args: string[] }, env?: { values: Record<string, string>; clear?: boolean }) {
|
|
try {
|
|
this.child = new Command(command.cmd, {
|
|
args: command.args,
|
|
stdin: "piped",
|
|
stdout: "piped",
|
|
stderr: "piped",
|
|
env: env?.values,
|
|
clearEnv: env?.clear,
|
|
}).spawn();
|
|
} catch (error) {
|
|
throw new WorkerTransportError("spawn", "worker 子进程无法启动", error);
|
|
}
|
|
this.pid = this.child.pid;
|
|
this.stdout = this.child.stdout.getReader();
|
|
this.stderrReader = this.child.stderr.getReader();
|
|
}
|
|
|
|
/** graceMs > 0 时先 SIGTERM 供文件操作清理;解析可直接 SIGKILL。等待始终有界。 */
|
|
terminate(error: unknown, graceMs = 0, onForceKill?: () => void): void {
|
|
if (this.settled || this.terminating !== undefined) return;
|
|
this.terminating = error;
|
|
const force = () => {
|
|
try {
|
|
this.child.kill("SIGKILL");
|
|
onForceKill?.();
|
|
} catch {
|
|
// 已退出
|
|
}
|
|
this.child.unref();
|
|
this.fail(error);
|
|
};
|
|
if (graceMs === 0) {
|
|
force();
|
|
return;
|
|
}
|
|
try {
|
|
this.child.kill("SIGTERM");
|
|
} catch {
|
|
// 已退出
|
|
}
|
|
this.killTimer = setTimeout(force, graceMs);
|
|
}
|
|
|
|
request(
|
|
req: WorkerRequest,
|
|
options: { maxRequestBytes: number; maxResponseBytes: number; onProgress?: (value: unknown) => void },
|
|
): Promise<WorkerReply> {
|
|
if (this.started) throw new Error("一次性 worker 连接不能重复请求");
|
|
this.started = true;
|
|
if (this.terminating !== undefined) return Promise.reject(this.terminating);
|
|
if (!this.pid)
|
|
return this.child.status.then(
|
|
() => {
|
|
this.cleanup();
|
|
throw new WorkerTransportError("exit", "worker 未启动");
|
|
},
|
|
(error) => {
|
|
this.cleanup();
|
|
throw new WorkerTransportError("spawn", "worker 子进程无法启动", error);
|
|
},
|
|
);
|
|
return new Promise((resolve, reject) => {
|
|
this.reject = reject;
|
|
const errPump = this.readStderr();
|
|
let final: WorkerReply | undefined;
|
|
const pump = (async () => {
|
|
for await (const line of workerLines(this.stdout, options.maxResponseBytes)) {
|
|
const msg = decodeMessage(line, req.id);
|
|
if (final) throw new WorkerTransportError("protocol", "worker 结果之后仍发送消息");
|
|
if (msg.type === "progress") options.onProgress?.(msg.value);
|
|
else final = msg;
|
|
}
|
|
})();
|
|
pump.catch((error) => this.terminate(this.transportError(error)));
|
|
this.child.status.then(
|
|
async (status) => {
|
|
try {
|
|
await Promise.all([pump, errPump]);
|
|
if (this.settled) return;
|
|
if (this.terminating !== undefined) return this.fail(this.terminating);
|
|
if (!status.success || !final)
|
|
throw new WorkerTransportError(
|
|
"exit",
|
|
`worker 异常退出(code=${status.code} signal=${status.signal}):${this.stderr}`,
|
|
);
|
|
this.cleanup();
|
|
resolve(final);
|
|
} catch (error) {
|
|
this.fail(this.terminating ?? this.transportError(error));
|
|
}
|
|
},
|
|
(error) => this.fail(new WorkerTransportError("spawn", "worker 子进程无法启动", error)),
|
|
);
|
|
(async () => {
|
|
const bytes = encodeWorkerMessage(req, options.maxRequestBytes);
|
|
const writer = this.child.stdin.getWriter();
|
|
try {
|
|
await writer.write(bytes);
|
|
await writer.close();
|
|
} finally {
|
|
writer.releaseLock();
|
|
}
|
|
})().catch((error) => this.terminate(this.transportError(error)));
|
|
if (this.terminating !== undefined) this.fail(this.terminating);
|
|
});
|
|
}
|
|
|
|
private transportError(error: unknown): WorkerTransportError {
|
|
return error instanceof WorkerTransportError
|
|
? error
|
|
: new WorkerTransportError("protocol", `worker 通信失败:${(error as Error).message}`, error);
|
|
}
|
|
|
|
private async readStderr(): Promise<void> {
|
|
const decoder = new TextDecoder();
|
|
try {
|
|
for (;;) {
|
|
const { done, value } = await this.stderrReader.read();
|
|
if (done) return;
|
|
this.stderr = (this.stderr + decoder.decode(value, { stream: true })).slice(-2000);
|
|
}
|
|
} catch {
|
|
// 终止时管道可能断开
|
|
}
|
|
}
|
|
|
|
private fail(error: unknown): void {
|
|
if (this.settled) return;
|
|
this.cleanup();
|
|
this.reject?.(error);
|
|
}
|
|
|
|
private cleanup(): void {
|
|
this.settled = true;
|
|
clearTimeout(this.killTimer);
|
|
void this.stdout.cancel().catch(() => {});
|
|
void this.stderrReader.cancel().catch(() => {});
|
|
}
|
|
}
|