Skip to content

Commit 7c0c112

Browse files
authored
feat(vscode): communicate with worker using ipc instead of websocket (#691)
1 parent 1075950 commit 7c0c112

File tree

10 files changed

+137
-221
lines changed

10 files changed

+137
-221
lines changed

.vscode/launch.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,8 @@
1515
"${workspaceFolder}/packages/vscode/sample"
1616
],
1717
"outFiles": ["${workspaceFolder}/packages/vscode/dist/**/*.js"],
18-
"preLaunchTask": "npm: build:local"
18+
"preLaunchTask": "npm: build:local",
19+
"autoAttachChildProcesses": true
1920
}
2021
]
2122
}

packages/vscode/package.json

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -70,15 +70,13 @@
7070
"@types/mocha": "^10.0.10",
7171
"@types/node": "^22.16.5",
7272
"@types/vscode": "1.97.0",
73-
"@types/ws": "^8.18.1",
7473
"@vscode/test-cli": "^0.0.12",
7574
"@vscode/test-electron": "^2.5.2",
7675
"@vscode/vsce": "3.6.2",
77-
"get-port": "^7.1.0",
76+
"birpc": "2.6.1",
7877
"glob": "^7.2.3",
7978
"mocha": "^11.7.4",
8079
"ovsx": "^0.10.6",
81-
"typescript": "^5.9.3",
82-
"ws": "^8.18.3"
80+
"typescript": "^5.9.3"
8381
}
8482
}

packages/vscode/src/logger.ts

Lines changed: 6 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,40 +1,21 @@
1-
import { formatWithOptions } from 'node:util';
21
import vscode from 'vscode';
2+
import { BaseLogger, type LogLevel } from './shared/logger';
33

