Skip to content

Commit 4b0c1ed

Browse files
committed
JS: Reuse API instance and other changes
1 parent 6fa636b commit 4b0c1ed

4 files changed

Lines changed: 193 additions & 73 deletions

File tree

‎javascript/extractor/lib/typescript/README.md‎

Lines changed: 62 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -59,12 +59,12 @@ The Bazel build produces two archives: `:typescript` (`javascript.zip`) contains
5959
the compiled ESM wrapper and JavaScript API; `:native` (`native.zip`) contains the
6060
matching native packages for the target CodeQL distribution:
6161

62-
| Distribution | Native packages |
63-
| --- | --- |
64-
| `linux64` | `@typescript/typescript-linux-x64` |
65-
| `linux-arm64` | `@typescript/typescript-linux-arm64` |
66-
| `osx64` | `@typescript/typescript-darwin-arm64` and `@typescript/typescript-darwin-x64` |
67-
| `win64` | `@typescript/typescript-win32-x64` |
62+
| Distribution | Native packages |
63+
| ------------- | ----------------------------------------------------------------------------- |
64+
| `linux64` | `@typescript/typescript-linux-x64` |
65+
| `linux-arm64` | `@typescript/typescript-linux-arm64` |
66+
| `osx64` | `@typescript/typescript-darwin-arm64` and `@typescript/typescript-darwin-x64` |
67+
| `win64` | `@typescript/typescript-win32-x64` |
6868

6969
The build installs the selected packages explicitly with `--force --no-save`,
7070
using the pinned versions and integrities in the lockfile. Merely passing
@@ -97,12 +97,13 @@ Linux arm64, and Windows x64, including Windows 2022.
9797
Native lifecycle and memory
9898
---------------------------
9999

100-
Each file owns a fresh native API/process. AST projection and JSON serialization
101-
finish before the program is disposed and the API is closed, including on
102-
failure. No native process or compiler cache is retained between files, during
103-
stdout backpressure, or across reset/quit. This deliberately favors bounded
104-
resource lifetime over reusing a compiler process; the measured cost is described
105-
below.
100+
A single native API/process is created lazily and reused across files and
101+
prepare-files batches. Each file still owns a separate program: AST projection
102+
and JSON serialization finish before the program is disposed, including on
103+
failure. Program disposal releases its snapshot and reference-counted source-file
104+
caches without restarting the compiler. Reset closes the API, and the next parse
105+
creates a new one. Quit, EOF, failure, and completion of single-file mode also
106+
close the API.
106107

107108
`SEMMLE_TYPESCRIPT_MEMORY_THRESHOLD` remains a positive megabyte budget, defaulting
108109
to 1000 when the Java extractor does not supply it. At native-process creation,
@@ -111,9 +112,9 @@ and passes the remaining bytes, with a minimum of one, as the child's
111112
`GOMEMLIMIT`. The parent's original `GOMEMLIMIT` is restored after spawning.
112113
This is a Go garbage-collector soft limit, not a hard combined RSS ceiling:
113114
Node allocations during extraction, native non-Go allocations, and oversized
114-
individual files can exceed the budget. Closing the native process after every
115-
file prevents native caches and heap capacity from accumulating across files.
116-
Node RSS exceeding the budget produces a warning on stderr.
115+
individual files can exceed the budget. The child's budget is fixed until the
116+
API is closed; later changes to Node RSS do not resize it. Node RSS exceeding
117+
the budget at native-process creation produces a warning on stderr.
117118

118119
Native stderr is inherited separately from the JSON stdout protocol. Syntax
119120
diagnostics remain successful AST responses; native, I/O, and protocol failures
@@ -139,7 +140,7 @@ spelling. Namespace classification and variable declaration kinds are derived
139140
in Java without synthetic flags or properties.
140141

141142
The tests cover AST projection, metadata, UTF-16 source positions, syntax
142-
diagnostics, TSX, repeated parses, native process disposal, the child memory
143+
diagnostics, TSX, repeated parses, native process reuse and disposal, the child memory
143144
budget, reset/quit/EOF, cancelled prefetch, runtime overrides, and fatal native
144145
failures. Run them with Node.js 16.20.0 to verify the minimum supported runtime.
145146

