@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.
- package/README.md +36 -3
- package/extensions/pi-undo.ts +75 -15
- package/package.json +7 -6
- package/src/controller.ts +377 -45
- package/src/git-runner.ts +356 -51
- package/src/journal.ts +5 -2
- package/src/model.ts +2 -0
- package/src/native-metadata.ts +47 -87
- package/src/native-restore.ts +49 -56
- package/src/operation-context.ts +266 -0
- package/src/pi-runtime.ts +119 -15
- package/src/quarantine.ts +3 -0
- package/src/recovery.ts +3 -3
- package/src/restore-engine.ts +102 -76
- package/src/root-discovery.ts +46 -5
- package/src/snapshot-store.ts +39 -16
- package/src/status-reporter.ts +41 -1
- package/src/workspace-lock.ts +4 -1
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
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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(
|
|
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
|
-
|
|
181
|
+
if (!terminationFailed) untrackProcessGroup(child);
|
|
182
|
+
signal?.removeEventListener("abort", onAbort);
|
|
157
183
|
};
|
|
158
184
|
|
|
159
185
|
const finish = (): void => {
|
|
160
|
-
if (settled
|
|
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:
|
|
170
|
-
killed: killed ||
|
|
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 &&
|
|
179
|
-
reject(new GitRunError("git_failed", `git 退出码为 ${String(
|
|
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,
|
|
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
|
|
210
|
-
|
|
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
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
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
|
|
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
|
-
|
|
229
|
-
|
|
230
|
-
|
|
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)
|
|
316
|
+
const finish = (killed: boolean): void => {
|
|
317
|
+
if (settled) {
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
235
320
|
settled = true;
|
|
236
|
-
|
|
321
|
+
clearTimeout(timeout);
|
|
322
|
+
resolve(killed);
|
|
237
323
|
};
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
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)
|
|
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
|
}
|