@davideasden/pi-undo 0.2.24 → 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.
package/src/git-runner.ts CHANGED
@@ -1,9 +1,14 @@
1
1
  import { spawn, type ChildProcess } from "node:child_process";
2
2
 
3
+ import { type OperationContext, configuredTimeout, currentOperationContext, isOperationTimeoutReason, markProcessExitUnconfirmed, reportProcessDiagnostic } from "./operation-context.ts";
4
+
3
5
  export const DEFAULT_STDERR_LIMIT = 64 * 1024;
6
+ /** Git 单次调用的默认预算;调用链上的 deadline 可进一步收紧它。 */
7
+ export const DEFAULT_GIT_TIMEOUT_MS = 120_000;
4
8
  const TERMINATION_GRACE_MS = 50;
5
9
  const PROCESS_TREE_EXIT_TIMEOUT_MS = 1_000;
6
10
  const PROCESS_TREE_POLL_MS = 10;
11
+ const TASKKILL_TIMEOUT_MS = 5_000;
7
12
  const activeProcessGroups = new Set<number>();
8
13
 
9
14
  process.once("exit", () => {
@@ -55,6 +60,29 @@ export interface GitRunner {
55
60
 
56
61
  export class GitRunner {
57
62
  async run(args: readonly string[], options: GitRunOptions = {}): Promise<GitRunResult> {
63
+ const started = performance.now();
64
+ // 仅记录已知子命令名,不保留路径、环境、stdin 或 stderr。
65
+ const commands = new Set(["cat-file", "ls-files", "ls-tree", "hash-object", "read-tree", "write-tree", "update-index", "rev-parse", "check-ignore", "config", "init"]);
66
+ const command = `git:${args.find((argument) => commands.has(argument)) ?? "other"}`;
67
+ let outcome = "failed";
68
+ let exitCode: number | null = null;
69
+ try {
70
+ const result = await this.runCommand(args, options);
71
+ outcome = result.timedOut ? "timeout" : result.aborted ? "cancelled" : "exit";
72
+ exitCode = result.code;
73
+ return result;
74
+ } catch (error) {
75
+ if (error instanceof GitRunError) {
76
+ outcome = error.code;
77
+ exitCode = error.result?.code ?? null;
78
+ }
79
+ throw error;
80
+ } finally {
81
+ reportProcessDiagnostic({ command, durationMs: Math.round(performance.now() - started), outcome, exitCode });
82
+ }
83
+ }
84
+
85
+ private async runCommand(args: readonly string[], options: GitRunOptions): Promise<GitRunResult> {
58
86
  const stderrLimit = options.stderrLimit ?? DEFAULT_STDERR_LIMIT;
59
87
  if (!Number.isInteger(stderrLimit) || stderrLimit < 0) {
60
88
  throw new RangeError("stderrLimit 必须是非负整数");
@@ -62,16 +90,15 @@ export class GitRunner {
62
90
  if (options.timeoutMs !== undefined && (!Number.isFinite(options.timeoutMs) || options.timeoutMs < 0)) {
63
91
  throw new RangeError("timeoutMs 必须是非负有限数字");
64
92
  }
65
- if (options.signal?.aborted) {
66
- return {
67
- stdout: "",
68
- stdoutBytes: new Uint8Array(),
69
- stderr: "",
70
- code: null,
71
- killed: true,
72
- timedOut: false,
73
- aborted: true,
74
- };
93
+ const context = currentOperationContext();
94
+ const signal = combineAbortSignals(options.signal, context?.signal);
95
+ const budget = resolveTimeoutBudget(options.timeoutMs, context);
96
+ if (signal?.aborted === true) {
97
+ const timedOut = isOperationTimeoutReason(signal.reason);
98
+ return killedResult({ aborted: !timedOut, timedOut });
99
+ }
100
+ if (budget.expired) {
101
+ return killedResult({ aborted: false, timedOut: true });
75
102
  }
76
103
 
77
104
  return new Promise<GitRunResult>((resolve, reject) => {
@@ -121,6 +148,12 @@ export class GitRunner {
121
148
  child.stdin?.end(typeof options.stdin === "string" ? options.stdin : Buffer.from(options.stdin));
122
149
  }
123
150
 
151
+ const finishTermination = (stopped: boolean): void => {
152
+ terminationFailed = !stopped;
153
+ terminationFinalized = true;
154
+ finish();
155
+ };
156
+
124
157
  const terminate = (reason: "timeout" | "abort"): void => {
125
158
  if (settled || killed) {
126
159
  return;
@@ -129,21 +162,14 @@ export class GitRunner {
129
162
  timedOut = reason === "timeout";
130
163
  aborted = reason === "abort";
131
164
  signalProcessTree(child, "SIGTERM");
165
+ // 宽限期后升级为 SIGKILL,并确认进程组真的退出;只有确认后才结束 Promise,
166
+ // 否则上层无法安全释放 lease。
132
167
  forceKill = setTimeout(() => {
133
- void forceTerminateProcessTree(child).then((stopped) => {
134
- terminationFailed = !stopped;
135
- terminationFinalized = true;
136
- finish();
137
- });
168
+ void forceTerminateProcessTree(child).then(finishTermination);
138
169
  }, TERMINATION_GRACE_MS);
139
170
  };
140
171
 
141
- const onAbort = (): void => terminate("abort");
142
- options.signal?.addEventListener("abort", onAbort, { once: true });
143
- if (options.timeoutMs !== undefined) {
144
- timeout = setTimeout(() => terminate("timeout"), options.timeoutMs);
145
- }
146
-
172
+ const onAbort = (): void => terminate(isOperationTimeoutReason(signal?.reason) ? "timeout" : "abort");
147
173
  const cleanUp = (): void => {
148
174
  settled = true;
149
175
  if (timeout) {
@@ -152,31 +178,41 @@ export class GitRunner {
152
178
  if (forceKill) {
153
179
  clearTimeout(forceKill);
154
180
  }
155
- untrackProcessGroup(child);
156
- options.signal?.removeEventListener("abort", onAbort);
181
+ if (!terminationFailed) untrackProcessGroup(child);
182
+ signal?.removeEventListener("abort", onAbort);
157
183
  };
158
184
 
159
185
  const finish = (): void => {
160
- if (settled || closeResult === undefined || (killed && !terminationFinalized)) {
186
+ if (settled) {
187
+ return;
188
+ }
189
+ // 被终止时先等终止确认;确认进程组已退出后即便 close 一直不到达也必须结束,
190
+ // 避免强杀后无限等待 close。
191
+ if (killed && !terminationFinalized) {
192
+ return;
193
+ }
194
+ if (closeResult === undefined && !terminationFinalized) {
161
195
  return;
162
196
  }
163
197
  cleanUp();
198
+ const exit = closeResult ?? { code: null, signal: null };
164
199
  const stdoutBuffer = Buffer.concat(stdout);
165
200
  const result: GitRunResult = {
166
201
  stdout: stdoutBuffer.toString("utf8"),
167
202
  stdoutBytes: new Uint8Array(stdoutBuffer),
168
203
  stderr: Buffer.concat(stderr).toString("utf8"),
169
- code: closeResult.code,
170
- killed: killed || closeResult.signal !== null,
204
+ code: exit.code,
205
+ killed: killed || exit.signal !== null,
171
206
  timedOut,
172
207
  aborted,
173
208
  };
174
209
  if (terminationFailed) {
210
+ markProcessExitUnconfirmed();
175
211
  reject(new GitRunError("git_termination_failed", "Git 进程组未能完全终止", result));
176
212
  return;
177
213
  }
178
- if (!killed && closeResult.code !== 0) {
179
- reject(new GitRunError("git_failed", `git 退出码为 ${String(closeResult.code)}`, result));
214
+ if (!killed && exit.code !== 0) {
215
+ reject(new GitRunError("git_failed", `git 退出码为 ${String(exit.code)}`, result));
180
216
  return;
181
217
  }
182
218
  resolve(result);
@@ -190,56 +226,113 @@ export class GitRunner {
190
226
  reject(new GitRunError("git_spawn_failed", error.message));
191
227
  });
192
228
 
193
- child.once("close", (code, signal) => {
229
+ child.once("close", (code, signalValue) => {
194
230
  if (settled) {
195
231
  return;
196
232
  }
197
- closeResult = { code, signal };
233
+ closeResult = { code, signal: signalValue };
234
+ if (!killed && signalValue !== null) {
235
+ // 父进程被外部信号终止时也清理后代,避免其在失败返回后继续写入。
236
+ void forceTerminateProcessTree(child).then(finishTermination);
237
+ return;
238
+ }
198
239
  if (!killed) {
199
240
  terminationFinalized = true;
200
241
  }
201
242
  finish();
202
243
  });
244
+
245
+ signal?.addEventListener("abort", onAbort, { once: true });
246
+ if (signal?.aborted) onAbort();
247
+ timeout = setTimeout(() => terminate("timeout"), budget.timeoutMs);
203
248
  });
204
249
  }
205
250
  }
206
251
 
252
+ /** 终止共享子进程:先 SIGTERM,宽限期后 SIGKILL,并返回进程组是否已确认退出。 */
253
+ async function terminateProcessTree(child: ChildProcess): Promise<boolean> {
254
+ signalProcessTree(child, "SIGTERM");
255
+ if (await waitForProcessTreeExit(child, TERMINATION_GRACE_MS)) {
256
+ return true;
257
+ }
258
+ return forceTerminateProcessTree(child);
259
+ }
260
+
207
261
  async function forceTerminateProcessTree(child: ChildProcess): Promise<boolean> {
208
262
  if (process.platform === "win32" && child.pid !== undefined) {
209
- await runTaskkill(child.pid, child);
210
- return true;
263
+ const taskkilled = await runTaskkill(child.pid);
264
+ if (!taskkilled) {
265
+ try {
266
+ child.kill("SIGKILL");
267
+ } catch {
268
+ // 已经退出时无需处理。
269
+ }
270
+ }
271
+ if (taskkilled) return waitForProcessTreeExit(child, PROCESS_TREE_EXIT_TIMEOUT_MS);
272
+ await waitForProcessTreeExit(child, PROCESS_TREE_EXIT_TIMEOUT_MS);
273
+ // 只证明父进程退出不足以证明其后代退出。
274
+ return false;
211
275
  }
212
276
  signalProcessTree(child, "SIGKILL");
213
- if (child.pid === undefined) {
214
- return true;
215
- }
216
- const deadline = Date.now() + PROCESS_TREE_EXIT_TIMEOUT_MS;
217
- while (processGroupExists(child.pid)) {
277
+ return waitForProcessTreeExit(child, PROCESS_TREE_EXIT_TIMEOUT_MS);
278
+ }
279
+
280
+ async function waitForProcessTreeExit(child: ChildProcess, timeoutMs: number): Promise<boolean> {
281
+ const deadline = Date.now() + timeoutMs;
282
+ for (;;) {
283
+ if (processTreeExitConfirmed(child)) {
284
+ return true;
285
+ }
218
286
  if (Date.now() >= deadline) {
219
287
  return false;
220
288
  }
221
289
  await delay(PROCESS_TREE_POLL_MS);
222
290
  }
223
- return true;
224
291
  }
225
292
 
226
- function runTaskkill(pid: number, child: ChildProcess): Promise<void> {
293
+ function processTreeExitConfirmed(child: ChildProcess): boolean {
294
+ if (process.platform === "win32") {
295
+ return child.exitCode !== null || child.signalCode !== null;
296
+ }
297
+ if (child.pid === undefined) {
298
+ return true;
299
+ }
300
+ return !processGroupExists(child.pid);
301
+ }
302
+
303
+ function runTaskkill(pid: number): Promise<boolean> {
227
304
  return new Promise((resolve) => {
228
- const killer = spawn("taskkill", ["/PID", String(pid), "/T", "/F"], {
229
- stdio: "ignore",
230
- windowsHide: true,
231
- });
305
+ let child: ChildProcess;
306
+ try {
307
+ child = spawn("taskkill", ["/PID", String(pid), "/T", "/F"], {
308
+ stdio: "ignore",
309
+ windowsHide: true,
310
+ });
311
+ } catch {
312
+ resolve(false);
313
+ return;
314
+ }
232
315
  let settled = false;
233
- const finish = (): void => {
234
- if (settled) return;
316
+ const finish = (killed: boolean): void => {
317
+ if (settled) {
318
+ return;
319
+ }
235
320
  settled = true;
236
- resolve();
321
+ clearTimeout(timeout);
322
+ resolve(killed);
237
323
  };
238
- killer.once("error", () => {
239
- child.kill("SIGKILL");
240
- finish();
241
- });
242
- killer.once("close", finish);
324
+ // taskkill 本身也可能挂住;超时后放弃它并回退到直接 SIGKILL。
325
+ const timeout = setTimeout(() => {
326
+ try {
327
+ child.kill("SIGKILL");
328
+ } catch {
329
+ // 已经退出时无需处理。
330
+ }
331
+ finish(false);
332
+ }, TASKKILL_TIMEOUT_MS);
333
+ timeout.unref();
334
+ child.once("error", () => finish(false));
335
+ child.once("close", (code) => finish(code === 0));
243
336
  });
244
337
  }
245
338
 
@@ -279,10 +372,222 @@ function signalProcessTree(child: ChildProcess, signal: NodeJS.Signals): void {
279
372
  child.kill(signal);
280
373
  }
281
374
 
375
+ export interface SupervisedProcessRequest {
376
+ readonly command: string;
377
+ readonly args: readonly string[];
378
+ readonly cwd?: string;
379
+ readonly env?: Readonly<Record<string, string | undefined>>;
380
+ readonly signal?: AbortSignal;
381
+ readonly timeoutMs: number;
382
+ readonly outputLimitBytes: number;
383
+ /** 输出超过限制时:truncate 只停止捕获,terminate 先终止进程组。 */
384
+ readonly outputOverflow?: "truncate" | "terminate";
385
+ }
386
+
387
+ export interface SupervisedProcessResult {
388
+ readonly outcome: "exit" | "timeout" | "cancelled" | "output_overflow";
389
+ readonly code: number | null;
390
+ readonly stdout: Buffer;
391
+ readonly stderr: Buffer;
392
+ /** 非 exit 结果下进程组是否已确认退出;false 表示调用方必须保留 lease。 */
393
+ readonly stopped: boolean;
394
+ }
395
+
396
+ /**
397
+ * native helper 等非 Git 子进程共用同一套进程组终止语义:超时/取消先终止进程组,
398
+ * 确认退出后才返回,避免"已取消但仍在后台写入"。
399
+ */
400
+ export async function runSupervisedProcess(request: SupervisedProcessRequest): Promise<SupervisedProcessResult> {
401
+ const started = performance.now();
402
+ let outcome = "failed";
403
+ let exitCode: number | null = null;
404
+ try {
405
+ const result = await superviseProcess(request);
406
+ outcome = result.stopped ? result.outcome : "process_exit_unconfirmed";
407
+ exitCode = result.code;
408
+ return result;
409
+ } finally {
410
+ reportProcessDiagnostic({ command: "native-helper", durationMs: Math.round(performance.now() - started), outcome, exitCode });
411
+ }
412
+ }
413
+
414
+ function superviseProcess(request: SupervisedProcessRequest): Promise<SupervisedProcessResult> {
415
+ if (!Number.isFinite(request.timeoutMs) || request.timeoutMs < 0) {
416
+ return Promise.reject(new RangeError("timeoutMs 必须是非负有限数字"));
417
+ }
418
+ if (!Number.isInteger(request.outputLimitBytes) || request.outputLimitBytes < 0) {
419
+ return Promise.reject(new RangeError("outputLimitBytes 必须是非负整数"));
420
+ }
421
+ if (request.signal?.aborted === true) {
422
+ return Promise.resolve({
423
+ outcome: "cancelled",
424
+ code: null,
425
+ stdout: Buffer.alloc(0),
426
+ stderr: Buffer.alloc(0),
427
+ stopped: true,
428
+ });
429
+ }
430
+ return new Promise<SupervisedProcessResult>((resolve, reject) => {
431
+ let child: ChildProcess;
432
+ try {
433
+ child = spawn(request.command, [...request.args], {
434
+ cwd: request.cwd,
435
+ env: mergeEnvironment(request.env),
436
+ detached: process.platform !== "win32",
437
+ shell: false,
438
+ stdio: ["ignore", "pipe", "pipe"],
439
+ windowsHide: true,
440
+ });
441
+ } catch (error) {
442
+ reject(error);
443
+ return;
444
+ }
445
+ trackProcessGroup(child);
446
+
447
+ const stdout: Buffer[] = [];
448
+ const stderr: Buffer[] = [];
449
+ let outputBytes = 0;
450
+ let outcome: SupervisedProcessResult["outcome"] = "exit";
451
+ let closeCode: number | null = null;
452
+ let closeArrived = false;
453
+ let stopped = false;
454
+ let settled = false;
455
+ let timeout: NodeJS.Timeout | undefined;
456
+ let termination: Promise<boolean> | undefined;
457
+
458
+ const cleanUp = (): void => {
459
+ settled = true;
460
+ if (timeout) {
461
+ clearTimeout(timeout);
462
+ }
463
+ if (outcome === "exit" || stopped) untrackProcessGroup(child);
464
+ request.signal?.removeEventListener("abort", onAbort);
465
+ };
466
+
467
+ const finish = (): void => {
468
+ if (settled) {
469
+ return;
470
+ }
471
+ cleanUp();
472
+ resolve({
473
+ outcome,
474
+ code: closeCode,
475
+ stdout: Buffer.concat(stdout),
476
+ stderr: Buffer.concat(stderr),
477
+ stopped: outcome === "exit" ? closeArrived : stopped,
478
+ });
479
+ };
480
+
481
+ const beginTermination = (reason: SupervisedProcessResult["outcome"]): void => {
482
+ if (settled || termination !== undefined || outcome !== "exit") {
483
+ return;
484
+ }
485
+ outcome = reason;
486
+ termination = terminateProcessTree(child).then((value) => {
487
+ if (!value) markProcessExitUnconfirmed();
488
+ stopped = value;
489
+ finish();
490
+ return value;
491
+ });
492
+ };
493
+
494
+ function onAbort(): void {
495
+ beginTermination("cancelled");
496
+ }
497
+
498
+ const capture = (target: Buffer[]) => (chunk: Buffer | string): void => {
499
+ if (settled) {
500
+ return;
501
+ }
502
+ const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
503
+ const remaining = request.outputLimitBytes - outputBytes;
504
+ if (bytes.length > remaining) {
505
+ if (remaining > 0) {
506
+ target.push(bytes.subarray(0, remaining));
507
+ outputBytes += remaining;
508
+ }
509
+ if (request.outputOverflow === "terminate") {
510
+ beginTermination("output_overflow");
511
+ } else {
512
+ outputBytes = request.outputLimitBytes;
513
+ }
514
+ return;
515
+ }
516
+ target.push(bytes);
517
+ outputBytes += bytes.length;
518
+ };
519
+ child.stdout?.on("data", capture(stdout));
520
+ child.stderr?.on("data", capture(stderr));
521
+
522
+ child.once("error", (error) => {
523
+ if (settled) {
524
+ return;
525
+ }
526
+ cleanUp();
527
+ reject(error);
528
+ });
529
+ child.once("close", (code) => {
530
+ closeCode = code;
531
+ closeArrived = true;
532
+ if (termination === undefined) {
533
+ finish();
534
+ }
535
+ });
536
+
537
+ request.signal?.addEventListener("abort", onAbort, { once: true });
538
+ if (request.signal?.aborted) onAbort();
539
+ timeout = setTimeout(() => beginTermination("timeout"), request.timeoutMs);
540
+ });
541
+ }
542
+
282
543
  export function createGitRunner(): GitRunner {
283
544
  return new GitRunner();
284
545
  }
285
546
 
547
+ function killedResult(options: { readonly aborted: boolean; readonly timedOut: boolean }): GitRunResult {
548
+ return {
549
+ stdout: "",
550
+ stdoutBytes: new Uint8Array(),
551
+ stderr: "",
552
+ code: null,
553
+ killed: true,
554
+ timedOut: options.timedOut,
555
+ aborted: options.aborted,
556
+ };
557
+ }
558
+
559
+ /** context.signal 与调用方 signal 都要能终止子进程;两者取并集。 */
560
+ function combineAbortSignals(
561
+ own: AbortSignal | undefined,
562
+ context: AbortSignal | undefined,
563
+ ): AbortSignal | undefined {
564
+ if (own === undefined) {
565
+ return context;
566
+ }
567
+ if (context === undefined || own === context) {
568
+ return own;
569
+ }
570
+ return AbortSignal.any([own, context]);
571
+ }
572
+
573
+ function resolveTimeoutBudget(
574
+ explicitTimeoutMs: number | undefined,
575
+ context: OperationContext | undefined,
576
+ ): { readonly timeoutMs: number; readonly expired: boolean } {
577
+ const defaultTimeoutMs = configuredTimeout("PI_UNDO_GIT_TIMEOUT_MS", DEFAULT_GIT_TIMEOUT_MS);
578
+ if (context === undefined) {
579
+ return { timeoutMs: explicitTimeoutMs ?? defaultTimeoutMs, expired: false };
580
+ }
581
+ const remaining = context.deadline - Date.now();
582
+ if (remaining <= 0) {
583
+ return { timeoutMs: 0, expired: true };
584
+ }
585
+ const timeoutMs = explicitTimeoutMs === undefined
586
+ ? Math.min(defaultTimeoutMs, remaining)
587
+ : Math.min(explicitTimeoutMs, remaining);
588
+ return { timeoutMs, expired: false };
589
+ }
590
+
286
591
  function mergeEnvironment(overrides: Readonly<Record<string, string | undefined>> | undefined): NodeJS.ProcessEnv {
287
592
  const environment: NodeJS.ProcessEnv = { ...process.env };
288
593
  for (const [key, value] of Object.entries(overrides ?? {})) {
package/src/journal.ts CHANGED
@@ -254,6 +254,7 @@ export class JournalStore {
254
254
  export async function inspectCursorMarkers(
255
255
  sessionFile: string,
256
256
  descriptor: OperationDescriptor,
257
+ expectedLogicalLeaf: string | null = descriptor.toLogicalLeaf,
257
258
  ): Promise<CursorMarkerInspection> {
258
259
  assertOperationDescriptor(descriptor);
259
260
  let content: string;
@@ -287,7 +288,8 @@ export async function inspectCursorMarkers(
287
288
  continue;
288
289
  }
289
290
  if (cursor.opId !== descriptor.opId) continue;
290
- if (!matchesDescriptor(cursor, descriptor)) return { kind: "conflict" };
291
+ if (!matchesDescriptor(cursor, descriptor) || cursor.fromLogicalLeaf !== descriptor.fromLogicalLeaf ||
292
+ cursor.toLogicalLeaf !== expectedLogicalLeaf) return { kind: "conflict" };
291
293
  const encoded = canonicalJson(cursor);
292
294
  if (matched !== undefined && matched !== encoded) return { kind: "conflict" };
293
295
  matched = encoded;
@@ -306,13 +308,14 @@ export async function finalizeCursorMarker(
306
308
  sessionFile: string,
307
309
  descriptor: OperationDescriptor,
308
310
  inspection: Extract<CursorMarkerInspection, { kind: "match" }>,
311
+ expectedLogicalLeaf: string | null = descriptor.toLogicalLeaf,
309
312
  ): Promise<void> {
310
313
  if (inspection.needsTrailingNewline) {
311
314
  await appendFile(sessionFile, "\n");
312
315
  }
313
316
  await fsyncFile(sessionFile);
314
317
  await fsyncDirectory(dirname(sessionFile));
315
- const verified = await inspectCursorMarkers(sessionFile, descriptor);
318
+ const verified = await inspectCursorMarkers(sessionFile, descriptor, expectedLogicalLeaf);
316
319
  if (verified.kind !== "match" || verified.needsTrailingNewline) {
317
320
  throw new Error("cursor marker 耐久化校验失败");
318
321
  }
package/src/model.ts CHANGED
@@ -12,6 +12,8 @@ export type ResultCode =
12
12
  | "noop"
13
13
  | "busy"
14
14
  | "idle_timeout"
15
+ | "operation_cancelled"
16
+ | "operation_timeout"
15
17
  | "capture_failed"
16
18
  | "restore_failed_safe"
17
19
  | "partial_restore"