@@ -193,19 +194,56 @@ codeql test run -j4 --search-path=javascript/extractor-pack:. \
193194
Performance comparison
194195
----------------------
195196

196-
On macOS arm64 with Node.js 18.15.0, a comparison against the pre-migration
197-
wrapper from `1971c368e1d` compiled with TypeScript 5.9.2 gave these medians over three
198-
runs:
197+
The current shared-API implementation was compared with refreshed `main` at
198+
`fb0ba8c8a8a` on October 21, 2026. Both ran on macOS arm64 with Node.js 18.15.0;
199+
`main` was built and run with TypeScript 5.9.2, and this branch used the pinned
200+
7.1 native compiler. These are medians of five fresh-process runs after one
201+
warm-up per variant and workload, alternating variant order:
199202

200-
| Workload | 5.9.2 time | 7.1 time | 5.9.2 combined RSS | 7.1 combined RSS |
203+
| Workload | Main time | Branch time | Main combined RSS | Branch combined RSS |
201204
| --- | --- | --- | --- | --- |
202-
| 103 TypeScript fixtures (33,589 bytes) | 0.237 s | 1.349 s | 124.1 MiB | 113.9 MiB |
203-
| Five parses of TypeScript's 588,128-byte declaration file | 0.991 s | 1.116 s | 269.3 MiB | 249.1 MiB |
205+
| 104 TypeScript fixtures (33,779 bytes) | 0.081 s | 0.120 s | 105.5 MiB | 110.4 MiB |
206+
| Five parses of TypeScript's 588,128-byte declaration file | 0.886 s | 0.992 s | 263.7 MiB | 247.2 MiB |
207+
208+
The branch takes 47.3% more time on the fixtures and 11.9% more on the declaration
209+
workload. Sampled combined RSS is 4.7% higher and 6.3% lower, respectively.
210+
Both variants parse identical inputs. Timing starts after the metadata handshake
211+
and includes prepare-files, AST extraction, serialization, and protocol responses,
212+
including the branch's first native-process startup. It excludes Java conversion
213+
and database import. RSS is the maximum sampled sum of Node and its native
214+
descendants, sampled at a nominal 5 ms interval; short-lived peaks may be missed.
215+
Current `main` includes parser cleanup absent from the older baseline below.
216+
217+
### Historical and API-lifetime comparisons
218+
219+
The initial implementation created a native process per file. On macOS arm64
220+
with Node.js 18.15.0, a comparison against the pre-migration wrapper from
221+
`1971c368e1d` compiled with TypeScript 5.9.2 gave these medians over three runs:
222+
223+
| Workload | 5.9.2 time | 7.1 time | 5.9.2 combined RSS | 7.1 combined RSS |
224+
| --------------------------------------------------------- | ---------- | -------- | ------------------ | ---------------- |
225+
| 103 TypeScript fixtures (33,589 bytes) | 0.237 s | 1.349 s | 124.1 MiB | 113.9 MiB |
226+
| Five parses of TypeScript's 588,128-byte declaration file | 0.991 s | 1.116 s | 269.3 MiB | 249.1 MiB |
204227

