import type { Stats } from "node:fs"; import { lstat, stat, type FileHandle } from "node:fs/promises"; import { RegularFileAdmissionFailure, openRegularFile, sameRegularFileState, RegularFileChangedError, } from "../filesystem/RegularFile.js"; import { OwnedFileHandle } from "../filesystem/OwnedFileHandle.js"; import { readBoundedFileBytes } from "../process/BoundedFileBytes.js"; import type { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js"; import type { AnalysisCleanupObservation } from "../domain/analysisErrorBase.js"; /** Filesystem admission policy for one selected regular input. */ export interface RegularFileReadOptions { readonly signal?: AbortSignal | undefined; readonly symlinks?: "follow" | "reject"; } export type RegularFileReadOutcome = | { readonly kind: "completed"; readonly value: Value } | { readonly kind: "failed"; readonly cause: unknown }; /** A close failed after reading; this error transfers the exact handle owner. */ export class RegularFileCleanupFailure extends Error { constructor( readonly path: string, readonly owner: OwnedFileHandle, readonly outcome: RegularFileReadOutcome, readonly cleanupCause: unknown, ) { super(`Regular file cleanup failed: ${path}`, { cause: outcome.kind === "failed" ? outcome.cause : cleanupCause, }); this.name = "RegularFileCleanupFailure"; } } /** Retry a failed close through its retained owner and report any remaining cleanup. */ export const retryRegularFileCleanup = async ( failure: RegularFileCleanupFailure, resources: ArtifactResourceScope, ): Promise<{ readonly outcome: RegularFileReadOutcome; readonly cleanup?: AnalysisCleanupObservation; }> => { const released = await resources.release({ kind: "file-handle", handle: failure.owner, resource: failure.path, }); if (released.kind === "released") return { outcome: failure.outcome }; return { outcome: failure.outcome, cleanup: { reason: `${failureMessage(failure.cleanupCause)}; retry: ${failureMessage(released.cause)}`, resources: [failure.path], }, }; }; const failureMessage = (cause: unknown): string => cause instanceof Error ? cause.message : String(cause); /** Admit one regular-file handle, retain it through the consumer, and always close it. */ export const withRegularFile = async ( path: string, read: (handle: FileHandle, stats: Stats) => Promise, options: RegularFileReadOptions = {}, ): Promise => { const { signal } = options; let handle: FileHandle; try { handle = await openRegularFile(path, { symlinks: options.symlinks ?? "follow", signal, }); } catch (cause: unknown) { if (!(cause instanceof RegularFileAdmissionFailure)) throw cause; throw new RegularFileCleanupFailure( path, cause.owner, { kind: "failed", cause: cause.cause }, cause.cleanupCause, ); } const owner = new OwnedFileHandle(handle); let outcome: RegularFileReadOutcome; try { const stats = await handle.stat(); signal?.throwIfAborted(); const value = await read(handle, stats); signal?.throwIfAborted(); const [opened, currentPath] = await Promise.all([ handle.stat(), options.symlinks === "reject" ? lstat(path) : stat(path), ]); if ( !sameRegularFileState(stats, opened) || !sameRegularFileState(stats, currentPath) ) throw new RegularFileChangedError(path); outcome = { kind: "completed", value }; } catch (cause: unknown) { outcome = { kind: "failed", cause }; } try { await owner.close(); } catch (cleanupCause: unknown) { throw new RegularFileCleanupFailure(path, owner, outcome, cleanupCause); } if (outcome.kind === "failed") throw outcome.cause; return outcome.value; }; /** Admit a regular file without waiting for a pipe, then read its verified handle. */ export const readRegularFile = ( path: string, options: RegularFileReadOptions = {}, ): Promise => withRegularFile( path, async (handle, stats) => { const bytes = await readBoundedFileBytes( handle, stats.size, options.signal, ); if (bytes === undefined || bytes.length !== stats.size) throw new RegularFileChangedError(path); return bytes; }, options, ); /** Read one admitted regular file as UTF-8 without waiting on a pipe. */ export const readRegularFileText = async ( path: string, options: RegularFileReadOptions = {}, ): Promise => { try { return (await readRegularFile(path, options)).toString("utf8"); } catch (cause: unknown) { if ( cause instanceof RegularFileCleanupFailure && cause.outcome.kind === "completed" && Buffer.isBuffer(cause.outcome.value) ) throw new RegularFileCleanupFailure( cause.path, cause.owner, { kind: "completed", value: cause.outcome.value.toString("utf8") }, cause.cleanupCause, ); throw cause; } };