mirror of
https://github.com/morluto/rea.git
synced 2026-11-07 05:56:19 -10:00
fix(filesystem): finish executable and project span reads
Reuse the positional range owner for target metadata, Hopper Mach-O selection and Kuna spans. Preserve physical failures, completed span evidence and cleanup ownership across cancellation and short reads. Validated 147 focused file and lifecycle tests; ten new cases reproduced failures against the prior implementation.
This commit is contained in:
1 parent
f68064e46c
commit
a7004c7df1
8 files changed
+280
-134
No files matched your search
@@ -1,3 +1,4 @@
|
||||
import { readFileHandleRange } from "../filesystem/readFileHandleRange.js";
|
||||
import { readPeCoffHeader } from "../domain/peCoffHeader.js";
|
||||
import {
|
||||
regularFileCancellation,
|
||||
@@ -750,23 +751,12 @@ const readHandleRange = async (
|
||||
position: number,
|
||||
length: number,
|
||||
signal?: AbortSignal,
|
||||
): Promise<Uint8Array> => {
|
||||
): Promise<Buffer> => {
|
||||
const bytes = await readFileHandleRange(handle, position, length, () =>
|
||||
throwIfTargetResolutionCancelled(signal),
|
||||
);
|
||||
throwIfTargetResolutionCancelled(signal);
|
||||
const bytes = Buffer.alloc(length);
|
||||
let filled = 0;
|
||||
while (filled < length) {
|
||||
if (filled > 0) throwIfTargetResolutionCancelled(signal);
|
||||
const observed = await handle.read(
|
||||
bytes,
|
||||
filled,
|
||||
length - filled,
|
||||
position + filled,
|
||||
);
|
||||
throwIfTargetResolutionCancelled(signal);
|
||||
if (observed.bytesRead === 0) break;
|
||||
filled += observed.bytesRead;
|
||||
}
|
||||
return bytes.subarray(0, filled);
|
||||
return bytes;
|
||||
};
|
||||
|
||||
/** Read exactly `size` bytes by offset; a short read means the file changed or ended early. */
|
||||
@@ -853,10 +843,7 @@ const readExecutableMetadata = async (
|
||||
): Promise<Result<ExecutableMetadata, ExecutableMetadataFailure>> => {
|
||||
const { handle, initial } = source;
|
||||
throwIfTargetResolutionCancelled(signal);
|
||||
const prefix = Buffer.alloc(4096);
|
||||
const prefixRead = await handle.read(prefix, 0, prefix.length, 0);
|
||||
throwIfTargetResolutionCancelled(signal);
|
||||
const bytes = prefix.subarray(0, prefixRead.bytesRead);
|
||||
const bytes = await readHandleRange(handle, 0, 4096, signal);
|
||||
// Header commitments such as Mach-O load commands and FAT slices are
|
||||
// checked against the whole file, not the probed prefix.
|
||||
const fileSize = initial.size;
|
||||
@@ -867,17 +854,14 @@ const readExecutableMetadata = async (
|
||||
| undefined;
|
||||
if (evidence.windowsHeader.state === "signature-needed") {
|
||||
const { offset } = evidence.windowsHeader;
|
||||
const signature = Buffer.alloc(Math.min(4, fileSize - offset));
|
||||
const observedSignature = await handle.read(
|
||||
signature,
|
||||
0,
|
||||
signature.length,
|
||||
offset,
|
||||
);
|
||||
throwIfTargetResolutionCancelled(signal);
|
||||
signatureEvidence = {
|
||||
offset,
|
||||
bytes: signature.subarray(0, observedSignature.bytesRead),
|
||||
bytes: await readHandleRange(
|
||||
handle,
|
||||
offset,
|
||||
Math.min(4, fileSize - offset),
|
||||
signal,
|
||||
),
|
||||
};
|
||||
evidence = withMzWindowsHeaderSignature(
|
||||
bytes,
|
||||
@@ -908,15 +892,12 @@ const readExecutableMetadata = async (
|
||||
// bounds are at most 65535 records inside a 65535-paragraph header.
|
||||
const { relocationTableRead } = evidence;
|
||||
if (relocationTableRead !== undefined) {
|
||||
const table = Buffer.alloc(relocationTableRead.byteLength);
|
||||
const observed = await handle.read(
|
||||
table,
|
||||
0,
|
||||
table.length,
|
||||
const observedBytes = await readHandleRange(
|
||||
handle,
|
||||
relocationTableRead.offset,
|
||||
relocationTableRead.byteLength,
|
||||
signal,
|
||||
);
|
||||
throwIfTargetResolutionCancelled(signal);
|
||||
const observedBytes = table.subarray(0, observed.bytesRead);
|
||||
evidence = parseMzHeaderEvidence(
|
||||
observedBytes,
|
||||
fileSize,
|
||||
@@ -1150,16 +1131,13 @@ const readMachoCommandFrames = async (
|
||||
offset + 8 <= input.fileSize &&
|
||||
(offset < windowOffset || offset + 8 > windowOffset + window.length)
|
||||
) {
|
||||
window = Buffer.alloc(Math.min(4096, input.fileSize - offset));
|
||||
windowOffset = offset;
|
||||
const observed = await handle.read(
|
||||
window,
|
||||
0,
|
||||
window.length,
|
||||
window = await readHandleRange(
|
||||
handle,
|
||||
(input.fileOffset ?? 0) + offset,
|
||||
Math.min(4096, input.fileSize - offset),
|
||||
input.signal,
|
||||
);
|
||||
throwIfTargetResolutionCancelled(input.signal);
|
||||
window = window.subarray(0, observed.bytesRead);
|
||||
}
|
||||
const relative = offset - windowOffset;
|
||||
const frame = commands.read(
|
||||
@@ -1176,10 +1154,12 @@ const readMachoSliceCommandFrames = async (
|
||||
slice: UniversalMachOSlice,
|
||||
signal?: AbortSignal,
|
||||
): Promise<Result<null, string>> => {
|
||||
const prefix = Buffer.alloc(Math.min(4096, slice.size));
|
||||
const observed = await handle.read(prefix, 0, prefix.length, slice.offset);
|
||||
throwIfTargetResolutionCancelled(signal);
|
||||
const bytes = prefix.subarray(0, observed.bytesRead);
|
||||
const bytes = await readHandleRange(
|
||||
handle,
|
||||
slice.offset,
|
||||
Math.min(4096, slice.size),
|
||||
signal,
|
||||
);
|
||||
const table = readMachoLoadCommandTableHeader(bytes, slice.size);
|
||||
if (!table.ok) return table;
|
||||
if (table.value === null)
|
||||
@@ -1203,21 +1183,13 @@ const readFatSliceHeaderEvidence = async (
|
||||
if (header.state === "needs-data") {
|
||||
// readFatContainerHeader enforces the 128-entry format bound before this
|
||||
// allocation; this is only the table, never a synthesized file image.
|
||||
const table = Buffer.alloc(header.requiredBytes);
|
||||
let bytesRead = 0;
|
||||
while (bytesRead < table.length) {
|
||||
throwIfTargetResolutionCancelled(signal);
|
||||
const read = await handle.read(
|
||||
table,
|
||||
bytesRead,
|
||||
table.length - bytesRead,
|
||||
bytesRead,
|
||||
);
|
||||
throwIfTargetResolutionCancelled(signal);
|
||||
if (read.bytesRead === 0) break;
|
||||
bytesRead += read.bytesRead;
|
||||
}
|
||||
header = readFatContainerHeader(table.subarray(0, bytesRead), fileSize);
|
||||
const table = await readHandleRange(
|
||||
handle,
|
||||
0,
|
||||
header.requiredBytes,
|
||||
signal,
|
||||
);
|
||||
header = readFatContainerHeader(table, fileSize);
|
||||
}
|
||||
if (header.state !== "parsed") return undefined;
|
||||
|
||||
@@ -1233,23 +1205,15 @@ const readFatSliceHeaderEvidence = async (
|
||||
continue;
|
||||
}
|
||||
const observedLength = Math.min(12, slice.size);
|
||||
const thinHeader = Buffer.alloc(observedLength);
|
||||
let bytesRead = 0;
|
||||
while (bytesRead < thinHeader.length) {
|
||||
throwIfTargetResolutionCancelled(signal);
|
||||
const read = await handle.read(
|
||||
thinHeader,
|
||||
bytesRead,
|
||||
thinHeader.length - bytesRead,
|
||||
slice.offset + bytesRead,
|
||||
);
|
||||
throwIfTargetResolutionCancelled(signal);
|
||||
if (read.bytesRead === 0) break;
|
||||
bytesRead += read.bytesRead;
|
||||
}
|
||||
const thinHeader = await readHandleRange(
|
||||
handle,
|
||||
slice.offset,
|
||||
observedLength,
|
||||
signal,
|
||||
);
|
||||
sliceHeadersByOffset.set(
|
||||
slice.offset,
|
||||
readMachoHeaderPrefixFacts(thinHeader.subarray(0, bytesRead)),
|
||||
readMachoHeaderPrefixFacts(thinHeader),
|
||||
);
|
||||
}
|
||||
return { header, sliceHeadersByOffset };
|
||||
@@ -1271,16 +1235,8 @@ const readPeMetadata = async (
|
||||
},
|
||||
): Promise<Result<ExecutableMetadata, string>> => {
|
||||
throwIfTargetResolutionCancelled(signal);
|
||||
const fileHeader = Buffer.alloc(24);
|
||||
const fileHeaderRead = await handle.read(
|
||||
fileHeader,
|
||||
0,
|
||||
fileHeader.length,
|
||||
offset,
|
||||
);
|
||||
throwIfTargetResolutionCancelled(signal);
|
||||
if (fileHeaderRead.bytesRead !== fileHeader.length)
|
||||
return err("invalid or truncated PE header");
|
||||
const fileHeader = await readHandleRange(handle, offset, 24, signal);
|
||||
if (fileHeader.length !== 24) return err("invalid or truncated PE header");
|
||||
const { windowsHeader } = withMzWindowsHeaderSignature(
|
||||
mzPrefix,
|
||||
fileSize,
|
||||
@@ -1296,10 +1252,13 @@ const readPeMetadata = async (
|
||||
const optionalHeaderSize = coff.value.optionalHeaderSize;
|
||||
if (optionalHeaderSize > 4096) return err("invalid PE optional header size");
|
||||
const numberOfSections = coff.value.sectionCount;
|
||||
const record = Buffer.alloc(24 + optionalHeaderSize);
|
||||
const recordRead = await handle.read(record, 0, record.length, offset);
|
||||
throwIfTargetResolutionCancelled(signal);
|
||||
if (recordRead.bytesRead !== record.length)
|
||||
const record = await readHandleRange(
|
||||
handle,
|
||||
offset,
|
||||
24 + optionalHeaderSize,
|
||||
signal,
|
||||
);
|
||||
if (record.length !== 24 + optionalHeaderSize)
|
||||
return err("invalid or truncated PE header");
|
||||
const codeView = await readPeCodeView(
|
||||
handle,
|
||||
@@ -1326,15 +1285,13 @@ const readPeCodeView = async (
|
||||
if (debugDir === undefined || numberOfSections > 96) return undefined;
|
||||
const sectionTableOffset = peOffset + 24 + optionalHeaderSize;
|
||||
const sectionTableSize = numberOfSections * 40;
|
||||
const sectionTable = Buffer.alloc(sectionTableSize);
|
||||
const sectionRead = await handle.read(
|
||||
sectionTable,
|
||||
0,
|
||||
sectionTableSize,
|
||||
const sectionTable = await readHandleRange(
|
||||
handle,
|
||||
sectionTableOffset,
|
||||
sectionTableSize,
|
||||
signal,
|
||||
);
|
||||
throwIfTargetResolutionCancelled(signal);
|
||||
if (sectionRead.bytesRead !== sectionTableSize) return undefined;
|
||||
if (sectionTable.length !== sectionTableSize) return undefined;
|
||||
const debugFileOffset = peRvaToFileOffset(
|
||||
debugDir.debugRva,
|
||||
sectionTable,
|
||||
@@ -1344,36 +1301,29 @@ const readPeCodeView = async (
|
||||
);
|
||||
if (debugFileOffset === undefined) return undefined;
|
||||
const maxDebugSize = Math.min(debugDir.debugSize, 4096);
|
||||
const debugDirectoryBuffer = Buffer.alloc(maxDebugSize);
|
||||
const debugDirRead = await handle.read(
|
||||
debugDirectoryBuffer,
|
||||
0,
|
||||
maxDebugSize,
|
||||
const debugDirectoryBuffer = await readHandleRange(
|
||||
handle,
|
||||
debugFileOffset,
|
||||
maxDebugSize,
|
||||
signal,
|
||||
);
|
||||
throwIfTargetResolutionCancelled(signal);
|
||||
for (const {
|
||||
fileOffset: pointerToRawData,
|
||||
declaredSize: sizeOfData,
|
||||
} of peCodeViewDataRanges(
|
||||
debugDirectoryBuffer.subarray(0, debugDirRead.bytesRead),
|
||||
debugDirectoryBuffer,
|
||||
sectionTable,
|
||||
numberOfSections,
|
||||
debugDir.sizeOfHeaders,
|
||||
)) {
|
||||
const maxCodeViewRead = Math.min(sizeOfData, 4096);
|
||||
const codeViewBuffer = Buffer.alloc(maxCodeViewRead);
|
||||
const codeViewRead = await handle.read(
|
||||
codeViewBuffer,
|
||||
0,
|
||||
maxCodeViewRead,
|
||||
const codeViewBuffer = await readHandleRange(
|
||||
handle,
|
||||
pointerToRawData,
|
||||
maxCodeViewRead,
|
||||
signal,
|
||||
);
|
||||
throwIfTargetResolutionCancelled(signal);
|
||||
const parsed = parseCodeViewData(
|
||||
codeViewBuffer.subarray(0, codeViewRead.bytesRead),
|
||||
sizeOfData,
|
||||
);
|
||||
const parsed = parseCodeViewData(codeViewBuffer, sizeOfData);
|
||||
if (parsed !== undefined) return parsed;
|
||||
}
|
||||
return undefined;
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import type { FileHandle } from "node:fs/promises";
|
||||
|
||||
/** Fill a positional window until its end or explicit EOF, without owning the descriptor. */
|
||||
/**
|
||||
* Fill a positional window until its end or explicit EOF, without owning the descriptor.
|
||||
* Checkpoints receive cumulative acquired bytes so an owner can defer cancellation
|
||||
* of a completed range to its finalization without losing the completed observation.
|
||||
*/
|
||||
export const readFileHandleRange = async (
|
||||
handle: Pick<FileHandle, "read">,
|
||||
position: number,
|
||||
length: number,
|
||||
assertActive: () => void = () => {},
|
||||
assertActive: (bytesRead: number) => void = () => {},
|
||||
): Promise<Buffer> => {
|
||||
if (
|
||||
!Number.isSafeInteger(position) ||
|
||||
@@ -15,11 +19,11 @@ export const readFileHandleRange = async (
|
||||
!Number.isSafeInteger(position + length)
|
||||
)
|
||||
throw new RangeError("Invalid file read range");
|
||||
assertActive();
|
||||
assertActive(0);
|
||||
const bytes = Buffer.alloc(length);
|
||||
let filled = 0;
|
||||
while (filled < length) {
|
||||
assertActive();
|
||||
assertActive(filled);
|
||||
const { bytesRead } = await handle.read(
|
||||
bytes,
|
||||
filled,
|
||||
@@ -29,8 +33,8 @@ export const readFileHandleRange = async (
|
||||
// Let the caller interpret physical EOF before checking cancellation.
|
||||
// A native I/O rejection also remains primary if cancellation arrived in flight.
|
||||
if (bytesRead === 0) break;
|
||||
assertActive();
|
||||
filled += bytesRead;
|
||||
assertActive(filled);
|
||||
}
|
||||
return bytes.subarray(0, filled);
|
||||
};
|
||||
@@ -4,9 +4,11 @@ import {
|
||||
access,
|
||||
chmod,
|
||||
mkdir,
|
||||
open,
|
||||
readFile,
|
||||
readdir,
|
||||
writeFile,
|
||||
type FileHandle,
|
||||
} from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
|
||||
@@ -130,6 +132,42 @@ const resolveHopperMachOImage = (
|
||||
resourceScope: inspectionResources.owner,
|
||||
});
|
||||
|
||||
it("retains Hopper FAT selection across short header, table and slice reads", async () => {
|
||||
const root = await createTestTempDirectory("rea-hopper-short-reads-");
|
||||
const fixture = buildFat32Container([
|
||||
{ cpuType: CPU_TYPES.arm64, cpuSubtype: 0, little: true },
|
||||
]);
|
||||
const path = await writeFixture(root, "fat", fixture);
|
||||
const target = fat32Target(path, fixture, "arm64");
|
||||
const expected = await resolveHopperMachOImage(target);
|
||||
expect(expected.ok).toBe(true);
|
||||
let shortReads = 0;
|
||||
const handles: FileHandle[] = [];
|
||||
const actual = await resolveHopperMachOImageWithOwner(target, {
|
||||
resourceScope: inspectionResources.owner,
|
||||
fileSystem: {
|
||||
open: (async (...args: Parameters<typeof open>) => {
|
||||
const handle = await open(...args);
|
||||
handles.push(handle);
|
||||
const read = handle.read.bind(handle);
|
||||
vi.spyOn(handle, "read").mockImplementation((async (
|
||||
...readArgs: unknown[]
|
||||
) => {
|
||||
if (typeof readArgs[2] === "number" && readArgs[2] > 3) {
|
||||
readArgs[2] = 3;
|
||||
shortReads += 1;
|
||||
}
|
||||
return Reflect.apply(read, handle, readArgs);
|
||||
}) as typeof handle.read);
|
||||
return handle;
|
||||
}) as typeof open,
|
||||
},
|
||||
});
|
||||
expect(actual).toEqual(expected);
|
||||
expect(shortReads).toBeGreaterThan(5);
|
||||
expect(handles.every((handle) => handle.fd === -1)).toBe(true);
|
||||
});
|
||||
|
||||
it.skipIf(process.platform === "win32")(
|
||||
"reports a FAT64 source that became unreadable as a host read denial",
|
||||
async (context) => {
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
import { err, ok, type Result } from "../domain/result.js";
|
||||
import { OwnedFileHandle } from "../filesystem/OwnedFileHandle.js";
|
||||
import { readFileHandleChunks } from "../filesystem/readFileHandleChunks.js";
|
||||
import { readFileHandleRange } from "../filesystem/readFileHandleRange.js";
|
||||
|
||||
import {
|
||||
copyHopperSource,
|
||||
@@ -280,11 +281,13 @@ const inspectHopperMachOImage = async (
|
||||
cause === undefined ? undefined : { cause },
|
||||
),
|
||||
);
|
||||
const read = async (offset: number, length: number): Promise<Buffer> => {
|
||||
const bytes = Buffer.alloc(length);
|
||||
const { bytesRead } = await file.read(bytes, 0, length, offset);
|
||||
return bytes.subarray(0, bytesRead);
|
||||
};
|
||||
const read = (offset: number, length: number): Promise<Buffer> =>
|
||||
readFileHandleRange(file, offset, length, () => {
|
||||
if (signal?.aborted)
|
||||
throw new AnalysisCancelledError("open_binary", {
|
||||
cause: signal.reason,
|
||||
});
|
||||
});
|
||||
const header = await read(0, 8);
|
||||
const fatPrefix = readFatHeaderPrefix(header);
|
||||
const thinPrefix = readMachoHeaderPrefixFacts(header);
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { RegularFileState } from "../filesystem/RegularFile.js";
|
||||
import {
|
||||
RegularFileChangedError,
|
||||
sameRegularFileState,
|
||||
RegularFileOperationCancelled,
|
||||
} from "../filesystem/RegularFile.js";
|
||||
import { assertKunaFileState } from "./KunaProjectState.js";
|
||||
import { createReadStream } from "node:fs";
|
||||
@@ -18,13 +19,13 @@ import { ProviderAdapterError } from "../domain/providerAdapterError.js";
|
||||
import { ProviderCleanupError } from "../domain/providerCleanupError.js";
|
||||
import { err, ok, type Result } from "../domain/result.js";
|
||||
import { OwnedFileHandle } from "../filesystem/OwnedFileHandle.js";
|
||||
import { readFileHandleRange } from "../filesystem/readFileHandleRange.js";
|
||||
import {
|
||||
RegularFileCancelled,
|
||||
RegularFileCleanupFailure,
|
||||
OwnedRegularFileOperationFailure,
|
||||
withOwnedFileHandle,
|
||||
} from "../filesystem/OwnedRegularFile.js";
|
||||
import { RegularFileOperationCancelled } from "../filesystem/RegularFile.js";
|
||||
import { ABORTED, waitForAbortable } from "../process/AbortablePromise.js";
|
||||
import type { KunaProject } from "./KunaProjectCache.js";
|
||||
import {
|
||||
@@ -107,12 +108,24 @@ const readSpan = async (
|
||||
if (!opened.ok) return opened;
|
||||
const owner = new OwnedFileHandle(opened.value);
|
||||
let read: Result<string, unknown>;
|
||||
let spanCancellation: AnalysisCancelledError | undefined;
|
||||
try {
|
||||
if (!sameRegularFileState(expected, await owner.handle.stat()))
|
||||
throw new RegularFileChangedError(path);
|
||||
const buffer = Buffer.alloc(length);
|
||||
const { bytesRead } = await owner.handle.read(buffer, 0, length, offset);
|
||||
if (bytesRead !== length)
|
||||
const buffer = await readFileHandleRange(
|
||||
owner.handle,
|
||||
offset,
|
||||
length,
|
||||
(bytesRead) => {
|
||||
// Preserve a fully acquired span for owned finalization, which records
|
||||
// cancellation alongside its text if descriptor cleanup fails.
|
||||
if (bytesRead < length && signal?.aborted)
|
||||
throw (spanCancellation = new AnalysisCancelledError(operation, {
|
||||
cause: signal.reason,
|
||||
}));
|
||||
},
|
||||
);
|
||||
if (buffer.length !== length)
|
||||
throw new Error(`${path} is shorter than its recorded span`);
|
||||
if (!sameRegularFileState(expected, await owner.handle.stat()))
|
||||
throw new RegularFileChangedError(path);
|
||||
@@ -137,6 +150,12 @@ const readSpan = async (
|
||||
}),
|
||||
},
|
||||
async () => {
|
||||
if (
|
||||
!read.ok &&
|
||||
spanCancellation !== undefined &&
|
||||
read.error === spanCancellation
|
||||
)
|
||||
throw read.error;
|
||||
if (!read.ok) throw new OwnedRegularFileOperationFailure(read.error);
|
||||
return read.value;
|
||||
},
|
||||
@@ -148,6 +167,8 @@ const readSpan = async (
|
||||
: await ownership.finishSpan(finish);
|
||||
return ok(value);
|
||||
} catch (cause: unknown) {
|
||||
if (spanCancellation !== undefined && cause === spanCancellation)
|
||||
return err(spanCancellation);
|
||||
if (cause instanceof OwnedRegularFileOperationFailure) {
|
||||
if (cause.operationCause instanceof RegularFileChangedError)
|
||||
throw cause.operationCause;
|
||||
|
||||
@@ -75,3 +75,15 @@ it("returns explicit EOF for the caller to interpret before late cancellation",
|
||||
}),
|
||||
).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("reports complete acquisition so the owner can finalize bytes after a late abort", async () => {
|
||||
const { handle, spy } = await source();
|
||||
const checkpoints: number[] = [];
|
||||
const bytes = await readFileHandleRange(handle, 0, 4, (acquired) => {
|
||||
checkpoints.push(acquired);
|
||||
if (spy.mock.calls.length >= 2 && acquired < 4)
|
||||
throw new Error("cancelled before full acquisition");
|
||||
});
|
||||
expect(bytes.toString()).toBe("0123");
|
||||
expect(checkpoints).toEqual([0, 0, 2, 2, 4]);
|
||||
});
|
||||
@@ -18,6 +18,7 @@ const fault = vi.hoisted(() => ({
|
||||
fail: false,
|
||||
span: "",
|
||||
short: false,
|
||||
maximum: Number.POSITIVE_INFINITY,
|
||||
reads: 0,
|
||||
entered: 0,
|
||||
closeEntered: 0,
|
||||
@@ -51,6 +52,8 @@ vi.mock("node:fs/promises", async (importOriginal) => {
|
||||
if (key === "read")
|
||||
return async (...readArgs: unknown[]) => {
|
||||
fault.entered += 1;
|
||||
if (typeof readArgs[2] === "number")
|
||||
readArgs[2] = Math.min(readArgs[2], fault.maximum);
|
||||
const read = await Reflect.apply(target.read, target, readArgs);
|
||||
fault.reads += 1;
|
||||
fault.afterRead();
|
||||
@@ -79,6 +82,7 @@ afterEach(() => {
|
||||
fault.fail = false;
|
||||
fault.span = "";
|
||||
fault.short = false;
|
||||
fault.maximum = Number.POSITIVE_INFINITY;
|
||||
fault.reads = 0;
|
||||
fault.entered = 0;
|
||||
fault.closeEntered = 0;
|
||||
@@ -92,9 +96,13 @@ afterEach(() => {
|
||||
fault.handles = [];
|
||||
});
|
||||
|
||||
it.each([false, true])(
|
||||
"preserves the span outcome and retries the exact handle when close fails (short=%s)",
|
||||
async (short) => {
|
||||
it.each([
|
||||
{ short: false, maximum: Number.POSITIVE_INFINITY },
|
||||
{ short: false, maximum: 3 },
|
||||
{ short: true, maximum: 3 },
|
||||
])(
|
||||
"preserves the span outcome and retries the exact handle when close fails ($short, $maximum)",
|
||||
async ({ short, maximum }) => {
|
||||
const { cacheRoot, open: createClient } = await session(
|
||||
"rea-kuna-span-close-",
|
||||
);
|
||||
@@ -113,6 +121,7 @@ it.each([false, true])(
|
||||
"program.asm",
|
||||
);
|
||||
fault.short = short;
|
||||
fault.maximum = maximum;
|
||||
fault.fail = true;
|
||||
const result = await client.execute(
|
||||
"procedure_assembly",
|
||||
@@ -126,7 +135,9 @@ it.each([false, true])(
|
||||
);
|
||||
expect(result.error.cleanupIncomplete).toBe(true);
|
||||
expect(result.error.cleanupResources).toContain(fault.span);
|
||||
expect(fault.reads).toBe(1);
|
||||
const reads = fault.reads;
|
||||
if (!short && maximum === 3) expect(reads).toBeGreaterThan(1);
|
||||
else expect(reads).toBe(1);
|
||||
expect(fault.closes).toBe(1);
|
||||
expect(fault.opens).toBe(1);
|
||||
const handle = fault.handles[0];
|
||||
@@ -155,7 +166,7 @@ it.each([false, true])(
|
||||
expect(blocked.ok).toBe(false);
|
||||
if (blocked.ok) return;
|
||||
expect(blocked.error.cleanupIncomplete).toBe(true);
|
||||
expect(fault.reads).toBe(1);
|
||||
expect(fault.reads).toBe(reads);
|
||||
expect(fault.opens).toBe(1);
|
||||
expect(fault.closes).toBeGreaterThan(1);
|
||||
expect(handle.fd).toBeGreaterThanOrEqual(0);
|
||||
@@ -284,6 +295,63 @@ it.each(
|
||||
},
|
||||
);
|
||||
|
||||
it.each([false, true])(
|
||||
"cancels continued positive short span reads and releases the exact owner (closeFails=%s)",
|
||||
async (closeFails) => {
|
||||
const { cacheRoot, open: createClient } = await session(
|
||||
"rea-kuna-span-short-cancel-",
|
||||
);
|
||||
const client = createClient();
|
||||
try {
|
||||
await run(client, "procedure_assembly", { procedure: "main" });
|
||||
const [key] = await readdir(join(cacheRoot, "projects"));
|
||||
fault.span = join(
|
||||
cacheRoot,
|
||||
"projects",
|
||||
key ?? "",
|
||||
"project",
|
||||
"program.asm",
|
||||
);
|
||||
fault.maximum = 3;
|
||||
fault.fail = closeFails;
|
||||
const controller = new AbortController();
|
||||
const reason = { requested: "continued span read" };
|
||||
fault.afterRead = () => {
|
||||
if (fault.reads === 2) controller.abort(reason);
|
||||
};
|
||||
const result = await client.execute(
|
||||
"procedure_assembly",
|
||||
{ procedure: "main" },
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) throw new Error("Expected continued-read cancellation");
|
||||
const primary = closeFails ? result.error.cleanupPrimary : result.error;
|
||||
expect(primary).toBeInstanceOf(AnalysisCancelledError);
|
||||
expect(primary?.cause).toBe(reason);
|
||||
expect(fault.reads).toBe(2);
|
||||
const handle = fault.handles[0];
|
||||
if (handle === undefined)
|
||||
throw new Error("Missing cancelled span descriptor");
|
||||
if (closeFails) {
|
||||
expect(result.error.cleanupIncomplete).toBe(true);
|
||||
expect(handle.fd).toBeGreaterThanOrEqual(0);
|
||||
fault.fail = false;
|
||||
expect(await client.close()).toEqual({ ok: true, value: null });
|
||||
expect(result.error.cleanupIncomplete).toBe(false);
|
||||
}
|
||||
expect(handle.fd).toBe(-1);
|
||||
} finally {
|
||||
fault.fail = false;
|
||||
fault.afterRead = () => undefined;
|
||||
fault.span = "";
|
||||
await client.close();
|
||||
for (const handle of fault.handles)
|
||||
if (handle.fd >= 0) await handle.close();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it("does not open a second span while a peer still holds a failed close", async () => {
|
||||
const { cacheRoot, open: createClient } = await session(
|
||||
"rea-kuna-span-close-gate-",
|
||||
|
||||
@@ -55,6 +55,8 @@ import {
|
||||
machoImage,
|
||||
dylibCommand,
|
||||
LC,
|
||||
CPU,
|
||||
fatImage,
|
||||
} from "../../src/artifacts/apple/MachoImage.fixture.js";
|
||||
import { ArtifactResourceScope } from "../../src/artifacts/ArtifactResourceScope.js";
|
||||
import { readElectronFuseImage } from "../../src/electron/ElectronFuseReader.js";
|
||||
@@ -66,6 +68,54 @@ import {
|
||||
OBSIDIAN_FUSE_BYTES,
|
||||
} from "../fixtures/electron/fuseImage.js";
|
||||
import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js";
|
||||
import { parseBinaryTarget } from "../../src/application/BinaryTargetResolver.js";
|
||||
import { elf, pe, dosMz } from "../../src/domain/binaryTarget.fixture.js";
|
||||
|
||||
const distantCommands = () => {
|
||||
const bytes = Buffer.alloc(32 + 4080 + 16);
|
||||
Buffer.from(machoImage({})).copy(bytes);
|
||||
bytes.writeUInt32LE(2, 16);
|
||||
bytes.writeUInt32LE(4096, 20);
|
||||
bytes.writeUInt32LE(0x7fffffff, 32);
|
||||
bytes.writeUInt32LE(4080, 36);
|
||||
bytes.writeUInt32LE(0x7fffffff, 4112);
|
||||
bytes.writeUInt32LE(16, 4116);
|
||||
return bytes;
|
||||
};
|
||||
|
||||
it.each([
|
||||
["ELF", () => elf(2, 1, 62)],
|
||||
["DOS MZ", () => dosMz()],
|
||||
[
|
||||
"PE CodeView",
|
||||
() => pe(0x8664, 64, 2, false, { pdbPath: "short.pdb", age: 3 }),
|
||||
],
|
||||
["distant PE", () => pe(0x8664, 8192)],
|
||||
["thin command windows", distantCommands],
|
||||
[
|
||||
"FAT slice command windows",
|
||||
() => fatImage([{ cpu: CPU.arm64, bytes: distantCommands() }]),
|
||||
],
|
||||
] as const)(
|
||||
"retains complete %s target admission under positive short reads",
|
||||
async (_label, fixture) => {
|
||||
const root = await createTestTempDirectory("rea-target-short-reads-");
|
||||
const path = join(root, "selected");
|
||||
await writeFile(path, fixture());
|
||||
const expected = await parseBinaryTarget(path, {
|
||||
cwd: root,
|
||||
hostArchitecture: "arm64",
|
||||
});
|
||||
expect(expected.ok).toBe(true);
|
||||
fault.path = path;
|
||||
fault.maximum = 3;
|
||||
expect(
|
||||
await parseBinaryTarget(path, { cwd: root, hostArchitecture: "arm64" }),
|
||||
).toEqual(expected);
|
||||
expect(fault.reads).toBeGreaterThan(5);
|
||||
expect(fault.handles.every((handle) => handle.fd === -1)).toBe(true);
|
||||
},
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
fault.path = "";
|
||||
|
||||
Reference in new issue
Block a user