Skip to content

Commit 5fe5470

Browse files
committed
fix(checkpoint): flush captured members through update handles on Windows
On native Windows every runtime.checkpoint_artifacts capture and materialize aborts with EPERM -4048 fsync: copyComponent flushes each copied member through a read-only handle (open(destination, "r") + sync()), and Windows FlushFileBuffers requires update access. loopx-project#6415 made the flush read-only so an ordinary non-root POSIX controller can capture a read-only participant's 0o444 copy, and the flush itself is deliberate (loopx-project#6258). Keep the flush on every platform: open the staged copy for update, and when the copy is read-only (POSIX 0o444 mode or the Windows read-only attribute) grant owner access only for the sync, then restore the reviewed mode before the copy is witnessed or published. The checkpoint artifact suite gains a read-only participant case and the established Windows symlink host-privilege guard; 14 of 14 tests pass on native Windows (was 5 of 13). Signed-off-by: JasonBuildAI <jasonbuildai@gmail.com>
1 parent 32c882f commit 5fe5470

2 files changed

Lines changed: 47 additions & 8 deletions

File tree

‎loopx/control_plane/runtime/checkpoint_artifacts.ts‎

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
*/
55
import {createHash} from "node:crypto";
66
import {createReadStream} from "node:fs";
7-
import {copyFile, lstat, mkdir, mkdtemp, open, readFile, readdir, rm} from "node:fs/promises";
7+
import {chmod, copyFile, lstat, mkdir, mkdtemp, open, readFile, readdir, rm} from "node:fs/promises";
88
import {dirname, isAbsolute, join, resolve, sep} from "node:path";
99
import type {JsonObject} from "../effect_program.ts";
1010
import {jsonObject, requireJsonObject, requireNonEmptyString} from "../runtime_decode.ts";
@@ -114,6 +114,28 @@ async function sqliteSidecar(path: string, suffixes: string[]): Promise<boolean>
114114
try {return (await lstat(database)).isFile() && await sqliteFile(database);}
115115
catch (error) {if ((error as NodeJS.ErrnoException).code === "ENOENT") return false; throw error;}
116116
}
117+
async function syncCopiedFile(path: string): Promise<void> {
118+
const handle = await open(path, "r+");
119+
try {await handle.sync();} finally {await handle.close();}
120+
}
121+
/** Flush a freshly copied checkpoint member through an updatable handle.
122+
* copyFile preserves a read-only participant's mode, and Windows
123+
* FlushFileBuffers rejects read-only handles, so a read-only copy grants owner
124+
* access only to this staged copy for the durability sync and restores its
125+
* mode before the caller witnesses or publishes it. POSIX syncs a read-only
126+
* handle directly, but the update path covers both read-only cases.
127+
*/
128+
async function flushCopiedFile(path: string): Promise<void> {
129+
try {await syncCopiedFile(path);}
130+
catch (error) {
131+
const code = (error as NodeJS.ErrnoException).code;
132+
if (code !== "EACCES" && code !== "EPERM") throw error;
133+
const mode = (await lstat(path)).mode & 0o777;
134+
await chmod(path, mode | 0o600);
135+
try {await syncCopiedFile(path);}
136+
finally {await chmod(path, mode);}
137+
}
138+
}
117139
async function copyComponent(source: string, destination: string, snapshotDatabases = true): Promise<void> {
118140
const stat = await lstat(source);
119141
if (stat.isSymbolicLink()) throw new Error("checkpoint source became a symlink");
@@ -135,10 +157,7 @@ async function copyComponent(source: string, destination: string, snapshotDataba
135157
// SQLite's existing physical snapshot owner seals committed WAL contents.
136158
if (snapshotDatabases && await sqliteFile(source)) await snapshotSqliteDatabase(source, destination);
137159
else await copyFile(source, destination);
138-
// copyFile preserves a read-only participant's mode. Syncing its bytes
139-
// needs no write access; r+ fails for an ordinary non-root controller.
140-
const handle = await open(destination, "r");
141-
try {await handle.sync();} finally {await handle.close();}
160+
await flushCopiedFile(destination);
142161
} else throw new Error("checkpoint source contains an unsupported file type");
143162
}
144163
function manifestBody(raw: unknown): JsonObject {

‎tests/control_plane_ts/checkpoint_artifacts.test.ts‎

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import assert from "node:assert/strict";
22
import test from "node:test";
3-
import {chmod, mkdir, mkdtemp, readFile, readdir, realpath, rm, symlink, writeFile} from "node:fs/promises";
3+
import {chmod, mkdir, mkdtemp, readFile, readdir, realpath, rm, stat, symlink, writeFile} from "node:fs/promises";
44
import {tmpdir} from "node:os";
55
import {join} from "node:path";
66
import {checkpointArtifacts} from "../../loopx/control_plane/runtime/checkpoint_artifacts.ts";
@@ -39,6 +39,22 @@ test("capture, verify and inert materialization preserve executable code and emp
3939
expected_sha256: captured.sha256, destination: join(root, "restored")}), /already exists/);
4040
});
4141

42+
test("a read-only participant's copied mode survives the durability flush and materialization", async t => {
43+
const {root, request} = await fixture(t);
44+
await chmod(join(root, "code/solution.sh"), 0o444);
45+
const components = request.components;
46+
const probe = await checkpointArtifacts({action: "probe", components});
47+
const captured = await checkpointArtifacts({...request, components, expected_source_sha256: probe.source_sha256});
48+
assert.equal(captured.integrity_verified, true);
49+
const copied = join(root, "captured/components/code/solution.sh");
50+
assert.equal((await stat(copied)).mode & 0o777, 0o444);
51+
await checkpointArtifacts({action: "materialize", root: request.destination, expected_sha256: captured.sha256,
52+
destination: join(root, "restored")});
53+
const restored = join(root, "restored/components/code/solution.sh");
54+
assert.equal(await readFile(restored, "utf8"), "#!/bin/sh\nexit 0\n");
55+
assert.equal((await stat(restored)).mode & 0o777, 0o444);
56+
});
57+
4258
test("changed source, duplicate identities and overlapping sources publish no unit", async t => {
4359
const {root, request} = await fixture(t);
4460
await writeFile(join(root, "conversation"), "new native cursor");
@@ -90,8 +106,12 @@ test("symlink sources, nested links and redirected destinations reject without p
90106
const {root, request} = await fixture(t);
91107
await symlink(join(root, "code"), join(root, "alias"), process.platform === "win32" ? "junction" : "dir");
92108
await assert.rejects(checkpointArtifacts({action: "probe", components: [{id: "code", source_path: join(root, "alias")}]}), /symlink/);
93-
await symlink(join(root, "conversation"), join(root, "code/link"));
94-
await assert.rejects(checkpointArtifacts(request), /symlink/);
109+
// File symlink creation needs Developer Mode or admin on Windows; the
110+
// junction cases still cover redirected sources and destinations.
111+
if (process.platform !== "win32") {
112+
await symlink(join(root, "conversation"), join(root, "code/link"));
113+
await assert.rejects(checkpointArtifacts(request), /symlink/);
114+
}
95115
await assert.rejects(checkpointArtifacts({...request, destination: join(root, "alias/capture")}), /symlink/);
96116
});
97117

0 commit comments

Comments
 (0)