205228
Timing covers prepare-files, extraction, serialization, and protocol responses
206229
after the metadata handshake, not Java conversion or database import. Combined
207230
RSS is the maximum sampled sum of Node and its native descendants, sampled every
208231
5 ms; short-lived peaks may be missed. The per-file native process costs about
209232
5.7x on tiny files and 1.13x on the declaration workload, while measured memory is
210-
slightly lower. The bounded lifecycle is retained deliberately; this migration
211-
does not claim a speedup.
233+
slightly lower.
234+
235+
A follow-up comparison on October 21, 2026, using the same platform and Node.js
236+
version, measured the per-file and shared-API lifetimes directly (medians of three
237+
runs):
238+
239+
| Workload | Per-file API time | Shared API time | Per-file combined RSS | Shared combined RSS |
240+
| --- | --- | --- | --- | --- |
241+
| 104 TypeScript fixtures (33,779 bytes) | 1.017 s | 0.118 s | 115.8 MiB | 109.4 MiB |
242+
| Five parses of the same declaration file | 1.044 s | 0.985 s | 250.2 MiB | 260.2 MiB |
243+
244+
AST responses were byte-identical between lifetimes. A longer shared-API run on
245+
Node.js 26.7.0 processed 50,000 distinct generated TypeScript files: native RSS
246+
was 27.9 MiB after 1,000 files and 30.4 MiB after 50,000, while combined RSS
247+
stabilized around 176 MiB. Another run completed 500 parses of the declaration
248+
file with peak native RSS of 56.5 MiB. These workloads showed no unbounded
249+
per-file retention, so the wrapper reuses the API without a fixed batch limit.

‎javascript/extractor/lib/typescript/package-runtime.mjs‎

Lines changed: 30 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,35 +1,53 @@
1-
import { chmodSync, mkdirSync, readFileSync, renameSync, rmSync, statSync } from "node:fs";
1+
import {
2+
chmodSync,
3+
mkdirSync,
4+
readFileSync,
5+
renameSync,
6+
rmSync,
7+
statSync,
8+
} from "node:fs";
29
import { join } from "node:path";
310

