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

+2 -2
View File
@@ -4,13 +4,13 @@
## 挂死隔离:`FsProxy`(对标 MP fsproxy)
`new FsProxy({ hangTimeoutMs, quickTimeoutMs, progressIntervalMs, killGraceMs, mode })` 把单个文件操作(copy / crossMove / move / match / sha256 / stat / checkRoot / sweep / unlink)放进一次性执行子进程 `fsworker.ts`(`deno run --no-config -A`,约 50 ms 启动),stdin 一条 JSON 请求,stdout 逐行心跳 + 结果。
`new FsProxy({ hangTimeoutMs, quickTimeoutMs, progressIntervalMs, killGraceMs, mode })` 把单个文件操作(copy / crossMove / move / match / sha256 / stat / checkRoot / sweep / unlink)放进一次性执行子进程 `fsworker.ts`(`bun fsworker.ts`),通过 `@umt/runtime/worker` 发送带 id 的逐行 JSON 请求、进度与结果,单条消息上限 16 MiB。
- 数据类操作(复制、跨卷搬运、哈希)按**进度停滞**判挂死:`hangTimeoutMs`(缺省 120 s)内字节数与阶段都没前进 → SIGTERM(worker 中止并清掉自己的 partial)→ `killGraceMs` 后 SIGKILL → `FsError('TIMEOUT')`。大文件复制几小时不会误杀。
- 快操作按总时长 `quickTimeoutMs`(缺省 30 s)。`statSafe` 超时返回 `{kind:"error",code:"TIMEOUT"}`,`checkRoot` 超时返回 `offline(timeout)`——**挂死不等于文件缺失**。
- 卡在 D 状态的子进程被 `unref`,不拖住本进程;被 SIGKILL 的 partial 由下次写前清理收走。
- 子进程里 sha256 走流式(带心跳),不走 `sha256sum`(看不到进度);吞吐见 `docs/porting/moviepilot-rust-transfer.md` 基准记录。
- `mode: "auto"`:`Deno.execPath()` 是 deno CLI → `deno run -A fsworker.ts`;否则视为 `deno compile` 出的 umt-server 二进制 → `<execPath> --role fsworker`(`apps/server/src/main.ts` 在加载其余模块前分派,启动约 150 ms)。编译产物的 `--allow-run` 必须含自身绝对路径(镜像内 `/app/umt-server`,见 `apps/server/deno.json`);起不来(NotCapable/ENOENT)自动回退 in-process(同样的看门狗,超时立即报 TIMEOUT 并中止,但卡死系统调用占住的线程无法回收),`stats().fallbackReason` 记原因。`stats().workerKind` = script / self / custom。
- 服务端缺省 `mode: "subprocess"`,启动失败直接报错;显式选择 `mode: "auto"`:`process.execPath` 是 bun CLI → `bun fsworker.ts`;否则视为 `bun build --compile` 出的 umt-server 二进制 → `<execPath> --role fsworker`(`apps/server/src/main.ts` 在加载其余模块前分派)。子进程起不来(ENOENT/EACCES)自动回退 in-process(同样的看门狗,超时立即报 TIMEOUT 并中止,但卡死系统调用占住的线程无法回收),`stats().fallbackReason` 记原因。`stats().workerKind` = script / self / custom。
- 容器验收开关:worker 进程设 `UMT_FS_TEST_HANG_AFTER_BYTES=<n>` 后,每个数据类操作的 worker 读到 n 字节即卡死 JS 线程并屏蔽 SIGTERM,用来在生产二进制里验证 停滞超时 → SIGKILL → TIMEOUT;生产勿设。
- 测试开关 `_test: { hangAfterBytes, ignoreTerm }`:worker 在累计进度达到阈值后阻塞 JS 线程,模拟卡死的系统调用。
+7 -19
View File
@@ -6,6 +6,7 @@ import { link, lstat, mkdir, open, readdir, rename, stat, unlink, utimes } from
import { basename, dirname, join } from "node:path";
import { Transform } from "node:stream";
import { pipeline } from "node:stream/promises";
import { Command } from "@umt/runtime";
import { errnoOf, FsError, toFsError } from "./errors.ts";
import { assertLocalPaths } from "./remote-guard.ts";
@@ -133,23 +134,10 @@ export function sha256Stats(): { counts: Record<Sha256Via, number>; subprocessUn
return { counts: { ...hashCounters }, subprocessUnavailable: sha256sumUnavailable };
}
type DenoLike = {
Command: new (
cmd: string,
opts: { args: string[]; stdout: "piped"; stderr: "piped"; signal?: AbortSignal },
) => { output(): Promise<{ success: boolean; code: number; stdout: Uint8Array; stderr: Uint8Array }> };
errors: { NotFound: new () => Error; NotCapable?: new () => Error; PermissionDenied: new () => Error };
};
async function sha256Subprocess(p: string, signal?: AbortSignal): Promise<string | null> {
const D = (globalThis as { Deno?: DenoLike }).Deno;
if (!D?.Command) {
sha256sumUnavailable = "no_deno_command";
return null;
}
let out: Awaited<ReturnType<InstanceType<DenoLike["Command"]>["output"]>>;
let out: Awaited<ReturnType<Command["output"]>>;
try {
out = await new D.Command("sha256sum", {
out = await new Command("sha256sum", {
args: ["-b", "--", p],
stdout: "piped",
stderr: "piped",
@@ -157,9 +145,9 @@ async function sha256Subprocess(p: string, signal?: AbortSignal): Promise<string
}).output();
} catch (e) {
if (signal?.aborted) throw new FsError("ABORTED", "aborted", { path: p, cause: e });
const notCapable = D.errors.NotCapable && e instanceof D.errors.NotCapable;
if (e instanceof D.errors.NotFound || e instanceof D.errors.PermissionDenied || notCapable) {
sha256sumUnavailable = e instanceof D.errors.NotFound ? "not_installed" : "not_permitted";
const code = (e as NodeJS.ErrnoException).code;
if (code === "ENOENT" || code === "EACCES" || code === "EPERM") {
sha256sumUnavailable = code === "ENOENT" ? "not_installed" : "not_permitted";
return null;
}
throw toFsError(e, p);
@@ -425,7 +413,7 @@ export async function copyWithVerify(src: string, dst: string, opts: CopyOptions
receipt.sampleDigest = b;
}
opts.signal?.throwIfAborted();
await utimes(tmp, srcStat.atime, srcStat.mtime);
await utimes(tmp, srcStat.atimeMs / 1000, srcStat.mtimeMs / 1000);
if (existing) receipt.replaced = await replaceCommit(tmp, dst, existing, opts.replaceOld);
else await commitNoClobber(tmp, dst);
await fsyncPath(dirname(dst));
+3
View File
@@ -108,3 +108,6 @@ export async function runFsOp(req: FsOpRequest, ctx: FsOpContext): Promise<unkno
throw new FsError("IO", `unknown fs op: ${(req as { op: string }).op}`);
}
}
/** 文件请求、扫描结果的单条协议消息上限。文件内容不通过管道传输。 */
export const FS_MESSAGE_BYTES = 16 * 1024 * 1024;
+12 -5
View File
@@ -132,7 +132,7 @@ describe("FsProxy(子进程)", () => {
});
describe("FsProxy 启动方式", () => {
it("deno CLI 下缺省 run fsworker.ts;显式 workerCommand 记为 custom", () => {
it("bun CLI 下缺省 run fsworker.ts;显式 workerCommand 记为 custom", () => {
const auto = defaultWorkerCommand();
expect(auto?.kind).toBe("script");
expect(auto?.args.at(-1)).toMatch(/fsworker\.ts$/);
@@ -148,6 +148,13 @@ describe("FsProxy 启动方式", () => {
expect(px.stats()).toMatchObject({ mode: "inprocess", fallbackReason: expect.stringMatching(/^spawn_failed/) });
});
it("强制子进程时启动失败报错,不在主进程执行文件操作", async () => {
const t = await tree({ "a.mkv": "x" });
const px = new FsProxy({ mode: "subprocess", workerCommand: { cmd: "/nonexistent/umt-worker", args: [] } });
expect(await px.statSafe(t.path("a.mkv"))).toMatchObject({ kind: "error", code: "IO" });
expect(px.stats()).toMatchObject({ mode: "subprocess", fallbackReason: null });
});
it("验收开关只在 UMT_FS_TEST_HOOKS=1 时生效(单独误设 HANG_AFTER_BYTES 无效)", () => {
const env = (m: Record<string, string>) => (k: string) => m[k];
expect(fsTestHookFromEnv(env({ UMT_FS_TEST_HANG_AFTER_BYTES: "1024" }))).toBeUndefined();
@@ -162,16 +169,16 @@ describe("FsProxy 启动方式", () => {
it("UMT_FS_TEST_HANG_AFTER_BYTES(容器验收开关)→ 数据类操作卡死 → SIGKILL → TIMEOUT;快操作不受影响", async () => {
const t = await tree({ "src/a.mkv": 3 * MiB, "dst/": null });
const px = new FsProxy({ mode: "subprocess", ...fast });
Deno.env.set("UMT_FS_TEST_HANG_AFTER_BYTES", String(MiB));
Deno.env.set("UMT_FS_TEST_HOOKS", "1");
process.env.UMT_FS_TEST_HANG_AFTER_BYTES = String(MiB);
process.env.UMT_FS_TEST_HOOKS = "1";
try {
expect(await px.statSafe(t.path("src/a.mkv"))).toMatchObject({ kind: "ok" });
const e = await px.copy(t.path("src/a.mkv"), t.path("dst/a.mkv")).catch((x) => x);
expect(e.code).toBe("TIMEOUT");
expect(px.stats()).toMatchObject({ timeouts: 1, killed: 1 });
} finally {
Deno.env.delete("UMT_FS_TEST_HANG_AFTER_BYTES");
Deno.env.delete("UMT_FS_TEST_HOOKS");
delete process.env.UMT_FS_TEST_HANG_AFTER_BYTES;
delete process.env.UMT_FS_TEST_HOOKS;
}
}, 30_000);
});
+73 -189
View File
@@ -5,18 +5,20 @@
// 统一转成 FsError('TIMEOUT')——普通可重试 IO 错误,交给任务层退避重试。卡在 D 状态的子进程被 unref,不拖住本进程。
// stat/checkRoot 超时返回 error/offline(timeout),**不**当成文件缺失(REQ §2.1 原则 4)。
//
// 与 MP 的差异:MP 是常驻 worker + 一把锁串行;我们每个操作一个子进程(~50 ms 启动,入库按文件计可忽略),
// 与 MP 的差异:MP 是常驻 worker + 一把锁串行;我们每个操作一个子进程,
// 天然并发、互不拖累,也没有"上一个请求挂死把 worker 占住"的问题。
// 启动方式:execPath 是 deno CLI(开发/测试)→ `deno run -A fsworker.ts`;否则视为 deno compile 出的 umt-server
// 二进制 → `<execPath> --role fsworker`(入口 main.ts 在加载其余模块前分派)。编译产物的 --allow-run 须含自身绝对路径
// (镜像内 /app/umt-server,见 apps/server/deno.json);起不来(NotCapable 等)回退 in-process:
// 启动方式:execPath 是 bun CLI(开发/测试)→ `bun fsworker.ts`;否则视为 bun build --compile 出的 umt-server
// 二进制 → `<execPath> --role fsworker`(入口 main.ts 在加载其余模块前分派)。
// 子进程无法启动时回退 in-process:
// 同样的心跳看门狗,超时立即以 TIMEOUT 失败并中止操作,但卡死的系统调用占住的线程无法回收(见 README)。
import { basename } from "node:path";
import { fileURLToPath } from "node:url";
import { WorkerConnection, WorkerTransportError } from "@umt/runtime/worker";
import type { CopyReceipt, Sha256Result } from "./copy.ts";
import { FsError, type FsErrorCode } from "./errors.ts";
import {
DATA_OPS,
FS_MESSAGE_BYTES,
type FsOpRequest,
type FsProgress,
type FsTestHook,
@@ -32,7 +34,7 @@ import type { RootHealth, StatResult } from "./stat.ts";
export type { FsProgress } from "./fsops.ts";
export interface FsProxyConfig {
/** auto:能起 deno 子进程就用子进程,否则 in-process;subprocess 强制子进程(起不来报错) */
/** auto:能起 bun 子进程就用子进程,否则 in-process;subprocess 强制子进程(起不来报错) */
mode?: "auto" | "subprocess" | "inprocess";
/** 数据类操作:进度停滞多久判挂死(缺省 120 s,同 MP FS_PROXY_STALL_TIMEOUT) */
hangTimeoutMs?: number;
@@ -62,15 +64,6 @@ export interface FsProxyStats {
fallbackReason: string | null;
}
type DenoChild = {
stdin: WritableStream<Uint8Array>;
stdout: ReadableStream<Uint8Array>;
stderr: ReadableStream<Uint8Array>;
status: Promise<{ success: boolean; code: number; signal: string | null }>;
kill(sig?: string): void;
unref(): void;
};
const WORKER_PATH = (() => {
try {
const u = new URL("./fsworker.ts", import.meta.url);
@@ -80,31 +73,12 @@ const WORKER_PATH = (() => {
}
})();
/** 当前运行时能用的缺省 worker 启动命令:deno CLI → run 脚本;编译二进制 → 自身 --role fsworker;都不行 → null */
/** 当前运行时能用的缺省 worker 启动命令:bun CLI → run 脚本;编译二进制 → 自身 --role fsworker;都不行 → null */
export function defaultWorkerCommand(): { cmd: string; args: string[]; kind: "script" | "self" } | null {
const D = (globalThis as { Deno?: { execPath?: () => string; Command?: unknown } }).Deno;
if (!D?.Command || !D.execPath) return null;
let exe: string;
try {
exe = D.execPath();
} catch {
return null;
}
if (/^deno(\.exe)?$/.test(basename(exe))) {
const exe = process.execPath;
if (/^bun(\.exe)?$/.test(basename(exe))) {
if (!WORKER_PATH) return null;
return {
cmd: exe,
args: [
"run",
"--no-config",
"--quiet",
"--no-prompt",
// Deno 对 /dev、/proc 下的路径(如 /dev/shm 上的库根)要求 --allow-all;worker 只执行本包固定的操作表
"--allow-all",
WORKER_PATH,
],
kind: "script",
};
return { cmd: exe, args: [WORKER_PATH], kind: "script" };
}
return { cmd: exe, args: ["--role", "fsworker"], kind: "self" };
}
@@ -122,12 +96,7 @@ export function fsTestHookFromEnv(get: (k: string) => string | undefined): FsTes
}
function envTestHook(): FsTestHook | undefined {
try {
const env = (globalThis as { Deno?: { env?: { get(k: string): string | undefined } } }).Deno?.env;
return env ? fsTestHookFromEnv((k) => env.get(k)) : undefined;
} catch {
return undefined;
}
return fsTestHookFromEnv((k) => process.env[k]);
}
const timeoutError = (op: string, why: string, path?: string) =>
@@ -154,7 +123,7 @@ function pathOf(req: FsOpRequest): string | undefined {
export class FsProxy {
readonly cfg: Required<Omit<FsProxyConfig, "workerCommand" | "mode">> & Pick<FsProxyConfig, "workerCommand">;
/** 子进程怎么起:script = deno run fsworker.ts;self = 编译二进制 --role fsworker;custom = 显式 workerCommand */
/** 子进程怎么起:script = bun fsworker.ts;self = 编译二进制 --role fsworker;custom = 显式 workerCommand */
readonly workerKind: "script" | "self" | "custom" | null;
#mode: "subprocess" | "inprocess";
#forced: boolean;
@@ -177,9 +146,9 @@ export class FsProxy {
if (mode === "inprocess") this.#mode = "inprocess";
else if (wc) this.#mode = "subprocess";
else {
if (mode === "subprocess") throw new Error("fsproxy: 当前运行时无法启动 deno 子进程,且未提供 workerCommand");
if (mode === "subprocess") throw new Error("fsproxy: 当前运行时无法启动 bun 子进程,且未提供 workerCommand");
this.#mode = "inprocess";
this.#fallbackReason = "no_deno_cli";
this.#fallbackReason = "no_bun_cli";
}
}
@@ -251,7 +220,8 @@ export class FsProxy {
try {
return await this.#runSubprocess(req, call);
} catch (e) {
if (!(e instanceof SpawnError) || this.#forced) throw e instanceof SpawnError ? e.cause : e;
if (!(e instanceof SpawnError)) throw e;
if (this.#forced) throw new FsError("IO", `文件 worker 无法启动:${e.message}`, { cause: e.cause });
this.#mode = "inprocess";
this.#fallbackReason = e.message;
}
@@ -310,144 +280,72 @@ export class FsProxy {
}
}
async #runSubprocess(req: FsOpRequest, call: FsCallOptions): Promise<unknown> {
const D = (globalThis as unknown as { Deno: DenoLike }).Deno;
const cmd = this.cfg.workerCommand!;
let child: DenoChild;
async #runSubprocess(req: FsOpRequest, call: FsCallOptions = {}): Promise<unknown> {
let connection: WorkerConnection;
try {
child = new D.Command(cmd.cmd, {
args: cmd.args,
stdin: "piped",
stdout: "piped",
stderr: "piped",
env: { UMT_FS_PROGRESS_INTERVAL_MS: String(this.cfg.progressIntervalMs) },
}).spawn();
} catch (e) {
throw new SpawnError(`spawn_failed:${(e as Error)?.name ?? "Error"}`, e);
connection = new WorkerConnection(this.cfg.workerCommand!, {
values: { UMT_FS_PROGRESS_INTERVAL_MS: String(this.cfg.progressIntervalMs) },
});
} catch (error) {
throw new SpawnError(`spawn_failed:${(error as Error).name}`, error);
}
this.#stats.spawned += 1;
const limit = this.#limitFor(req);
const w = child.stdin.getWriter();
let last = Date.now();
let lastKey = "";
const terminate = (error: FsError) => connection.terminate(error, this.cfg.killGraceMs, () => this.#stats.killed++);
const onAbort = () => terminate(new FsError("ABORTED", "aborted", { path: pathOf(req) }));
const watch = setInterval(
() => {
if (Date.now() - last > limit.ms) {
this.#stats.timeouts += 1;
clearInterval(watch);
terminate(timeoutError(req.op, limit.why, pathOf(req)));
}
},
Math.max(10, Math.min(1000, limit.ms / 4)),
);
call.signal?.addEventListener("abort", onAbort, { once: true });
const hook = call._test ?? (DATA_OPS.has(req.op) ? envTestHook() : undefined);
await w.write(new TextEncoder().encode(JSON.stringify(hook ? { ...req, _test: hook } : req)));
await w.close().catch(() => undefined);
let stderr = "";
const errPump = (async () => {
const dec = new TextDecoder();
try {
for await (const c of child.stderr) stderr = (stderr + dec.decode(c, { stream: true })).slice(-2000);
} catch {
// 被 kill 时流会出错
}
})();
return await new Promise<unknown>((resolve, reject) => {
let settled = false;
let last = Date.now();
let lastKey = "";
let killTimer: ReturnType<typeof setTimeout> | undefined;
/** 已判超时/已取消:之后 worker 自己报的结果(如 SIGTERM 后的 ABORTED)一律以此为准 */
let terminating: FsError | null = null;
const finish = (fn: () => void) => {
if (settled) return;
settled = true;
clearInterval(watch);
call.signal?.removeEventListener("abort", onAbort);
fn();
};
const terminate = (err: FsError) => {
if (settled || terminating) return;
terminating = err;
try {
child.kill("SIGTERM");
} catch {
// 已退出
}
clearInterval(watch);
killTimer = setTimeout(() => {
try {
child.kill("SIGKILL");
this.#stats.killed += 1;
} catch {
// SIGTERM 后已退出
}
// D 状态的子进程可能连 SIGKILL 都要等系统调用返回:不等它,unref 后直接报错
child.unref();
finish(() => reject(err));
}, this.cfg.killGraceMs);
};
const onAbort = () =>
terminate(new FsError("ABORTED", "aborted", { ...(pathOf(req) ? { path: pathOf(req) } : {}) }));
call.signal?.addEventListener("abort", onAbort, { once: true });
const watch = setInterval(
() => {
if (Date.now() - last > limit.ms) {
this.#stats.timeouts += 1;
terminate(timeoutError(req.op, limit.why, pathOf(req)));
}
try {
const pending = connection.request(
{
id: "1",
task: req.op,
input: hook ? { ...req, _test: hook } : req,
},
Math.max(10, Math.min(1000, limit.ms / 4)),
);
(async () => {
const dec = new TextDecoder();
let buf = "";
let final: WorkerFinal | null = null;
try {
for await (const c of child.stdout) {
buf += dec.decode(c, { stream: true });
let i = buf.indexOf("\n");
while (i >= 0) {
const line = buf.slice(0, i);
buf = buf.slice(i + 1);
i = buf.indexOf("\n");
if (!line.trim()) continue;
const msg = JSON.parse(line) as WorkerFinal & FsProgress;
if (msg.t === "hb") {
const key = `${msg.phase}:${msg.bytes}`;
if (!limit.stall || key !== lastKey) {
lastKey = key;
if (limit.stall) last = Date.now();
}
call.onProgress?.({ phase: msg.phase, bytes: msg.bytes, total: msg.total });
} else final = msg;
{
maxRequestBytes: FS_MESSAGE_BYTES,
maxResponseBytes: FS_MESSAGE_BYTES,
onProgress: (value) => {
const p = value as FsProgress;
if (!p || typeof p.phase !== "string" || !Number.isFinite(p.bytes) || !Number.isFinite(p.total))
throw new Error("文件 worker 进度格式无效");
const key = `${p.phase}:${p.bytes}`;
if (limit.stall && key !== lastKey) {
lastKey = key;
last = Date.now();
}
}
} catch {
// kill 时 stdout 断开
}
const status = await child.status;
await errPump;
if (terminating) {
// SIGTERM 生效(worker 清理后退出)就不必等到宽限期满
clearTimeout(killTimer);
const err = terminating;
return finish(() => reject(err));
}
if (final?.t === "ok") return finish(() => resolve(final!.result));
if (final?.t === "err")
return finish(() =>
reject(
new FsError(final!.code ?? "IO", final!.message ?? "fs worker error", {
...(final!.path ? { path: final!.path } : {}),
}),
),
);
finish(() =>
reject(
new FsError("IO", `fs worker 异常退出(code=${status.code} signal=${status.signal}):${stderr.trim()}`, {
...(pathOf(req) ? { path: pathOf(req) } : {}),
}),
),
);
})();
});
call.onProgress?.(p);
},
},
);
if (call.signal?.aborted) onAbort();
const reply = await pending;
if (reply.type === "result") return reply.result;
throw new FsError(reply.error.code as FsErrorCode, reply.error.message, reply.error.detail);
} catch (error) {
if (error instanceof FsError) throw error;
if (error instanceof WorkerTransportError && error.kind === "spawn")
throw new SpawnError(`spawn_failed:${(error.cause as Error)?.name ?? "Error"}`, error.cause);
throw new FsError("IO", (error as Error).message, { path: pathOf(req), cause: error });
} finally {
clearInterval(watch);
call.signal?.removeEventListener("abort", onAbort);
}
}
}
type WorkerFinal = { t: string; result?: unknown; code?: FsErrorCode; message?: string; path?: string };
class SpawnError extends Error {
constructor(
message: string,
@@ -457,20 +355,6 @@ class SpawnError extends Error {
}
}
type DenoLike = {
execPath(): string;
Command: new (
cmd: string,
opts: {
args: string[];
stdin: "piped";
stdout: "piped";
stderr: "piped";
env?: Record<string, string>;
},
) => { spawn(): DenoChild };
};
let shared: FsProxy | null = null;
/** 进程级缺省代理(配置由 server 启动时 configureFsProxy 注入;未配置时用缺省阈值) */
export function fsProxy(): FsProxy {
+22 -30
View File
@@ -1,34 +1,25 @@
// fsproxy 执行子进程:从 stdin 读一条 JSON 请求,执行单个文件操作,stdout 逐行 JSON 上报
// {t:"hb",phase,bytes,total}(按 progressIntervalMs 节流,阶段切换与阶段完成立即报)/ {t:"ok",result} / {t:"err",code,message}。
// SIGTERM → 中止当前操作(copy 会清掉自己的 partial)后退出;挂死在系统调用里时由父进程 SIGKILL 回收。
// 只依赖 node 内置模块,`deno run --no-config` 毫秒级启动。编译二进制可在入口按 --role fsworker 调 fsWorkerMain()。
import { decodeWorkerRequest, readWorkerRequestText, writeWorkerMessage } from "@umt/runtime/worker";
import { toFsError } from "./errors.ts";
import { type FsOpRequest, type FsProgress, runFsOp } from "./fsops.ts";
const enc = new TextEncoder();
function send(obj: unknown): void {
try {
Deno.stdout.writeSync(enc.encode(`${JSON.stringify(obj)}\n`));
} catch {
// 父进程已退出:无处可报
}
}
import { FS_MESSAGE_BYTES, type FsOpRequest, type FsProgress, runFsOp } from "./fsops.ts";
// 一次性文件 worker;固定操作分派、进度停滞策略与文件清理语义保留在文件模块。
/** 阻塞 JS 线程(测试用,模拟卡死的系统调用:信号监听器也不会运行) */
function blockForever(): never {
const a = new Int32Array(new SharedArrayBuffer(4));
for (;;) Atomics.wait(a, 0, 0);
}
export async function fsWorkerMain(progressIntervalMs = Number(Deno.env.get("UMT_FS_PROGRESS_INTERVAL_MS") ?? 1000)) {
const req = JSON.parse(await new Response(Deno.stdin.readable).text()) as FsOpRequest;
export async function fsWorkerMain(progressIntervalMs = Number(process.env.UMT_FS_PROGRESS_INTERVAL_MS ?? 1000)) {
const request = decodeWorkerRequest(await readWorkerRequestText(FS_MESSAGE_BYTES));
const req = request.input as FsOpRequest;
if (!req || req.op !== request.task) throw new Error("文件 worker 任务与操作不一致");
const send = (value: FsProgress) => writeWorkerMessage({ id: request.id, type: "progress", value }, FS_MESSAGE_BYTES);
const ac = new AbortController();
Deno.addSignalListener("SIGTERM", () => {
process.on("SIGTERM", () => {
if (req._test?.ignoreTerm) return;
ac.abort();
// 操作卡在系统调用里时 abort 解不开:给清理一点时间后直接退出
setTimeout(() => Deno.exit(143), 500);
setTimeout(() => process.exit(143), 500);
});
let lastAt = 0;
let lastPhase = "";
@@ -45,28 +36,29 @@ export async function fsWorkerMain(progressIntervalMs = Number(Deno.env.get("UMT
) {
lastAt = now;
lastPhase = p.phase;
send({ t: "hb", ...p });
send(p);
}
if (hang !== undefined && p.bytes >= hang) blockForever();
};
if (req._test?.hangAfterBytes === 0) {
send({ t: "hb", phase: "start", bytes: 0, total: 0 });
send({ phase: "start", bytes: 0, total: 0 });
blockForever();
}
try {
const result = await runFsOp(req, { signal: ac.signal, progress });
send({ t: "ok", result });
writeWorkerMessage({ id: request.id, type: "result", result }, FS_MESSAGE_BYTES);
} catch (e) {
const fe = toFsError(e);
send({
t: "err",
code: ac.signal.aborted ? "ABORTED" : fe.code,
message: fe.message,
...(fe.detail.path ? { path: fe.detail.path } : {}),
...(fe.detail.syscall ? { syscall: fe.detail.syscall } : {}),
});
writeWorkerMessage(
{
id: request.id,
type: "error",
error: { code: ac.signal.aborted ? "ABORTED" : fe.code, message: fe.message, detail: fe.detail },
},
FS_MESSAGE_BYTES,
);
}
Deno.exit(0);
process.exit(0);
}
if (import.meta.main) await fsWorkerMain();