fix(javascript): keep static facts after semantic results use the parent budget (#8648)

Static checkpoints were admitted against the same budget as semantic and
application results, half of the parent's free heap. Once those charges used
it, the next checkpoint stopped the analysis and every remaining source lost
its static facts, with most of the parent heap still free.

Semantic and application results still stop at that budget. Static checkpoints
may now continue to three quarters of the free heap while the live heap also
has room for another copy of what it holds. Without that room, admission falls
back to the result budget. The live condition is needed because the limit V8
reports counts young-generation capacity that retained objects cannot use:
without it, the parent aborted on heap exhaustion at small heap limits.

Reserved relationships are given up only when the larger share runs short. A
stop still fails with a typed error, which now reports the limit that applied,
what was available, the live heap in use and what the checkpoint needed.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kaoru0822andClaude Opus 5.5 authored and GitHub committed 2026-11-02 05:16:16 -08:00
1 parent 6a0ccf7eeb
commit 4a01211f11
4 files changed
+332 -59

No files matched your search

@@ -83,6 +83,12 @@ const OPERATION = "analyze_javascript_application";
const PROVIDER = "javascript-static-analysis";
const MIB = 1024 * 1024;
const STATIC_CHECKPOINT_RETENTION_EXPANSION = 6;
/**
* Share of the parent's free heap that static checkpoints may fill once the
* result budget is used. Unlike the live heap, the charges also cover application
* relationships that are reserved but not yet materialized.
*/
const STATIC_CAPACITY_SHARE = 0.75;
/** The worker runs on this executable; Bun accepts but ignores V8 heap flags. */
const WORKER_HEAP_FLAGS_ENFORCED =
javaScriptRuntime(process.versions).id === "node";
@@ -130,10 +136,11 @@ export class JavaScriptAnalysisWorker {
#revokedStaticProjections: JavaScriptRevokedStaticProjection[] = [];
#nextSourceIndex = 0;
#applicationProjectionFailure: AnalysisResourceConstraintError | undefined;
#parentCapacityExhausted = false;
#parentCapacityExhaustion: Readonly<Record<string, number>> | undefined;
#sourcesRemaining: number | undefined;
#deadline: ProviderStartupDeadline | undefined;
#resultBudgetBytes = 0;
#staticCapacityBytes = 0;
/** Null when the runtime enforces the V8 heap flags instead. */
readonly #processMemoryLimitBytes: number | null;
readonly #onStdout = (chunk: Buffer | string): void => {
@@ -248,7 +255,7 @@ export class JavaScriptAnalysisWorker {
this.#staticProjectionReservations = [];
this.#revokedStaticProjections = [];
this.#nextSourceIndex = 0;
this.#parentCapacityExhausted = false;
this.#parentCapacityExhaustion = undefined;
this.#applicationProjectionFailure = undefined;
this.#refreshResultBudget();
return true;
@@ -262,17 +269,20 @@ export class JavaScriptAnalysisWorker {
/**
* Reserve host-side control, schema/Evidence work, and a maximum record's
* transient copies. Admission also accounts for retained earlier files.
* transient copies. Everything retained is charged against what is left.
*
* Semantic and application results stop at half of it. Static checkpoints
* may go on to a larger share while the live heap has room for them, so a
* charge for earlier results cannot cost a later source its static facts.
*/
#refreshResultBudget(): void {
this.#resultBudgetBytes = Math.max(
const capacity = Math.max(
0,
Math.floor(
(this.#remainingParentHeapBytes() -
24 * JAVASCRIPT_ANALYSIS_TRANSFER_RECORD_BYTES) /
2,
),
this.#remainingParentHeapBytes() -
24 * JAVASCRIPT_ANALYSIS_TRANSFER_RECORD_BYTES,
);
this.#resultBudgetBytes = Math.floor(capacity / 2);
this.#staticCapacityBytes = Math.floor(capacity * STATIC_CAPACITY_SHARE);
}
/** Analyze inert source; hard worker failure does not terminate this process. */
@@ -290,7 +300,7 @@ export class JavaScriptAnalysisWorker {
const id = randomUUID();
const sourceIndex = this.#nextSourceIndex++;
this.#applicationProjectionFailure = undefined;
this.#parentCapacityExhausted = false;
this.#parentCapacityExhaustion = undefined;
this.#revokedStaticProjections = [];
if (this.#sourcesRemaining !== undefined)
this.#sourcesRemaining = Math.max(0, this.#sourcesRemaining - 1);
@@ -388,20 +398,8 @@ export class JavaScriptAnalysisWorker {
sourceIndex,
response,
);
if (this.#parentCapacityExhausted)
throw new AnalysisResourceConstraintError(
OPERATION,
"memory",
`The parent's result capacity was exhausted after retaining static facts for ${file.path}; remaining sources were not analyzed`,
{
...this.#limits(),
parent_result_budget_bytes: this.#resultBudgetBytes,
parent_result_budget_exhausted: true,
},
{
remediationAction: JAVASCRIPT_PARENT_RESULT_BUDGET_REMEDIATION,
},
);
const exhausted = this.#parentCapacityFailure(file.path);
if (exhausted !== undefined) throw exhausted;
offeredResultBudget =
this.#applicationProjectionFailure !== undefined &&
this.#sourcesRemaining !== undefined &&
@@ -799,11 +797,21 @@ export class JavaScriptAnalysisWorker {
// Application relationships are materialized only after all source files
// have been examined. Reclaim their pending reservations to keep room for
// later static observations, without relaxing the actual retained bound.
const needed =
retainedBytes - (this.#resultBudgetBytes - this.#estimatedRetainedBytes);
const { heap_size_limit, used_heap_size } = getHeapStatistics();
const liveAvailable =
this.#remainingParentHeapBytes() -
heap_size_limit -
used_heap_size -
24 * JAVASCRIPT_ANALYSIS_TRANSFER_RECORD_BYTES;
// Past the result budget, the live heap must also keep room for another
// copy of what it holds, which final graph and Evidence work needs. The
// limit read here counts young-generation capacity that retained objects
// cannot use, so being merely below it does not protect the parent.
const staticCeiling =
retainedBytes + used_heap_size <= liveAvailable
? this.#staticCapacityBytes
: this.#resultBudgetBytes;
const needed =
retainedBytes - (staticCeiling - this.#estimatedRetainedBytes);
if (needed > 0 && retainedBytes <= liveAvailable) {
// Plan all revocations first so an insufficient set leaves the earlier
// relationships intact rather than sacrificing them for a failed read.
@@ -836,12 +844,17 @@ export class JavaScriptAnalysisWorker {
// admission so a second read cannot reject after revocations commit.
const remaining = Math.max(
0,
Math.min(
this.#resultBudgetBytes - this.#estimatedRetainedBytes,
liveAvailable,
),
Math.min(staticCeiling - this.#estimatedRetainedBytes, liveAvailable),
);
this.#parentCapacityExhausted = retainedBytes > remaining;
this.#parentCapacityExhaustion =
retainedBytes > remaining
? {
parent_static_capacity_bytes: staticCeiling,
parent_available_static_capacity_bytes: remaining,
parent_used_heap_bytes: used_heap_size,
static_retention_estimate_bytes: retainedBytes,
}
: undefined;
this.#estimatedRetainedBytes += retainedBytes;
const expandedBytes = estimateJavaScriptStaticApplicationProjection(
facts,
@@ -873,6 +886,26 @@ export class JavaScriptAnalysisWorker {
return facts;
}
#parentCapacityFailure(
path: string,
): AnalysisResourceConstraintError | undefined {
if (this.#parentCapacityExhaustion === undefined) return undefined;
return new AnalysisResourceConstraintError(
OPERATION,
"memory",
`The parent's result capacity was exhausted after retaining static facts for ${path}; remaining sources were not analyzed`,
{
...this.#limits(),
parent_result_budget_bytes: this.#resultBudgetBytes,
...this.#parentCapacityExhaustion,
parent_result_budget_exhausted: true,
},
{
remediationAction: JAVASCRIPT_PARENT_RESULT_BUDGET_REMEDIATION,
},
);
}
#limits() {
return {
...(WORKER_HEAP_FLAGS_ENFORCED