mirror of
https://github.com/morluto/rea.git
synced 2026-10-11 21:39:55 +00:00
test: strengthen archive and resource cleanup assertions
Replace duplicate path-helper checks with real ZIP inventory coverage and replace global memory sampling with bounded-chunk assertions while retaining 64 MiB digest coverage. Await artifact client cleanup, preserve Hopper lease ownership during teardown, and expose typed application failures.
This commit is contained in:
@@ -73,11 +73,11 @@ describe("JebAnalysisService admission", () => {
|
||||
const requests: JebRequest[] = [];
|
||||
const service = new JebAnalysisService(fakePort(requests));
|
||||
const result = await service.execute("list_jeb_units", {});
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) return;
|
||||
expect(requests[0]?.operation).toBe("list_jeb_units");
|
||||
if (!result.ok) throw result.error;
|
||||
expect(requests).toHaveLength(1);
|
||||
const request = requests[0];
|
||||
if (request?.operation !== "list_jeb_units") return;
|
||||
if (request?.operation !== "list_jeb_units")
|
||||
throw new Error("Expected the list_jeb_units provider request");
|
||||
expect(request.input.index).toBe(0);
|
||||
expect(request.input.count).toBe(100);
|
||||
expect(result.value.normalized_result).toEqual({ observed: true });
|
||||
|
||||
@@ -129,6 +129,7 @@ describe("entry-order validation and reader controls", () => {
|
||||
["pkg/data.txt", "pkg/data.txt/"],
|
||||
["pkg/data.txt/", "pkg/data.txt"],
|
||||
["pkg/data.txt", "pkg"],
|
||||
["pkg", "pkg/data.txt"],
|
||||
["../escape.txt"],
|
||||
])("keeps invalid/colliding paths rejected for %j", async (...entries) => {
|
||||
const root = await createTestTempDirectory("rea-entry-order-invalid-");
|
||||
@@ -158,6 +159,27 @@ describe("entry-order validation and reader controls", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("retains case-distinct file prefixes without inventing a directory", async () => {
|
||||
const root = await createTestTempDirectory("rea-entry-order-case-prefix-");
|
||||
const archive = join(root, "case-prefix.zip");
|
||||
for (const entries of [
|
||||
["Foo", "foo/bar"],
|
||||
["foo/bar", "Foo"],
|
||||
]) {
|
||||
await writeOrderedZip(archive, entries);
|
||||
const inventory = await inventoryArtifact(archive);
|
||||
expect(artifactParentPaths(inventory)).toEqual({
|
||||
".": null,
|
||||
Foo: ".",
|
||||
"foo/bar": ".",
|
||||
});
|
||||
for (const path of entries)
|
||||
expect(artifactOccurrenceAt(inventory, path).hash_status).toBe(
|
||||
"verified",
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("retains high-cardinality case-folded directories and their descendants", async () => {
|
||||
const root = await createTestTempDirectory(
|
||||
"rea-entry-order-case-capacity-",
|
||||
|
||||
@@ -8,8 +8,8 @@ import { writeOrderedZip } from "../../fixtures/artifactEntryOrder.js";
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
|
||||
import { parseBinaryTarget } from "../../../src/application/BinaryTargetResolver.js";
|
||||
import { ArtifactProvider } from "../../../src/artifacts/ArtifactProvider.js";
|
||||
import { ZipArtifactReader } from "../../../src/artifacts/ZipArtifactReader.js";
|
||||
import { withArtifactClient } from "../../support/artifactClientScope.js";
|
||||
import { artifactInventoryResultSchema } from "../../../src/domain/artifactGraph.js";
|
||||
|
||||
it("bounds a requested metadata read without rejecting the underlying valid ZIP", async () => {
|
||||
@@ -139,15 +139,12 @@ it("retains duplicate ZIP members without replacing their source identities", as
|
||||
|
||||
const target = await parseBinaryTarget(path);
|
||||
if (!target.ok) throw target.error;
|
||||
const client = new ArtifactProvider(process.env).createClient(target.value);
|
||||
try {
|
||||
await withArtifactClient(target.value, async (client) => {
|
||||
expect(await client.execute("inventory_artifact", {})).toMatchObject({
|
||||
ok: false,
|
||||
error: { _tag: "ArtifactOperationError", reason: "path" },
|
||||
});
|
||||
} finally {
|
||||
await client.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("can enumerate case-distinct ZIP members again without losing entry identity", async () => {
|
||||
@@ -200,9 +197,9 @@ it("rejects overlapping ZIP member data ranges through artifact inventory", asyn
|
||||
|
||||
const target = await parseBinaryTarget(path);
|
||||
if (!target.ok) throw target.error;
|
||||
const result = await new ArtifactProvider(process.env)
|
||||
.createClient(target.value)
|
||||
.execute("inventory_artifact", {});
|
||||
const result = await withArtifactClient(target.value, (client) =>
|
||||
client.execute("inventory_artifact", {}),
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
@@ -221,9 +218,10 @@ it("preserves encrypted-member and corrupt-CRC observations", async () => {
|
||||
|
||||
const encryptedTarget = await parseBinaryTarget(encryptedPath);
|
||||
if (!encryptedTarget.ok) throw encryptedTarget.error;
|
||||
const encryptedResult = await new ArtifactProvider(process.env)
|
||||
.createClient(encryptedTarget.value)
|
||||
.execute("inventory_artifact", {});
|
||||
const encryptedResult = await withArtifactClient(
|
||||
encryptedTarget.value,
|
||||
(client) => client.execute("inventory_artifact", {}),
|
||||
);
|
||||
if (!encryptedResult.ok) throw encryptedResult.error;
|
||||
const encryptedInventory = artifactInventoryResultSchema.parse(
|
||||
encryptedResult.value.result,
|
||||
@@ -237,11 +235,10 @@ it("preserves encrypted-member and corrupt-CRC observations", async () => {
|
||||
);
|
||||
// REA does not decrypt entries, so complete extraction is unsupported.
|
||||
const extractionRoot = join(root, "encrypted-output");
|
||||
expect(
|
||||
await new ArtifactProvider(process.env)
|
||||
.createClient(encryptedTarget.value)
|
||||
.execute("extract_artifact", { output_root: extractionRoot }),
|
||||
).toMatchObject({
|
||||
const extraction = await withArtifactClient(encryptedTarget.value, (client) =>
|
||||
client.execute("extract_artifact", { output_root: extractionRoot }),
|
||||
);
|
||||
expect(extraction).toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
_tag: "AnalysisUnsupportedTargetError",
|
||||
@@ -272,9 +269,10 @@ it("preserves encrypted-member and corrupt-CRC observations", async () => {
|
||||
|
||||
const corruptTarget = await parseBinaryTarget(corruptPath);
|
||||
if (!corruptTarget.ok) throw corruptTarget.error;
|
||||
const corruptResult = await new ArtifactProvider(process.env)
|
||||
.createClient(corruptTarget.value)
|
||||
.execute("inventory_artifact", {});
|
||||
const corruptResult = await withArtifactClient(
|
||||
corruptTarget.value,
|
||||
(client) => client.execute("inventory_artifact", {}),
|
||||
);
|
||||
expect(corruptResult).toMatchObject({
|
||||
ok: false,
|
||||
error: { _tag: "ArtifactOperationError", reason: "io" },
|
||||
@@ -288,9 +286,9 @@ it("rejects a truncated ZIP through artifact inventory", async () => {
|
||||
|
||||
const target = await parseBinaryTarget(path);
|
||||
if (!target.ok) throw target.error;
|
||||
const result = await new ArtifactProvider(process.env)
|
||||
.createClient(target.value)
|
||||
.execute("inventory_artifact", {});
|
||||
const result = await withArtifactClient(target.value, (client) =>
|
||||
client.execute("inventory_artifact", {}),
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
|
||||
@@ -24,6 +24,7 @@ import { hashReadable } from "../../../src/artifacts/ArtifactHash.js";
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
|
||||
const memberBytes = 64 * 1024 * 1024;
|
||||
const maximumObservedChunkBytes = 1024 * 1024;
|
||||
const expectedSha256 = (): string => {
|
||||
const hash = createHash("sha256");
|
||||
const zeros = Buffer.alloc(64 * 1024);
|
||||
@@ -727,7 +728,7 @@ describe("ASAR entry streaming", () => {
|
||||
["packed", {}],
|
||||
["unpacked", { unpack: "large.bin" }],
|
||||
] as const)(
|
||||
"opens a %s member without allocating its full contents",
|
||||
"streams a %s member in bounded chunks with its complete digest",
|
||||
async (_kind, options) => {
|
||||
const root = await createTestTempDirectory("rea-asar-streaming-");
|
||||
const source = join(root, "source");
|
||||
@@ -745,13 +746,21 @@ describe("ASAR entry streaming", () => {
|
||||
const entry = entries.find(({ path }) => path === "large.bin");
|
||||
if (entry === undefined) throw new Error("Expected large ASAR member");
|
||||
|
||||
const before = process.memoryUsage().arrayBuffers;
|
||||
const stream = await reader.open(entry);
|
||||
const afterOpen = process.memoryUsage().arrayBuffers;
|
||||
const observed = await hashReadable(stream);
|
||||
const allocation = afterOpen - before;
|
||||
let largestChunkBytes = 0;
|
||||
const measuredStream = Readable.from(
|
||||
(async function* () {
|
||||
for await (const chunk of stream) {
|
||||
largestChunkBytes = Math.max(largestChunkBytes, chunk.byteLength);
|
||||
yield chunk;
|
||||
}
|
||||
})(),
|
||||
);
|
||||
const observed = await hashReadable(measuredStream);
|
||||
|
||||
expect(allocation).toBeLessThan(memberBytes / 2);
|
||||
expect(largestChunkBytes).toBeLessThanOrEqual(
|
||||
maximumObservedChunkBytes,
|
||||
);
|
||||
expect(observed).toMatchObject({
|
||||
sha256: expectedSha256(),
|
||||
bytes: memberBytes,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { access, mkdtemp, rm } from "node:fs/promises";
|
||||
import { access, mkdtemp, rmdir } from "node:fs/promises";
|
||||
import { expect, it, onTestFinished } from "vitest";
|
||||
|
||||
import { createAnalysisExecution } from "../../../src/application/AnalysisProvider.js";
|
||||
@@ -9,10 +9,7 @@ import type {
|
||||
BridgeSession,
|
||||
} from "../../../src/hopper/BridgeLauncher.js";
|
||||
import { HopperClient } from "../../../src/hopper/HopperClient.js";
|
||||
import {
|
||||
acquireHopperTargetLease,
|
||||
type HopperTargetLease,
|
||||
} from "../../../src/hopper/HopperTargetLease.js";
|
||||
import { acquireHopperTargetLease } from "../../../src/hopper/HopperTargetLease.js";
|
||||
import { cleanupOwnedProcessGroup } from "../../../src/process/ProcessOwnership.js";
|
||||
import { spawnOwnedProviderProcess } from "../../../src/process/ProviderProcess.js";
|
||||
import { hopperFixturePath } from "../providers/hopper/hopperClient.fixture.js";
|
||||
@@ -21,11 +18,22 @@ import {
|
||||
createTestBinarySession,
|
||||
} from "../../fixtures/binarySession.js";
|
||||
|
||||
const createShortLeaseDirectory = async (): Promise<string> => {
|
||||
// A redirected TMPDIR can exceed macOS's Unix socket path limit once
|
||||
// Hopper appends its lease key. Remove only an empty directory after the
|
||||
// owning client has closed; recursive removal could hide a retained lease.
|
||||
const directory = await mkdtemp("/tmp/rea-hl-");
|
||||
onTestFinished(async () => {
|
||||
await rmdir(directory);
|
||||
await expect(access(directory)).rejects.toMatchObject({ code: "ENOENT" });
|
||||
});
|
||||
return directory;
|
||||
};
|
||||
|
||||
class RetryingHopperLauncher implements BridgeLauncher {
|
||||
cleanupAllowed = false;
|
||||
cleanupAttempts = 0;
|
||||
directory: string | undefined;
|
||||
lease: HopperTargetLease | undefined;
|
||||
|
||||
constructor(
|
||||
readonly leaseDirectory: string,
|
||||
@@ -49,7 +57,6 @@ class RetryingHopperLauncher implements BridgeLauncher {
|
||||
return err(
|
||||
new HopperStartError({ userMessage: "fixture lease is held" }),
|
||||
);
|
||||
this.lease = lease.lease;
|
||||
this.directory = session.directory;
|
||||
const started = await spawnOwnedProviderProcess({
|
||||
command: process.execPath,
|
||||
@@ -83,8 +90,7 @@ class RetryingHopperLauncher implements BridgeLauncher {
|
||||
}
|
||||
|
||||
it("retains Hopper process handles, private files, and lease until an actual session cleanup retry succeeds", async () => {
|
||||
const leaseDirectory = await mkdtemp("/tmp/rea-hl-");
|
||||
onTestFinished(() => rm(leaseDirectory, { recursive: true, force: true }));
|
||||
const leaseDirectory = await createShortLeaseDirectory();
|
||||
const [first, second] = await createBinarySessionTargets();
|
||||
const launcher = new RetryingHopperLauncher(leaseDirectory, first);
|
||||
const hopper = new HopperClient({ launcher, startupTimeoutMs: 10_000 });
|
||||
@@ -109,9 +115,10 @@ it("retains Hopper process handles, private files, and lease until an actual ses
|
||||
});
|
||||
onTestFinished(async () => {
|
||||
launcher.cleanupAllowed = true;
|
||||
await session.close();
|
||||
await hopper.close();
|
||||
await launcher.lease?.release();
|
||||
const sessionClosed = await session.close();
|
||||
if (!sessionClosed.ok) throw sessionClosed.error;
|
||||
const hopperClosed = await hopper.close();
|
||||
if (!hopperClosed.ok) throw hopperClosed.error;
|
||||
});
|
||||
expect(await session.open(first)).toMatchObject({ ok: true });
|
||||
expect(await session.close()).toMatchObject({
|
||||
@@ -144,15 +151,14 @@ it("retains Hopper process handles, private files, and lease until an actual ses
|
||||
});
|
||||
|
||||
it("reports a direct Hopper close failure and retries the retained owned process", async () => {
|
||||
const leaseDirectory = await mkdtemp("/tmp/rea-hl-");
|
||||
onTestFinished(() => rm(leaseDirectory, { recursive: true, force: true }));
|
||||
const leaseDirectory = await createShortLeaseDirectory();
|
||||
const [target] = await createBinarySessionTargets();
|
||||
const launcher = new RetryingHopperLauncher(leaseDirectory, target, true);
|
||||
const hopper = new HopperClient({ launcher, startupTimeoutMs: 10_000 });
|
||||
onTestFinished(async () => {
|
||||
launcher.cleanupAllowed = true;
|
||||
await hopper.close();
|
||||
await launcher.lease?.release();
|
||||
const closed = await hopper.close();
|
||||
if (!closed.ok) throw closed.error;
|
||||
});
|
||||
expect(await hopper.start()).toMatchObject({ ok: true });
|
||||
expect(await hopper.close()).toMatchObject({
|
||||
|
||||
@@ -7,20 +7,14 @@ import { describe, expect, it } from "vitest";
|
||||
import { createTestTempDirectory } from "../../../fixtures/temporaryDirectory.js";
|
||||
|
||||
import { createDirectAnalysis } from "../../../../src/composition/directAnalysis.js";
|
||||
import {
|
||||
ArtifactPathRegistry,
|
||||
destinationCaseCollisionMessage,
|
||||
ENGLISH_UNICODE_CASE_INVENTORY_LIMITATION,
|
||||
normalizeArtifactPath,
|
||||
} from "../../../../src/artifacts/ArtifactPaths.js";
|
||||
import { ArtifactProvider } from "../../../../src/artifacts/ArtifactProvider.js";
|
||||
import { ArtifactReaderFailure } from "../../../../src/artifacts/ArtifactReader.js";
|
||||
import { ENGLISH_UNICODE_CASE_INVENTORY_LIMITATION } from "../../../../src/artifacts/ArtifactPaths.js";
|
||||
import { artifactExtractionExecutionSchema } from "../../../../src/contracts/artifactToolContracts.js";
|
||||
import { artifactInventoryResultSchema } from "../../../../src/domain/artifactGraph.js";
|
||||
import type { BinaryTarget } from "../../../../src/domain/binaryTargetTypes.js";
|
||||
import { parseBinaryTarget } from "../../../../src/application/BinaryTargetResolver.js";
|
||||
import { parseEvidence } from "../../../../src/domain/evidence.js";
|
||||
import { projectAnalysisError } from "../../../../src/domain/analysisErrorProjection.js";
|
||||
import { withArtifactClient } from "../../../support/artifactClientScope.js";
|
||||
|
||||
const { runProviderAnalysis } = createDirectAnalysis({});
|
||||
|
||||
@@ -61,15 +55,15 @@ describe("artifact archive safety", () => {
|
||||
),
|
||||
).toBe(true);
|
||||
const output = join(root, "output");
|
||||
const extracted = await new ArtifactProvider(process.env)
|
||||
.createClient(parsed.value)
|
||||
.execute(
|
||||
const extracted = await withArtifactClient(parsed.value, (client) =>
|
||||
client.execute(
|
||||
"extract_artifact",
|
||||
artifactExtractionExecutionSchema.parse({
|
||||
output_root: output,
|
||||
}),
|
||||
);
|
||||
expect(extracted.ok).toBe(true);
|
||||
),
|
||||
);
|
||||
if (!extracted.ok) throw extracted.error;
|
||||
expect(await readFile(join(output, "Assets", "main.js"), "utf8")).toBe(
|
||||
"export default 'windows';",
|
||||
);
|
||||
@@ -83,60 +77,6 @@ describe("artifact archive safety", () => {
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("rejects unsafe paths and collisions", async () => {
|
||||
let unsafePathError: unknown;
|
||||
try {
|
||||
normalizeArtifactPath("../escape");
|
||||
} catch (error: unknown) {
|
||||
unsafePathError = error;
|
||||
}
|
||||
expect(unsafePathError).toBeInstanceOf(ArtifactReaderFailure);
|
||||
expect(unsafePathError).toMatchObject({
|
||||
reason: "path",
|
||||
message: 'Artifact path is not normalized: "../escape"',
|
||||
});
|
||||
const registry = new ArtifactPathRegistry();
|
||||
registry.add("A.js", "file");
|
||||
expect(() => registry.add("a.js", "file")).not.toThrow();
|
||||
expect(() => registry.add("A.js", "file")).toThrow(ArtifactReaderFailure);
|
||||
|
||||
for (const [filePath, childPath] of [
|
||||
["Foo", "foo/bar"],
|
||||
["root/Foo", "root/foo/bar"],
|
||||
] as const) {
|
||||
const casePrefixRegistry = new ArtifactPathRegistry();
|
||||
casePrefixRegistry.add(filePath, "file");
|
||||
expect(() => casePrefixRegistry.add(childPath, "file")).not.toThrow();
|
||||
}
|
||||
|
||||
const exactPrefixRegistry = new ArtifactPathRegistry();
|
||||
exactPrefixRegistry.add("Foo", "file");
|
||||
expect(() => exactPrefixRegistry.add("Foo/bar", "file")).toThrow(
|
||||
ArtifactReaderFailure,
|
||||
);
|
||||
|
||||
const sameDirectoryRegistry = new ArtifactPathRegistry();
|
||||
sameDirectoryRegistry.add("Foo/one.js", "file");
|
||||
expect(() => sameDirectoryRegistry.add("Foo/two.js", "file")).not.toThrow();
|
||||
|
||||
const caseVariantDirectoryRegistry = new ArtifactPathRegistry();
|
||||
caseVariantDirectoryRegistry.add("Foo/one.js", "file");
|
||||
expect(() =>
|
||||
caseVariantDirectoryRegistry.add("foo/two.js", "file"),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it("names a destination case collision from the directory listing", () => {
|
||||
expect(
|
||||
destinationCaseCollisionMessage("res/2f.xml", ["2F.xml", "other.xml"]),
|
||||
).toBe(
|
||||
"Destination filesystem cannot store both res/2f.xml and res/2F.xml; inventory retains both logical names.",
|
||||
);
|
||||
expect(
|
||||
destinationCaseCollisionMessage("res/2f.xml", ["2f.xml", "2F.xml"]),
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
it("extracts case-distinct names only when the destination can preserve them", async () => {
|
||||
@@ -163,12 +103,12 @@ it("extracts case-distinct names only when the destination can preserve them", a
|
||||
.sort(),
|
||||
).toEqual(["Main.js", "main.js"]);
|
||||
const output = join(root, "output");
|
||||
const extracted = await new ArtifactProvider(process.env)
|
||||
.createClient(parsed.value)
|
||||
.execute(
|
||||
const extracted = await withArtifactClient(parsed.value, (client) =>
|
||||
client.execute(
|
||||
"extract_artifact",
|
||||
artifactExtractionExecutionSchema.parse({ output_root: output }),
|
||||
);
|
||||
),
|
||||
);
|
||||
if (!caseSensitive) {
|
||||
if (extracted.ok) throw new Error("expected a destination case collision");
|
||||
expect(projectAnalysisError(extracted.error)).toMatchObject({
|
||||
@@ -199,12 +139,12 @@ it("preserves colon names in inventory and refuses Windows stream materializatio
|
||||
);
|
||||
|
||||
const output = join(root, "output");
|
||||
const extracted = await new ArtifactProvider(process.env)
|
||||
.createClient(parsed.value)
|
||||
.execute(
|
||||
const extracted = await withArtifactClient(parsed.value, (client) =>
|
||||
client.execute(
|
||||
"extract_artifact",
|
||||
artifactExtractionExecutionSchema.parse({ output_root: output }),
|
||||
);
|
||||
),
|
||||
);
|
||||
if (process.platform === "win32") {
|
||||
if (extracted.ok) throw new Error("expected a Windows stream path refusal");
|
||||
const detail =
|
||||
@@ -232,9 +172,9 @@ it("preserves colon names in inventory and refuses Windows stream materializatio
|
||||
});
|
||||
|
||||
const inventory = async (targetValue: BinaryTarget) => {
|
||||
const result = await new ArtifactProvider(process.env)
|
||||
.createClient(targetValue)
|
||||
.execute("inventory_artifact", {});
|
||||
const result = await withArtifactClient(targetValue, (client) =>
|
||||
client.execute("inventory_artifact", {}),
|
||||
);
|
||||
if (!result.ok) throw result.error;
|
||||
return artifactInventoryResultSchema.parse(result.value.result);
|
||||
};
|
||||
|
||||
@@ -21,8 +21,7 @@ const { runProviderAnalysis } = createDirectAnalysis({});
|
||||
export const requireSuccessfulProjection = (
|
||||
result: Result<Evidence, AnalysisError>,
|
||||
): Evidence => {
|
||||
if (!result.ok)
|
||||
throw new TypeError("Expected application projection to succeed");
|
||||
if (!result.ok) throw result.error;
|
||||
return result.value;
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { onTestFinished } from "vitest";
|
||||
import type { AnalysisClient } from "../../src/application/AnalysisProvider.js";
|
||||
import { ArtifactProvider } from "../../src/artifacts/ArtifactProvider.js";
|
||||
import type { BinaryTarget } from "../../src/domain/binaryTargetTypes.js";
|
||||
|
||||
export const withArtifactClient = async <T>(
|
||||
target: BinaryTarget,
|
||||
action: (client: AnalysisClient) => Promise<T>,
|
||||
): Promise<T> => {
|
||||
const client = new ArtifactProvider(process.env).createClient(target);
|
||||
onTestFinished(async () => {
|
||||
const closed = await client.close();
|
||||
if (!closed.ok) throw closed.error;
|
||||
});
|
||||
let outcome:
|
||||
| { readonly ok: true; readonly value: T }
|
||||
| { readonly ok: false; readonly error: unknown };
|
||||
try {
|
||||
outcome = { ok: true, value: await action(client) };
|
||||
} catch (error) {
|
||||
outcome = { ok: false, error };
|
||||
}
|
||||
const closed = await client.close();
|
||||
if (!closed.ok) {
|
||||
if (!outcome.ok)
|
||||
throw new AggregateError(
|
||||
[outcome.error, closed.error],
|
||||
"Artifact operation and client cleanup failed",
|
||||
);
|
||||
throw closed.error;
|
||||
}
|
||||
if (!outcome.ok) throw outcome.error;
|
||||
return outcome.value;
|
||||
};
|
||||
Reference in New Issue
Block a user