Skip to content

Commit 39be28b

Browse files
[api] Add timing collection and dump via environment variable (#64846)
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 9920adc3-3fc7-4d27-8ec1-c9c4490b9cf9
1 parent 0a27bad commit 39be28b

9 files changed

Lines changed: 286 additions & 27 deletions

File tree

‎packages/typescript/src/api/async/api.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -838,8 +838,8 @@ export class API<FromLSP extends boolean = false> implements FormatDiagnosticsHo
838838
*
839839
* Fetching the snapshot issues a lightweight request to the server to
840840
* retrieve its timing collection. Collection must be enabled via the
841-
* `collectTiming` option; when it is not, the returned snapshot has
842-
* `enabled: false` and zeroed totals.
841+
* `collectTiming` option or `TS_API_TIMING_DIR` environment variable; when
842+
* it is not, the returned snapshot has `enabled: false` and zeroed totals.
843843
*/
844844
getTimingInfo(): Promise<TimingInfo> {
845845
return this.client.getTimingInfo();

‎packages/typescript/src/api/async/client.ts‎

Lines changed: 53 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ import {
4343
TimingCollector,
4444
type TimingInfo,
4545
} from "../timing.ts";
46+
import { TimingDump } from "../timingDump.ts";
4647

4748
export type { ClientOptions, ClientSocketOptions, ClientSpawnOptions };
4849

@@ -59,16 +60,22 @@ export class Client {
5960
private closed = false;
6061
private connecting: Promise<void> | undefined;
6162
private timing: TimingCollector | undefined;
63+
private timingDump: TimingDump | undefined;
64+
private closing: Promise<void> | undefined;
6265
private batchedRequests: { method: APIRequest["method"]; params: APIRequest["params"]; resolve: (value: unknown) => void; reject: (reason?: any) => void; }[] = [];
6366
private nextBatch: NodeJS.Immediate | "manual" | undefined;
6467

6568
constructor(options: ClientOptions) {
69+
this.timingDump = TimingDump.fromEnvironment("async", isSpawnOptions(options) ? "spawn" : "socket");
70+
if (this.timingDump || isSpawnOptions(options) && options.collectTiming) {
71+
this.timing = new TimingCollector();
72+
}
73+
if (this.timingDump && isSpawnOptions(options)) {
74+
options = { ...options, collectTiming: true };
75+
}
6676
this.options = options;
6777
if (isSpawnOptions(options)) {
6878
configureFileSystemCallbacks(options.fs);
69-
if (options.collectTiming) {
70-
this.timing = new TimingCollector();
71-
}
7279
}
7380
}
7481

@@ -401,26 +408,52 @@ export class Client {
401408
return this.connection.sendRequest(requestType, undefined);
402409
}
403410

404-
async close(): Promise<void> {
411+
close(): Promise<void> {
412+
return this.closing ??= this.closeWorker();
413+
}
414+
415+
private async closeWorker(): Promise<void> {
405416
await this.connecting?.catch(() => {}); // if connection is still in-progress, wait for it to finish before closing the connection
406417
this.closed = true;
407-
if (this.connection) {
408-
this.connection.dispose();
409-
this.connection = undefined;
410-
}
411-
if (this.socket) {
412-
this.socket.destroy();
413-
this.socket = undefined;
418+
try {
419+
if (this.timingDump && this.timing) {
420+
const local = this.timing.getInfo();
421+
let server: ServerTimingInfo | undefined;
422+
let serverTimingError: string | undefined;
423+
const connection = this.connection;
424+
// Closing alone does not reject pending JSON-RPC requests.
425+
const onClose = connection?.onClose(() => connection.dispose());
426+
try {
427+
if (this.connected) server = await this.fetchServerTiming();
428+
}
429+
catch (error) {
430+
serverTimingError = String(error);
431+
}
432+
finally {
433+
onClose?.dispose();
434+
}
435+
this.timingDump.write(local, server, serverTimingError);
436+
}
414437
}
415-
if (this.process) {
416-
// Close stdin to unblock the server's read loop, allowing it to exit cleanly.
417-
// The server is blocked on stdin.Read(), so just sending SIGTERM would deadlock:
418-
// - Node won't exit while child is alive
419-
// - Child can't process SIGTERM while blocked on read
420-
// - Read won't error until stdin is closed
421-
this.process.stdin?.end();
422-
this.process = undefined;
438+
finally {
439+
if (this.connection) {
440+
this.connection.dispose();
441+
this.connection = undefined;
442+
}
443+
if (this.socket) {
444+
this.socket.destroy();
445+
this.socket = undefined;
446+
}
447+
if (this.process) {
448+
// Close stdin to unblock the server's read loop, allowing it to exit cleanly.
449+
// The server is blocked on stdin.Read(), so just sending SIGTERM would deadlock:
450+
// - Node won't exit while child is alive
451+
// - Child can't process SIGTERM while blocked on read
452+
// - Read won't error until stdin is closed
453+
this.process.stdin?.end();
454+
this.process = undefined;
455+
}
456+
this.connected = false;
423457
}
424-
this.connected = false;
425458
}
426459
}

‎packages/typescript/src/api/options.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,9 @@ export interface ClientSpawnOptions {
3333
* measures its own per-request processing time; both are combined (along
3434
* with an estimated transport overhead) in the snapshot returned by
3535
* {@link API.getTimingInfo}.
36+
* Setting `TS_API_TIMING_DIR` to a nonempty output directory also enables
37+
* collection, even when this option is false, and writes a JSON snapshot
38+
* when the API is closed.
3639
*/
3740
collectTiming?: boolean | undefined;
3841
}

‎packages/typescript/src/api/sync/api.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1343,8 +1343,8 @@ export class API<FromLSP extends boolean = false> implements FormatDiagnosticsHo
13431343
*
13441344
* Fetching the snapshot issues a lightweight request to the server to
13451345
* retrieve its timing collection. Collection must be enabled via the
1346-
* `collectTiming` option; when it is not, the returned snapshot has
1347-
* `enabled: false` and zeroed totals.
1346+
* `collectTiming` option or `TS_API_TIMING_DIR` environment variable; when
1347+
* it is not, the returned snapshot has `enabled: false` and zeroed totals.
13481348
*/
13491349
get getTimingInfo(): {
13501350
(): TimingInfo;

‎packages/typescript/src/api/sync/client.ts‎

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,20 +25,27 @@ import {
2525
TimingCollector,
2626
type TimingInfo,
2727
} from "../timing.ts";
28+
import { TimingDump } from "../timingDump.ts";
2829

2930
export type { ClientOptions, ClientSocketOptions, ClientSpawnOptions };
3031

3132
export class Client {
3233
private channel: SyncRpcChannel;
3334
private encoder = new TextEncoder();
3435
private timing: TimingCollector | undefined;
36+
private timingDump: TimingDump | undefined;
37+
private closed = false;
3538
private maxResponseBytesPerPage: number | undefined;
3639

3740
constructor(options: ClientOptions) {
3841
if (!isSpawnOptions(options)) {
3942
throw new Error("Socket connections are not yet supported in the sync client");
4043
}
4144

45+
this.timingDump = TimingDump.fromEnvironment("sync", "spawn");
46+
if (this.timingDump) {
47+
options = { ...options, collectTiming: true };
48+
}
4249
const args = getAPIProcessArgs(options, false);
4350
this.maxResponseBytesPerPage = options.maxResponseBytesPerPage;
4451

@@ -184,6 +191,24 @@ export class Client {
184191
}
185192

186193
close(): void {
187-
this.channel.close();
194+
if (this.closed) return;
195+
this.closed = true;
196+
try {
197+
if (this.timingDump && this.timing) {
198+
const local = this.timing.getInfo();
199+
let server: ServerTimingInfo | undefined;
200+
let serverTimingError: string | undefined;
201+
try {
202+
server = JSON.parse(this.channel.requestSync("getServerTiming", "")) as ServerTimingInfo;
203+
}
204+
catch (error) {
205+
serverTimingError = String(error);
206+
}
207+
this.timingDump.write(local, server, serverTimingError);
208+
}
209+
}
210+
finally {
211+
this.channel.close();
212+
}
188213
}
189214
}

‎packages/typescript/src/api/syncChannel.ts‎

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,7 @@ export class SyncRpcChannel {
113113
private writeFd: number;
114114
private pipeFd: number | undefined;
115115
private callbacks = new Map<string, (name: string, payload: string) => string>();
116+
private callbackFailure: { error: unknown; } | undefined;
116117

117118
private methodBufCache = new Map<string, Buffer>();
118119

@@ -280,6 +281,9 @@ export class SyncRpcChannel {
280281
if (this.readFd < 0) {
281282
throw new Error("SyncRpcChannel is closed");
282283
}
284+
if (this.callbackFailure) {
285+
throw this.callbackFailure.error;
286+
}
283287
}
284288

285289
private getMethodBuf(method: string): Buffer {
@@ -326,7 +330,14 @@ export class SyncRpcChannel {
326330
);
327331
}
328332
case MSG_CALL: {
329-
this.handleCall(this._msgName.toString("utf-8"), this._msgPayload);
333+
try {
334+
this.handleCall(this._msgName.toString("utf-8"), this._msgPayload);
335+
}
336+
catch (error) {
337+
// The pending response is unread, so further I/O cannot be synchronized.
338+
this.callbackFailure = { error };
339+
throw error;
340+
}
330341
break;
331342
}
332343
default:

‎packages/typescript/src/api/timing.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -182,7 +182,7 @@ export function disabledServerTimingInfo(): ServerTimingInfo {
182182
* requests used to fetch timing) do not misalign the two ring buffers.
183183
*/
184184
export function combineTimingInfo(client: TimingInfo, server: ServerTimingInfo): TimingInfo {
185-
if (!client.enabled) {
185+
if (!client.enabled || !server.enabled) {
186186
return client;
187187
}
188188

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import { randomUUID } from "node:crypto";
2+
import {
3+
mkdirSync,
4+
writeFileSync,
5+
} from "node:fs";
6+
import {
7+
join,
8+
resolve,
9+
} from "node:path";
10+
import {
11+
combineTimingInfo,
12+
type ServerTimingInfo,
13+
type TimingInfo,
14+
} from "./timing.ts";
15+
16+
/** One final snapshot per client, written before its connection is closed. */
17+
export class TimingDump {
18+
private readonly instanceId = randomUUID();
19+
private readonly startedAt = new Date().toISOString();
20+
private readonly directory: string;
21+
private readonly mode: "sync" | "async";
22+
private readonly transport: "spawn" | "socket";
23+
24+
static fromEnvironment(mode: "sync" | "async", transport: "spawn" | "socket"): TimingDump | undefined {
25+
const directory = process.env.TS_API_TIMING_DIR;
26+
return directory ? new TimingDump(resolve(directory), mode, transport) : undefined;
27+
}
28+
29+
private constructor(directory: string, mode: "sync" | "async", transport: "spawn" | "socket") {
30+
this.directory = directory;
31+
this.mode = mode;
32+
this.transport = transport;
33+
}
34+
35+
write(client: TimingInfo, server: ServerTimingInfo | undefined, serverTimingError?: string): void {
36+
const snapshot = {
37+
schemaVersion: 1,
38+
instanceId: this.instanceId,
39+
pid: process.pid,
40+
mode: this.mode,
41+
transport: this.transport,
42+
startedAt: this.startedAt,
43+
closedAt: new Date().toISOString(),
44+
timing: server ? combineTimingInfo(client, server) : client,
45+
serverTiming: server ?? null,
46+
serverTimingError,
47+
};
48+
mkdirSync(this.directory, { recursive: true });
49+
writeFileSync(
50+
join(this.directory, `timing-${process.pid}-${this.instanceId}.json`),
51+
JSON.stringify(snapshot) + "\n",
52+
{ flag: "wx" },
53+
);
54+
}
55+
}

0 commit comments

Comments
 (0)