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
2 changes: 2 additions & 0 deletions docs/session.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,8 @@ Existing cleanup-pending history consumers replay incrementally with fresh per-r

Each transcript's committed GC retirement history admits at most 50,000 distinct persisted state files and 512 MiB of unique stored receipt bytes, with the same 64 MiB individual-receipt limit. Separately, a scope's shared receipt directory admits at most 50,000 retirement-receipt entries across all transcript targets. Readers, publishers, prepared-target discovery, and protocol inspectors use streamed bounded inventories and no-follow bounded reads; an over-limit target or shared directory fails the whole inspection rather than disappearing from discovery. Replay keeps incremental continuation evidence, checks canonical numeric contiguous attempts, and rejects immutable-field changes or authority expansion, including shrink then reintroduction. Publishers hold a scope-wide lease while admitting the projected new receipt and per-transcript bytes before candidate codec serialization, buffer allocation, or publication, preventing concurrent appends for distinct transcripts from overshooting the shared directory-entry limit. An idempotent nonappend does not consume another stored slot. These stored-history limits are distinct from the fresh per-replay inventory/read/work limits above; no aggregate operation-wide cumulative read, transcript/hash, nested-work, or append-reservation budget is installed. None of these limits establish writer quiescence or physical reclamation.

Leased and read-only GC receipt readers admit stored-history bytes from the original no-follow descriptor's size and link count before allocating receipt buffers. Reads cannot expand beyond that admitted size, and final descriptor and named-file generation checks reject growth or replacement. Reader cleanup preserves the original capture failure when closing also fails. These checks do not establish operation-wide traversal admission or writer quiescence.

Discovery retains each processed scope's original binding and receipt inventory, including authenticated absence, until its terminal consistency check. A new populated scope or journal inserted into an already-processed scope causes refusal instead of silent target omission or adoption of newly observed authority. Original readers are closed on success and refusal.

Exchange-placeholder capture and cleanup verification use a bounded native empty-directory proof. It retains no-follow root/parent handles, stops at the first child instead of traversing or hashing foreign payload, and rechecks the original native identity and metadata. An unexpected child or replacement refuses cleanup without changing it. This proof does not establish physical reclamation, revoke live writers, or bound other GC traversal paths.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
### Fixed

