fix(javascript): match resource release methods to the acquired handle (#7773)

* fix(javascript): match resource release methods to the acquired handle

Every acquisition family admitted every release method, so `h.close()`,
`h.destroy()` and `h.end()` each resolved as a complete release of whatever the
binding held. Node exposes a different subset per acquisition, and the rejected
pairs throw at the call site instead.

Measured on Node 24.22.1:

| acquisition | value | release members |
| --- | --- | --- |
| `fs.openSync` | number | none |
| `fs.open` (callback) | undefined | none |
| `fs.createReadStream` | ReadStream | `close`, `destroy` (`end` is the byte-offset field) |
| `fs.createWriteStream` | WriteStream | `close`, `destroy`, `end` |
| `fs/promises.open` | FileHandle | `close` |
| `net.connect` | Socket | `destroy`, `end` |
| `tls.connect` | TLSSocket | `destroy`, `end` |

So `fs.openSync(...)` + `fd.close()`, `createReadStream(...)` + `.end()`,
`net.connect(...)` + `.close()` and the FileHandle cases emitted a `releases` /
`resolved` fact for a call that throws.

Carry the specifier with each acquisition candidate, list the release methods
each pair actually has, and emit no release when the pair is not one of them. A
release stays `complete` only when the binding held exactly one acquisition and
that acquisition admitted the method.

`tls.createConnection` is not a Node export at all: `'createConnection' in
require('node:tls')` is false and the ESM import does not link. Drop it from the
table so a `tls.createConnection` call yields no acquisition.

* chore: retrigger title and readiness checks after the title was corrected

* fix(javascript): cover a mixed-family binding before completing a release

greptile asked for the mixed-family case: a binding that can hold either a
FileHandle or a socket, then calls close(). The FileHandle is the only linked
acquisition and the release stays partial.

That case also showed the `candidates.length === 1` guard was dead: an
acquisition of another family is not in `linked`, so `holdsOnlyAcquisitions`
already returns false for it and the release already stayed partial without the
guard. Removing the guard and keeping the test means the behaviour is now pinned
by a test rather than by two overlapping conditions.
This commit is contained in:
吴佳浩Alben authored and GitHub committed 2026-10-29 08:23:53 +08:00
1 parent a965cbab06
commit b7442c1958
3 files changed
+196 -8

No files matched your search

@@ -3,6 +3,7 @@ import * as t from "@babel/types";
import {
BUILTIN_RESOURCE_SPECIFIERS,
BUILTIN_RESOURCE_METHODS,
BUILTIN_RESOURCE_RELEASE_METHODS,
} from "./javascriptBuiltinResourceMethods.js";
import type {
JavaScriptSemanticCallable,
@@ -29,8 +30,23 @@ import { range } from "./javascriptStaticAnalysisHelpers.js";
const RELEASE_METHODS = ["close", "destroy", "end"] as const;
/**
* Release methods the value from this acquisition actually has. An unlisted
* specifier/method pair admits none, so a call to it emits no release fact.
*/
const releaseMethodsFor = (
acquisition: ResourceCandidate,
): readonly ("close" | "destroy" | "end")[] => {
const key = `${acquisition.specifier}:${acquisition.method}`;
const table: Readonly<
Record<string, readonly ("close" | "destroy" | "end")[]>
> = BUILTIN_RESOURCE_RELEASE_METHODS;
return table[key] ?? [];
};
interface ResourceCandidate {
readonly node: t.CallExpression | t.OptionalCallExpression;
readonly specifier: string;
readonly method: JavaScriptSemanticResourceOperation["method"];
readonly ownerCallableId: string | null;
readonly resultBindingId: string | null;
@@ -62,11 +78,12 @@ const collectAcquisitions = (
const output: ResourceCandidate[] = [];
traverseDataEffects(program, context, (node) => {
if (!t.isCallExpression(node) && !t.isOptionalCallExpression(node)) return;
const method = acquisitionMethod(node.callee, context.state);
if (method === null) return;
const reference = acquisitionMethod(node.callee, context.state);
if (reference === null) return;
output.push({
node,
method,
specifier: reference.specifier,
method: reference.method,
ownerCallableId: context.callableStack.at(-1) ?? null,
resultBindingId: outerDataEffectBinding(node, context),
});
@@ -102,9 +119,15 @@ const collectReleases = (
member.object,
member.object.name,
);
const linked =
const candidates =
binding === undefined ? [] : (byBinding.get(binding.bindingId) ?? []);
if (binding === undefined || linked.length === 0) return;
if (binding === undefined || candidates.length === 0) return;
const linked = candidates.filter((acquisition) =>
releaseMethodsFor(acquisition).includes(method),
);
// No linked acquisition exposes this release method, so the call is not a
// release of it at all: Node throws at the call site instead.
if (linked.length === 0) return;
output.push({
resourceId: `resource:release:${String(node.start ?? -1)}:${String(node.end ?? -1)}`,
kind: "release",
@@ -169,10 +192,15 @@ const immutableAcquisition = (
resolution: "complete",
});
interface AcquisitionReference {
readonly specifier: string;
readonly method: JavaScriptSemanticResourceOperation["method"];
}
const acquisitionMethod = (
callee: t.Node,
state: JavaScriptSemanticAnalysisState,
): JavaScriptSemanticResourceOperation["method"] | null => {
): AcquisitionReference | null => {
for (const specifier of BUILTIN_RESOURCE_SPECIFIERS) {
const method = builtinDataEffectMethod(callee, state, [specifier]);
if (
@@ -183,7 +211,10 @@ const acquisitionMethod = (
] as readonly string[]
).includes(method)
)
return method as JavaScriptSemanticResourceOperation["method"];
return {
specifier,
method: method as JavaScriptSemanticResourceOperation["method"],
};
}
return null;
};