Skip to content

Commit 0d0e3c4

Browse files
authored
fix: do not emit unlistened transport error before connect completes (#297)
A socket error before the transport reached the connected phase emitted 'error' with no listener attached, crashing the process with ERR_UNHANDLED_ERROR instead of rejecting connect().
1 parent a9e716a commit 0d0e3c4

2 files changed

Lines changed: 61 additions & 2 deletions

File tree

src/lib/remote-xpc/remote-xpc-framed-transport.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -135,11 +135,13 @@ export class RemoteXpcFramedTransport extends EventEmitter {
135135
this.handleData(chunk);
136136
});
137137
socket.on('error', (error: Error) => {
138-
if (this.closing) {
138+
const wasConnected = this.connected;
139+
this.connected = false;
140+
if (this.closing || !wasConnected) {
141+
log.debug(`RemoteXPC transport error outside connected phase: ${error.message}`);
139142
return;
140143
}
141144
log.error(`RemoteXPC transport error: ${error.message}`);
142-
this.connected = false;
143145
this.emit('error', error);
144146
});
145147
socket.on('close', () => {
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import assert from 'node:assert/strict';
2+
import * as net from 'node:net';
3+
import {describe, it} from 'node:test';
4+
5+
import {RemoteXpcFramedTransport} from '../../../src/lib/remote-xpc/remote-xpc-framed-transport.js';
6+
7+
function listenOnLoopback(server: net.Server): Promise<number> {
8+
return new Promise<number>((resolve, reject) => {
9+
server.once('error', reject);
10+
server.listen(0, '::1', (): void => {
11+
resolve((server.address() as net.AddressInfo).port);
12+
});
13+
});
14+
}
15+
16+
function closeServer(server: net.Server): Promise<void> {
17+
return new Promise<void>((resolve) => server.close(() => resolve()));
18+
}
19+
20+
describe('RemoteXpcFramedTransport', function () {
21+
it('rejects connect() on socket failure instead of emitting an unlistened error', async function () {
22+
const probe = net.createServer();
23+
const port = await listenOnLoopback(probe);
24+
await closeServer(probe);
25+
26+
const transport = new RemoteXpcFramedTransport(['::1', port]);
27+
await assert.rejects(transport.connect({timeoutMs: 2000}), /ECONNREFUSED/);
28+
});
29+
30+
it('emits error when the socket fails in the connected phase', async function () {
31+
let onAccepted: (socket: net.Socket) => void = () => undefined;
32+
const acceptedPromise = new Promise<net.Socket>((resolve) => {
33+
onAccepted = resolve;
34+
});
35+
const server = net.createServer((socket): void => {
36+
onAccepted(socket);
37+
});
38+
const port = await listenOnLoopback(server);
39+
40+
let acceptedSocket: net.Socket | undefined;
41+
const transport = new RemoteXpcFramedTransport(['::1', port]);
42+
try {
43+
await transport.connect({timeoutMs: 2000});
44+
acceptedSocket = await acceptedPromise;
45+
46+
const errorPromise = new Promise<Error>((resolve) => transport.once('error', resolve));
47+
acceptedSocket.resetAndDestroy();
48+
49+
const error = await errorPromise;
50+
assert.match(error.message, /ECONNRESET/);
51+
} finally {
52+
await transport.close();
53+
acceptedSocket?.destroy();
54+
await closeServer(server);
55+
}
56+
});
57+
});

0 commit comments

Comments
 (0)