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
This commit is contained in:
morluto
2026-10-21 05:33:35 +08:00
parent 60956c8c56
commit ab58edcdf5
2 changed files with 56 additions and 5 deletions
@@ -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<SafeOutputTree["write"]>
) {
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);
@@ -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<WrittenBundleFile> => {
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"),