mirror of
https://github.com/morluto/rea.git
synced 2026-11-13 18:22:42 +00:00
* fix(decompile): retain published index on failure * fix(decompile): preserve publication failure facts * docs(decompile): explain retained publication receipts * fix(decompile): retain receipts across provider cleanup chains * fix(evidence): retain diagnostics through cleanup primary chains * refactor(evidence): retain receipts once on the returned error
319 lines
10 KiB
TypeScript
319 lines
10 KiB
TypeScript
import {
|
|
link,
|
|
lstat,
|
|
mkdtemp,
|
|
open,
|
|
rename,
|
|
rmdir,
|
|
unlink,
|
|
type FileHandle,
|
|
} from "node:fs/promises";
|
|
import { join } from "node:path";
|
|
|
|
import { AnalysisCleanupLease } from "../../artifacts/AnalysisCleanupLease.js";
|
|
import { ArtifactFileCleanupFailure } from "../../artifacts/ArtifactFileOperation.js";
|
|
import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js";
|
|
import { RegularFileCleanupFailure } from "../../filesystem/OwnedRegularFile.js";
|
|
import { ArtifactResourceScope } from "../../artifacts/ArtifactResourceScope.js";
|
|
import { DECOMPILE_DIRECTORY_JOURNAL_NAME } from "../../contracts/decompileDirectory.js";
|
|
import type { AnalysisError } from "../../domain/analysisErrorBase.js";
|
|
import { AnalysisOutputError } from "../../domain/analysisErrorCore.js";
|
|
import { analysisErrorWithCleanupFailure } from "../../domain/analysisErrorCleanup.js";
|
|
import { err, ok, type Result } from "../../domain/result.js";
|
|
import { OwnedFileHandle } from "../../filesystem/OwnedFileHandle.js";
|
|
import { OwnedStagedOutput } from "../../filesystem/OwnedStagedOutput.js";
|
|
import {
|
|
hashStableFile,
|
|
stableFileHashCause,
|
|
} from "../../filesystem/StableFileHash.js";
|
|
import type { OwnedLease } from "../../process/OwnedLease.js";
|
|
import type { RetainedResourceScope } from "../AnalysisProvider.js";
|
|
|
|
/** Publication committed before its staging cleanup failed. */
|
|
export class ExportPublishedCleanupFailure extends ArtifactReaderFailure {
|
|
constructor(
|
|
readonly publishedPath: string,
|
|
failure: ArtifactReaderFailure,
|
|
) {
|
|
super(
|
|
failure.reason,
|
|
failure.message,
|
|
{
|
|
cause: failure,
|
|
userMessage: failure.userMessage,
|
|
resolvedPath: failure.resolvedPath,
|
|
cancellation: failure.cancellation,
|
|
cleanup: failure.cleanup,
|
|
cleanupLeases: failure.cleanupLeases,
|
|
partialObservation: failure.partialObservation,
|
|
...(failure.boundary === undefined
|
|
? {}
|
|
: { boundary: failure.boundary }),
|
|
...(failure.admission === undefined
|
|
? {}
|
|
: { admission: failure.admission }),
|
|
},
|
|
failure.details,
|
|
);
|
|
}
|
|
}
|
|
|
|
/** Own every descriptor and staging area until cleanup, then release the directory lease. */
|
|
export class ExportResources {
|
|
readonly #files = new ArtifactResourceScope();
|
|
readonly #cleanup: AnalysisCleanupLease;
|
|
#removeRoot = false;
|
|
#journalIdentity: FileIdentity | undefined;
|
|
#rootRemoved = false;
|
|
#retained = false;
|
|
#functionsRemoved = false;
|
|
#journalRemoved = false;
|
|
#leaseReleased = false;
|
|
#functionsIdentity: FileIdentity | undefined;
|
|
|
|
constructor(
|
|
readonly root: string,
|
|
private readonly identity: FileIdentity,
|
|
private readonly lease: OwnedLease,
|
|
private readonly scope: RetainedResourceScope | undefined,
|
|
) {
|
|
const resources = this;
|
|
this.#cleanup = AnalysisCleanupLease.forCloseOwner(
|
|
{
|
|
close: async () => {
|
|
await this.#files.retryCleanup();
|
|
if (this.#removeRoot && !this.#rootRemoved)
|
|
await this.#removeCreatedRoot();
|
|
await this.lease.release();
|
|
this.#leaseReleased = true;
|
|
},
|
|
get cleanupResources() {
|
|
return resources.cleanupResources;
|
|
},
|
|
},
|
|
root,
|
|
);
|
|
}
|
|
|
|
get cleanupResources(): readonly string[] {
|
|
return [
|
|
...this.#files.cleanupResources,
|
|
...(this.#removeRoot && !this.#rootRemoved ? [this.root] : []),
|
|
...(this.#leaseReleased ? [] : [this.lease.socketPath ?? this.root]),
|
|
];
|
|
}
|
|
|
|
ownFunctionsDirectory(identity: FileIdentity): void {
|
|
this.#functionsIdentity = identity;
|
|
}
|
|
|
|
ownFile(handle: FileHandle, path: string, journal = false) {
|
|
const file = new OwnedFileHandle(handle);
|
|
const owner = {
|
|
kind: "lease" as const,
|
|
resource: path,
|
|
lease: {
|
|
release: async () => {
|
|
if (journal && handle.fd >= 0)
|
|
this.#journalIdentity = await handle
|
|
.stat({ bigint: true })
|
|
.catch(() => undefined);
|
|
await file.close();
|
|
},
|
|
},
|
|
};
|
|
this.#files.retain(owner);
|
|
return async (): Promise<boolean> =>
|
|
(await this.#files.release(owner)).kind === "released";
|
|
}
|
|
|
|
/** Check existing output with bounded memory and the shared file identity owner. */
|
|
async hashFile(path: string, signal?: AbortSignal): Promise<string> {
|
|
let cleanupLease: AnalysisCleanupLease | undefined;
|
|
try {
|
|
const hash = await hashStableFile(path, {
|
|
...(signal === undefined ? {} : { signal }),
|
|
retainFailedClose: (handle) => {
|
|
cleanupLease = new AnalysisCleanupLease(() => handle.close(), [path]);
|
|
this.#files.retain({
|
|
kind: "lease",
|
|
resource: path,
|
|
lease: cleanupLease,
|
|
});
|
|
},
|
|
});
|
|
return hash.sha256;
|
|
} catch (cause: unknown) {
|
|
if (
|
|
cause instanceof RegularFileCleanupFailure &&
|
|
cleanupLease !== undefined
|
|
)
|
|
throw new ArtifactFileCleanupFailure(cause, cleanupLease);
|
|
throw stableFileHashCause(cause);
|
|
}
|
|
}
|
|
|
|
/** Publish only a verified staged file; failed close or removal stays owned. */
|
|
async publish(
|
|
bytes: string | Buffer,
|
|
destination: string,
|
|
mode: "link" | "rename",
|
|
): Promise<void> {
|
|
const directory = await mkdtemp(join(this.root, ".decompile-stage-"));
|
|
const staging = await OwnedStagedOutput.create(directory);
|
|
const owner = {
|
|
kind: "staged-output" as const,
|
|
resource: directory,
|
|
output: staging,
|
|
};
|
|
this.#files.retain(owner);
|
|
let outcome:
|
|
| { readonly kind: "completed" }
|
|
| { readonly kind: "failed"; readonly cause: unknown } = {
|
|
kind: "completed",
|
|
};
|
|
try {
|
|
const path = join(directory, "content");
|
|
const handle = await open(path, "wx", 0o600);
|
|
staging.setFile(new OwnedFileHandle(handle));
|
|
await handle.writeFile(bytes);
|
|
await handle.sync();
|
|
await staging.closeFile();
|
|
await staging.assertReadyForPublication(path);
|
|
if (mode === "link") await link(path, destination);
|
|
else {
|
|
await rename(path, destination);
|
|
staging.recordPublishedRename();
|
|
}
|
|
} catch (cause: unknown) {
|
|
outcome = { kind: "failed", cause };
|
|
}
|
|
const released = await this.#files.release(owner);
|
|
if (released.kind === "failed") {
|
|
const failure = this.#files.cleanupFailure(outcome, [released]);
|
|
throw outcome.kind === "completed"
|
|
? new ExportPublishedCleanupFailure(destination, failure)
|
|
: failure;
|
|
}
|
|
if (outcome.kind === "failed") throw outcome.cause;
|
|
}
|
|
|
|
/** Keep the lease through all initialization cleanup, including any retry. */
|
|
async failedInitialization(
|
|
error: AnalysisError,
|
|
): Promise<Result<never, AnalysisError>> {
|
|
this.#removeRoot = true;
|
|
const closed = await this.close();
|
|
return closed.ok
|
|
? err(error)
|
|
: err(
|
|
analysisErrorWithCleanupFailure(
|
|
error,
|
|
closed.error,
|
|
"decompile_all",
|
|
).bindCleanupDependencies([closed.error]),
|
|
);
|
|
}
|
|
|
|
async close(): Promise<Result<void, AnalysisError>> {
|
|
try {
|
|
await this.#cleanup.release();
|
|
return ok(undefined);
|
|
} catch (cause: unknown) {
|
|
const error = new AnalysisOutputError(
|
|
"decompile_all",
|
|
"decompile export cleanup failed",
|
|
{
|
|
cause,
|
|
cleanup: {
|
|
reason: "Owned export resources could not be released.",
|
|
resources: [this.root],
|
|
},
|
|
},
|
|
);
|
|
error.bindCleanupResourceOwner(this.#cleanup);
|
|
this.#cleanup.bindCleanupError(error);
|
|
if (!this.#retained && this.scope !== undefined) {
|
|
this.#retained = true;
|
|
const cleanup = this.#cleanup;
|
|
this.scope.retain(
|
|
{
|
|
close: () => cleanup.release(),
|
|
get cleanupResources() {
|
|
return cleanup.cleanupResources;
|
|
},
|
|
},
|
|
this.root,
|
|
);
|
|
}
|
|
return err(error);
|
|
}
|
|
}
|
|
|
|
async #removeCreatedRoot(): Promise<void> {
|
|
const root = await lstat(this.root, { bigint: true });
|
|
if (
|
|
!sameIdentity(root, this.identity) ||
|
|
!root.isDirectory() ||
|
|
root.isSymbolicLink()
|
|
)
|
|
throw new Error("Export directory identity changed during cleanup");
|
|
// Initialization has not exported functions. Refuse unexpected entries rather
|
|
// than recursively deleting work introduced by another owner.
|
|
if (!this.#journalRemoved) {
|
|
const journal = join(this.root, DECOMPILE_DIRECTORY_JOURNAL_NAME);
|
|
const stat = await lstat(journal, { bigint: true }).catch(
|
|
(cause: unknown) => {
|
|
if (isMissing(cause)) return undefined;
|
|
throw cause;
|
|
},
|
|
);
|
|
if (stat !== undefined) {
|
|
if (
|
|
this.#journalIdentity === undefined ||
|
|
!sameIdentity(stat, this.#journalIdentity)
|
|
)
|
|
throw new Error(
|
|
"Cannot establish ownership of the journal during cleanup",
|
|
);
|
|
await unlink(journal);
|
|
}
|
|
this.#journalRemoved = true;
|
|
}
|
|
if (!this.#functionsRemoved) {
|
|
const path = join(this.root, "functions");
|
|
const stat = await lstat(path, { bigint: true }).catch(
|
|
(cause: unknown) => {
|
|
if (isMissing(cause) && this.#functionsIdentity === undefined)
|
|
return undefined;
|
|
throw cause;
|
|
},
|
|
);
|
|
if (stat !== undefined) {
|
|
if (
|
|
this.#functionsIdentity === undefined ||
|
|
!sameIdentity(stat, this.#functionsIdentity)
|
|
)
|
|
throw new Error(
|
|
"Functions directory identity changed during cleanup",
|
|
);
|
|
await rmdir(path);
|
|
}
|
|
this.#functionsRemoved = true;
|
|
}
|
|
await rmdir(this.root);
|
|
this.#rootRemoved = true;
|
|
}
|
|
}
|
|
|
|
interface FileIdentity {
|
|
readonly dev: bigint;
|
|
readonly ino: bigint;
|
|
}
|
|
|
|
const sameIdentity = (left: FileIdentity, right: FileIdentity): boolean =>
|
|
left.dev === right.dev && left.ino === right.ino;
|
|
|
|
const isMissing = (cause: unknown): boolean =>
|
|
cause instanceof Error && "code" in cause && cause.code === "ENOENT";
|