fix(javascript): keep releases through a replaced handle as candidates (#3897)

A release call was linked to its acquisition through the receiver's lexical
binding alone, so `stream = { destroy() {} }; stream.destroy()` still
produced a resolved `releases` edge to the original stream and entered the
default ownership trace.

A release is now complete only when every value written to the handle
binding is the linked acquisition itself (through parentheses, TypeScript
wrappers or `await`) or a nullish placeholder such as `let h = null`, which
cannot be released. Any other write, conditional replacement, or
destructuring keeps the linked release as a candidate (partial).

Fixes #3313

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kaoru0822
2026-10-14 23:40:14 -07:00
committed by GitHub
parent ab45bf641e
commit fc712b3750
2 changed files with 104 additions and 4 deletions
@@ -0,0 +1,59 @@
import { expect, it } from "vitest";
import { analyzeJavaScriptSemantics } from "./javascriptSemanticAnalysis.js";
const releases = (source: string) =>
analyzeJavaScriptSemantics(source)
.resourceOperations.filter(({ kind }) => kind === "release")
.map(({ method, linkedResourceIds, resolution }) => ({
method,
linked: linkedResourceIds.length,
resolution,
}));
it.each([
[
"a declaration",
`import { createReadStream } from "node:fs";
const stream = createReadStream("input.txt");`,
],
[
"a later assignment",
`import { createReadStream } from "node:fs";
let stream;
stream = createReadStream("input.txt");`,
],
[
"an awaited handle",
`import { open } from "node:fs/promises";
const stream = await open("input.txt");`,
],
[
"a nullish placeholder before the acquisition",
`import { open } from "node:fs/promises";
let stream = null;
try { stream = await open("input.txt"); } finally {}`,
],
])(
"keeps a release complete when the handle holds only %s",
(_name, acquire) => {
expect(releases(`${acquire}\nstream.close();`)).toEqual([
{ method: "close", linked: 1, resolution: "complete" },
]);
},
);
it.each([
["an unconditional replacement", "stream = { destroy() {} };"],
["a conditional replacement", "if (flag) stream = { destroy() {} };"],
["a destructuring replacement", "({ stream } = other);"],
])("keeps a release partial after %s", (_name, replacement) => {
expect(
releases(`
import { createReadStream } from "node:fs";
let stream = createReadStream("input.txt");
${replacement}
stream.destroy();
`),
).toEqual([{ method: "destroy", linked: 1, resolution: "partial" }]);
});
@@ -8,7 +8,11 @@ import type {
JavaScriptSemanticCallable,
JavaScriptSemanticResourceOperation,
} from "./javascriptSemanticIr.js";
import { semanticStaticPropertyName } from "./javascriptAstValues.js";
import {
semanticStaticPropertyName,
unwrapJavaScriptExpression,
} from "./javascriptAstValues.js";
import { isUnmodifiedGlobalReference } from "./javascriptSemanticAmbientWrites.js";
import {
resolveSemanticBindingState,
type JavaScriptSemanticAnalysisState,
@@ -100,21 +104,58 @@ const collectReleases = (
);
const linked =
binding === undefined ? [] : (byBinding.get(binding.bindingId) ?? []);
if (linked.length === 0) return;
if (binding === undefined || linked.length === 0) return;
output.push({
resourceId: `resource:release:${String(node.start ?? -1)}:${String(node.end ?? -1)}`,
kind: "release",
method,
location: range(node),
ownerCallableId: context.callableStack.at(-1) ?? null,
resultBindingId: binding?.bindingId ?? null,
resultBindingId: binding.bindingId,
linkedResourceIds: linked.map(resourceCandidateId).sort(),
resolution: linked.length === 1 ? "complete" : "partial",
resolution:
linked.length === 1 &&
holdsOnlyAcquisitions(binding, linked, context.state)
? "complete"
: "partial",
});
});
return output;
};
/**
* A lexical handle identifies its acquisition only while every value written
* to the binding is that acquisition itself. Any replacement, even a
* conditional or later one, leaves the released resource a candidate. A
* nullish placeholder, as in `let h = null; try { h = open() } finally {
* h?.close() }`, releases nothing else: the call is skipped or throws.
*/
const holdsOnlyAcquisitions = (
binding: JavaScriptSemanticBindingState,
linked: readonly ResourceCandidate[],
state: JavaScriptSemanticAnalysisState,
): boolean =>
[...binding.initializers, ...binding.referenceInitializers].every(
({ node, projection }) => {
if (projection.length > 0) return false;
let value = unwrapJavaScriptExpression(node).node;
if (t.isAwaitExpression(value))
value = unwrapJavaScriptExpression(value.argument).node;
return (
isNullishPlaceholder(value, state) ||
linked.some((acquisition) => acquisition.node === value)
);
},
);
const isNullishPlaceholder = (
value: t.Node,
state: JavaScriptSemanticAnalysisState,
): boolean =>
t.isNullLiteral(value) ||
t.isUnaryExpression(value, { operator: "void" }) ||
isUnmodifiedGlobalReference(value, state, "undefined");
const immutableAcquisition = (
candidate: ResourceCandidate,
): JavaScriptSemanticResourceOperation => ({