diff --git a/scripts/bench-latency.ts b/scripts/bench-latency.ts new file mode 100644 index 0000000..4c5bef2 --- /dev/null +++ b/scripts/bench-latency.ts @@ -0,0 +1,163 @@ +import { performance } from "node:perf_hooks"; +import { spawn } from "../src/index.ts"; + +type IterationResult = { + spawnToFirstMs: number; + roundTripMs: number; +}; + +const DEFAULT_ITERATIONS = 30; +const DEFAULT_TIMEOUT_MS = 5000; + +async function main(): Promise { + if (process.platform === "win32") { + throw new Error("bench-latency currently supports Unix only"); + } + + const iterationsArg = Number.parseInt(process.argv[2] ?? "", 10); + const iterations = Number.isFinite(iterationsArg) && iterationsArg > 0 ? iterationsArg : DEFAULT_ITERATIONS; + const timeoutArg = Number.parseInt(process.argv[3] ?? "", 10); + const timeoutMs = Number.isFinite(timeoutArg) && timeoutArg > 0 ? timeoutArg : DEFAULT_TIMEOUT_MS; + + const results: IterationResult[] = []; + + for (let i = 0; i < iterations; i++) { + const result = await runIteration(i, timeoutMs); + results.push(result); + console.log( + `iter ${String(i + 1).padStart(2, "0")}/${iterations}: ` + + `spawn->first=${result.spawnToFirstMs.toFixed(3)}ms ` + + `roundtrip=${result.roundTripMs.toFixed(3)}ms`, + ); + } + + const spawnMetrics = summarize(results.map((r) => r.spawnToFirstMs)); + const roundTripMetrics = summarize(results.map((r) => r.roundTripMs)); + + console.log(""); + console.log(`iterations: ${iterations}`); + printMetrics("spawn_to_first_ms", spawnMetrics); + printMetrics("write_roundtrip_ms", roundTripMetrics); +} + +async function runIteration(iteration: number, timeoutMs: number): Promise { + const nonce = `${Date.now()}_${process.pid}_${iteration}`; + const readyMarker = `__zigpty_ready_${nonce}__`; + const echoMarker = `__zigpty_echo_${nonce}__`; + + const tSpawn = performance.now(); + const pty = spawn("/bin/sh", ["-c", `printf '${readyMarker}\\n'; exec cat`], { cols: 80, rows: 24 }); + + let firstChunkAt: number | null = null; + let output = ""; + let resolveReady!: () => void; + let rejectReady!: (err: Error) => void; + const readyPromise = new Promise((resolve, reject) => { + resolveReady = resolve; + rejectReady = reject; + }); + + let resolveEcho!: () => void; + let rejectEcho!: (err: Error) => void; + const echoPromise = new Promise((resolve, reject) => { + resolveEcho = resolve; + rejectEcho = reject; + }); + echoPromise.catch(() => {}); // prevent unhandled rejection on early exit + + let wroteMarker = false; + + const onDataDisposable = pty.onData((data) => { + const now = performance.now(); + if (firstChunkAt === null) { + firstChunkAt = now; + } + + const text = typeof data === "string" ? data : data.toString("utf8"); + output += text; + + if (output.includes(readyMarker)) { + resolveReady(); + } + if (wroteMarker && output.includes(echoMarker)) { + resolveEcho(); + } + }); + + const onExitDisposable = pty.onExit((info) => { + const message = `pty exited early (exitCode=${info.exitCode}, signal=${info.signal})`; + rejectReady(new Error(message)); + rejectEcho(new Error(message)); + }); + + const readyTimer = setTimeout(() => { + rejectReady(new Error(`timeout waiting for ready marker after ${timeoutMs}ms`)); + }, timeoutMs); + + try { + await readyPromise; + } finally { + clearTimeout(readyTimer); + } + + const spawnToFirstMs = (firstChunkAt ?? performance.now()) - tSpawn; + + const tWrite = performance.now(); + wroteMarker = true; + pty.write(`${echoMarker}\n`); + + const echoTimer = setTimeout(() => { + rejectEcho(new Error(`timeout waiting for echo marker after ${timeoutMs}ms`)); + }, timeoutMs); + + let roundTripMs = 0; + try { + await echoPromise; + roundTripMs = performance.now() - tWrite; + } finally { + clearTimeout(echoTimer); + onDataDisposable.dispose(); + onExitDisposable.dispose(); + try { + pty.close(); + } catch {} + await Promise.race([pty.exited.catch(() => undefined), sleep(50)]); + } + + return { spawnToFirstMs, roundTripMs }; +} + +function summarize(values: number[]): { min: number; max: number; avg: number; p95: number } { + const sorted = [...values].sort((a, b) => a - b); + const min = sorted[0] ?? 0; + const max = sorted[sorted.length - 1] ?? 0; + const sum = values.reduce((acc, value) => acc + value, 0); + const avg = values.length > 0 ? sum / values.length : 0; + const p95 = percentile(sorted, 95); + return { min, max, avg, p95 }; +} + +function percentile(sortedValues: number[], p: number): number { + if (sortedValues.length === 0) return 0; + const rank = Math.ceil((p / 100) * sortedValues.length) - 1; + const index = Math.max(0, Math.min(rank, sortedValues.length - 1)); + return sortedValues[index]!; +} + +function printMetrics(label: string, metrics: { min: number; max: number; avg: number; p95: number }): void { + console.log( + `${label}: min=${metrics.min.toFixed(3)} avg=${metrics.avg.toFixed(3)} ` + + `max=${metrics.max.toFixed(3)} p95=${metrics.p95.toFixed(3)} ms`, + ); +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); +} + +main().catch((error) => { + console.error(error instanceof Error ? error.message : String(error)); + process.exitCode = 1; +}); diff --git a/src/terminal.ts b/src/terminal.ts index 92d4f91..c25ee9c 100644 --- a/src/terminal.ts +++ b/src/terminal.ts @@ -40,6 +40,7 @@ export class Terminal implements AsyncDisposable { private _onData?: (terminal: Terminal, data: Uint8Array) => void; private _onExit?: (terminal: Terminal, exitCode: number, signal: string | null) => void; private _onDrain?: (terminal: Terminal) => void; + private _textDecoder = new TextDecoder(); /** @internal Listeners for waitFor support. */ _dataListeners: Array<(data: string) => void> = []; @@ -182,7 +183,7 @@ export class Terminal implements AsyncDisposable { _emitData(data: Uint8Array): void { this._onData?.(this, data); if (this._dataListeners.length > 0) { - const text = new TextDecoder().decode(data); + const text = this._textDecoder.decode(data); for (const listener of this._dataListeners) { listener(text); } @@ -211,7 +212,7 @@ export class Terminal implements AsyncDisposable { private _writeWindows(data: string | Uint8Array): number { if (!this._winHandle) return 0; - const str = typeof data === "string" ? data : new TextDecoder().decode(data); + const str = typeof data === "string" ? data : this._textDecoder.decode(data); const len = typeof data === "string" ? Buffer.byteLength(data) : data.byteLength; const doWrite = () => this._winNative!.write(this._winHandle!, str); if (this._winReady) doWrite(); diff --git a/zig/pty_darwin.zig b/zig/pty_darwin.zig index 2f4cce2..377ce46 100644 --- a/zig/pty_darwin.zig +++ b/zig/pty_darwin.zig @@ -62,16 +62,41 @@ pub fn resetSignalHandlers() void { } } -extern fn sysconf(name: c_int) c_long; - pub fn closeExcessFds() void { - // macOS has no close_range or /proc/self/fd. - // Use sysconf(_SC_OPEN_MAX) to get the actual limit. - const SC_OPEN_MAX = 5; // _SC_OPEN_MAX on macOS - const max_fd = sysconf(SC_OPEN_MAX); - const limit: c_int = if (max_fd > 0) @intCast(max_fd) else 256; - var fd: c_int = 3; - while (fd < limit) : (fd += 1) { - _ = std.c.close(fd); + // Enumerate /dev/fd to close only open FDs, avoids ~1M close() syscalls via sysconf. + const dir_fd = std.c.open("/dev/fd", .{ + .ACCMODE = .RDONLY, + .DIRECTORY = true, + .CLOEXEC = true, + }); + if (dir_fd < 0) { + // Last resort: brute-force close FDs 3..255 + var fd: c_int = 3; + while (fd < 256) : (fd += 1) { + _ = std.c.close(fd); + } + return; + } + defer _ = std.c.close(dir_fd); + + var base: i64 = 0; + var buf: [1024]u8 align(@alignOf(std.c.dirent)) = undefined; + while (true) { + const nread = std.c.getdirentries(dir_fd, &buf, buf.len, &base); + if (nread <= 0) break; + + var offset: usize = 0; + const end: usize = @intCast(nread); + while (offset < end) { + const d: *align(1) const std.c.dirent = @ptrCast(buf[offset..]); + const reclen: usize = d.reclen; + if (reclen == 0 or offset + reclen > end) break; + offset += reclen; + + const fd_num = std.fmt.parseInt(c_int, d.name[0..d.namlen], 10) catch continue; + if (fd_num > 2 and fd_num != dir_fd) { + _ = std.c.close(fd_num); + } + } } } diff --git a/zig/pty_unix.zig b/zig/pty_unix.zig index 3570989..35cc9d8 100644 --- a/zig/pty_unix.zig +++ b/zig/pty_unix.zig @@ -122,6 +122,7 @@ fn forkImpl(env: napi.napi_env, info: napi.napi_callback_info) !napi.napi_value pty.exitCallJs, &tsfn, )); + _ = napi.napi_unref_threadsafe_function(env, tsfn); const ctx = alloc.create(ExitContext) catch { _ = napi.napi_release_threadsafe_function(tsfn, .abort);