diff --git a/native/windows/src/filesystem.cc b/native/windows/src/filesystem.cc index 7690e22e30..2b2b0ffe90 100644 --- a/native/windows/src/filesystem.cc +++ b/native/windows/src/filesystem.cc @@ -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(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") { diff --git a/src/javascript/analysis/JavaScriptAnalysisSourceTransfer.test.ts b/src/javascript/analysis/JavaScriptAnalysisSourceTransfer.test.ts new file mode 100644 index 0000000000..933688692a --- /dev/null +++ b/src/javascript/analysis/JavaScriptAnalysisSourceTransfer.test.ts @@ -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(); + return { + ...actual, + writeFile: (...args: Parameters) => + 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) => { + const actual = + await vi.importActual( + "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(); + }); +}); diff --git a/src/javascript/analysis/JavaScriptAnalysisSourceTransfer.ts b/src/javascript/analysis/JavaScriptAnalysisSourceTransfer.ts new file mode 100644 index 0000000000..6abef49857 --- /dev/null +++ b/src/javascript/analysis/JavaScriptAnalysisSourceTransfer.ts @@ -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 => { + 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((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, + }); +}; diff --git a/src/javascript/analysis/JavaScriptAnalysisWorker.ts b/src/javascript/analysis/JavaScriptAnalysisWorker.ts index f6a5e92553..72af9e850e 100644 --- a/src/javascript/analysis/JavaScriptAnalysisWorker.ts +++ b/src/javascript/analysis/JavaScriptAnalysisWorker.ts @@ -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({ diff --git a/src/windows/WindowsPrivateRuntime.test.ts b/src/windows/WindowsPrivateRuntime.test.ts index 947d71d21e..e4ca90b45b 100644 --- a/src/windows/WindowsPrivateRuntime.test.ts +++ b/src/windows/WindowsPrivateRuntime.test.ts @@ -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); diff --git a/src/windows/WindowsPrivateRuntime.ts b/src/windows/WindowsPrivateRuntime.ts index a32488aaa8..c76f51e951 100644 --- a/src/windows/WindowsPrivateRuntime.ts +++ b/src/windows/WindowsPrivateRuntime.ts @@ -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,