diff --git a/src/artifacts/ReleaseExposureReader.ts b/src/artifacts/ReleaseExposureReader.ts index 753c6695d0..cb9d323f9b 100644 --- a/src/artifacts/ReleaseExposureReader.ts +++ b/src/artifacts/ReleaseExposureReader.ts @@ -3110,19 +3110,29 @@ const readSpan = async ( `Artifact changed before read: ${filePath}`, ); if (offset + length > observed.size) return null; - const bytes = await readFileHandleRange(handle, offset, length, () => { - if (signal?.aborted) - throw new AnalysisCancelledError(OPERATION, { - cause: signal.reason, - }); - }); + // Charge bytes as they arrive, so a read that fails after partial + // progress still counts what it consumed against the allowance. + let charged = 0; + const bytes = await readFileHandleRange( + handle, + offset, + length, + (bytesRead) => { + state.memberBytesRead += bytesRead - charged; + charged = bytesRead; + if (signal?.aborted) + throw new AnalysisCancelledError(OPERATION, { + cause: signal.reason, + }); + }, + ); const after = await handle.stat(); if (!sameRegularFileState(initial, after)) throw new ArtifactReaderFailure( "integrity", `Artifact changed during read: ${filePath}`, ); - state.memberBytesRead += bytes.length; + state.memberBytesRead += bytes.length - charged; return Buffer.from(bytes); }, ); diff --git a/src/desktop/HostFileRead.test.ts b/src/desktop/HostFileRead.test.ts index 040397f819..3752ef2ce9 100644 --- a/src/desktop/HostFileRead.test.ts +++ b/src/desktop/HostFileRead.test.ts @@ -32,14 +32,11 @@ describe.skipIf(process.platform === "win32")("probeHostFile", () => { }); }); - it("keeps a transient failure unresolved", async () => { - // EIO is not a refusal and does not settle the candidate, so the walk still - // treats it as a check that may succeed on a retry. + it("reports a readable regular file as a file", async () => { + // The transient (EIO) branch is covered in HostFileRead.transient.test.ts. const root = await createTestTempDirectory("rea-host-file-probe-"); const path = join(root, "libcore.so"); await writeFile(path, "carrier"); - // A regular file is probeable; this asserts the settled side of the probe - // still reports the kind, so the transient branch is not reached by mistake. await expect(probeHostFile(path)).resolves.toEqual({ kind: "file", isFile: true, diff --git a/src/desktop/HostFileRead.transient.test.ts b/src/desktop/HostFileRead.transient.test.ts new file mode 100644 index 0000000000..40ffaa1225 --- /dev/null +++ b/src/desktop/HostFileRead.transient.test.ts @@ -0,0 +1,26 @@ +import { expect, it, vi } from "vitest"; + +vi.mock("node:fs/promises", async (importOriginal) => { + const fs = await importOriginal(); + return { + ...fs, + stat: async (...args: Parameters) => { + if (String(args[0]).endsWith("transient.so")) + throw Object.assign(new Error("injected I/O failure"), { + code: "EIO", + }); + return fs.stat(...args); + }, + }; +}); + +import { hostFileKind, probeHostFile } from "./HostFileRead.js"; + +it("keeps a transient stat failure unresolved rather than absent", async () => { + // EIO neither proves absence nor lets ld.so search past the candidate. + await expect(probeHostFile("/fixture/lib/transient.so")).resolves.toEqual({ + kind: "unknown", + unreachable: false, + }); + await expect(hostFileKind("/fixture/lib/transient.so")).resolves.toBeNull(); +});