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
163 changes: 163 additions & 0 deletions scripts/bench-latency.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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<IterationResult> {
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<void>((resolve, reject) => {
resolveReady = resolve;
rejectReady = reject;
});

let resolveEcho!: () => void;
let rejectEcho!: (err: Error) => void;
const echoPromise = new Promise<void>((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<void> {
return new Promise((resolve) => {
setTimeout(resolve, ms);
});
}

main().catch((error) => {
console.error(error instanceof Error ? error.message : String(error));
process.exitCode = 1;
});
5 changes: 3 additions & 2 deletions src/terminal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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> = [];

Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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();
Expand Down
45 changes: 35 additions & 10 deletions zig/pty_darwin.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
}
}
1 change: 1 addition & 0 deletions zig/pty_unix.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down