Skip to content

Commit cc964a1

Browse files
committed
dofs: Commit through the mutated hardlink
Remember the lexical and effective paths that authorized a buffered mutation so final release does not depend on which hardlink alias closes last.
1 parent 0f1d6b2 commit cc964a1

3 files changed

Lines changed: 51 additions & 7 deletions

File tree

packages/dofs/src/fs/mount-guard.test.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { describe, expect, it } from "vitest";
44

55
import type { Database } from "../storage.js";
66
import { stageBlob } from "../sync/blobs.js";
7+
import { link } from "./link.js";
78
import { mkdir } from "./mkdir.js";
89
import {
910
assertNotReadOnly,
@@ -193,6 +194,24 @@ describe("writeFile under a read-only mount", () => {
193194
});
194195
});
195196

197+
it("commits writable hardlink mutations when a read-only alias closes last", async () => {
198+
await withDB((db) => {
199+
mkdir(db, "/mnt", {}, () => 0);
200+
writeFileSync(db, "/outside.txt", new TextEncoder().encode("seed"), {}, () => 0);
201+
link(db, "/outside.txt", "/mnt/file.txt");
202+
stageMount(db, "/mnt", "read-only");
203+
204+
openWriteBufferSync(db, "/outside.txt");
205+
openWriteBufferSync(db, "/mnt/file.txt");
206+
writeRangeSync(db, "/outside.txt", new TextEncoder().encode("done"), 0, {}, () => 1);
207+
releaseWriteBufferSync(db, "/outside.txt", () => 2);
208+
expect(() => releaseWriteBufferSync(db, "/mnt/file.txt", () => 2)).not.toThrow();
209+
210+
expect(new TextDecoder().decode(readRangeSync(db, "/outside.txt", 0, 4))).toBe("done");
211+
expect(new TextDecoder().decode(readRangeSync(db, "/mnt/file.txt", 0, 4))).toBe("done");
212+
});
213+
});
214+
196215
it("evicts rejected dirty bytes before a later read-only open", async () => {
197216
await withDB((db) => {
198217
mkdir(db, "/mnt", {}, () => 0);

packages/dofs/src/fs/writeBuffer.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,11 @@ export interface WriteBufferEntry {
2929
// Mode the caller wants persisted on release. Defaults to the
3030
// inode's existing mode at open time when the caller has none.
3131
mode: number;
32+
// Lexical and effective paths used by the most recent successful
33+
// mutation. Dirty release validates these paths rather than whichever
34+
// hardlink alias happens to close last.
35+
dirtyPath?: string;
36+
dirtyTargetPath?: string;
3237
// Pending-create state. When set, no inode row exists yet; release
3338
// will INSERT the node + dirent + chunks in one transaction. The
3439
// synthetic inode id used to key this entry in the cache is stored

packages/dofs/src/fs/writeFile.ts

Lines changed: 27 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -154,7 +154,7 @@ interface ChunkRef {
154154
}
155155

156156
type WriteTarget =
157-
| { kind: "existing"; inode: number }
157+
| { kind: "existing"; inode: number; canonicalPath: string }
158158
| { kind: "create"; parentInode: number; leafName: string; canonicalPath: string };
159159

160160
interface DirectWriteTarget {
@@ -275,7 +275,11 @@ function resolveWriteTarget(
275275
);
276276
}
277277
if (node.type !== "symlink") {
278-
return { kind: "existing", inode: direct.existingInode };
278+
return {
279+
kind: "existing",
280+
inode: direct.existingInode,
281+
canonicalPath: direct.canonicalPath,
282+
};
279283
}
280284

281285
countSymlinkFollow(follows, canonical);
@@ -603,7 +607,10 @@ function resolveFileInode(db: Database, path: string): { inode: number; mode: nu
603607
return { inode: node.inode, mode: node.mode };
604608
}
605609

606-
function resolveWritableFileInode(db: Database, path: string): { inode: number; mode: number } {
610+
function resolveWritableFileInode(
611+
db: Database,
612+
path: string,
613+
): { inode: number; mode: number; canonicalPath: string } {
607614
const { parts, path: canonical } = canonicalizePath(path);
608615
if (parts.length === 0) {
609616
throw createWorkspaceError("EISDIR", "cannot write to the root directory", canonical);
@@ -616,7 +623,7 @@ function resolveWritableFileInode(db: Database, path: string): { inode: number;
616623
if (mode === undefined) {
617624
throw createWorkspaceError("ENOENT", `no such file: ${canonical}`, canonical);
618625
}
619-
return { inode: target.inode, mode };
626+
return { inode: target.inode, mode, canonicalPath: target.canonicalPath };
620627
}
621628

622629
function directTargetForPath(db: Database, path: string): DirectWriteTarget {
@@ -820,7 +827,11 @@ export function releaseWriteBufferSync(db: Database, path: string, now: () => nu
820827
const buffered = entry.buf.subarray(0, entry.size);
821828

822829
try {
823-
resolveWritableFileInode(db, path);
830+
if (entry.dirtyPath === undefined || entry.dirtyTargetPath === undefined) {
831+
throw createWorkspaceError("EIO", `buffer has no writable path: ${canonical}`, canonical);
832+
}
833+
assertNotReadOnly(db, entry.dirtyPath);
834+
assertNotReadOnly(db, entry.dirtyTargetPath);
824835
db.transactionSync(() => {
825836
if (entry.size === 0) {
826837
// An empty file owns no chunk rows; clear any old ones the
@@ -931,6 +942,7 @@ function commitPendingBuffer(db: Database, entry: WriteBufferEntry, now: () => n
931942
throw error;
932943
}
933944
promotePendingToInode(db, pendingInode, realInode);
945+
entry.dirty = false;
934946
return realInode;
935947
}
936948

@@ -1028,7 +1040,11 @@ export function writeRangeSync(
10281040
return bytes.byteLength;
10291041
}
10301042

1031-
const { inode, mode: existingMode } = resolveWritableFileInode(db, path);
1043+
const {
1044+
inode,
1045+
mode: existingMode,
1046+
canonicalPath: targetPath,
1047+
} = resolveWritableFileInode(db, path);
10321048
const mode = (options.mode ?? existingMode) & 0o7777;
10331049
const buffered = getWriteBuffer(db, inode);
10341050

@@ -1046,6 +1062,8 @@ export function writeRangeSync(
10461062
if (writeEnd > buffered.size) buffered.size = writeEnd;
10471063
buffered.mode = mode;
10481064
buffered.dirty = true;
1065+
buffered.dirtyPath = canonical;
1066+
buffered.dirtyTargetPath = targetPath;
10491067
return bytes.byteLength;
10501068
}
10511069

@@ -1103,7 +1121,7 @@ export function truncateFileSync(
11031121
return;
11041122
}
11051123

1106-
const { inode, mode } = resolveWritableFileInode(db, path);
1124+
const { inode, mode, canonicalPath: targetPath } = resolveWritableFileInode(db, path);
11071125
const buffered = getWriteBuffer(db, inode);
11081126

11091127
if (buffered !== undefined) {
@@ -1114,6 +1132,8 @@ export function truncateFileSync(
11141132
}
11151133
buffered.size = size;
11161134
buffered.dirty = true;
1135+
buffered.dirtyPath = canonical;
1136+
buffered.dirtyTargetPath = targetPath;
11171137
return;
11181138
}
11191139

0 commit comments

Comments
 (0)