- Admit GC receipt-history bytes from the original descriptor before allocation, bound reads to its captured size, and reject named-file generation changes in leased and read-only receipt readers.
- Preserve a managed-file capture failure when reader cleanup also fails.
Original file line number Diff line number Diff line change
Expand Up @@ -2972,8 +2972,9 @@ function readManagedGcReceiptSnapshot(
history?: MutableManagedGcReceiptHistory,
): ManagedFileSnapshot | null {
try {
return store.readExpectedBounded(relativePath, MANAGED_GC_RECEIPT_MAX_BYTES, size => {
if (history) managedGcAdmitHistoryBytes(history, size, relativePath);
return store.readExpectedBounded(relativePath, MANAGED_GC_RECEIPT_MAX_BYTES, (size, descriptor) => {
if (descriptor.nlink !== 1n || descriptor.size !== size) throw new Error("source_changed");
if (history) managedGcAdmitHistoryBytes(history, descriptor.size, relativePath);
});
} catch (error) {
if (error instanceof Error && error.message === "artifact_capacity_exceeded") managedGcJournalCapacity();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2805,7 +2805,10 @@ export class ManagedSessionDescendantStore {
named.isSymbolicLink() ||
named.dev !== before.dev ||
named.ino !== before.ino ||
named.nlink !== before.nlink
named.nlink !== before.nlink ||
named.size !== before.size ||
named.mtimeNs !== before.mtimeNs ||
named.ctimeNs !== before.ctimeNs
)
throw new Error("source_changed");
const rootAfter = fs.lstatSync(this.#baseDir, { bigint: true });
Expand Down Expand Up @@ -2851,6 +2854,7 @@ export class ManagedSessionDescendantStore {
return {
readRange: (start, length) => {
if (closed) throw new Error("closed");
if (start + length > expectedDescriptor.size) throw new Error("range_not_present");
return this.readRangeExpectedSync(relativePath, start, length, expectedDescriptor).bytes;
},
close: () => {
Expand Down Expand Up @@ -2992,7 +2996,7 @@ export class ManagedSessionDescendantStore {
readExpectedBounded(
relativePath: string,
maxBytes: number,
admitSize?: (size: number) => void,
admitSize?: (size: number, descriptor: ManagedFileIdentity) => void,
): ManagedFileSnapshot | null {
if (!Number.isSafeInteger(maxBytes) || maxBytes < 0) throw new Error("invalid_capture_limit");
this.#assertBound();
Expand All @@ -3019,17 +3023,37 @@ export class ManagedSessionDescendantStore {
if (stat.nlink !== 1n) throw new Error("source_changed");
if (!Number.isSafeInteger(stat.size) || stat.size < 0) throw new Error("source_changed");
if (stat.size > maxBytes) throw new Error("artifact_capacity_exceeded");
admitSize?.(stat.size);
const bytes = Buffer.alloc(stat.size);
const lease = this.openReadLease(this.#relative(resolved), stat);
let failed = false;
let failure: unknown;
let snapshot: ManagedFileSnapshot | undefined;
try {
admitSize?.(stat.size, {
dev: stat.dev,
ino: stat.ino,
nlink: stat.nlink,
size: stat.size,
mtimeNs: stat.mtimeNs,
ctimeNs: stat.ctimeNs,
});
const bytes = Buffer.alloc(stat.size);
for (let offset = 0; offset < bytes.byteLength; ) {
const length = Math.min(1024 * 1024, bytes.byteLength - offset);
bytes.set(lease.readRange(offset, length), offset);
offset += length;
}
snapshot = {
bytes,
identity: {
dev: stat.dev,
ino: stat.ino,
nlink: stat.nlink,
size: stat.size,
mtimeNs: stat.mtimeNs,
ctimeNs: stat.ctimeNs,
sha256: createHash("sha256").update(bytes).digest("hex"),
},
};
} catch (error) {
failed = true;
failure = error;
Expand All @@ -3043,18 +3067,8 @@ export class ManagedSessionDescendantStore {
}
}
if (failed) throw failure;
return {
bytes,
identity: {
dev: stat.dev,
ino: stat.ino,
nlink: stat.nlink,
size: stat.size,
mtimeNs: stat.mtimeNs,
ctimeNs: stat.ctimeNs,
sha256: createHash("sha256").update(bytes).digest("hex"),
},
};
if (!snapshot) throw new Error("Managed descendant snapshot is unavailable");
return snapshot;
}

/** Remove an exact captured file without reopening its pathname as authority. */
Expand Down Expand Up @@ -3706,7 +3720,7 @@ export function captureManagedFilePrefixNoFollow(pathname: string, maxBytes: num
export function captureManagedFileNoFollowBounded(
pathname: string,
maxBytes: number,
admitSize?: (size: number) => void,
admitSize?: (size: number, descriptor: ManagedFileIdentity) => void,
): ManagedFileSnapshot {
if (!Number.isSafeInteger(maxBytes) || maxBytes < 0) throw new Error("invalid_capture_limit");
return captureManagedFileNoFollowLimit(pathname, maxBytes, true, admitSize);
Expand All @@ -3721,9 +3735,12 @@ function captureManagedFileNoFollowLimit(
pathname: string,
maxBytes?: number,
rejectOversized = false,
admitSize?: (size: number) => void,
admitSize?: (size: number, descriptor: ManagedFileIdentity) => void,
): ManagedFileSnapshot {
const fd = fs.openSync(pathname, fs.constants.O_RDONLY | fs.constants.O_NONBLOCK | (fs.constants.O_NOFOLLOW ?? 0));
let failed = false;
let failure: unknown;
let snapshot: ManagedFileSnapshot | undefined;
try {
const before = fs.fstatSync(fd, { bigint: true });
if (!before.isFile() || (rejectOversized ? before.nlink !== 1n : before.nlink > 1n))
Expand All @@ -3732,7 +3749,7 @@ function captureManagedFileNoFollowLimit(
if (!Number.isSafeInteger(fileSize) || fileSize < 0) throw new Error("source_changed");
if (rejectOversized && maxBytes !== undefined && fileSize > maxBytes)
throw new Error("artifact_capacity_exceeded");
admitSize?.(fileSize);
admitSize?.(fileSize, identity(before));
const captureSize = maxBytes === undefined ? fileSize : Math.min(fileSize, maxBytes);
const bytes = Buffer.alloc(captureSize);
let offset = 0;
Expand All @@ -3746,10 +3763,22 @@ function captureManagedFileNoFollowLimit(
const named = fs.lstatSync(pathname, { bigint: true });
if (!named.isFile() || named.isSymbolicLink() || !sameIdentity(identity(before), identity(named)))
throw new Error("source_changed");
return { bytes, identity: identity(before, createHash("sha256").update(bytes).digest("hex")) };
} finally {
snapshot = { bytes, identity: identity(before, createHash("sha256").update(bytes).digest("hex")) };
} catch (error) {
failed = true;
failure = error;
}
try {
fs.closeSync(fd);
} catch (error) {
if (!failed) {
failed = true;
failure = error;
}
}
if (failed) throw failure;
if (!snapshot) throw new Error("Managed file capture is unavailable");
return snapshot;
}

/** Streams a managed file once while retaining only a bounded header prefix and the full descriptor-bound digest. */
Expand Down
Loading
Loading