@davideasden/pi-undo 0.2.22 → 0.2.25

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,13 +1,16 @@
1
- import { spawn } from "node:child_process";
2
1
  import { randomUUID } from "node:crypto";
3
2
  import { constants } from "node:fs";
4
3
  import { access, rm, writeFile } from "node:fs/promises";
5
4
  import { join } from "node:path";
6
5
 
6
+ import { GitRunError, runSupervisedProcess } from "./git-runner.ts";
7
+ import { OperationError, type OperationProcessOptions, operationProcessOptions } from "./operation-context.ts";
7
8
  import { nativeExecutable } from "./native-restore.ts";
8
9
 
9
10
  const NATIVE_INSPECT_TIMEOUT_MS = 30_000;
10
11
  const NATIVE_INSPECT_OUTPUT_LIMIT = 32 * 1024 * 1024;
12
+ const NATIVE_PROBE_TIMEOUT_MS = 5_000;
13
+ const NATIVE_PROBE_OUTPUT_LIMIT = 64 * 1024;
11
14
 
12
15
  export interface NativeMetadataEntry {
13
16
  readonly path: string;
@@ -43,6 +46,8 @@ export class NativeMetadataInspector implements NativeMetadataPort {
43
46
  requestDirectory: string,
44
47
  ): Promise<readonly NativeMetadataEntry[] | undefined> {
45
48
  if (paths.length === 0) return [];
49
+ // 已取消/超时的操作不启动新的 helper,也不写出请求文件。
50
+ const budget = operationProcessOptions(NATIVE_INSPECT_TIMEOUT_MS);
46
51
  if (!await this.supportsInspect(requestDirectory)) return undefined;
47
52
  const executable = this.executable!;
48
53
  const requestPath = join(requestDirectory, `native-inspect-${process.pid}-${randomUUID()}.json`);
@@ -52,7 +57,7 @@ export class NativeMetadataInspector implements NativeMetadataPort {
52
57
  workspaceRoot,
53
58
  paths,
54
59
  }), { mode: 0o600, flag: "wx" });
55
- return await runNativeInspect(executable, requestPath, paths);
60
+ return await runNativeInspect(executable, requestPath, paths, budget);
56
61
  } finally {
57
62
  await rm(requestPath, { force: true }).catch(() => {});
58
63
  }
@@ -73,100 +78,55 @@ export class NativeMetadataInspector implements NativeMetadataPort {
73
78
  }
74
79
  }
75
80
 
