Files
rea/src/application/JsonInputFile.ts
T
morluto 949966712e fix(files): preserve physical read failures across late cancellation
Tag physical read outcomes at the owning boundary and preserve opaque caller cancellation reasons through JSON pipelines and reference imports. Share named bounded-read options across consumers and retain failed cleanup ownership for retry.

Closes #5686
2026-10-21 06:14:03 +08:00

736 lines
25 KiB
TypeScript

import { constants } from "node:buffer";
import { createHash } from "node:crypto";
import type { Stats } from "node:fs";
import type { FileHandle } from "node:fs/promises";
import { pipeline } from "node:stream/promises";
import { getHeapStatistics } from "node:v8";
import { asStream, none } from "stream-chain";
import fun from "stream-chain/fun.js";
import parser from "stream-json/parser.js";
import streamValues from "stream-json/streamers/stream-values.js";
import streamArray from "stream-json/streamers/stream-array.js";
import {
assertRegularFileActive,
RegularFileChangedError,
} from "../filesystem/RegularFile.js";
import type { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js";
import { sealOwnedJsonInput } from "./OwnedJsonInput.js";
import {
RegularFileReadFailure,
withRegularFile,
type RegularFileReadOptions,
} from "./RegularFileRead.js";
import {
JSON_BYTE_ORDER_MARK_MESSAGE,
JSON_INPUT_RESOURCE_REMEDIATION,
parseUtf8Json,
} from "./Utf8JsonInput.js";
import {
AnalysisInputError,
AnalysisResourceConstraintError,
} from "../domain/analysisErrorCore.js";
import { err, ok, type Result } from "../domain/result.js";
type JsonInputFileFailure =
| AnalysisInputError
| AnalysisResourceConstraintError;
const READ_CHUNK_BYTES = 64 * 1024;
const PACKED_TOKEN_PART_CODE_UNITS = 64 * 1024;
// Retain native-parser speed for modest inputs while bounding its extra copies.
// This selects an implementation; larger files remain accepted through streaming.
const NATIVE_PARSE_READ_BUDGET = 8 * 1024 * 1024;
/** Physical descriptor failure remains primary if cancellation arrives afterward. */
export class JsonInputPhysicalReadFailure extends Error {
readonly _tag = "JsonInputPhysicalReadFailure";
readonly physicalCause: unknown;
constructor(physicalCause: unknown) {
super("JSON input descriptor read failed");
this.physicalCause = physicalCause;
}
}
/** Explicit cancellation fact; the caller's value is opaque and retained as data. */
export class JsonInputCancellationFailure extends Error {
readonly _tag = "JsonInputCancellationFailure";
constructor(readonly reason: unknown) {
super(
reason instanceof Error ? reason.message : "JSON input read cancelled",
);
}
}
interface JsonInputPhysicalReadObservation {
failure?: JsonInputPhysicalReadFailure;
}
const readPhysicalJsonInput = async <Value>(
observation: JsonInputPhysicalReadObservation,
read: () => Promise<Value>,
): Promise<Value> => {
try {
return await read();
} catch (physicalCause: unknown) {
const failure = new JsonInputPhysicalReadFailure(physicalCause);
observation.failure = failure;
throw failure;
}
};
const withJsonInputFile = <Value>(
path: string,
read: (handle: FileHandle, stats: Stats) => Promise<Value>,
options: Pick<RegularFileReadOptions, "signal" | "resources">,
): Promise<Value> =>
withRegularFile(path, read, {
...options,
cancellationFailure: (reason) => new JsonInputCancellationFailure(reason),
}).catch((cause: unknown) => {
if (cause instanceof RegularFileReadFailure) throw cause.operationCause;
throw cause;
});
const withJsonInputPipeline = async <Value>(
signal: AbortSignal | undefined,
run: (signal?: AbortSignal) => Promise<Value>,
): Promise<Value> => {
if (signal === undefined) return run();
const controller = new AbortController();
const cancel = (): void => {
controller.abort(new JsonInputCancellationFailure(signal.reason));
};
if (signal.aborted) cancel();
else signal.addEventListener("abort", cancel, { once: true });
try {
return await run(controller.signal);
} finally {
signal.removeEventListener("abort", cancel);
}
};
/** Heap a whole-file native JSON.parse may need: the assembler's 64x container expansion plus byte and text copies. */
export const nativeJsonParseRequiredBytes = (
inputBytes: number,
snapshot = false,
): number =>
// The assembler estimate reserves up to 128 bytes per two-byte container.
// Allow that 64x expansion plus byte/text copies before native JSON.parse,
// whose individual container allocations cannot be intercepted.
(inputBytes * 72 + READ_CHUNK_BYTES) * (snapshot ? 2 : 1);
/** Snapshot mode also reserves one projection for cooperative sealing bookkeeping. */
const nativeParseFits = (inputBytes: number, snapshot = false): boolean =>
inputBytes <= NATIVE_PARSE_READ_BUDGET &&
nativeJsonParseRequiredBytes(inputBytes, snapshot) <=
getHeapStatistics().total_available_size;
/** One selected root member; repeated keys begin again with last-value semantics. */
export interface JsonObjectMemberReader {
readonly mode: "value" | "array";
readonly begin: () => void;
readonly accept: (value: unknown) => void | Promise<void>;
readonly reject: (cause: unknown) => void;
}
/** Scan selected members without retaining skipped subtrees or prior array items. */
export const readJsonObjectFile = (
path: string,
operation: string,
scans: readonly ReadonlyMap<string, JsonObjectMemberReader>[],
signal?: AbortSignal,
resources?: ArtifactResourceScope,
): Promise<Result<void, JsonInputFileFailure>> =>
withJsonInputFile(
path,
async (handle, stats) => {
let previousDigest: string | undefined;
const physicalRead: JsonInputPhysicalReadObservation = {};
try {
const native = await readNativeJson(
handle,
stats.size,
path,
operation,
signal,
false,
physicalRead,
);
if (native !== undefined) {
if (!native.ok) return native;
const value = native.value;
if (
typeof value !== "object" ||
value === null ||
Array.isArray(value)
)
return err(
invalidJson(operation, "JSON document root must be an object"),
);
for (const members of scans)
for (const [key, member] of members) {
if (!Object.hasOwn(value, key)) continue;
assertRegularFileActive(signal);
member.begin();
const selected: unknown = Reflect.get(value, key);
if (member.mode === "array" && !Array.isArray(selected)) {
member.reject(
new TypeError(`JSON member ${key} must be an array`),
);
continue;
}
for (const item of member.mode === "array" &&
Array.isArray(selected)
? selected
: [selected]) {
assertRegularFileActive(signal);
try {
await member.accept(item);
} catch (cause: unknown) {
member.reject(cause);
}
}
}
return ok(undefined);
}
for (const members of scans) {
const hash = createHash("sha256");
let rootObject = false;
let depth = 0;
let key: string | undefined;
let consume:
| ((token: parser.Token) => typeof none | Promise<typeof none>)
| undefined;
await withJsonInputPipeline(signal, (pipelineSignal) =>
pipeline(
decodedChunks(
handle,
stats.size,
path,
signal,
(bytes) => {
hash.update(bytes);
},
physicalRead,
),
asStream(
fun(
parser({
jsonStreaming: false,
streamValues: false,
packKeys: false,
packStrings: false,
packNumbers: false,
}),
packJsonScalars(operation, stats.size, path),
(token: parser.Token): typeof none | Promise<typeof none> => {
const opens =
token.name === "startObject" ||
token.name === "startArray";
const closes =
token.name === "endObject" || token.name === "endArray";
if (depth === 0) {
rootObject = token.name === "startObject";
} else if (
rootObject &&
depth === 1 &&
token.name === "keyValue"
) {
key = token.value;
} else if (rootObject && depth === 1 && !closes) {
const member =
key === undefined ? undefined : members.get(key);
consume = undefined;
if (member !== undefined) {
member.begin();
if (
member.mode === "array" &&
token.name !== "startArray"
) {
member.reject(
new TypeError(
`JSON member ${key} must be an array`,
),
);
} else {
consume = memberConsumer(
member,
operation,
stats.size,
path,
);
}
}
key = undefined;
}
const selected = consume;
if (opens) depth += 1;
if (closes) depth -= 1;
if (rootObject && depth === 1 && token.name !== "keyValue")
consume = undefined;
if (selected === undefined) return none;
return selected(token);
},
),
),
pipelineSignal === undefined ? {} : { signal: pipelineSignal },
),
);
if (!rootObject)
return err(
invalidJson(operation, "JSON document root must be an object"),
);
const digest = hash.digest("hex");
if (previousDigest !== undefined && digest !== previousDigest)
throw new RegularFileChangedError(path);
previousDigest = digest;
}
return ok(undefined);
} catch (cause: unknown) {
if (physicalRead.failure !== undefined) throw physicalRead.failure;
const failure = jsonReadFailure(cause, operation, stats.size, path);
if (failure !== undefined) return err(failure);
throw cause;
}
},
{
signal,
...(resources === undefined ? {} : { resources }),
},
);
const memberConsumer = (
member: JsonObjectMemberReader,
operation: string,
size: number,
path: string,
) => {
let admit = admitJsonAssembly(operation, size, path);
const streamer: parser.TokenConsumer<{ readonly value: unknown }> =
member.mode === "array" ? streamArray() : streamValues();
const consume = fun(
(token: parser.Token) => admit(token),
streamer,
(row: unknown): typeof none | Promise<typeof none> => {
if (typeof row !== "object" || row === null || !("value" in row))
throw new Error("Unexpected JSON member parser result");
// The preceding array item has left the assembler; retain only what the
// consumer owns and use live headroom when admitting the next item.
admit = admitJsonAssembly(operation, size, path);
try {
const accepted = member.accept(row.value);
if (accepted instanceof Promise)
return accepted.then(
() => none,
(cause: unknown) => {
member.reject(cause);
return none;
},
);
} catch (cause: unknown) {
member.reject(cause);
}
return none;
},
);
return (token: parser.Token): typeof none | Promise<typeof none> => {
const consumed = consume(token);
return consumed instanceof Promise ? consumed.then(() => none) : none;
};
};
/** Parse strict UTF-8 JSON without requiring a whole-document string. */
export const readJsonInputFile = (
path: string,
operation: string,
signal?: AbortSignal,
resources?: ArtifactResourceScope,
snapshot = false,
): Promise<Result<unknown, JsonInputFileFailure>> =>
withJsonInputFile(
path,
async (handle, stats) => {
let found = false;
let value: unknown;
const physicalRead: JsonInputPhysicalReadObservation = {};
try {
const native = await readNativeJson(
handle,
stats.size,
path,
operation,
signal,
snapshot,
physicalRead,
);
if (native !== undefined)
return native.ok && snapshot
? await sealOwnedJsonInput(native.value, operation, signal)
: native;
await withJsonInputPipeline(signal, (pipelineSignal) =>
pipeline(
decodedChunks(
handle,
stats.size,
path,
signal,
undefined,
physicalRead,
),
// Fuse synchronous token stages so only file chunks cross the async
// stream boundary. Admission still precedes scalar/value assembly.
asStream(
fun(
parser({
jsonStreaming: false,
streamValues: false,
packKeys: false,
packStrings: false,
packNumbers: false,
}),
packJsonScalars(operation, stats.size, path),
admitJsonAssembly(
operation,
stats.size,
path,
snapshot ? 2 : 1,
),
streamValues(),
(row: unknown): typeof none => {
if (
found ||
typeof row !== "object" ||
row === null ||
!("key" in row) ||
row.key !== 0 ||
!("value" in row)
)
throw new Error("Unexpected JSON parser result");
found = true;
value = row.value;
return none;
},
),
),
pipelineSignal === undefined ? {} : { signal: pipelineSignal },
),
);
return found
? snapshot
? await sealOwnedJsonInput(value, operation, signal)
: ok(value)
: err(invalidJson(operation, "JSON document is empty"));
} catch (cause: unknown) {
if (physicalRead.failure !== undefined) throw physicalRead.failure;
const failure = jsonReadFailure(cause, operation, stats.size, path);
if (failure !== undefined) return err(failure);
throw cause;
}
},
{
signal,
...(resources === undefined ? {} : { resources }),
},
);
const jsonReadFailure = (
cause: unknown,
operation: string,
size: number,
path: string,
): JsonInputFileFailure | undefined => {
if (cause instanceof AnalysisResourceConstraintError) return cause;
if (
cause instanceof TypeError &&
"code" in cause &&
cause.code === "ERR_ENCODING_INVALID_ENCODED_DATA"
)
return invalidJson(operation, "JSON input is not valid UTF-8", cause);
if (
cause instanceof SyntaxError ||
(cause instanceof Error && cause.message.startsWith("Parser "))
)
return invalidJson(operation, cause.message, cause);
if (
cause instanceof Error &&
((cause instanceof RangeError &&
cause.message === "Invalid string length") ||
("code" in cause && cause.code === "ERR_STRING_TOO_LONG"))
)
return new AnalysisResourceConstraintError(
operation,
"memory",
"An individual JSON string or number token exceeds the runtime string limit",
{
input_path: path,
input_file_bytes: size,
max_string_code_units: constants.MAX_STRING_LENGTH,
},
{
cause,
remediationAction: JSON_INPUT_RESOURCE_REMEDIATION,
},
);
return undefined;
};
const packJsonScalars = (
operation: string,
inputFileBytes: number,
path: string,
) => {
let kind: "keyValue" | "stringValue" | "numberValue" | undefined;
let length = 0;
let value = "";
let parts: string[] = [];
let partLength = 0;
return (token: parser.Token): parser.Token | typeof none => {
if (typeof token !== "object" || token === null || !("name" in token))
throw new Error("Unexpected JSON parser token");
switch (token.name) {
case "startKey":
case "startString":
kind = token.name === "startKey" ? "keyValue" : "stringValue";
break;
case "startNumber":
kind = "numberValue";
break;
case "numberChunk":
case "stringChunk":
if (
kind === undefined ||
!("value" in token) ||
typeof token.value !== "string"
)
throw new Error("Unexpected JSON scalar fragment");
length += token.value.length;
if (length > constants.MAX_STRING_LENGTH)
throw new RangeError("Invalid string length");
parts.push(token.value);
partLength += token.value.length;
if (partLength >= PACKED_TOKEN_PART_CODE_UNITS) {
// The tokenizer emits short fragments. Coalesce them before retaining
// a scalar so millions of substring/rope nodes cannot exhaust the heap
// before the native string-length constraint can be reported.
requireStringAssemblyHeadroom(
operation,
inputFileBytes,
path,
length,
);
value += parts.join("");
parts = [];
partLength = 0;
}
break;
case "endKey":
case "endString":
case "endNumber": {
if (kind === undefined) throw new Error("Unexpected JSON scalar end");
requireStringAssemblyHeadroom(operation, inputFileBytes, path, length);
const packed = { name: kind, value: value + parts.join("") };
kind = undefined;
length = 0;
value = "";
parts = [];
partLength = 0;
return packed;
}
default:
return token;
}
return none;
};
};
/** Admit the retained value graph before the upstream assembler grows it. */
const admitJsonAssembly = (
operation: string,
inputFileBytes: number,
path: string,
retainedMultiplier = 1,
) => {
let projectedBytes = 0;
let nextCheck = 0;
return (token: parser.Token): parser.Token => {
if (typeof token !== "object" || token === null || !("name" in token))
throw new Error("Unexpected JSON assembly token");
if (token.name === "startObject" || token.name === "startArray") {
projectedBytes += 128;
} else if (token.name === "keyValue" || token.name === "stringValue") {
if (!("value" in token) || typeof token.value !== "string")
throw new Error("Unexpected packed JSON string");
projectedBytes += 64 + token.value.length * 2;
} else if (
token.name === "numberValue" ||
token.name === "nullValue" ||
token.name === "trueValue" ||
token.name === "falseValue"
) {
projectedBytes += 64;
}
if (projectedBytes >= nextCheck) {
const heap = getHeapStatistics();
// Conservative capacity for containers, property/array slots and UTF-16
// values. Keep another whole projection available for container growth,
// plus the next chunk of estimates between samples. This is admission
// against live runtime headroom, not a fixed document-size policy.
const requiredBytes =
projectedBytes * retainedMultiplier + READ_CHUNK_BYTES;
if (requiredBytes > heap.total_available_size)
throw new AnalysisResourceConstraintError(
operation,
"memory",
"Insufficient heap headroom to materialize the complete JSON value",
{
input_path: path,
input_file_bytes: inputFileBytes,
projected_value_bytes: projectedBytes,
assembly_headroom_bytes: requiredBytes,
available_heap_bytes: heap.total_available_size,
heap_size_limit_bytes: heap.heap_size_limit,
},
{ remediationAction: JSON_INPUT_RESOURCE_REMEDIATION },
);
nextCheck = projectedBytes + READ_CHUNK_BYTES;
}
return token;
};
};
const requireStringAssemblyHeadroom = (
operation: string,
inputFileBytes: number,
path: string,
stringCodeUnits: number,
): void => {
if (stringCodeUnits < PACKED_TOKEN_PART_CODE_UNITS) return;
const heap = getHeapStatistics();
// Concatenation or key interning may flatten the retained rope. Reserve its
// UTF-16 representation and the next coalesced part before that allocation.
const requiredBytes = 2 * (stringCodeUnits + PACKED_TOKEN_PART_CODE_UNITS);
if (requiredBytes > heap.total_available_size)
throw new AnalysisResourceConstraintError(
operation,
"memory",
"Insufficient heap headroom to assemble a complete JSON string",
{
input_path: path,
input_file_bytes: inputFileBytes,
string_code_units: stringCodeUnits,
string_assembly_headroom_bytes: requiredBytes,
available_heap_bytes: heap.total_available_size,
heap_size_limit_bytes: heap.heap_size_limit,
},
{
remediationAction: JSON_INPUT_RESOURCE_REMEDIATION,
},
);
};
async function* decodedChunks(
handle: FileHandle,
admittedBytes: number,
path: string,
signal: AbortSignal | undefined,
observe?: (bytes: Uint8Array) => void,
physicalRead: JsonInputPhysicalReadObservation = {},
): AsyncGenerator<string> {
const decoder = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true });
let leading = true;
const checked = (text: string): string => {
if (leading && text.length > 0) {
leading = false;
if (text.startsWith("\uFEFF")) throw new JsonByteOrderMarkError();
}
return text;
};
const bytes = Buffer.allocUnsafe(READ_CHUNK_BYTES);
let observedBytes = 0;
while (true) {
assertRegularFileActive(signal);
const read = await readPhysicalJsonInput(physicalRead, () =>
handle.read(
bytes,
0,
Math.min(bytes.length, admittedBytes - observedBytes + 1),
observedBytes,
),
);
assertRegularFileActive(signal);
if (read.bytesRead === 0) break;
observedBytes += read.bytesRead;
if (observedBytes > admittedBytes) throw new RegularFileChangedError(path);
const chunk = bytes.subarray(0, read.bytesRead);
observe?.(chunk);
yield checked(decoder.decode(chunk, { stream: true }));
}
if (observedBytes !== admittedBytes) throw new RegularFileChangedError(path);
yield checked(decoder.decode());
}
/** A streamed JSON file begins with a byte-order mark; reported as invalid JSON. */
class JsonByteOrderMarkError extends SyntaxError {
constructor() {
super(JSON_BYTE_ORDER_MARK_MESSAGE);
}
}
const readPrefix = async (
handle: FileHandle,
size: number,
signal: AbortSignal | undefined,
physicalRead: JsonInputPhysicalReadObservation,
): Promise<{ readonly bytes: Buffer; readonly complete: boolean }> => {
const bytes = Buffer.allocUnsafe(size);
let offset = 0;
while (offset < bytes.length) {
assertRegularFileActive(signal);
const read = await readPhysicalJsonInput(physicalRead, () =>
handle.read(bytes, offset, bytes.length - offset, null),
);
assertRegularFileActive(signal);
if (read.bytesRead === 0)
return { bytes: bytes.subarray(0, offset), complete: true };
offset += read.bytesRead;
}
// One byte beyond the admitted extent proves the selected file changed.
return { bytes, complete: false };
};
const readNativeJson = async (
handle: FileHandle,
size: number,
path: string,
operation: string,
signal?: AbortSignal,
snapshot = false,
physicalRead: JsonInputPhysicalReadObservation = {},
): Promise<Result<unknown, JsonInputFileFailure> | undefined> => {
if (!nativeParseFits(size, snapshot)) return undefined;
const prefix = await readPrefix(handle, size + 1, signal, physicalRead);
if (!prefix.complete || prefix.bytes.length !== size)
throw new RegularFileChangedError(path);
const parsed = parseUtf8Json(
prefix.bytes,
operation,
path,
"json-file-input",
);
return parsed.ok
? ok(parsed.value)
: err(invalidJson(operation, parsed.error, parsed.cause));
};
const invalidJson = (
operation: string,
message: string,
cause?: unknown,
): AnalysisInputError =>
new AnalysisInputError(
operation,
cause === undefined ? undefined : { cause },
[{ path: [], reason: "invalid_format", expected: "JSON", message }],
);