UNPKG

typescript

Version:

TypeScript is a language for application scale JavaScript development

508 lines 21.6 kB
/** * Pure JS replacement for @typescript/libsyncrpc. * * Spawns a child process and communicates with it synchronously over * stdin/stdout pipes using the same MessagePack-based tuple protocol: * [MessageType (u8), method (bin), payload (bin)] * * Synchronous I/O is achieved by calling fs.readSync / fs.writeSync * directly on the pipe file descriptors obtained from the spawned * ChildProcess. */ import { spawn, } from "node:child_process"; import { closeSync, openSync, readSync, writeSync, } from "node:fs"; import { binHeaderSize, MSGPACK_BIN16, MSGPACK_BIN32, MSGPACK_BIN8, MSGPACK_FIXARRAY3, MSGPACK_UINT8, writeBinHeader, } from "./node/msgpack.js"; // ── MessageType constants ──────────────────────────────────────────── // Sent by channel (parent → child) const MSG_REQUEST = 1; const MSG_CALL_RESPONSE = 2; const MSG_CALL_ERROR = 3; // Sent by child (child → parent) const MSG_RESPONSE = 4; const MSG_ERROR = 5; const MSG_CALL = 6; // Pre-allocated buffer used by Atomics.wait for tiny sleeps when a // non-blocking fd returns EAGAIN. const sleepBuf = new Int32Array(new SharedArrayBuffer(4)); // Shared empty buffer – avoids allocating Buffer.alloc(0) on every // zero-length bin field. const EMPTY_BUF = Buffer.alloc(0); // ── Global cleanup tracking ───────────────────────────────────────── // Track all live child processes so they can be killed on process exit. // This mimics the auto-cleanup behavior of the native libsyncrpc module, // whose Rust/C++ destructors would kill children automatically. const liveChildren = new Set(); process.on("exit", () => { for (const child of liveChildren) { try { child.kill(); } catch { // swallow – process may already be dead } } liveChildren.clear(); }); /** * SyncRpcChannel – drop-in replacement for the native libsyncrpc class. * * API surface intentionally matches the original: * - constructor(exe, args) * - requestSync(method, payload): string * - requestBinarySync(method, payload): Uint8Array * - registerCallback(name, cb) * - close() * * The protocol is unversioned; both sides (this JS channel and the Go * child process) must be built from the same tree. * * This class is **not** thread-safe. All calls must originate from a * single thread — do not share an instance across worker threads. */ export class SyncRpcChannel { child; readFd; writeFd; pipeFd; callbacks = new Map(); methodBufCache = new Map(); // When true, the payload byte lengths of each request/response are recorded // and exposed via the `last*` fields below. These count only the JSON/binary // payload bytes, not the MessagePack tuple framing (message type, method // name, and length headers). collectTiming; // Per-request payload byte measurements for the most recently completed // request. Only meaningful when `collectTiming` is true. Callers read these // immediately after a request returns (the channel is strictly serial). lastBytesSent = 0; lastBytesReceived = 0; _msgType = 0; _msgName = EMPTY_BUF; _msgPayload = EMPTY_BUF; headerBuf = Buffer.allocUnsafe(4); // Read-ahead buffer – reduces readSync syscalls by buffering data from the pipe. readBuf = Buffer.allocUnsafe(65536); readBufPos = 0; readBufLen = 0; // Write buffer – assembles entire tuples for a single writeSync. writeBuf = Buffer.allocUnsafe(65536); constructor(exe, args, collectTiming = false) { this.collectTiming = collectTiming; const isWindows = process.platform === "win32"; if (isWindows) { // On Windows, libuv pipe handles don't expose POSIX fds, so // readSync/writeSync can't be used on stdio pipes. Instead, // we create a Windows named pipe path, pass it to the child // via --pipe, and open it with fs.openSync which returns a // real C-runtime fd backed by a proper HANDLE. const pipePath = `\\\\.\\pipe\\tsgo-sync-${process.pid}-${Date.now()}`; this.child = spawn(exe, [...args, "--pipe", pipePath], { stdio: ["ignore", "ignore", "inherit"], }); // Retry openSync until the child creates the named pipe. let fd; for (let i = 0; i < 500; i++) { try { fd = openSync(pipePath, "r+"); break; } catch { if (this.child.exitCode !== null) { throw new Error(`Child process exited with code ${this.child.exitCode} before pipe was ready`); } Atomics.wait(sleepBuf, 0, 0, 10); } } if (fd === undefined) { this.child.kill(); throw new Error("SyncRpcChannel: timed out connecting to named pipe"); } this.readFd = fd; this.writeFd = fd; this.pipeFd = fd; } else { // POSIX: use stdio pipe file descriptors directly. this.child = spawn(exe, args, { stdio: ["pipe", "pipe", "inherit"], }); const stdout = this.child.stdout; const stdin = this.child.stdin; this.readFd = stdout._handle.fd; this.writeFd = stdin._handle.fd; if (typeof this.readFd !== "number" || this.readFd < 0 || typeof this.writeFd !== "number" || this.writeFd < 0) { stdout.destroy(); stdin.destroy(); this.child.kill(); throw new Error("SyncRpcChannel: could not obtain pipe file descriptors."); } // Set the pipe handles to blocking mode. Under node --test's // process isolation, pipes are created in non-blocking mode // (for the IPC channel). This causes readSync/writeSync to get // EAGAIN, requiring costly 1ms sleeps per retry. Setting // blocking mode ensures readSync blocks properly until data // arrives, matching the behavior of the native libsyncrpc. stdout._handle.setBlocking?.(true); stdin._handle.setBlocking?.(true); // Prevent Node's event-loop from reading stdout or keeping the // process alive – we will use fs.readSync exclusively. stdout.pause(); stdout.unref(); stdin.unref(); } // Track for auto-cleanup on process exit. liveChildren.add(this.child); this.child.unref(); } // ── Public API ────────────────────────────────────────────────── /** * Send a request and synchronously wait for the response (string). * Handles Call (callback) messages from the child inline. */ requestSync(method, payload) { this.ensureOpen(); const result = this.requestBytesSync(method, payload); return result.toString("utf-8"); } /** * Send a request and synchronously wait for the response (binary). * Handles Call (callback) messages from the child inline. */ requestBinarySync(method, payload) { this.ensureOpen(); return this.requestBytesSync(method, payload); } /** Register a string→string callback that the child may invoke. */ registerCallback(name, callback) { this.callbacks.set(name, callback); } /** Kill the child process and release resources. */ close() { try { liveChildren.delete(this.child); if (this.pipeFd !== undefined) { closeSync(this.pipeFd); this.pipeFd = undefined; } // Destroy the stdio streams so that their pipe handles are closed // and no longer prevent the event loop from draining. this.child.stdout?.destroy(); this.child.stdin?.destroy(); this.child.kill(); this.readFd = -1; this.writeFd = -1; } catch { // swallow – process may already be dead } } // ── Core request loop ─────────────────────────────────────────── ensureOpen() { if (this.readFd < 0) { throw new Error("SyncRpcChannel is closed"); } } getMethodBuf(method) { let buf = this.methodBufCache.get(method); if (buf === undefined) { buf = Buffer.from(method, "utf-8"); this.methodBufCache.set(method, buf); } return buf; } requestBytesSync(method, payload) { const methodBuf = this.getMethodBuf(method); if (this.collectTiming) { this.lastBytesSent = typeof payload === "string" ? Buffer.byteLength(payload, "utf-8") : payload.length; this.lastBytesReceived = 0; } this.writeTuple(MSG_REQUEST, methodBuf, payload); for (;;) { this.readTuple(); switch (this._msgType) { case MSG_RESPONSE: { // Compare raw bytes instead of decoding to string. if (!methodBuf.equals(this._msgName)) { throw new Error(`name mismatch for response: expected \`${method}\`, got \`${this._msgName.toString("utf-8")}\``); } if (this.collectTiming) { this.lastBytesReceived = this._msgPayload.length; } return this._msgPayload; } case MSG_ERROR: { if (methodBuf.equals(this._msgName)) { throw new Error(this._msgPayload.toString("utf-8")); } throw new Error(`name mismatch for response: expected \`${method}\`, got \`${this._msgName.toString("utf-8")}\``); } case MSG_CALL: { this.handleCall(this._msgName.toString("utf-8"), this._msgPayload); break; } default: throw new Error(`Invalid message type from child: ${this._msgType}`); } } } // ── Callback handling ─────────────────────────────────────────── /** * Handle an incoming MSG_CALL from the child process. * * After sending the error response back to the child, this method * intentionally re-throws to abort the caller's request loop. * A failed callback is treated as unrecoverable to match the * behavior of the native libsyncrpc addon. */ handleCall(name, payload) { const cb = this.callbacks.get(name); if (!cb) { const errMsg = `unknown callback: \`${name}\`. Please make sure to register it on the JavaScript side before invoking it.`; this.writeTuple(MSG_CALL_ERROR, Buffer.from(name, "utf-8"), Buffer.from(errMsg, "utf-8")); throw new Error(`no callback named \`${name}\` found`); } try { const result = cb(name, payload.toString("utf-8")); this.writeTuple(MSG_CALL_RESPONSE, Buffer.from(name, "utf-8"), Buffer.from(result, "utf-8")); } catch (e) { const errMsg = String(e instanceof Error ? e.message : e).trim(); this.writeTuple(MSG_CALL_ERROR, Buffer.from(name, "utf-8"), Buffer.from(errMsg, "utf-8")); throw new Error(`Error calling callback \`${name}\`: ${errMsg}`); } } // ── MessagePack tuple write ───────────────────────────────────── /** * Write a complete [type, name, payload] tuple in as few writeSync * calls as possible. For messages that fit in the pre-allocated * write buffer (64 KB), everything is assembled and sent in a single * syscall. Larger messages use two syscalls: one for the header * portion and one for the payload data. */ writeTuple(type, name, payload) { const nameLen = name.length; const payloadIsString = typeof payload === "string"; const payloadLen = payloadIsString ? Buffer.byteLength(payload, "utf-8") : payload.length; const nameHdrSize = binHeaderSize(nameLen); const payloadHdrSize = binHeaderSize(payloadLen); const headerSize = 2 + nameHdrSize + nameLen + payloadHdrSize; const totalSize = headerSize + payloadLen; if (totalSize <= this.writeBuf.length) { // Small message: assemble into write buffer, one syscall let off = 0; this.writeBuf[off++] = MSGPACK_FIXARRAY3; this.writeBuf[off++] = type; off = writeBinHeader(this.writeBuf, off, nameLen); name.copy(this.writeBuf, off); off += nameLen; off = writeBinHeader(this.writeBuf, off, payloadLen); if (payloadLen > 0) { if (payloadIsString) { // Encode string directly into write buffer — avoids // Buffer.from(string, 'utf-8') allocation entirely. this.writeBuf.write(payload, off, payloadLen, "utf-8"); } else if (payload instanceof Buffer) { payload.copy(this.writeBuf, off); } else { this.writeBuf.set(payload, off); } } this.writeAllBuf(this.writeBuf, totalSize); } else { // Large message: header + name in one call, payload in another let off = 0; this.writeBuf[off++] = MSGPACK_FIXARRAY3; this.writeBuf[off++] = type; off = writeBinHeader(this.writeBuf, off, nameLen); name.copy(this.writeBuf, off); off += nameLen; off = writeBinHeader(this.writeBuf, off, payloadLen); this.writeAllBuf(this.writeBuf, off); if (payloadLen > 0) { if (payloadIsString) { // Large string: must allocate (can't stream-encode // across multiple writeSync calls). this.writeAllBuf(Buffer.from(payload, "utf-8")); } else { this.writeAllBuf(payload); } } } } // ── MessagePack tuple read ────────────────────────────────────── /** * Read a [type, name, payload] tuple into instance fields * (_msgType, _msgName, _msgPayload) to avoid allocating a * short-lived 3-element array on every call. */ readTuple() { // Fixed 3-element array marker const marker = this.readByte(); if (marker !== MSGPACK_FIXARRAY3) { throw new Error(`Expected fixed 3-element array (0x93), received: 0x${marker.toString(16)}`); } // Message type – positive fixint or uint8 const tb = this.readByte(); if (tb <= 0x7f) { this._msgType = tb; } else if (tb === MSGPACK_UINT8) { this._msgType = this.readByte(); } else { throw new Error(`Expected positive fixint or uint8 marker, received: 0x${tb.toString(16)}`); } this._msgName = this.readBin(); this._msgPayload = this.readBin(); } /** * Read a MessagePack bin field. */ readBin() { const marker = this.readByte(); let size; switch (marker) { case MSGPACK_BIN8: size = this.readByte(); break; case MSGPACK_BIN16: this.readExactInto(this.headerBuf, 2); size = (this.headerBuf[0] << 8) | this.headerBuf[1]; break; case MSGPACK_BIN32: this.readExactInto(this.headerBuf, 4); size = this.headerBuf.readUInt32BE(0); break; default: throw new Error(`Expected binary data (0xc4-0xc6), received: 0x${marker.toString(16)}`); } if (size === 0) return EMPTY_BUF; return this.readExact(size); } // ── Low-level synchronous I/O ─────────────────────────────────── /** Build an EOF error with the child's exit code/signal if available. */ eofError() { const code = this.child.exitCode; const signal = this.child.signalCode; const detail = signal ? `killed by signal ${signal}` : code !== null ? `exited with code ${code}` : "unknown reason"; return new Error(`Unexpected EOF while reading from child process (${detail})`); } /** Read a single byte from the buffered read-ahead. */ readByte() { if (this.readBufPos >= this.readBufLen) { this.fillReadBuffer(); } return this.readBuf[this.readBufPos++]; } readExact(length) { // Use allocUnsafeSlow (not allocUnsafe) so the buffer has its own // backing ArrayBuffer at byteOffset 0. This is critical because // callers such as RemoteSourceFile create DataView/Uint8Array over // buffer.buffer with absolute offsets. Buffer.allocUnsafe returns // slices of a shared pool whose byteOffset is non-zero, corrupting // those downstream views. // // allocUnsafeSlow (vs alloc) skips zero-fill, which matters for // large transfers — e.g. checker.ts at ~57 MB saves ~5 ms of // unnecessary memset. The buffer is immediately filled by // readExactInto so uninitialized memory is never exposed. const buf = Buffer.allocUnsafeSlow(length); this.readExactInto(buf, length); return buf; } /** * Fill the internal read-ahead buffer from the pipe fd. * Retries on EAGAIN for non-blocking mode compatibility. */ fillReadBuffer() { this.readBufPos = 0; this.readBufLen = 0; for (;;) { try { const n = readSync(this.readFd, this.readBuf, 0, this.readBuf.length, null); if (n === 0) { throw this.eofError(); } this.readBufLen = n; return; } catch (e) { if (e instanceof Error && ("code" in e) && (e.code === "EAGAIN" || e.code === "EWOULDBLOCK")) { Atomics.wait(sleepBuf, 0, 0, 1); continue; } throw e; } } } /** * Synchronously read exactly `length` bytes into `buffer`. * Serves from the internal read-ahead buffer first; for large reads * that exceed the buffer size, reads directly from the fd to avoid * an extra copy. */ readExactInto(buffer, length) { let pos = 0; while (pos < length) { const avail = this.readBufLen - this.readBufPos; if (avail > 0) { // Serve from read-ahead buffer const toCopy = Math.min(avail, length - pos); this.readBuf.copy(buffer, pos, this.readBufPos, this.readBufPos + toCopy); this.readBufPos += toCopy; pos += toCopy; } else if (length - pos >= this.readBuf.length) { // Remaining data is larger than read buffer; read directly // into the target to avoid unnecessary copying. try { const n = readSync(this.readFd, buffer, pos, length - pos, null); if (n === 0) { throw this.eofError(); } pos += n; } catch (e) { if (e instanceof Error && ("code" in e) && (e.code === "EAGAIN" || e.code === "EWOULDBLOCK")) { Atomics.wait(sleepBuf, 0, 0, 1); continue; } throw e; } } else { // Refill the read-ahead buffer this.fillReadBuffer(); } } } /** * Synchronously write all bytes from `data` (up to `length`). * Retries on EAGAIN. */ writeAllBuf(data, length) { const total = length ?? data.length; let pos = 0; while (pos < total) { try { const n = writeSync(this.writeFd, data, pos, total - pos); pos += n; } catch (e) { if (e instanceof Error && ("code" in e) && (e.code === "EAGAIN" || e.code === "EWOULDBLOCK")) { Atomics.wait(sleepBuf, 0, 0, 1); continue; } throw e; } } } } //# sourceMappingURL=syncChannel.js.map