76
- function probeNativeInspect(executable: string, isolatedDirectory: string): Promise<boolean> {
77
- return new Promise((resolve) => {
78
- const child = spawn(executable, ["--capabilities"], {
81
+ async function probeNativeInspect(executable: string, isolatedDirectory: string): Promise<boolean> {
82
+ try {
83
+ const result = await runSupervisedProcess({
84
+ command: executable,
85
+ args: ["--capabilities"],
79
86
  cwd: isolatedDirectory,
80
- shell: false,
81
- stdio: ["ignore", "pipe", "ignore"],
82
- windowsHide: true,
87
+ timeoutMs: NATIVE_PROBE_TIMEOUT_MS,
88
+ outputLimitBytes: NATIVE_PROBE_OUTPUT_LIMIT,
89
+ outputOverflow: "terminate",
83
90
  });
84
- const stdout: Buffer[] = [];
85
- let bytes = 0;
86
- let settled = false;
87
- const timeout = setTimeout(() => child.kill("SIGKILL"), 5_000);
88
- child.stdout?.on("data", (chunk: Buffer | string) => {
89
- const value = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
90
- bytes += value.length;
91
- if (bytes <= 64 * 1024) stdout.push(value);
92
- else child.kill("SIGKILL");
93
- });
94
- child.once("error", () => {
95
- if (settled) return;
96
- settled = true;
97
- clearTimeout(timeout);
98
- resolve(false);
99
- });
100
- child.once("close", (code) => {
101
- if (settled) return;
102
- settled = true;
103
- clearTimeout(timeout);
104
- if (code !== 0 || bytes > 64 * 1024) return resolve(false);
105
- try {
106
- const value: unknown = JSON.parse(Buffer.concat(stdout).toString("utf8"));
107
- resolve(isRecord(value) && value.ok === true && Array.isArray(value.capabilities) &&
108
- value.capabilities.includes("inspect-v1"));
109
- } catch {
110
- resolve(false);
111
- }
112
- });
113
- });
91
+ if (!result.stopped || result.outcome !== "exit" || result.code !== 0) return false;
92
+ const value: unknown = JSON.parse(result.stdout.toString("utf8"));
93
+ return isRecord(value) && value.ok === true && Array.isArray(value.capabilities) &&
94
+ value.capabilities.includes("inspect-v1");
95
+ } catch {
96
+ return false;
97
+ }
114
98
  }
115
99
 
116
- function runNativeInspect(
100
+ async function runNativeInspect(
117
101
  executable: string,
118
102
  requestPath: string,
119
103
  expectedPaths: readonly string[],
104
+ budget: OperationProcessOptions,
120
105
  ): Promise<readonly NativeMetadataEntry[]> {
121
- return new Promise((resolve, reject) => {
122
- const child = spawn(executable, ["--inspect", requestPath], {
123
- shell: false,
124
- stdio: ["ignore", "pipe", "pipe"],
125
- windowsHide: true,
126
- });
127
- const stdout: Buffer[] = [];
128
- const stderr: Buffer[] = [];
129
- let outputBytes = 0;
130
- let settled = false;
131
- let overflow = false;
132
- const timeout = setTimeout(() => child.kill("SIGKILL"), NATIVE_INSPECT_TIMEOUT_MS);
133
- const capture = (target: Buffer[]) => (chunk: Buffer | string): void => {
134
- const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
135
- if (outputBytes + bytes.length > NATIVE_INSPECT_OUTPUT_LIMIT) {
136
- overflow = true;
137
- child.kill("SIGKILL");
138
- return;
139
- }
140
- target.push(bytes);
141
- outputBytes += bytes.length;
142
- };
143
- child.stdout?.on("data", capture(stdout));
144
- child.stderr?.on("data", capture(stderr));
145
- child.once("error", (error) => {
146
- if (settled) return;
147
- settled = true;
148
- clearTimeout(timeout);
149
- reject(error);
150
- });
151
- child.once("close", (code) => {
152
- if (settled) return;
153
- settled = true;
154
- clearTimeout(timeout);
155
- if (overflow) {
156
- reject(new Error("native metadata inspect 输出超过限制"));
157
- return;
158
- }
159
- if (code !== 0) {
160
- reject(new Error(`native metadata inspect 失败:${Buffer.concat(stderr).toString("utf8").trim()}`));
161
- return;
162
- }
163
- try {
164
- resolve(parseInspectResponse(Buffer.concat(stdout).toString("utf8"), expectedPaths));
165
- } catch (error) {
166
- reject(error);
167
- }
168
- });
106
+ const result = await runSupervisedProcess({
107
+ command: executable,
108
+ args: ["--inspect", requestPath],
109
+ signal: budget.signal,
110
+ timeoutMs: budget.timeoutMs,
111
+ outputLimitBytes: NATIVE_INSPECT_OUTPUT_LIMIT,
112
+ outputOverflow: "terminate",
169
113
  });
114
+ if (!result.stopped) {
115
+ throw new GitRunError("git_termination_failed", "native metadata inspect 进程未能确认终止");
116
+ }
117
+ if (result.outcome === "cancelled") {
118
+ throw new OperationError("operation_cancelled", "native metadata inspect 已被取消");
119
+ }
120
+ if (result.outcome === "timeout") {
121
+ throw new OperationError("operation_timeout", "native metadata inspect 超时");
122
+ }
123
+ if (result.outcome === "output_overflow") {
124
+ throw new Error("native metadata inspect 输出超过限制");
125
+ }
126
+ if (result.code !== 0) {
127
+ throw new Error(`native metadata inspect 失败:${result.stderr.toString("utf8").trim()}`);
128
+ }
129
+ return parseInspectResponse(result.stdout.toString("utf8"), expectedPaths);
170
130
  }
171
131
 
172
132
  function parseInspectResponse(text: string, expectedPaths: readonly string[]): readonly NativeMetadataEntry[] {
@@ -1,10 +1,11 @@
1
- import { spawn } from "node:child_process";
2
1
  import { constants } from "node:fs";
3
2
  import { access, writeFile } from "node:fs/promises";
4
3
  import { fileURLToPath } from "node:url";
5
4
  import { dirname, join } from "node:path";
6
5
 
7
6
  import type { DurablePack } from "./durable-pack.ts";
7
+ import { GitRunError, runSupervisedProcess } from "./git-runner.ts";
8
+ import { OperationError, type OperationProcessOptions, operationProcessOptions, rethrowOperationFailure } from "./operation-context.ts";
8
9
  import type { MutationJournal } from "./mutation-journal.ts";
9
10
 
10
11
  const NATIVE_TIMEOUT_MS = 120_000;
@@ -20,9 +21,11 @@ export async function createNativeFileBatch(options: {
20
21
  readonly workspaceRoot: string;
21
22
  readonly planDigest: string;
22
23
  readonly journal: MutationJournal;
24
+ /** 测试注入用;默认使用随包分发的平台二进制。 */
25
+ readonly executable?: string;
23
26
  }): Promise<NativeFileBatch | undefined> {
24
27
  if (process.env.PI_UNDO_DISABLE_NATIVE === "1") return undefined;
25
- const executable = nativeExecutable();
28
+ const executable = options.executable ?? nativeExecutable();
26
29
  if (executable === undefined) return undefined;
27
30
  try {
28
31
  await access(executable, constants.X_OK);
@@ -33,6 +36,8 @@ export async function createNativeFileBatch(options: {
33
36
  const paths = pack.paths();
34
37
  if (paths.length === 0) return;
35
38
  if (pack.planDigest !== options.planDigest) throw new Error("native file batch planDigest 不匹配");
39
+ // 取消/超时检查在写出 request 之前:已取消的操作不产生新工作,也不留下请求文件。
40
+ const budget = operationProcessOptions(NATIVE_TIMEOUT_MS);
36
41
  const request = {
37
42
  schemaVersion: 1,
38
43
  opId: options.journal.operationId,
@@ -63,7 +68,7 @@ export async function createNativeFileBatch(options: {
63
68
  }),
64
69
  };
65
70
  await writeFile(requestPath, JSON.stringify(request), { mode: 0o600 });
66
- await runNative(executable, requestPath, paths.length);
71
+ await runNative(executable, requestPath, paths.length, budget);
67
72
  };
68
73
  return {
69
74
  available: true,
@@ -72,7 +77,8 @@ export async function createNativeFileBatch(options: {
72
77
  try {
73
78
  await execute(pack, join(dirname(pack.storagePath), `native-verify-${process.pid}.json`), true);
74
79
  return true;
75
- } catch {
80
+ } catch (error) {
81
+ rethrowOperationFailure(error);
76
82
  return false;
77
83
  }
78
84
  },
@@ -90,57 +96,44 @@ export function nativeExecutable(): string | undefined {
90
96
  return fileURLToPath(new URL(`../native/bin/pi-undo-fs-${platform}-${architecture}${extension}`, import.meta.url));
91
97
  }
92
98
 
93
- function runNative(executable: string, requestPath: string, expected: number): Promise<void> {
94
- return new Promise((resolve, reject) => {
95
- const child = spawn(executable, [requestPath], {
96
- shell: false,
97
- stdio: ["ignore", "pipe", "pipe"],
98
- windowsHide: true,
99
- });
100
- const stdout: Buffer[] = [];
101
- const stderr: Buffer[] = [];
102
- let outputBytes = 0;
103
- let settled = false;
104
- const timeout = setTimeout(() => child.kill("SIGKILL"), NATIVE_TIMEOUT_MS);
105
- const capture = (target: Buffer[]) => (chunk: Buffer | string): void => {
106
- if (outputBytes >= NATIVE_OUTPUT_LIMIT) return;
107
- const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
108
- const captured = bytes.subarray(0, NATIVE_OUTPUT_LIMIT - outputBytes);
109
- target.push(captured);
110
- outputBytes += captured.length;
111
- };
112
- child.stdout?.on("data", capture(stdout));
113
- child.stderr?.on("data", capture(stderr));
114
- child.once("error", (error) => {
115
- if (settled) return;
116
- settled = true;
117
- clearTimeout(timeout);
118
- reject(error);
119
- });
120
- child.once("close", (code) => {
121
- if (settled) return;
122
- settled = true;
123
- clearTimeout(timeout);
124
- if (code !== 0) {
125
- reject(new Error(`native restore 失败:${Buffer.concat(stderr).toString("utf8").trim()}`));
126
- return;
127
- }
128
- try {
129
- const result: unknown = JSON.parse(Buffer.concat(stdout).toString("utf8"));
130
- if (
131
- typeof result !== "object" ||
132
- result === null ||
133
- !("ok" in result) ||
134
- result.ok !== true ||
135
- !("processed" in result) ||
136
- result.processed !== expected
137
- ) {
138
- throw new Error("native restore 响应无效");
139
- }
140
- resolve();
141
- } catch (error) {
142
- reject(error);
143
- }
144
- });
99
+ async function runNative(
100
+ executable: string,
101
+ requestPath: string,
102
+ expected: number,
103
+ budget: OperationProcessOptions,
104
+ ): Promise<void> {
105
+ const result = await runSupervisedProcess({
106
+ command: executable,
107
+ args: [requestPath],
108
+ signal: budget.signal,
109
+ timeoutMs: budget.timeoutMs,
110
+ outputLimitBytes: NATIVE_OUTPUT_LIMIT,
145
111
  });
112
+ if (!result.stopped) {
113
+ // 无法证明 helper 已停止:保留 lease,让上层走恢复流程。
114
+ throw new GitRunError("git_termination_failed", "native restore 进程未能确认终止");
115
+ }
116
+ if (result.outcome === "cancelled") {
117
+ throw new OperationError("operation_cancelled", "native restore 已被取消");
118
+ }
119
+ if (result.outcome === "timeout") {
120
+ throw new OperationError("operation_timeout", "native restore 超时");
121
+ }
122
+ if (result.outcome === "output_overflow") {
123
+ throw new Error("native restore 输出超过限制");
124
+ }
125
+ if (result.code !== 0) {
126
+ throw new Error(`native restore 失败:${result.stderr.toString("utf8").trim()}`);
127
+ }
128
+ const parsed: unknown = JSON.parse(result.stdout.toString("utf8"));
129
+ if (
130
+ typeof parsed !== "object" ||
131
+ parsed === null ||
132
+ !("ok" in parsed) ||
133
+ parsed.ok !== true ||
134
+ !("processed" in parsed) ||
135
+ parsed.processed !== expected
136
+ ) {
137
+ throw new Error("native restore 响应无效");
138
+ }
146
139
  }
@@ -0,0 +1,266 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks";
2
+
3
+ export type OperationErrorCode = "operation_cancelled" | "operation_timeout";
4
+
5
+ /**
6
+ * 一次可取消操作的共享作用域。调用方(撤回/捕获/恢复入口)创建 context,
7
+ * 低层只读取当前继承的 context:deadline 是硬上限,signal 是取消通道。
8
+ */
9
+ export interface ProcessDiagnostic {
10
+ readonly command: string;
11
+ readonly durationMs: number;
12
+ readonly outcome: string;
13
+ readonly exitCode: number | null;
14
+ }
15
+
16
+ export interface OperationContext {
17
+ readonly signal: AbortSignal;
18
+ readonly deadline: number;
19
+ readonly onProgress?: (phase: string) => void;
20
+ readonly onProcess?: (diagnostic: ProcessDiagnostic) => void;
21
+ }
22
+
23
+ export class OperationError extends Error {
24
+ readonly code: OperationErrorCode;
25
+
26
+ constructor(code: OperationErrorCode, message: string, options?: ErrorOptions) {
27
+ super(message, options);
28
+ this.name = "OperationError";
29
+ this.code = code;
30
+ }
31
+ }
32
+
33
+ /** 超时与主动取消都走 AbortSignal;用固定 reason 区分两者。 */
34
+ const OPERATION_TIMEOUT_REASON = "operation_timeout";
35
+
36
+ const storage = new AsyncLocalStorage<OperationContext | undefined>();
37
+ const unsafeContexts = new WeakSet<OperationContext>();
38
+
39
+ export function markProcessExitUnconfirmed(): void {
40
+ const context = storage.getStore();
41
+ if (context !== undefined) unsafeContexts.add(context);
42
+ }
43
+
44
+ export function operationHasUnconfirmedExit(): boolean {
45
+ const context = storage.getStore();
46
+ return context !== undefined && unsafeContexts.has(context);
47
+ }
48
+
49
+ /**
50
+ * 在 context 下运行 body;context 为 undefined 时清除继承的 context,
51
+ * 保证嵌套作用域之间不会泄漏取消状态。
52
+ */
53
+ export function runWithOperationContext<T>(context: OperationContext | undefined, body: () => T): T {
54
+ return storage.run(context, body);
55
+ }
56
+
57
+ export function currentOperationContext(): OperationContext | undefined {
58
+ return storage.getStore();
59
+ }
60
+
61
+ /**
62
+ * 取消或超时检查:可在任意 await 边界调用。没有 context 时是空操作。
63
+ * 已取消/超时抛 OperationError,code 区分 operation_cancelled 与 operation_timeout。
64
+ */
65
+ export function checkOperation(): void {
66
+ const context = storage.getStore();
67
+ if (context === undefined) {
68
+ return;
69
+ }
70
+ if (unsafeContexts.has(context)) throw Object.assign(new Error("子进程未确认退出"), { code: "process_exit_unconfirmed" });
71
+ if (context.signal.aborted) {
72
+ throw operationErrorFromAbortReason(context.signal.reason);
73
+ }
74
+ if (Date.now() >= context.deadline) {
75
+ throw new OperationError("operation_timeout", "操作超过截止时间");
76
+ }
77
+ }
78
+
79
+ /** 报告可读阶段(供状态显示使用);没有 context 或没有 onProgress 时是空操作。 */
80
+ export function reportOperationProgress(phase: string): void {
81
+ storage.getStore()?.onProgress?.(phase);
82
+ }
83
+
84
+ export function reportProcessDiagnostic(diagnostic: ProcessDiagnostic): void {
85
+ try {
86
+ storage.getStore()?.onProcess?.(diagnostic);
87
+ } catch {
88
+ // 诊断失败不能改变事务结果。
89
+ }
90
+ }
91
+
92
+ export interface OperationProcessOptions {
93
+ readonly signal?: AbortSignal;
94
+ readonly timeoutMs: number;
95
+ }
96
+
97
+ /**
98
+ * 低层子进程包装器使用的预算:取「剩余 deadline」与 defaultTimeoutMs 的较小值。
99
+ * 已取消或已超时直接抛出,避免取消后继续启动新工作。
100
+ */
101
+ export function operationProcessOptions(defaultTimeoutMs = 120_000): OperationProcessOptions {
102
+ if (!Number.isFinite(defaultTimeoutMs) || defaultTimeoutMs < 0) {
103
+ throw new RangeError("defaultTimeoutMs 必须是非负有限数字");
104
+ }
105
+ const context = storage.getStore();
106
+ if (context === undefined) {
107
+ return { timeoutMs: defaultTimeoutMs };
108
+ }
109
+ checkOperation();
110
+ const remaining = context.deadline - Date.now();
111
+ return {
112
+ signal: context.signal,
113
+ timeoutMs: Math.min(defaultTimeoutMs, remaining),
114
+ };
115
+ }
116
+
117
+ export interface OperationScopeOptions {
118
+ /** 不传表示没有 deadline(只受 signal 控制)。 */
119
+ readonly timeoutMs?: number;
120
+ /** 上层取消(例如 /undo-cancel 或父作用域)会传播到本作用域。 */
121
+ readonly signal?: AbortSignal;
122
+ readonly onProgress?: (phase: string) => void;
123
+ readonly onProcess?: (diagnostic: ProcessDiagnostic) => void;
124
+ readonly now?: () => number;
125
+ }
126
+
127
+ export interface OperationScope {
128
+ readonly context: OperationContext;
129
+ /** 请求主动取消。 */
130
+ cancel(): void;
131
+ /** 清除超时计时器与父 signal 监听。 */
132
+ dispose(): void;
133
+ }
134
+
135
+ /**
136
+ * 创建一次可取消操作的 context。超时通过计时器把 signal 置为 timeout reason,
137
+ * 使 checkOperation/operationProcessOptions 能区分超时与主动取消。
138
+ */
139
+ export function createOperationScope(options: OperationScopeOptions = {}): OperationScope {
140
+ const now = options.now ?? Date.now;
141
+ const controller = new AbortController();
142
+ const deadline = options.timeoutMs === undefined
143
+ ? Number.POSITIVE_INFINITY
144
+ : now() + options.timeoutMs;
145
+ let timeout: NodeJS.Timeout | undefined;
146
+ if (options.timeoutMs !== undefined) {
147
+ timeout = setTimeout(() => controller.abort(OPERATION_TIMEOUT_REASON), Math.max(0, options.timeoutMs));
148
+ timeout.unref();
149
+ }
150
+ const onParentAbort = (): void => controller.abort(options.signal?.reason ?? "operation_cancelled");
151
+ if (options.signal !== undefined) {
152
+ if (options.signal.aborted) {
153
+ onParentAbort();
154
+ } else {
155
+ options.signal.addEventListener("abort", onParentAbort, { once: true });
156
+ }
157
+ }
158
+ const context: OperationContext = {
159
+ signal: controller.signal,
160
+ deadline,
161
+ ...(options.onProgress === undefined ? {} : { onProgress: options.onProgress }),
162
+ ...(options.onProcess === undefined ? {} : { onProcess: options.onProcess }),
163
+ };
164
+ return {
165
+ context,
166
+ cancel: () => controller.abort("operation_cancelled"),
167
+ dispose: () => {
168
+ if (timeout !== undefined) {
169
+ clearTimeout(timeout);
170
+ }
171
+ options.signal?.removeEventListener("abort", onParentAbort);
172
+ },
173
+ };
174
+ }
175
+
176
+ export async function allCompleted<T extends readonly unknown[]>(tasks: T): Promise<{ -readonly [K in keyof T]: Awaited<T[K]> }> {
177
+ const outcomes = await Promise.allSettled(tasks);
178
+ const failure = outcomes.find((outcome) => outcome.status === "rejected");
179
+ if (failure?.status === "rejected") throw failure.reason;
180
+ return outcomes.map((outcome) => (outcome as PromiseFulfilledResult<unknown>).value) as { -readonly [K in keyof T]: Awaited<T[K]> };
181
+ }
182
+
183
+ export function configuredTimeout(name: string, fallback: number): number {
184
+ const raw = process.env[name];
185
+ if (raw === undefined || raw === "") return fallback;
186
+ const value = Number(raw);
187
+ if (!Number.isSafeInteger(value) || value <= 0 || value > 2_147_483_647) throw new Error(`${name} 必须是正整数毫秒`);
188
+ return value;
189
+ }
190
+
191
+ export async function withRecoveryBudget<T>(body: () => Promise<T>, options: OperationScopeOptions = {}): Promise<T> {
192
+ if (operationHasUnconfirmedExit()) checkOperation();
193
+ const scope = createOperationScope({
194
+ timeoutMs: configuredTimeout("PI_UNDO_OPERATION_TIMEOUT_MS", 300_000),
195
+ onProcess: currentOperationContext()?.onProcess,
196
+ ...options,
197
+ });
198
+ try {
199
+ return await runWithOperationContext(scope.context, async () => {
200
+ try {
201
+ return await body();
202
+ } finally {
203
+ if (operationHasUnconfirmedExit()) checkOperation();
204
+ }
205
+ });
206
+ } catch (error) {
207
+ if (isUnconfirmedExit(error)) markProcessExitUnconfirmed();
208
+ throw error;
209
+ } finally {
210
+ scope.dispose();
211
+ }
212
+ }
213
+
214
+ export function isUnconfirmedExit(error: unknown): boolean {
215
+ return errorChain(error).some((value) => value.code === "git_termination_failed" || value.code === "process_exit_unconfirmed");
216
+ }
217
+
218
+ /** 包装层保留 cause 时仍能识别取消,避免被误报为普通 capture 失败。 */
219
+ export function operationFailure(error: unknown): OperationErrorCode | undefined {
220
+ for (const value of errorChain(error)) {
221
+ if (value.code === "operation_cancelled" || value.code === "operation_timeout") return value.code;
222
+ const result = value.result as { timedOut?: boolean; aborted?: boolean } | undefined;
223
+ if (result?.timedOut) return "operation_timeout";
224
+ if (result?.aborted) return "operation_cancelled";
225
+ }
226
+ return undefined;
227
+ }
228
+
229
+ export function rethrowOperationFailure(error: unknown): void {
230
+ if (isUnconfirmedExit(error) || operationFailure(error) !== undefined) throw error;
231
+ }
232
+
233
+ function errorChain(error: unknown): Array<Record<string, unknown>> {
234
+ const chain: Array<Record<string, unknown>> = [];
235
+ const seen = new Set<unknown>();
236
+ while (typeof error === "object" && error !== null && !seen.has(error)) {
237
+ seen.add(error);
238
+ const value = error as Record<string, unknown>;
239
+ chain.push(value);
240
+ error = value.cause;
241
+ }
242
+ return chain;
243
+ }
244
+
245
+ function operationErrorFromAbortReason(reason: unknown): OperationError {
246
+ if (isTimeoutReason(reason)) {
247
+ return new OperationError("operation_timeout", "操作超过截止时间");
248
+ }
249
+ return new OperationError("operation_cancelled", "操作已被取消");
250
+ }
251
+
252
+ /** 判断 AbortSignal 的 reason 是否代表超时(而不是主动取消)。 */
253
+ export function isOperationTimeoutReason(reason: unknown): boolean {
254
+ return isTimeoutReason(reason);
255
+ }
256
+
257
+ function isTimeoutReason(reason: unknown): boolean {
258
+ if (reason === OPERATION_TIMEOUT_REASON) {
259
+ return true;
260
+ }
261
+ if (typeof reason !== "object" || reason === null) {
262
+ return false;
263
+ }
264
+ const value = reason as { readonly code?: unknown; readonly name?: unknown };
265
+ return value.code === OPERATION_TIMEOUT_REASON || value.name === "TimeoutError";
266
+ }