fix: optimize Windows apply-patch fs path
This commit is contained in:
@@ -0,0 +1,247 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { Readable, Writable } from "node:stream";
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import {
|
||||
ApplyPatchV2Error,
|
||||
runApplyPatchV2,
|
||||
type ApplyPatchV2BulkReplacementWritePlan,
|
||||
type ApplyPatchV2FileSystem,
|
||||
} from "./apply-patch-v2";
|
||||
|
||||
class CaptureWritable extends Writable {
|
||||
chunks: Buffer[] = [];
|
||||
|
||||
_write(chunk: Buffer | string, _encoding: BufferEncoding, callback: (error?: Error | null) => void): void {
|
||||
this.chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
||||
callback();
|
||||
}
|
||||
|
||||
text(): string {
|
||||
return Buffer.concat(this.chunks).toString("utf8");
|
||||
}
|
||||
}
|
||||
|
||||
function sha256Hex(text: string): string {
|
||||
return createHash("sha256").update(text, "utf8").digest("hex");
|
||||
}
|
||||
|
||||
function applyReplacementPlan(original: string, plan: ApplyPatchV2BulkReplacementWritePlan): string {
|
||||
const lines = original.split("\n");
|
||||
if (lines.at(-1) === "") lines.pop();
|
||||
for (const [start, oldLength, newLines] of [...plan.replacements].sort((left, right) => right[0] - left[0])) {
|
||||
lines.splice(start, oldLength, ...newLines);
|
||||
}
|
||||
return lines.length === 0 ? "" : `${lines.join("\n")}\n`;
|
||||
}
|
||||
|
||||
describe("apply-patch v2 fs bulk update path", () => {
|
||||
test("single fs update uses readFiles and applyReplacementsBulk instead of whole-file write", async () => {
|
||||
const files = new Map<string, string>([["docs/test.md", "alpha\nold\nomega\n"]]);
|
||||
const calls: string[] = [];
|
||||
const fs: ApplyPatchV2FileSystem = {
|
||||
async stat(path) {
|
||||
calls.push(`stat:${path}`);
|
||||
throw new Error("stat should not be used by the single-file bulk path");
|
||||
},
|
||||
async readBlock(path) {
|
||||
calls.push(`readBlock:${path}`);
|
||||
throw new Error("readBlock should not be used by the single-file bulk path");
|
||||
},
|
||||
async writeFile(path) {
|
||||
calls.push(`writeFile:${path}`);
|
||||
throw new Error("writeFile should not be used for update hunks with replacement bulk");
|
||||
},
|
||||
async deleteFile(path) {
|
||||
calls.push(`deleteFile:${path}`);
|
||||
},
|
||||
async readFiles(paths) {
|
||||
calls.push(`readFiles:${paths.join(",")}`);
|
||||
return new Map(paths.map((path) => [path, files.get(path) ?? ""]));
|
||||
},
|
||||
async applyReplacementsBulk(paths, plans) {
|
||||
const targets = Array.from(paths);
|
||||
calls.push(`applyReplacementsBulk:${targets.join(",")}`);
|
||||
for (const target of targets) {
|
||||
const plan = plans.get(target);
|
||||
if (plan === undefined) throw new Error(`missing plan for ${target}`);
|
||||
const next = applyReplacementPlan(files.get(target) ?? "", plan);
|
||||
expect(Buffer.byteLength(next, "utf8")).toBe(plan.finalBytes);
|
||||
expect(sha256Hex(next)).toBe(plan.finalSha256);
|
||||
files.set(target, next);
|
||||
}
|
||||
},
|
||||
};
|
||||
const stdout = new CaptureWritable();
|
||||
const stderr = new CaptureWritable();
|
||||
|
||||
const exitCode = await runApplyPatchV2({
|
||||
executor: { fs },
|
||||
stdin: Readable.from([[
|
||||
"*** Begin Patch",
|
||||
"*** Update File: docs/test.md",
|
||||
"@@",
|
||||
" alpha",
|
||||
"-old",
|
||||
"+new",
|
||||
" omega",
|
||||
"*** End Patch",
|
||||
].join("\n")]),
|
||||
stdout,
|
||||
stderr,
|
||||
timing: { transport: "local", route: "D601:win/F/Work/ConStart" },
|
||||
});
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
expect(files.get("docs/test.md")).toBe("alpha\nnew\nomega\n");
|
||||
expect(calls).toEqual(["readFiles:docs/test.md", "applyReplacementsBulk:docs/test.md"]);
|
||||
expect(stdout.text()).toContain("M docs/test.md");
|
||||
expect(stderr.text()).toContain("\"remoteOperationCounts\":{\"fs.readFiles\":1,\"fs.applyReplacementsBulk\":1}");
|
||||
});
|
||||
|
||||
test("bulk read failure falls back to block reads but still writes through replacement bulk", async () => {
|
||||
const files = new Map<string, string>([
|
||||
["a.md", "first\nold-a\nlast\n"],
|
||||
["b.md", "first\nold-b\nlast\n"],
|
||||
]);
|
||||
const calls: string[] = [];
|
||||
const fs: ApplyPatchV2FileSystem = {
|
||||
async stat(path) {
|
||||
calls.push(`stat:${path}`);
|
||||
const text = files.get(path);
|
||||
if (text === undefined) throw new Error(`missing ${path}`);
|
||||
return { bytes: Buffer.byteLength(text, "utf8"), sha256: sha256Hex(text) };
|
||||
},
|
||||
async readBlock(path, blockIndex, blockBytes) {
|
||||
calls.push(`readBlock:${path}:${blockIndex}:${blockBytes}`);
|
||||
const text = files.get(path);
|
||||
if (text === undefined) throw new Error(`missing ${path}`);
|
||||
return blockIndex === 0 ? Buffer.from(text, "utf8") : Buffer.alloc(0);
|
||||
},
|
||||
async writeFile(path) {
|
||||
calls.push(`writeFile:${path}`);
|
||||
throw new Error("writeFile should not be used after bulk read fallback");
|
||||
},
|
||||
async deleteFile(path) {
|
||||
calls.push(`deleteFile:${path}`);
|
||||
},
|
||||
async readFiles(paths) {
|
||||
calls.push(`readFiles:${paths.join(",")}`);
|
||||
throw new ApplyPatchV2Error("windows apply-patch fs operation failed: read-bulk-b64", {
|
||||
operation: "read-bulk-b64",
|
||||
targetCount: paths.length,
|
||||
remoteElapsedMs: 60000,
|
||||
landed: false,
|
||||
});
|
||||
},
|
||||
async applyReplacementsBulk(paths, plans) {
|
||||
const targets = Array.from(paths);
|
||||
calls.push(`applyReplacementsBulk:${targets.join(",")}`);
|
||||
for (const target of targets) {
|
||||
const plan = plans.get(target);
|
||||
if (plan === undefined) throw new Error(`missing plan for ${target}`);
|
||||
files.set(target, applyReplacementPlan(files.get(target) ?? "", plan));
|
||||
}
|
||||
},
|
||||
};
|
||||
const stdout = new CaptureWritable();
|
||||
const stderr = new CaptureWritable();
|
||||
|
||||
const exitCode = await runApplyPatchV2({
|
||||
executor: { fs },
|
||||
stdin: Readable.from([[
|
||||
"*** Begin Patch",
|
||||
"*** Update File: a.md",
|
||||
"@@",
|
||||
" first",
|
||||
"-old-a",
|
||||
"+new-a",
|
||||
" last",
|
||||
"*** Update File: b.md",
|
||||
"@@",
|
||||
" first",
|
||||
"-old-b",
|
||||
"+new-b",
|
||||
" last",
|
||||
"*** End Patch",
|
||||
].join("\n")]),
|
||||
stdout,
|
||||
stderr,
|
||||
timing: { transport: "local", route: "D601:win/F/Work/ConStart" },
|
||||
});
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
expect(files.get("a.md")).toBe("first\nnew-a\nlast\n");
|
||||
expect(files.get("b.md")).toBe("first\nnew-b\nlast\n");
|
||||
expect(calls).toEqual([
|
||||
"readFiles:a.md,b.md",
|
||||
"stat:a.md",
|
||||
"readBlock:a.md:0:45000",
|
||||
"stat:b.md",
|
||||
"readBlock:b.md:0:45000",
|
||||
"applyReplacementsBulk:a.md,b.md",
|
||||
]);
|
||||
expect(stdout.text()).toContain("M a.md");
|
||||
expect(stdout.text()).toContain("M b.md");
|
||||
expect(stderr.text()).toContain("\"remoteFailureCount\":1");
|
||||
});
|
||||
|
||||
test("remote fs failure prints operation details", async () => {
|
||||
const fs: ApplyPatchV2FileSystem = {
|
||||
async stat() {
|
||||
throw new Error("stat should not be used");
|
||||
},
|
||||
async readBlock() {
|
||||
throw new Error("readBlock should not be used");
|
||||
},
|
||||
async writeFile() {
|
||||
throw new Error("writeFile should not be used");
|
||||
},
|
||||
async deleteFile() {
|
||||
throw new Error("deleteFile should not be used");
|
||||
},
|
||||
async readFiles(paths) {
|
||||
return new Map(paths.map((path) => [path, "alpha\nold\nomega\n"]));
|
||||
},
|
||||
async applyReplacementsBulk() {
|
||||
throw new ApplyPatchV2Error("windows apply-patch fs operation failed: apply-replacements-bulk-stdin", {
|
||||
operation: "apply-replacements-bulk-stdin",
|
||||
route: "D601:win/F/Work/ConStart",
|
||||
targetCount: 1,
|
||||
inputBytes: 190,
|
||||
expectedBytes: "22",
|
||||
expectedSha256: "abc123",
|
||||
remoteElapsedMs: 60000,
|
||||
landed: false,
|
||||
exitCode: 124,
|
||||
stderrTail: "UNIDESK_SSH_RUNTIME_TIMEOUT timeoutSeconds=60",
|
||||
});
|
||||
},
|
||||
};
|
||||
const stdout = new CaptureWritable();
|
||||
const stderr = new CaptureWritable();
|
||||
|
||||
const exitCode = await runApplyPatchV2({
|
||||
executor: { fs },
|
||||
stdin: Readable.from([[
|
||||
"*** Begin Patch",
|
||||
"*** Update File: docs/test.md",
|
||||
"@@",
|
||||
" alpha",
|
||||
"-old",
|
||||
"+new",
|
||||
" omega",
|
||||
"*** End Patch",
|
||||
].join("\n")]),
|
||||
stdout,
|
||||
stderr,
|
||||
timing: { transport: "local", route: "D601:win/F/Work/ConStart" },
|
||||
});
|
||||
|
||||
expect(exitCode).toBe(1);
|
||||
expect(stdout.text()).toBe("");
|
||||
const err = stderr.text();
|
||||
expect(err).toContain("Remote operation: operation=apply-replacements-bulk-stdin route=D601:win/F/Work/ConStart targetCount=1 inputBytes=190 expectedBytes=22 sha256=abc123 elapsedMs=60000 landed=false exitCode=124");
|
||||
expect(err).toContain("Remote stderr tail:");
|
||||
expect(err).toContain("UNIDESK_SSH_RUNTIME_TIMEOUT timeoutSeconds=60");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user