Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions changelog.d/disk-read-root-and-fifos.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
- **The workspace's disk reads refuse a mount root that isn't the mount's
directory.** `readFileFromDisk`, `readBinaryFromDisk` and `read_image` read
whatever was at a mount's root path, so a root renamed away and replaced by
a link to another directory, or by another directory, was read as the
mount. Once the workspace has recorded the root's identity, with the first
evidence it gathers there, such a root is refused as `mount_unavailable`,
in the words a sync uses for it, until a sync with `acceptRoot` takes it.
A root that is gone, or is a file, is `mount_unavailable` too, with errno
ENOENT or ENOTDIR, where it was "File not found". A root that changes
during a read, until the file has been opened and checked, gets the same
code and errno as one changed before the read.
- **`readBinaryFromDisk`'s `maxBytes` bounds the read itself.** A file that
grows past it after the read's size check is refused as "File too large",
having loaded at most `maxBytes` bytes and one more, where it was read
whole.
- **Two opens no longer wait on a FIFO.** A directory's sync after a push
opened the directory blocking, so a FIFO put at its path held the sync
until something opened it for writing. The tool presentation file's read
did the same, synchronously, holding the whole process. Each now opens
without waiting, and refuses what isn't a directory or a regular file.
51 changes: 46 additions & 5 deletions src/modules/workspace/disk-agreement.ts
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,12 @@ export class DiskAgreement {
* place owes none (recordWalk).
*/
private unsynced = false;
/**
* Whether memory holds a whole state: false until a rebuild first runs to
* its end. A rebuild fills a new map and swaps it in only at its end, so
* one that throws partway leaves whatever whole state came before it.
*/
private whole = false;
/** Whether this host observes file modes (not on Windows): where it doesn't, no record it writes claims one. */
private readonly modesObserved: () => boolean;

Expand All @@ -206,8 +212,10 @@ export class DiskAgreement {
* recorded (the store may hold it where this process can't read it back),
* and a decision from stale P can record what reopening contradicts, such
* as a conflict that stays after the right evidence is back. So until the
* store is reopened, no evidence is read, written or checkpointed, and
* every pass and tool call that needs it fails.
* store is reopened, no evidence is read (but for rootIdentityForRead's
* one exception), written or checkpointed, and every pass and tool call
* that needs it fails. The same holds, the exception included, when the
* open's rebuild threw: there is no whole evidence to read.
*/
ensureReconciled(): void {
if (this.journal.needsReopen) {
Expand All @@ -216,6 +224,12 @@ export class DiskAgreement {
'an append to its journal failed inside the store, and the evidence may lack what it recorded',
);
}
if (!this.whole) {
throw new Error(
'Disk-agreement evidence is unavailable until the store is reopened, as at the next start: ' +
'reading it back from the store failed',
);
}
if (this.journal.needsReconcile) this.rebuild();
}

Expand Down Expand Up @@ -253,16 +267,24 @@ export class DiskAgreement {
const mount = replayed.get(entry.mount);
if (mount) apply(mount, entry);
}
this.mounts.clear();
// Filled apart and swapped in at the end, so an append that throws here
// leaves memory as it was, whole, rather than with the mounts after it
// missing. Nothing reaches that today: only an open with a moved mount
// appends here, the workspace opens each agreement once, and after the
// open a rebuild finds every mount's record and appends none.
const rebuilt = new Map<string, MountEvidence>();
for (const { name, root } of this.configured) {
const found = replayed.get(name);
if (found && sameRootPath(found.root, root)) {
this.mounts.set(name, found);
rebuilt.set(name, found);
} else {
this.mounts.set(name, { root, paths: new Map(), walks: new Map() });
rebuilt.set(name, { root, paths: new Map(), walks: new Map() });
this.journal.append({ t: 'mount', mount: name, root });
}
}
this.mounts.clear();
for (const [name, mount] of rebuilt) this.mounts.set(name, mount);
this.whole = true;
}

/** The identity of the root this mount's evidence was gathered under, once any was. */
Expand All @@ -271,6 +293,25 @@ export class DiskAgreement {
return this.mounts.get(mount)?.identity;
}

/**
* The identity a disk read checks the mount's root against: the one in
* memory once memory is whole, in every state of the journal, and an error
* until then. It is the last identity whose append succeeded, since write()
* appends before it applies, and no reconcile can change it, since root
* entries are written without a sync; were one made durable, its failed
* sync would leave the identity from before it: an old one, which refuses
* the new root, or none, as for a mount with no recorded identity. This
* is the one exception to ensureReconciled's "no evidence is read": the
* layer stops reading so that a decision from stale evidence can't record
* what reopening contradicts, and a read records nothing. Refusing reads
* instead would stop every host read of the workspace's disk until a
* restart.
*/
rootIdentityForRead(mount: string): RootIdentity | undefined {
if (!this.whole) this.ensureReconciled(); // throws: there's no whole state to read
return this.mounts.get(mount)?.identity;
}

/**
* Record the root's identity just before the first evidence a mount gains:
* the caller saw it as the root while gathering that evidence. A no-op once
Expand Down
Loading
Loading