mirror of
https://github.com/morluto/rea.git
synced 2026-11-07 05:56:19 -10:00
fix(javascript): transient Windows private-runtime source-transfer (#8769)
* fix(javascript): transient Windows private-runtime source-transfer Parent source-transfer into the Windows private runtime root failed with EPERM on path-based exclusive create. Write through a native transient private-runtime path that closes the handle after flush so per-job rm still works, and yield around the sync write so abort deadlines can fire. * fix(javascript): yield timer phase before Windows source-transfer write
This commit is contained in:
6 files changed
+281
-8
No files matched your search
@@ -824,7 +824,7 @@ napi_value filesystemCall(napi_env env, const std::wstring& operation,
|
||||
set(env, result, "handle", wrap(env, std::move(file)));
|
||||
return result;
|
||||
}
|
||||
if (operation == L"runtime_write") {
|
||||
if (operation == L"runtime_write" || operation == L"runtime_write_transient") {
|
||||
bool isBuffer; check(napi_is_buffer(env, args[2], &isBuffer));
|
||||
require(isBuffer, "Private write requires a byte buffer", root.path, ERROR_INVALID_PARAMETER);
|
||||
void* bytes; size_t length; check(napi_get_buffer_info(env, args[2], &bytes, &length));
|
||||
@@ -832,7 +832,20 @@ napi_value filesystemCall(napi_env env, const std::wstring& operation,
|
||||
auto file = createPrivateFile(root, wide(env, args[1]));
|
||||
writeBytes(file.get(), bytes, static_cast<DWORD>(length), root.path);
|
||||
require(FlushFileBuffers(file.get()), "Flush private runtime file failed", root.path);
|
||||
root.immutableFiles.push_back(std::move(file));
|
||||
if (operation == L"runtime_write") {
|
||||
// Retain the write lease with FILE_SHARE_READ only so later opens cannot
|
||||
// acquire write or delete access until runtime_close releases it.
|
||||
root.immutableFiles.push_back(std::move(file));
|
||||
} else {
|
||||
// Close without retaining. Per-job path deletion (and child readFile) can
|
||||
// proceed while the private DACL still protects the staged bytes.
|
||||
try {
|
||||
requireReleased(file, "Close transient runtime file failed", root.path);
|
||||
} catch (...) {
|
||||
parkUnresolved(root, file);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
return null(env);
|
||||
}
|
||||
if (operation == L"runtime_snapshot") {
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const writeFileMock = vi.hoisted(() => vi.fn());
|
||||
const windowsWriteTransientFile = vi.hoisted(() => vi.fn());
|
||||
const windowsWriteFile = vi.hoisted(() => vi.fn());
|
||||
const windowsPrivateRuntime = vi.hoisted(() =>
|
||||
vi.fn(() => ({
|
||||
writeTransientFile: windowsWriteTransientFile,
|
||||
writeFile: windowsWriteFile,
|
||||
})),
|
||||
);
|
||||
|
||||
vi.mock("node:fs/promises", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("node:fs/promises")>();
|
||||
return {
|
||||
...actual,
|
||||
writeFile: (...args: Parameters<typeof actual.writeFile>) =>
|
||||
writeFileMock(...args),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../../windows/WindowsPrivateRuntime.js", () => ({
|
||||
windowsPrivateRuntime,
|
||||
}));
|
||||
|
||||
import { writeJavaScriptAnalysisSourceTransfer } from "./JavaScriptAnalysisSourceTransfer.js";
|
||||
|
||||
afterEach(() => {
|
||||
writeFileMock.mockReset();
|
||||
windowsWriteTransientFile.mockReset();
|
||||
windowsWriteFile.mockReset();
|
||||
windowsPrivateRuntime.mockReset();
|
||||
windowsPrivateRuntime.mockImplementation(() => ({
|
||||
writeTransientFile: windowsWriteTransientFile,
|
||||
writeFile: windowsWriteFile,
|
||||
}));
|
||||
});
|
||||
|
||||
describe("writeJavaScriptAnalysisSourceTransfer", () => {
|
||||
it("uses the transient private-runtime write on Windows and never path-creates", async () => {
|
||||
const eperm = Object.assign(new Error("EPERM"), { code: "EPERM" });
|
||||
writeFileMock.mockRejectedValue(eperm);
|
||||
const path = join("C:\\rea-javascript-analysis-fixture", "job.source");
|
||||
|
||||
await writeJavaScriptAnalysisSourceTransfer(path, "source-bytes", {
|
||||
platform: "win32",
|
||||
});
|
||||
|
||||
expect(windowsPrivateRuntime).toHaveBeenCalledWith(
|
||||
"C:\\rea-javascript-analysis-fixture",
|
||||
);
|
||||
expect(windowsWriteTransientFile).toHaveBeenCalledWith(
|
||||
"job.source",
|
||||
"source-bytes",
|
||||
);
|
||||
expect(windowsWriteFile).not.toHaveBeenCalled();
|
||||
expect(writeFileMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("honors cancellation before a Windows private-runtime write", async () => {
|
||||
const controller = new AbortController();
|
||||
controller.abort("cancelled");
|
||||
|
||||
await expect(
|
||||
writeJavaScriptAnalysisSourceTransfer(
|
||||
join("C:\\rea-javascript-analysis-fixture", "job.source"),
|
||||
"source-bytes",
|
||||
{ platform: "win32", signal: controller.signal },
|
||||
),
|
||||
).rejects.toBe("cancelled");
|
||||
expect(windowsPrivateRuntime).not.toHaveBeenCalled();
|
||||
expect(windowsWriteTransientFile).not.toHaveBeenCalled();
|
||||
expect(writeFileMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("yields through a timer phase so a pending deadline abort can fire", async () => {
|
||||
const controller = new AbortController();
|
||||
setTimeout(() => {
|
||||
controller.abort("cancelled-during-yield");
|
||||
}, 0);
|
||||
|
||||
await expect(
|
||||
writeJavaScriptAnalysisSourceTransfer(
|
||||
join("C:\\rea-javascript-analysis-fixture", "job.source"),
|
||||
"source-bytes",
|
||||
{ platform: "win32", signal: controller.signal },
|
||||
),
|
||||
).rejects.toBe("cancelled-during-yield");
|
||||
expect(windowsWriteTransientFile).not.toHaveBeenCalled();
|
||||
expect(windowsPrivateRuntime).not.toHaveBeenCalled();
|
||||
expect(writeFileMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("honors cancellation after a completed Windows private-runtime write", async () => {
|
||||
const controller = new AbortController();
|
||||
windowsWriteTransientFile.mockImplementation(() => {
|
||||
controller.abort("cancelled-after-write");
|
||||
});
|
||||
|
||||
await expect(
|
||||
writeJavaScriptAnalysisSourceTransfer(
|
||||
join("C:\\rea-javascript-analysis-fixture", "job.source"),
|
||||
"source-bytes",
|
||||
{ platform: "win32", signal: controller.signal },
|
||||
),
|
||||
).rejects.toBe("cancelled-after-write");
|
||||
expect(windowsWriteTransientFile).toHaveBeenCalledOnce();
|
||||
expect(windowsWriteFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps exclusive owner-only create on POSIX", async () => {
|
||||
writeFileMock.mockImplementation(
|
||||
async (...args: Parameters<typeof writeFile>) => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import("node:fs/promises")>(
|
||||
"node:fs/promises",
|
||||
);
|
||||
return actual.writeFile(...args);
|
||||
},
|
||||
);
|
||||
const root = await mkdtemp(join(tmpdir(), "rea-js-source-transfer-"));
|
||||
const path = join(root, "job.source");
|
||||
try {
|
||||
await writeJavaScriptAnalysisSourceTransfer(path, "source-bytes", {
|
||||
platform: "linux",
|
||||
});
|
||||
expect(await readFile(path, "utf8")).toBe("source-bytes");
|
||||
expect(writeFileMock).toHaveBeenCalledWith(path, "source-bytes", {
|
||||
flag: "wx",
|
||||
mode: 0o600,
|
||||
signal: undefined,
|
||||
});
|
||||
expect(windowsPrivateRuntime).not.toHaveBeenCalled();
|
||||
await expect(
|
||||
writeJavaScriptAnalysisSourceTransfer(path, "again", {
|
||||
platform: "linux",
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "EEXIST" });
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("surfaces EPERM from the POSIX exclusive create path", async () => {
|
||||
const eperm = Object.assign(new Error("EPERM"), { code: "EPERM" });
|
||||
writeFileMock.mockRejectedValue(eperm);
|
||||
|
||||
await expect(
|
||||
writeJavaScriptAnalysisSourceTransfer("/tmp/job.source", "source-bytes", {
|
||||
platform: "linux",
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "EPERM" });
|
||||
expect(windowsPrivateRuntime).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import { win32 } from "node:path";
|
||||
|
||||
import { windowsPrivateRuntime } from "../../windows/WindowsPrivateRuntime.js";
|
||||
|
||||
/**
|
||||
* Parent source-transfer write into the worker-owned private runtime root.
|
||||
*
|
||||
* Windows private roots install a protected DACL and retain the directory
|
||||
* handle; Node path-based exclusive create can fail with EPERM there. Write
|
||||
* through the native private-runtime owner with a transient handle so the
|
||||
* worker can `readFile` the path and the parent can still `rm` `${id}.source`
|
||||
* after the job. POSIX keeps exclusive owner-only create.
|
||||
*/
|
||||
export const writeJavaScriptAnalysisSourceTransfer = async (
|
||||
path: string,
|
||||
source: string,
|
||||
options: {
|
||||
readonly platform?: NodeJS.Platform;
|
||||
readonly signal?: AbortSignal;
|
||||
} = {},
|
||||
): Promise<void> => {
|
||||
const platform = options.platform ?? process.platform;
|
||||
options.signal?.throwIfAborted();
|
||||
if (platform === "win32") {
|
||||
// Yield through a timer phase so ProviderStartupDeadline setTimeout
|
||||
// callbacks and other macrotasks can deliver abort before the sync
|
||||
// native create/write/flush/close; check again after it returns.
|
||||
await new Promise<void>((resolve) => {
|
||||
setTimeout(resolve, 0);
|
||||
});
|
||||
options.signal?.throwIfAborted();
|
||||
windowsPrivateRuntime(win32.dirname(path)).writeTransientFile(
|
||||
win32.basename(path),
|
||||
source,
|
||||
);
|
||||
options.signal?.throwIfAborted();
|
||||
return;
|
||||
}
|
||||
await writeFile(path, source, {
|
||||
flag: "wx",
|
||||
mode: 0o600,
|
||||
signal: options.signal,
|
||||
});
|
||||
};
|
||||
@@ -7,8 +7,9 @@ import type {
|
||||
JavaScriptSemanticFileProjection,
|
||||
} from "../../domain/javascript/javascriptSemanticFileProjection.js";
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import { readFile, rm, stat, writeFile } from "node:fs/promises";
|
||||
import { readFile, rm, stat } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { writeJavaScriptAnalysisSourceTransfer } from "./JavaScriptAnalysisSourceTransfer.js";
|
||||
import { StringDecoder } from "node:string_decoder";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { getHeapStatistics } from "node:v8";
|
||||
@@ -333,11 +334,11 @@ export class JavaScriptAnalysisWorker {
|
||||
);
|
||||
supervisor.resetOutput();
|
||||
this.#phase = "source-transfer";
|
||||
await writeFile(join(rootPath, `${id}.source`), file.text.value, {
|
||||
flag: "wx",
|
||||
mode: 0o600,
|
||||
signal: deadline.signal,
|
||||
});
|
||||
await writeJavaScriptAnalysisSourceTransfer(
|
||||
join(rootPath, `${id}.source`),
|
||||
file.text.value,
|
||||
{ signal: deadline.signal },
|
||||
);
|
||||
const { text: _text, ...metadata } = file;
|
||||
let offeredResultBudget = 0;
|
||||
const request = javaScriptWorkerRequestSchema.parse({
|
||||
|
||||
@@ -162,6 +162,49 @@ const admittedFile = {
|
||||
handle: { kind: "admitted-file" },
|
||||
};
|
||||
|
||||
describe("Windows private runtime transient write", () => {
|
||||
it("writes through runtime_write_transient and never retains via runtime_write", async () => {
|
||||
const operations: string[] = [];
|
||||
nativeCall.mockImplementation((operation: string) => {
|
||||
operations.push(operation);
|
||||
if (operation === "runtime_create") return observation;
|
||||
return null;
|
||||
});
|
||||
const runtime = WindowsPrivateRuntime.create("C:\\", "rea-");
|
||||
|
||||
runtime.writeTransientFile("job.source", "source-bytes");
|
||||
|
||||
expect(operations).toEqual(["runtime_create", "runtime_write_transient"]);
|
||||
expect(nativeCall).toHaveBeenCalledWith("runtime_write_transient", [
|
||||
observation.handle,
|
||||
"job.source",
|
||||
Buffer.from("source-bytes"),
|
||||
]);
|
||||
expect(operations).not.toContain("runtime_write");
|
||||
await runtime.close();
|
||||
});
|
||||
|
||||
it("keeps immutable runtime_write distinct from transient staging", async () => {
|
||||
const operations: string[] = [];
|
||||
nativeCall.mockImplementation((operation: string) => {
|
||||
operations.push(operation);
|
||||
if (operation === "runtime_create") return observation;
|
||||
return null;
|
||||
});
|
||||
const runtime = WindowsPrivateRuntime.create("C:\\", "rea-");
|
||||
|
||||
runtime.writeFile("descriptor.json", "fixture");
|
||||
runtime.writeTransientFile("job.source", "source-bytes");
|
||||
|
||||
expect(operations).toEqual([
|
||||
"runtime_create",
|
||||
"runtime_write",
|
||||
"runtime_write_transient",
|
||||
]);
|
||||
await runtime.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Windows private runtime admitted file close", () => {
|
||||
it("retries the same file wrapper before the runtime tree walk", async () => {
|
||||
const failure = nativeFailure(5);
|
||||
|
||||
@@ -112,6 +112,19 @@ export class WindowsPrivateRuntime {
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Write a new private file once, flush, and close the writer without retaining
|
||||
* it in `immutableFiles`. Use for staged job inputs that a child must read and
|
||||
* the parent must later `rm` while the runtime lease stays open.
|
||||
*/
|
||||
writeTransientFile(relativePath: string, content: string): void {
|
||||
this.authority.call("runtime_write_transient", [
|
||||
this.observation.handle,
|
||||
relativePath,
|
||||
Buffer.from(content),
|
||||
]);
|
||||
}
|
||||
|
||||
/** Copy admitted source bytes and digest the bytes actually written. */
|
||||
async snapshot(
|
||||
sourcePath: string,
|
||||
|
||||
Reference in new issue
Block a user