typescript
Version:
TypeScript is a language for application scale JavaScript development
508 lines • 21.6 kB
JavaScript
/**
* 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