4-
function formatValues(values: unknown[]): string {
5-
return formatWithOptions({ depth: 4 }, ...values);
6-
}
7-
8-
export class Logger implements vscode.Disposable {
4+
export class MasterLogger extends BaseLogger implements vscode.Disposable {
95
readonly #channel: vscode.LogOutputChannel;
106

117
constructor(private readonly name = 'Rstest') {
8+
super();
129
this.#channel = vscode.window.createOutputChannel(this.name, { log: true });
1310
}
1411

15-
public trace(...values: unknown[]) {
16-
this.#channel.trace(formatValues(values));
17-
}
18-
19-
public debug(...values: unknown[]) {
20-
this.#channel.debug(formatValues(values));
21-
}
22-
23-
public info(...values: unknown[]) {
24-
this.#channel.info(formatValues(values));
25-
}
26-
27-
public warn(...values: unknown[]) {
28-
this.#channel.warn(formatValues(values));
29-
}
30-
31-
public error(...values: unknown[]) {
32-
this.#channel.error(formatValues(values));
12+
override log(level: LogLevel, message: string) {
13+
this.#channel[level](message);
3314
}
3415

3516
public dispose() {
3617
this.#channel.dispose();
3718
}
3819
}
3920

40-
export const logger = new Logger();
21+
export const logger = new MasterLogger();

packages/vscode/src/master.ts

Lines changed: 40 additions & 101 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,16 @@
11
import { spawn } from 'node:child_process';
2-
import { createServer } from 'node:http';
32
import path, { dirname } from 'node:path';
4-
import getPort from 'get-port';
3+
import { createBirpc } from 'birpc';
54
import vscode from 'vscode';
6-
import type { WebSocket } from 'ws';
7-
import { WebSocketServer } from 'ws';
85
import { getConfigValue } from './config';
96
import { logger } from './logger';
10-
import type {
11-
WorkerEvent,
12-
WorkerEventFinish,
13-
WorkerRunTestData,
14-
} from './types';
7+
import type { LogLevel } from './shared/logger';
8+
import type { WorkerRunTestData } from './types';
9+
import { promiseWithTimeout } from './utils';
10+
import type { Worker } from './worker';
1511

1612
export class RstestApi {
17-
public ws: WebSocket | null = null;
18-
private testPromises: Map<
19-
string,
20-
{ resolve: (value: any) => void; reject: (reason?: any) => void }
21-
> = new Map();
13+
public worker: Pick<Worker, 'initRstest' | 'runTest'> | null = null;
2214
private versionMismatchWarned = false;
2315

2416
public resolveRstestPath(): { cwd: string; rstestPath: string }[] {
@@ -116,140 +108,87 @@ export class RstestApi {
116108
}
117109

118110
public async runTest(item: vscode.TestItem) {
119-
if (this.ws) {
111+
if (this.worker) {
120112
const data: WorkerRunTestData = {
121-
type: 'runTest',
122113
id: item.id,
123114
fileFilters: [item.uri!.fsPath],
124115
testNamePattern: item.label,
125116
};
126117

127-
// Create a promise that will be resolved when we get a response with the matching ID
128-
const promise = new Promise<any>((resolve, reject) => {
129-
this.testPromises.set(item.id, { resolve, reject });
130-
131-
// Set a timeout to prevent hanging indefinitely
132-
setTimeout(() => {
133-
const promiseObj = this.testPromises.get(item.id);
134-
if (promiseObj) {
135-
this.testPromises.delete(item.id);
136-
reject(new Error(`Test execution timed out for ${item.label}`));
137-
}
138-
}, 10000); // 10 seconds timeout
139-
});
140-
141-
this.ws.send(JSON.stringify(data));
142-
return promise;
118+
return promiseWithTimeout(
119+
this.worker.runTest(data),
120+
10_000,
121+
new Error(`Test execution timed out for ${item.label}`),
122+
); // 10 seconds timeout
143123
}
144124
}
145125

146126
public async runFileTests(fileItem: vscode.TestItem) {
147-
if (this.ws) {
127+
if (this.worker) {
148128
const fileId = `file_${fileItem.id}`;
149129
const data: WorkerRunTestData = {
150-
type: 'runTest',
151130
id: fileId,
152131
fileFilters: [fileItem.uri!.fsPath],
153132
testNamePattern: '', // Empty pattern to run all tests in the file
154133
};
155134

156-
// Create a promise that will be resolved when we get a response with the matching ID
157-
const promise = new Promise<WorkerEventFinish>((resolve, reject) => {
158-
this.testPromises.set(fileId, { resolve, reject });
159-
160-
// Set a timeout to prevent hanging indefinitely
161-
setTimeout(() => {
162-
const promiseObj = this.testPromises.get(fileId);
163-
if (promiseObj) {
164-
this.testPromises.delete(fileId);
165-
reject(
166-
new Error(
167-
`File test execution timed out for ${fileItem.uri!.fsPath}`,
168-
),
169-
);
170-
}
171-
}, 30000); // 30 seconds timeout for file-level tests
172-
});
173-
174-
this.ws.send(JSON.stringify(data));
175-
return promise;
135+
return promiseWithTimeout(
136+
this.worker.runTest(data),
137+
30_000,
138+
new Error(`File test execution timed out for ${fileItem.uri!.fsPath}`),
139+
); // 30 seconds timeout for file-level tests
176140
}
177141
}
178142

179143
public async createChildProcess() {
144+
const { cwd, rstestPath } = this.resolveRstestPath()[0];
145+
if (!cwd || !rstestPath) {
146+
logger.error('Failed to resolve rstest path or cwd');
147+
return;
148+
}
149+
180150
const execArgv: string[] = [];
181151
const workerPath = path.resolve(__dirname, 'worker.js');
182-
const port = await getPort();
183-
const wsAddress = `ws://localhost:${port}`;
184152
logger.debug('Spawning worker process', {
185153
workerPath,
186-
wsAddress,
187154
});
188155
const rstestProcess = spawn('node', [...execArgv, workerPath], {
189-
stdio: 'pipe',
156+
cwd,
157+
stdio: ['pipe', 'pipe', 'pipe', 'ipc'],
158+
serialization: 'advanced',
190159
env: {
191160
...process.env,
192161
TEST: 'true',
193-
RSTEST_WS_ADDRESS: wsAddress,
194162
},
195163
});
196164

197165
rstestProcess.stdout?.on('data', (d) => {
198166
const content = d.toString();
199-
logger.debug('worker stdout', content.trimEnd());
167+
logger.debug('[worker stdout]', content.trimEnd());
200168
});
201169

202170
rstestProcess.stderr?.on('data', (d) => {
203171
const content = d.toString();
204-
logger.error('worker stderr', content.trimEnd());
172+
logger.error('[worker stderr]', content.trimEnd());
205173
});
206174

207-
const server = createServer().listen(port).unref();
208-
const wss = new WebSocketServer({ server });
209-
210-
wss.once('connection', (ws) => {
211-
this.ws = ws;
212-
logger.debug('Worker connected', { wsAddress });
213-
const { cwd, rstestPath } = this.resolveRstestPath()[0];
214-
if (!cwd || !rstestPath) {
215-
logger.error('Failed to resolve rstest path or cwd');
216-
return;
217-
}
218-
219-
ws.send(
220-
JSON.stringify({
221-
type: 'init',
222-
rstestPath,
223-
cwd,
224-
}),
225-
);
226-
logger.debug('Sent init payload to worker', { cwd, rstestPath });
227-
228-
ws.on('message', (_data) => {
229-
const _message = JSON.parse(_data.toString()) as WorkerEvent;
230-
if (_message.type === 'finish') {
231-
const message: WorkerEventFinish = _message;
232-
logger.debug('Received worker completion event', {
233-
id: message.id,
234-
testResult: message.testResults,
235-
testFileResult: message.testFileResults,
236-
});
237-
// Check if we have a pending promise for this test ID
238-
const promiseObj = this.testPromises.get(message.id);
239-
if (promiseObj) {
240-
// Resolve the promise with the message data
241-
promiseObj.resolve(message);
242-
// Remove the promise from the map
243-
this.testPromises.delete(message.id);
244-
}
245-
}
246-
});
175+
this.worker = createBirpc<Worker, RstestApi>(this, {
176+
post: (data) => rstestProcess.send(data),
177+
on: (fn) => rstestProcess.on('message', fn),
178+
bind: 'functions',
247179
});
248180

181+
await this.worker.initRstest({ cwd, rstestPath });
182+
logger.debug('Sent init payload to worker', { cwd, rstestPath });
183+
249184
rstestProcess.on('exit', (code, signal) => {
250185
logger.debug('Worker process exited', { code, signal });
251186
});
252187
}
253188

254189
public async createRstestWorker() {}
190+
191+
async log(level: LogLevel, message: string) {
192+
logger[level](message);
193+
}
255194
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { formatWithOptions } from 'node:util';
2+
3+
export type LogLevel = 'trace' | 'debug' | 'info' | 'warn' | 'error';
4+
5+
export type Logger = {
6+
[K in LogLevel]: (...params: unknown[]) => void;
7+
};
8+
9+
export abstract class BaseLogger implements Logger {
10+
constructor(private prefix?: string) {}
11+
protected log(level: LogLevel, message: string): void {
12+
console[level](message);
13+
}
14+
private logWithFormat(level: LogLevel, params: unknown[]) {
15+
this.log(
16+
level,
17+
formatWithOptions(
18+
{ depth: 4 },
19+
...(this.prefix ? [`[${this.prefix}]`] : []),
20+
...params,
21+
),
22+
);
23+
}
24+
trace(...params: unknown[]) {
25+
this.logWithFormat('trace', params);
26+
}
27+
debug(...params: unknown[]) {
28+
this.logWithFormat('debug', params);
29+
}
30+
info(...params: unknown[]) {
31+
this.logWithFormat('info', params);
32+
}
33+
warn(...params: unknown[]) {
34+
this.logWithFormat('warn', params);
35+
}
36+
error(...params: unknown[]) {
37+
this.logWithFormat('error', params);
38+
}
39+
}

packages/vscode/src/types.ts

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,25 +2,19 @@ import type { TestFileResult, TestResult } from '@rstest/core';
22

33
//#region master -> worker
44
export type WorkerInitData = {
5-
type: 'init';
65
rstestPath: string;
76
cwd: string;
87
};
98

109
export type WorkerRunTestData = {
1110
id: string;
12-
type: 'runTest';
1311
fileFilters: string[];
1412
testNamePattern: string;
1513
};
1614
// #endregion
1715

1816
//#region worker -> master
19-
export type WorkerEvent = WorkerEventFinish;
20-
2117
export type WorkerEventFinish = {
22-
type: 'finish';
23-
id: string;
2418
testResults: TestResult[];
2519
testFileResults?: TestFileResult[];
2620
};

packages/vscode/src/utils.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,3 +33,14 @@ export function getWorkspaceTestPatterns(): WorkspaceTestPattern[] {
3333
}));
3434
});
3535
}
36+
37+
export function promiseWithTimeout<T>(
38+
promise: Promise<T>,
39+
timeout: number,
40+
error: Error,
41+
) {
42+
return Promise.race<T>([
43+
promise,
44+
new Promise((_, reject) => setTimeout(reject, timeout, error)),
45+
]);
46+
}

0 commit comments

Comments
 (0)