refactor(core): migrate to Bun and converge acquisition policies and Agent boundaries

This commit is contained in:
root committed 2026-10-13 14:37:03 +08:00
1 parent 3f1c6d02f6
commit 27c0f27434
268 files changed
+7468 -7852

No files matched your search

+15
View File
@@ -0,0 +1,15 @@
{
"name": "@umt/runtime",
"version": "0.3.44",
"private": true,
"type": "module",
"exports": {
".": "./src/index.ts",
"./worker": "./src/worker.ts"
},
"scripts": {
"typecheck": "bun x --bun tsc --noEmit -p tsconfig.json",
"test": "bun x --bun vitest run"
},
"dependencies": {}
}
+212
View File
@@ -0,0 +1,212 @@
import { spawn } from "node:child_process";
import { readFileSync, writeSync } from "node:fs";
import { mkdir, open, opendir, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
import { hostname } from "node:os";
import { Readable, Writable } from "node:stream";
export const runtimeHostname = hostname;
export const runtimeReadTextFile = (path: string) => readFile(path, "utf8");
export const runtimeReadTextFileSync = (path: string) => readFileSync(path, "utf8");
export const runtimeWriteTextFile = (path: string, text: string) => writeFile(path, text);
export const runtimeMkdir = mkdir;
export const runtimeRename = rename;
export const runtimeRemove = rm;
export const runtimeReadDir = opendir;
export async function runtimeStat(path: string) {
const s = await stat(path);
return { size: s.size, isFile: s.isFile(), isDirectory: s.isDirectory() };
}
export async function runtimeOpen(
path: string,
opts: { read?: boolean; write?: boolean; create?: boolean; truncate?: boolean },
) {
const f = await open(path, opts.write ? (opts.truncate ? "w" : "a") : "r");
return {
stat: async () => {
const s = await f.stat();
return { size: s.size, isFile: s.isFile(), isDirectory: s.isDirectory() };
},
close: () => f.close(),
write: async (bytes: Uint8Array) => (await f.write(bytes)).bytesWritten,
get readable() {
return Readable.toWeb(f.createReadStream({ autoClose: true })) as unknown as ReadableStream<Uint8Array>;
},
};
}
export const runtimeWriteSync = (bytes: Uint8Array) => writeSync(1, bytes);
export const runtimeWrite = async (bytes: Uint8Array) => runtimeWriteSync(bytes);
export const runtimeUnrefTimer = (timer: unknown) => (timer as { unref?: () => void }).unref?.();
type CommandOptions = {
args?: string[];
stdin?: "piped" | "null" | "inherit";
stdout?: "piped" | "null" | "inherit";
stderr?: "piped" | "null" | "inherit";
env?: Record<string, string>;
clearEnv?: boolean;
signal?: AbortSignal;
};
export type ChildProcess = ReturnType<Command["spawn"]>;
export class Command {
constructor(
private cmd: string,
private opts: CommandOptions = {},
) {}
spawn() {
const o = this.opts;
const io = (s: string | undefined) => (s === "piped" ? "pipe" : s === "null" ? "ignore" : "inherit");
const p = spawn(this.cmd, o.args ?? [], {
stdio: [io(o.stdin), io(o.stdout ?? "piped"), io(o.stderr ?? "piped")],
env: o.clearEnv ? o.env : { ...process.env, ...o.env },
signal: o.signal,
});
const status = new Promise<{ success: boolean; code: number; signal: string | null }>((resolve, reject) => {
p.once("error", reject);
p.once("close", (code, signal) => resolve({ success: code === 0, code: code ?? 1, signal }));
});
// Attach a handler immediately: callers may still be writing stdin when spawn fails.
status.catch(() => {});
const child = {
pid: p.pid!,
stdin: p.stdin ? Writable.toWeb(p.stdin) : new WritableStream<Uint8Array>(),
stdout: p.stdout
? (Readable.toWeb(p.stdout) as unknown as ReadableStream<Uint8Array>)
: new ReadableStream<Uint8Array>({
start(c) {
c.close();
},
}),
stderr: p.stderr
? (Readable.toWeb(p.stderr) as unknown as ReadableStream<Uint8Array>)
: new ReadableStream<Uint8Array>({
start(c) {
c.close();
},
}),
status,
kill: (signal: string = "SIGTERM") => {
p.kill(signal as NodeJS.Signals);
},
unref: () => {
p.unref();
},
output: async () => {
const [s, stdout, stderr] = await Promise.all([
status,
new Response(child.stdout).bytes(),
new Response(child.stderr).bytes(),
]);
return { ...s, stdout, stderr };
},
};
return child;
}
output() {
return this.spawn().output();
}
}
export interface NetAddr {
hostname: string;
port: number;
}
export interface HttpServer {
addr: NetAddr;
finished: Promise<void>;
shutdown(): Promise<void>;
}
type SocketData = { client: WebSocket };
const upgrades = new WeakMap<Request, Bun.Server<SocketData>>();
export function serve(
opts: { port: number; hostname?: string; signal?: AbortSignal; onListen?: (addr: NetAddr) => void },
fetcher: (req: Request) => Response | Promise<Response>,
): HttpServer {
let finish!: () => void;
const finished = new Promise<void>((resolve) => {
finish = resolve;
});
const server = Bun.serve<SocketData>({
port: opts.port,
hostname: opts.hostname,
async fetch(req, server) {
upgrades.set(req, server);
const response = await fetcher(req);
upgrades.delete(req);
return upgradedRequests.has(req) ? undefined : response;
},
websocket: {
open(ws) {
const c = ws.data.client;
socketBindings.get(c)?.(ws);
socketBindings.delete(c);
Object.defineProperty(c, "readyState", { value: WebSocket.OPEN, configurable: true });
c.dispatchEvent(new Event("open"));
},
message(ws, data) {
ws.data.client.dispatchEvent(
new MessageEvent("message", {
data:
typeof data === "string" ? data : data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength),
}),
);
},
close(ws, code, reason) {
const c = ws.data.client;
Object.defineProperty(c, "readyState", { value: WebSocket.CLOSED, configurable: true });
c.dispatchEvent(new CloseEvent("close", { code, reason }));
},
},
});
const addr = { hostname: opts.hostname ?? "0.0.0.0", port: server.port! };
opts.onListen?.(addr);
const shutdown = async () => {
await server.stop(true);
finish();
};
opts.signal?.addEventListener(
"abort",
() => {
void shutdown();
},
{ once: true },
);
if (opts.signal?.aborted) void shutdown();
return { addr, finished, shutdown };
}
const upgradedRequests = new WeakSet<Request>();
export function upgradeWebSocket(req: Request, opts: { protocol?: string } = {}) {
const server = upgrades.get(req);
if (!server) throw new Error("WebSocket upgrade requires a Bun HTTP server");
// EventTarget facade preserves the bridge's browser WebSocket callbacks.
const client = new EventTarget() as WebSocket;
let ws: Bun.ServerWebSocket<SocketData> | undefined;
Object.defineProperty(client, "readyState", { value: WebSocket.CONNECTING, configurable: true });
for (const event of ["open", "message", "close", "error"]) {
let callback: ((e: Event) => void) | null = null;
Object.defineProperty(client, `on${event}`, {
get: () => callback,
set: (fn) => {
if (callback) client.removeEventListener(event, callback);
callback = fn;
if (fn) client.addEventListener(event, fn);
},
});
}
client.send = (data) => {
ws?.send(data as string | ArrayBuffer);
};
client.close = (code, reason) => {
ws?.close(code, reason);
};
const data = { client };
// Capture the native socket in open before dispatching the bridge callbacks.
socketBindings.set(client, (socket) => {
ws = socket;
});
if (!server.upgrade(req, { data, headers: opts.protocol ? { "Sec-WebSocket-Protocol": opts.protocol } : undefined }))
throw new Error("WebSocket upgrade failed");
const response = new Response(null);
upgradedRequests.add(req);
return { socket: client, response };
}
const socketBindings = new WeakMap<WebSocket, (ws: Bun.ServerWebSocket<SocketData>) => void>();
+262
View File
@@ -0,0 +1,262 @@
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(() => {});
}
}
+37
View File
@@ -0,0 +1,37 @@
import { decodeWorkerRequest, readWorkerRequestText, writeWorkerMessage } from "../../src/worker.ts";
const req = decodeWorkerRequest(await readWorkerRequestText(4096));
const send = (type: "result", result: unknown) => writeWorkerMessage({ id: req.id, type, result }, 4096);
switch (req.task) {
case "normal":
writeWorkerMessage({ id: req.id, type: "progress", value: { bytes: 2 } }, 4096);
send("result", req.input);
break;
case "large":
writeWorkerMessage({ id: req.id, type: "result", result: "中文".repeat(200_000) }, 2 * 1024 * 1024);
break;
case "wrong-id":
writeWorkerMessage({ id: "other", type: "result", result: true }, 4096);
break;
case "truncated":
process.stdout.write('{"id":"1"');
break;
case "oversized":
process.stdout.write("x".repeat(8192));
break;
case "after-result":
send("result", true);
send("result", false);
break;
case "failed-exit":
send("result", true);
process.exitCode = 1;
break;
case "hang":
setInterval(() => {}, 1000);
break;
case "stderr":
process.stderr.write("x".repeat(65536));
send("result", true);
break;
}
+82
View File
@@ -0,0 +1,82 @@
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
import { WorkerConnection, workerLines } from "../src/worker.ts";
const fixture = fileURLToPath(new URL("./fixtures/worker.ts", import.meta.url));
const limits = { maxRequestBytes: 4096, maxResponseBytes: 4096 };
function connection() {
return new WorkerConnection({ cmd: process.execPath, args: [fixture] });
}
const request = (task: string) => ({ id: "1", task, input: "中文" });
describe("worker 管道与回收", () => {
it("拆开的 UTF-8 和多行消息仍能正确分帧", async () => {
const bytes = new TextEncoder().encode("中文\n第二行\n");
const stream = new ReadableStream<Uint8Array>({
start(c) {
for (const b of bytes) c.enqueue(Uint8Array.of(b));
c.close();
},
});
const lines = [];
for await (const line of workerLines(stream.getReader(), 64)) lines.push(line);
expect(lines).toEqual(["中文", "第二行"]);
});
it("转发进度、返回结果,并持续排空大量 stderr", async () => {
const seen: unknown[] = [];
expect(await connection().request(request("normal"), { ...limits, onProgress: (p) => seen.push(p) })).toEqual({
id: "1",
type: "result",
result: "中文",
});
expect(seen).toEqual([{ bytes: 2 }]);
expect(await connection().request(request("stderr"), limits)).toMatchObject({ result: true });
});
it("大结果跨多个管道分块也完整返回", async () => {
const reply = await connection().request(request("large"), { ...limits, maxResponseBytes: 2 * 1024 * 1024 });
expect(reply).toEqual({ id: "1", type: "result", result: "中文".repeat(200_000) });
});
it.each([
["wrong-id", "protocol"],
["truncated", "protocol"],
["oversized", "size"],
["after-result", "protocol"],
["failed-exit", "exit"],
])("拒绝 %s,而不接受部分或错误响应", async (task, kind) => {
await expect(connection().request(request(task), limits)).rejects.toMatchObject({ kind });
});
it("请求大小超限也回收已启动的进程", async () => {
const c = connection();
await expect(c.request({ ...request("hang"), input: "x".repeat(8192) }, limits)).rejects.toMatchObject({
kind: "size",
});
});
it("启动失败保留 spawn 分类", async () => {
const c = new WorkerConnection({ cmd: "/nonexistent/umt-worker", args: [] });
await expect(c.request(request("normal"), limits)).rejects.toMatchObject({ kind: "spawn" });
});
it("执行中的请求可终止,返回调用方的原因", async () => {
const c = connection();
const pending = c.request(request("hang"), limits);
const reason = new Error("调用方取消");
c.terminate(reason, 50);
await expect(pending).rejects.toBe(reason);
});
it("进度消费者抛错时回收连接,不让任务继续等待", async () => {
await expect(
connection().request(request("normal"), {
...limits,
onProgress: () => {
throw new Error("进度无效");
},
}),
).rejects.toMatchObject({ kind: "protocol", message: expect.stringContaining("进度无效") });
});
});
+1
View File
@@ -0,0 +1 @@
{ "extends": "../../config/tsconfig.base.json", "include": ["src", "test"] }