From ab58edcdf50b4bd687900ee2dc362ecc5c3dce15 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Wed, 21 Oct 2026 05:33:35 +0800 Subject: [PATCH] fix(environment): retain tarball descriptor ownership during export Use the shared positional reader for both tarball passes so early output refusal cannot close the descriptor ahead of its owner. Closes #5684 --- .../environmentBundleWrite.test.ts | 53 +++++++++++++++++++ .../environment/environmentBundleWrite.ts | 8 ++- 2 files changed, 56 insertions(+), 5 deletions(-) diff --git a/src/application/environment/environmentBundleWrite.test.ts b/src/application/environment/environmentBundleWrite.test.ts index 4169cb2c44..6bbac53e89 100644 --- a/src/application/environment/environmentBundleWrite.test.ts +++ b/src/application/environment/environmentBundleWrite.test.ts @@ -1,4 +1,5 @@ import { + appendFile, chmod, mkdir, readFile, @@ -70,6 +71,58 @@ it("retains the admitted tarball descriptor and refuses a pathname replacement d } }); +it("keeps tarball ownership when the admitted inode grows during output", async () => { + const root = await createTestTempDirectory("rea-tarball-growth-"); + const files = await writeEnvironmentServeFiles(root); + const inputs = await readEnvironmentExportInputs( + files.manifestPath, + files.capturePath, + ); + if (!inputs.ok) throw inputs.error; + const tarball = join(root, "package.tgz"); + await writeFile(tarball, Buffer.alloc(2 * 1024 * 1024, 0x41)); + const directory = join(root, "bundle"); + const original = SafeOutputTree.prototype.write; + let grown = false; + const writer = vi + .spyOn(SafeOutputTree.prototype, "write") + .mockImplementation(async function ( + this: SafeOutputTree, + ...args: Parameters + ) { + const [relative] = args; + if (!grown && relative === "envs/grafana-example/rea-agents.tgz") { + grown = true; + await appendFile(tarball, Buffer.alloc(1024 * 1024, 0x42)); + } + return original.apply(this, args); + }); + try { + const result = await exportEnvironmentBundle({ + ...inputs.value, + source: environmentServeSource(), + directory, + packageTarball: tarball, + }); + expect(grown).toBe(true); + expect(result).toMatchObject({ + ok: false, + error: { + _tag: "AnalysisOutputError", + operation: "environment-export", + reason: + "Extracted content exceeds the inventoried size: envs/grafana-example/rea-agents.tgz", + }, + }); + if (result.ok) + throw new Error("Expected the changed tarball to be refused"); + expect(result.error.cleanup).toBeUndefined(); + expect(result.error.message).not.toContain("invalidated"); + } finally { + writer.mockRestore(); + } +}); + it("uses confirmed publication after the output owner's descriptor cleanup completes", async () => { const root = await createTestTempDirectory("rea-bundle-published-"); const files = await writeEnvironmentServeFiles(root); diff --git a/src/application/environment/environmentBundleWrite.ts b/src/application/environment/environmentBundleWrite.ts index 06e7296b7d..d3ec3c55dc 100644 --- a/src/application/environment/environmentBundleWrite.ts +++ b/src/application/environment/environmentBundleWrite.ts @@ -10,6 +10,7 @@ import { import { AnalysisCleanupLease } from "../../artifacts/AnalysisCleanupLease.js"; import { ArtifactFileCleanupFailure } from "../../artifacts/ArtifactFileOperation.js"; import { RegularFileCleanupFailure } from "../../filesystem/OwnedRegularFile.js"; +import { readFileHandleChunks } from "../../filesystem/readFileHandleChunks.js"; import { streamChunkToBuffer } from "../../artifacts/StreamBytes.js"; import { ArtifactResourceScope } from "../../artifacts/ArtifactResourceScope.js"; import { SafeOutputTree } from "../../artifacts/SafeOutputTree.js"; @@ -51,10 +52,7 @@ const writeTarball = async ( ): Promise => { const hash = createHash("sha256"); let observed = 0; - for await (const chunk of source.handle.createReadStream({ - start: 0, - autoClose: false, - })) { + for await (const chunk of readFileHandleChunks(source.handle)) { const bytes = streamChunkToBuffer(chunk); observed += bytes.length; if (observed > source.size) throw new RegularFileChangedError(source.path); @@ -63,7 +61,7 @@ const writeTarball = async ( if (observed !== source.size) throw new RegularFileChangedError(source.path); const written = await tree.write( relative, - source.handle.createReadStream({ start: 0, autoClose: false }), + readFileHandleChunks(source.handle), { bytes: source.size, sha256: hash.digest("hex"),