@davideasden/pi-undo 0.2.24 → 0.2.26
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 +38 -1
- package/docs/native-performance.md +52 -0
- package/extensions/pi-undo.ts +75 -15
- package/native/bin/pi-undo-fs-darwin-arm64 +0 -0
- package/package.json +10 -8
- 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-capabilities.ts +34 -0
- package/src/native-directory-scan.ts +114 -0
- package/src/native-metadata.ts +47 -87
- package/src/native-restore.ts +61 -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 +115 -88
- package/src/root-discovery.ts +99 -18
- package/src/snapshot-store.ts +39 -16
- package/src/status-reporter.ts +41 -1
- package/src/workspace-lock.ts +4 -1
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { constants } from "node:fs";
|
|
2
|
+
import { access, mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
|
|
6
|
+
import { GitRunError, runSupervisedProcess } from "./git-runner.ts";
|
|
7
|
+
import { probeNativeCapability } from "./native-capabilities.ts";
|
|
8
|
+
import { nativeExecutable } from "./native-restore.ts";
|
|
9
|
+
import { checkOperation, configuredTimeout, OperationError, operationProcessOptions } from "./operation-context.ts";
|
|
10
|
+
|
|
11
|
+
export interface NativeRepositoryCandidate {
|
|
12
|
+
readonly path: string;
|
|
13
|
+
readonly dev: bigint;
|
|
14
|
+
readonly ino: bigint;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface NativeDirectoryScan {
|
|
18
|
+
readonly directories: number;
|
|
19
|
+
readonly repositories: readonly NativeRepositoryCandidate[];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface NativeDirectoryScanPort {
|
|
23
|
+
scan(workspaceRoot: string): Promise<NativeDirectoryScan | undefined>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** 只缓存 helper 能力;每个拓扑检查点均重新完整扫描工作区,包括 ignored 目录。 */
|
|
27
|
+
export class NativeDirectoryScanner implements NativeDirectoryScanPort {
|
|
28
|
+
private readonly executable: string | undefined;
|
|
29
|
+
private capability: Promise<boolean> | undefined;
|
|
30
|
+
|
|
31
|
+
constructor(executable = nativeExecutable()) {
|
|
32
|
+
this.executable = process.env.PI_UNDO_DISABLE_NATIVE === "1" || process.platform === "win32"
|
|
33
|
+
? undefined : executable;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async scan(workspaceRoot: string): Promise<NativeDirectoryScan | undefined> {
|
|
37
|
+
checkOperation();
|
|
38
|
+
if (this.executable === undefined || (this.capability !== undefined && !await this.capability)) return undefined;
|
|
39
|
+
const requestDirectory = await mkdtemp(join(tmpdir(), "pi-undo-native-scan-"));
|
|
40
|
+
try {
|
|
41
|
+
this.capability ??= this.supportsScan(requestDirectory).catch((error) => {
|
|
42
|
+
// 取消或未确认终止不能污染后续操作的能力缓存。
|
|
43
|
+
this.capability = undefined;
|
|
44
|
+
throw error;
|
|
45
|
+
});
|
|
46
|
+
if (!await this.capability) return undefined;
|
|
47
|
+
const budget = operationProcessOptions(configuredTimeout("PI_UNDO_OPERATION_TIMEOUT_MS", 300_000));
|
|
48
|
+
const requestPath = join(requestDirectory, "request.json");
|
|
49
|
+
await writeFile(requestPath, JSON.stringify({ schemaVersion: 1, workspaceRoot }), { mode: 0o600, flag: "wx" });
|
|
50
|
+
const result = await runSupervisedProcess({
|
|
51
|
+
command: this.executable,
|
|
52
|
+
args: ["--scan-directories", requestPath],
|
|
53
|
+
...budget,
|
|
54
|
+
outputLimitBytes: 32 * 1024 * 1024,
|
|
55
|
+
outputOverflow: "terminate",
|
|
56
|
+
});
|
|
57
|
+
if (!result.stopped) throw new GitRunError("git_termination_failed", "native 目录扫描进程未能确认终止");
|
|
58
|
+
if (result.outcome === "cancelled") {
|
|
59
|
+
checkOperation();
|
|
60
|
+
throw new OperationError("operation_cancelled", "native 目录扫描已被取消");
|
|
61
|
+
}
|
|
62
|
+
if (result.outcome === "timeout") throw new OperationError("operation_timeout", "native 目录扫描超时");
|
|
63
|
+
if (result.outcome !== "exit" || result.code !== 0) {
|
|
64
|
+
throw new Error(`native 目录扫描失败:${result.stderr.toString("utf8").trim() || result.outcome}`);
|
|
65
|
+
}
|
|
66
|
+
checkOperation();
|
|
67
|
+
return parseScanResponse(result.stdout.toString("utf8"));
|
|
68
|
+
} finally {
|
|
69
|
+
await rm(requestDirectory, { recursive: true, force: true }).catch(() => {});
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
private async supportsScan(directory: string): Promise<boolean> {
|
|
74
|
+
try {
|
|
75
|
+
await access(this.executable!, constants.X_OK);
|
|
76
|
+
} catch {
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
return probeNativeCapability(this.executable!, "scan-directories-v1", directory);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function parseScanResponse(text: string): NativeDirectoryScan | undefined {
|
|
84
|
+
const value: unknown = JSON.parse(text);
|
|
85
|
+
// 深度上限是执行器的资源边界;此时丢弃部分结果,由 TypeScript 从根完整重扫。
|
|
86
|
+
if (isRecord(value) && value.ok === false && value.code === "depth_limit") return undefined;
|
|
87
|
+
if (!isRecord(value) || value.ok !== true || !Number.isSafeInteger(value.directories) ||
|
|
88
|
+
(value.directories as number) < 1 || !Array.isArray(value.repositories) ||
|
|
89
|
+
value.repositories.length >= (value.directories as number)) {
|
|
90
|
+
throw new Error("native 目录扫描响应无效");
|
|
91
|
+
}
|
|
92
|
+
const seen = new Set<string>();
|
|
93
|
+
const repositories = value.repositories.map((candidate): NativeRepositoryCandidate => {
|
|
94
|
+
if (!isRecord(candidate) || typeof candidate.path !== "string" || candidate.path.includes("\0") ||
|
|
95
|
+
candidate.path.split("/").some((part) => part === "" || part === "." || part === ".." || part === ".git") ||
|
|
96
|
+
seen.has(candidate.path)) {
|
|
97
|
+
throw new Error("native 仓库候选路径无效");
|
|
98
|
+
}
|
|
99
|
+
seen.add(candidate.path);
|
|
100
|
+
return { path: candidate.path, dev: parseIdentity(candidate.dev), ino: parseIdentity(candidate.ino) };
|
|
101
|
+
});
|
|
102
|
+
return { directories: value.directories as number, repositories };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function parseIdentity(value: unknown): bigint {
|
|
106
|
+
if (typeof value !== "string" || !/^(?:0|[1-9][0-9]{0,19})$/.test(value) || BigInt(value) > (1n << 64n) - 1n) {
|
|
107
|
+
throw new Error("native 目录身份字段无效");
|
|
108
|
+
}
|
|
109
|
+
return BigInt(value);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
113
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
114
|
+
}
|
package/src/native-metadata.ts
CHANGED
|
@@ -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
|
-
|
|
78
|
-
const
|
|
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
|
-
|
|
81
|
-
|
|
82
|
-
|
|
87
|
+
timeoutMs: NATIVE_PROBE_TIMEOUT_MS,
|
|
88
|
+
outputLimitBytes: NATIVE_PROBE_OUTPUT_LIMIT,
|
|
89
|
+
outputOverflow: "terminate",
|
|
83
90
|
});
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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[] {
|
package/src/native-restore.ts
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
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";
|
|
9
|
+
import { probeNativeCapability } from "./native-capabilities.ts";
|
|
8
10
|
import type { MutationJournal } from "./mutation-journal.ts";
|
|
9
11
|
|
|
10
12
|
const NATIVE_TIMEOUT_MS = 120_000;
|
|
@@ -20,19 +22,27 @@ export async function createNativeFileBatch(options: {
|
|
|
20
22
|
readonly workspaceRoot: string;
|
|
21
23
|
readonly planDigest: string;
|
|
22
24
|
readonly journal: MutationJournal;
|
|
25
|
+
/** 测试注入用;默认使用随包分发的平台二进制。 */
|
|
26
|
+
readonly executable?: string;
|
|
27
|
+
readonly requiredCapability?: "restore-files-v2";
|
|
23
28
|
}): Promise<NativeFileBatch | undefined> {
|
|
24
29
|
if (process.env.PI_UNDO_DISABLE_NATIVE === "1") return undefined;
|
|
25
|
-
const executable = nativeExecutable();
|
|
30
|
+
const executable = options.executable ?? nativeExecutable();
|
|
26
31
|
if (executable === undefined) return undefined;
|
|
27
32
|
try {
|
|
28
33
|
await access(executable, constants.X_OK);
|
|
29
34
|
} catch {
|
|
30
35
|
return undefined;
|
|
31
36
|
}
|
|
37
|
+
if (options.requiredCapability !== undefined && !await probeNativeCapability(
|
|
38
|
+
executable, options.requiredCapability, dirname(options.journal.storagePath),
|
|
39
|
+
)) return undefined;
|
|
32
40
|
const execute = async (pack: DurablePack, requestPath: string, verifyOnly: boolean): Promise<void> => {
|
|
33
41
|
const paths = pack.paths();
|
|
34
42
|
if (paths.length === 0) return;
|
|
35
43
|
if (pack.planDigest !== options.planDigest) throw new Error("native file batch planDigest 不匹配");
|
|
44
|
+
// 取消/超时检查在写出 request 之前:已取消的操作不产生新工作,也不留下请求文件。
|
|
45
|
+
const budget = operationProcessOptions(NATIVE_TIMEOUT_MS);
|
|
36
46
|
const request = {
|
|
37
47
|
schemaVersion: 1,
|
|
38
48
|
opId: options.journal.operationId,
|
|
@@ -63,7 +73,7 @@ export async function createNativeFileBatch(options: {
|
|
|
63
73
|
}),
|
|
64
74
|
};
|
|
65
75
|
await writeFile(requestPath, JSON.stringify(request), { mode: 0o600 });
|
|
66
|
-
await runNative(executable, requestPath, paths.length);
|
|
76
|
+
await runNative(executable, requestPath, paths.length, budget);
|
|
67
77
|
};
|
|
68
78
|
return {
|
|
69
79
|
available: true,
|
|
@@ -72,13 +82,21 @@ export async function createNativeFileBatch(options: {
|
|
|
72
82
|
try {
|
|
73
83
|
await execute(pack, join(dirname(pack.storagePath), `native-verify-${process.pid}.json`), true);
|
|
74
84
|
return true;
|
|
75
|
-
} catch {
|
|
85
|
+
} catch (error) {
|
|
86
|
+
rethrowOperationFailure(error);
|
|
76
87
|
return false;
|
|
77
88
|
}
|
|
78
89
|
},
|
|
79
90
|
};
|
|
80
91
|
}
|
|
81
92
|
|
|
93
|
+
export function nativeRestoreCapability(pack: DurablePack): "restore-files-v2" | undefined {
|
|
94
|
+
const paths = pack.paths();
|
|
95
|
+
const deletes = paths.filter((path) => pack.artifacts(path)?.target === null);
|
|
96
|
+
return deletes.some((path) => path.includes("/")) || (deletes.length > 0 && deletes.length < paths.length)
|
|
97
|
+
? "restore-files-v2" : undefined;
|
|
98
|
+
}
|
|
99
|
+
|
|
82
100
|
export function nativeExecutable(): string | undefined {
|
|
83
101
|
const platform = process.platform === "darwin"
|
|
84
102
|
? "darwin"
|
|
@@ -90,57 +108,44 @@ export function nativeExecutable(): string | undefined {
|
|
|
90
108
|
return fileURLToPath(new URL(`../native/bin/pi-undo-fs-${platform}-${architecture}${extension}`, import.meta.url));
|
|
91
109
|
}
|
|
92
110
|
|
|
93
|
-
function runNative(
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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
|
-
});
|
|
111
|
+
async function runNative(
|
|
112
|
+
executable: string,
|
|
113
|
+
requestPath: string,
|
|
114
|
+
expected: number,
|
|
115
|
+
budget: OperationProcessOptions,
|
|
116
|
+
): Promise<void> {
|
|
117
|
+
const result = await runSupervisedProcess({
|
|
118
|
+
command: executable,
|
|
119
|
+
args: [requestPath],
|
|
120
|
+
signal: budget.signal,
|
|
121
|
+
timeoutMs: budget.timeoutMs,
|
|
122
|
+
outputLimitBytes: NATIVE_OUTPUT_LIMIT,
|
|
145
123
|
});
|
|
124
|
+
if (!result.stopped) {
|
|
125
|
+
// 无法证明 helper 已停止:保留 lease,让上层走恢复流程。
|
|
126
|
+
throw new GitRunError("git_termination_failed", "native restore 进程未能确认终止");
|
|
127
|
+
}
|
|
128
|
+
if (result.outcome === "cancelled") {
|
|
129
|
+
throw new OperationError("operation_cancelled", "native restore 已被取消");
|
|
130
|
+
}
|
|
131
|
+
if (result.outcome === "timeout") {
|
|
132
|
+
throw new OperationError("operation_timeout", "native restore 超时");
|
|
133
|
+
}
|
|
134
|
+
if (result.outcome === "output_overflow") {
|
|
135
|
+
throw new Error("native restore 输出超过限制");
|
|
136
|
+
}
|
|
137
|
+
if (result.code !== 0) {
|
|
138
|
+
throw new Error(`native restore 失败:${result.stderr.toString("utf8").trim()}`);
|
|
139
|
+
}
|
|
140
|
+
const parsed: unknown = JSON.parse(result.stdout.toString("utf8"));
|
|
141
|
+
if (
|
|
142
|
+
typeof parsed !== "object" ||
|
|
143
|
+
parsed === null ||
|
|
144
|
+
!("ok" in parsed) ||
|
|
145
|
+
parsed.ok !== true ||
|
|
146
|
+
!("processed" in parsed) ||
|
|
147
|
+
parsed.processed !== expected
|
|
148
|
+
) {
|
|
149
|
+
throw new Error("native restore 响应无效");
|
|
150
|
+
}
|
|
146
151
|
}
|