411
const platforms = {
5-
"linux64": ["linux-x64"],
12+
linux64: ["linux-x64"],
613
"linux-arm64": ["linux-arm64"],
7-
"osx64": ["darwin-arm64", "darwin-x64"],
8-
"win64": ["win32-x64"],
14+
osx64: ["darwin-arm64", "darwin-x64"],
15+
win64: ["win32-x64"],
916
};
1017
const packagesOnly = process.argv[2] === "--packages";
1118
const distribution = process.argv[packagesOnly ? 3 : 2];
1219
if (!Object.hasOwn(platforms, distribution)) {
1320
throw new Error("Unsupported CodeQL distribution: " + distribution);
1421
}
15-
const readJson = filename => JSON.parse(readFileSync(filename, "utf8"));
22+
const readJson = (filename) => JSON.parse(readFileSync(filename, "utf8"));
1623
const typescript = readJson("node_modules/typescript/package.json");
1724
if (packagesOnly) {
18-
console.log(platforms[distribution]
19-
.map(platform => "@typescript/typescript-" + platform + "@" + typescript.version).join(" "));
25+
console.log(
26+
platforms[distribution]
27+
.map(
28+
(platform) =>
29+
"@typescript/typescript-" + platform + "@" + typescript.version,
30+
)
31+
.join(" "),
32+
);
2033
process.exit(0);
2134
}
2235
mkdirSync("native");
2336
for (const platform of platforms[distribution]) {
2437
const name = "typescript-" + platform;
2538
const source = join("node_modules", "@typescript", name);
2639
const manifest = readJson(join(source, "package.json"));
27-
if (manifest.name !== "@typescript/" + name
28-
|| manifest.version !== typescript.version
29-
|| typescript.optionalDependencies[manifest.name] !== manifest.version) {
30-
throw new Error("Native runtime does not match TypeScript: " + manifest.name);
40+
if (
41+
manifest.name !== "@typescript/" + name ||
42+
manifest.version !== typescript.version ||
43+
typescript.optionalDependencies[manifest.name] !== manifest.version
44+
) {
45+
throw new Error(
46+
"Native runtime does not match TypeScript: " + manifest.name,
47+
);
3148
}
32-
const executable = join(source, "lib", platform.startsWith("win32-") ? "tsc.exe" : "tsc");
49+
const executableName = platform.startsWith("win32-") ? "tsc.exe" : "tsc";
50+
const executable = join(source, "lib", executableName);
3351
if (!statSync(executable).isFile()) {
3452
throw new Error("Native executable is not a file: " + executable);
3553
}

‎javascript/extractor/lib/typescript/src/main.ts‎

Lines changed: 47 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,10 @@ let stopped = false;
3939
let readLineInterface: readline.Interface | undefined;
4040
let reportedMemoryPressure = false;
4141

42+
// The API instance used to interact with the TypeScript compiler. Should be
43+
// accessed through `getAPI` and `closeAPI`.
44+
let api: ts.API | undefined;
45+
4246
function createAPI(): ts.API {
4347
const threshold = Number(process.env.SEMMLE_TYPESCRIPT_MEMORY_THRESHOLD ?? 1000) * 1000000;
4448
if (!Number.isSafeInteger(threshold) || threshold <= 0) {
@@ -61,29 +65,34 @@ function createAPI(): ts.API {
6165
}
6266
}
6367

68+
function getAPI(): ts.API {
69+
return api ??= createAPI();
70+
}
71+
72+
function closeAPI() {
73+
const previous = api;
74+
api = undefined;
75+
previous?.close();
76+
}
77+
6478
function extractFile(filename: string): string {
65-
const api = createAPI();
79+
const program = getAPI().createProgram([pathlib.resolve(filename)], {
80+
experimentalDecorators: true,
81+
jsx: ts.JsxEmit.Preserve,
82+
noResolve: true,
83+
noLib: true,
84+
});
6685
try {
67-
const program = api.createProgram([pathlib.resolve(filename)], {
68-
experimentalDecorators: true,
69-
jsx: ts.JsxEmit.Preserve,
70-
noResolve: true,
71-
noLib: true,
72-
});
73-
try {
74-
const ast = program.getSourceFile(pathlib.resolve(filename));
75-
if (ast == null) {
76-
throw new Error("TypeScript did not return a source file for " + filename);
77-
}
78-
return JSON.stringify({
79-
type: "ast",
80-
ast: extractAst(ast, program.getSyntacticDiagnostics()),
81-
});
82-
} finally {
83-
program.dispose();
86+
const ast = program.getSourceFile(pathlib.resolve(filename));
87+
if (ast == null) {
88+
throw new Error("TypeScript did not return a source file for " + filename);
8489
}
90+
return JSON.stringify({
91+
type: "ast",
92+
ast: extractAst(ast, program.getSyntacticDiagnostics()),
93+
});
8594
} finally {
86-
api.close();
95+
program.dispose();
8796
}
8897
}
8998

@@ -97,10 +106,16 @@ function prepareNextFile() {
97106
function fail(error: unknown) {
98107
stopped = true;
99108
state = new State();
100-
readLineInterface?.close();
101-
process.stdin.destroy();
102109
console.error(error);
103110
process.exitCode = 1;
111+
try {
112+
closeAPI();
113+
} catch (closeError) {
114+
console.error(closeError);
115+
} finally {
116+
readLineInterface?.close();
117+
process.stdin.destroy();
118+
}
104119
}
105120

106121
function afterResponse(expectedState: State) {
@@ -139,6 +154,11 @@ function runReadLineInterface() {
139154
stopped = true;
140155
state = new State();
141156
process.stdin.destroy();
157+
try {
158+
closeAPI();
159+
} catch (error) {
160+
fail(error);
161+
}
142162
});
143163
rl.on("error", fail);
144164
rl.on("line", (line: string) => {
@@ -156,6 +176,7 @@ function runReadLineInterface() {
156176
break;
157177
case "reset":
158178
state = new State();
179+
closeAPI();
159180
console.log('{"type":"reset-done"}');
160181
break;
161182
case "get-metadata":
@@ -185,7 +206,11 @@ try {
185206
if (argument === "--version") {
186207
console.log("parser-wrapper with TypeScript " + ts.version);
187208
} else if (pathlib.extname(argument) === ".ts" || pathlib.extname(argument) === ".tsx") {
188-
process.stdout.write(extractFile(argument) + "\n");
209+
try {
210+
process.stdout.write(extractFile(argument) + "\n");
211+
} finally {
212+
closeAPI();
213+
}
189214
} else {
190215
console.error("Unrecognized file or flag: " + argument);
191216
process.exitCode = 1;

0 commit comments

Comments
 (0)