Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 0 additions & 15 deletions src/ffi/libcurl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,20 +45,6 @@ const curl_easy_init = lib.func("void * curl_easy_init()");
const curl_easy_cleanup = lib.func("void curl_easy_cleanup(void *)");
const curl_easy_perform = lib.func("int curl_easy_perform(void *)");

/**
* Async version of curl_easy_perform that runs in a worker thread
* via Koffi's .async() support. This avoids blocking the event loop,
* which is critical when the mock server runs in the same process.
*/
function curl_easy_perform_async(handle: CurlHandle): Promise<number> {
return new Promise<number>((resolve, reject) => {
(curl_easy_perform as unknown as { async: (handle: CurlHandle, cb: (err: Error | null, code: number) => void) => void })
.async(handle, (err: Error | null, code: number) => {
if (err) reject(err);
else resolve(code);
});
});
}
const curl_easy_duphandle = lib.func("void * curl_easy_duphandle(void *)");
const curl_easy_reset = lib.func("void curl_easy_reset(void *)");
const curl_easy_strerror = lib.func("const char * curl_easy_strerror(int)");
Expand Down Expand Up @@ -507,7 +493,6 @@ export {
curl_easy_init,
curl_easy_cleanup,
curl_easy_perform,
curl_easy_perform_async,
curl_easy_duphandle,
curl_easy_reset,
curl_easy_strerror,
Expand Down
75 changes: 71 additions & 4 deletions src/websocket/websocket.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,28 @@

import { Curl } from "../core/easy.js";
import {
curl_easy_perform_async,
curl_multi_init,
curl_multi_add_handle,
curl_multi_remove_handle,
curl_multi_perform,
curl_multi_cleanup,
curl_multi_info_read,
curl_ws_recv,
curl_ws_send,
type CurlHandle,
type CurlMultiHandle,
} from "../ffi/libcurl.js";
import { CurlOpt, CurlCode, CurlWsFlag } from "../ffi/constants.js";
import { WebSocketError, WebSocketClosed } from "../utils/errors.js";
import { Headers } from "../http/headers.js";
import type { WebSocketOptions } from "../types/options.js";

/** How long to wait between `curl_multi_perform` calls while the handshake is in progress. */
const CONNECT_POLL_MS = 1;

/** `CURLMSG_DONE` — the only message type curl's multi interface defines. */
const CURLMSG_DONE = 1;

/**
* WebSocket message types
*/
Expand Down Expand Up @@ -63,6 +75,7 @@ export class AsyncWebSocket {
private receiveBuffer: Buffer;
private messageQueue: WebSocketMessage[] = [];

private multi: CurlMultiHandle | null = null;
private pollTimer: ReturnType<typeof setTimeout> | null = null;
private pollInterval: number = 10; // ms between polls

Expand Down Expand Up @@ -110,24 +123,77 @@ export class AsyncWebSocket {
}

/**
* Perform the WebSocket connection handshake using Koffi's async
* worker thread to avoid blocking the Node.js event loop.
* Perform the WebSocket connection handshake, on the main thread and without blocking it.
*
* It is driven with the multi interface rather than `curl_easy_perform`, for two reasons
* that pull in opposite directions and are both satisfied here.
*
* It must not run on a libuv worker thread. That is what Koffi's `.async()` does, and
* driving libcurl from one leaves per-thread state behind whose pthread TSD destructor
* lives in the libcurl image. At process exit the worker thread is torn down and
* `_pthread_tsd_cleanup` calls that destructor after the image has gone, so the process
* dies with SIGSEGV *after* the script has finished — silently, since the script produced
* all of its output first. It is not specific to WebSockets: any `curl_easy_perform`
* issued through `.async()` does it, a plain HTTPS GET included.
*
* It must also not block the event loop, or a caller talking to a server in its own
* process — which is exactly what this repository's tests do — would deadlock.
*
* `curl_multi_perform` gives both: it returns as soon as there is nothing to do right
* now, so the handshake advances across event-loop turns without ever leaving the main
* thread. The handle stays attached to the multi for the life of the socket; removing it
* would drop the connection that `CONNECT_ONLY` exists to keep.
*/
private async performConnect(): Promise<void> {
try {
const code = await curl_easy_perform_async(this.handle);
this.multi = curl_multi_init();
if (!this.multi) throw new WebSocketError("Failed to initialize curl multi handle");
curl_multi_add_handle(this.multi, this.handle);

let running = 1;
while (running > 0) {
const perform = curl_multi_perform(this.multi);
if (perform.code !== 0) {
throw new WebSocketError(`WS connect failed with multi code ${perform.code}`);
}
running = perform.runningHandles;
if (running > 0) await new Promise((resolve) => setTimeout(resolve, CONNECT_POLL_MS));
}

const code = this.readTransferResult();
if (code !== CurlCode.CURLE_OK) {
throw new WebSocketError(`WS connect failed with code ${code}`);
}
this._connected = true;
} catch (error) {
this._closed = true;
this.releaseMulti();
this.curl.cleanup();
if (error instanceof WebSocketError) throw error;
throw new WebSocketError(`Failed to connect: ${error}`);
}
}

/** The completion code the multi recorded for this transfer, once it stopped running. */
private readTransferResult(): number {
if (!this.multi) return CurlCode.CURLE_OK;
for (;;) {
const { message } = curl_multi_info_read(this.multi);
if (!message) return CurlCode.CURLE_OK;
// CURLMSG_DONE is the only message curl defines, and it carries the easy result.
if (message.msg === CURLMSG_DONE) return message.result;
}
}

/** Detach and free the multi handle. Safe to call more than once. */
private releaseMulti(): void {
if (!this.multi) return;
const multi = this.multi;
this.multi = null;
curl_multi_remove_handle(multi, this.handle);
curl_multi_cleanup(multi);
}

/**
* Get the WebSocket URL
*/
Expand Down Expand Up @@ -430,6 +496,7 @@ export class AsyncWebSocket {
}

// Cleanup
this.releaseMulti();
this.curl.cleanup();
}

Expand Down
69 changes: 69 additions & 0 deletions tests/websocket-exit.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
/**
* The process must exit cleanly after a WebSocket has been opened.
*
* This is a child-process test on purpose. The failure it guards against happens *after*
* the script finishes — libcurl driven from a libuv worker thread leaves per-thread state
* whose pthread TSD destructor lives in the libcurl image, and at exit the worker thread is
* torn down and calls that destructor after the image has gone. The script produces its
* output, then the process dies with SIGSEGV. Nothing observable from inside the process
* can catch that; only the exit status can.
*/
import { spawn } from "node:child_process";
import { getWebSocketUrl } from "./mock-server.js";

const repositoryRoot = new URL("..", import.meta.url).pathname;
const builtEntry = `${repositoryRoot}dist/index.js`;

/**
* Run a snippet in a fresh Node process and report how it terminated.
*
* Asynchronous on purpose: the mock server this connects to lives in *this* process, so a
* blocking `spawnSync` would stop it answering and the child would hang.
*/
function runInChild(source: string): Promise<{ status: number | null; signal: string | null }> {
return new Promise((resolve) => {
const child = spawn(process.execPath, ["--input-type=module", "-e", source], {
stdio: ["ignore", "pipe", "pipe"],
timeout: 30_000,
});
let stderr = "";
child.stderr.on("data", (chunk) => {
stderr += String(chunk);
});
child.on("close", (status, signal) => {
if (process.env.IMPERS_DEBUG_TESTS) {
console.log("child:", JSON.stringify({ status, signal, stderr: stderr.slice(0, 600) }));
}
resolve({ status, signal });
});
});
}

describe("process exit after WebSocket use", () => {
// The child cannot run the TypeScript sources — they use NodeNext `.js` specifiers, which
// Node's type stripping does not resolve — so it imports the build. Built here rather than
// assumed present: a stale `dist/` would let this test pass against the very code it
// exists to reject.
beforeAll(async () => {
const build = spawn("npm", ["run", "build"], { cwd: repositoryRoot, stdio: "ignore" });
const code = await new Promise<number | null>((resolve) => build.on("close", resolve));
if (code !== 0) throw new Error(`npm run build exited with ${code}`);
}, 300_000);

it.each([
["closed before exiting", true],
["left open at exit", false],
])("exits cleanly with the socket %s", async (_label, closeFirst) => {
const source = `
const { wsConnect } = await import(${JSON.stringify(builtEntry)});
const ws = await wsConnect(${JSON.stringify(`${getWebSocketUrl()}/ws/echo`)});
${closeFirst ? "await ws.close();" : ""}
`;

const { status, signal } = await runInChild(source);

// A segfault surfaces as signal SIGSEGV, or as status 139 when a shell is involved.
expect(signal).toBeNull();
expect(status).toBe(0);
});
});
2 changes: 1 addition & 1 deletion tests/websocket.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { AsyncWebSocket, wsConnect } from "../src/websocket/websocket.js";
import { WebSocketError, WebSocketClosed } from "../src/utils/errors.js";
import { getWebSocketUrl } from "./mock-server.js";

describe.skip("AsyncWebSocket", () => {
describe("AsyncWebSocket", () => {
let wsUrl: string;

beforeAll(() => {
Expand Down