Skip to content

UniPty v0.2.2: wider engine choice on Node, a bound for output memory

jixoai v0.2.2 unipty

0.2.1 and 0.2.2 covered together. Node gains a second official route, @unipty/backend-zigpty, with zero runtime dependencies and prebuilds for 8 platform tuples in the main tarball; Windows runs, with the buffering semantics declared as they are; stalled output can spill to disk, so memory has a bound. No breaking changes.

이 글은 영어로 작성되었습니다. 중국어 버전 읽기 →

$ npm view @unipty/backend-zigpty version
0.2.1

UniPty v0.2.2 is out (2026-09-07). This post covers 0.2.1 and 0.2.2 together, grouped by theme; both releases are listed at the end.

The main line: Node gains a second official route, @unipty/backend-zigpty, built on the third-party zigpty (npm zigpty@0.2.1, MIT, maintained by pi0), a PTY engine written in Zig. Together with the existing @unipty/backend-node-pty, Node now has two official routes to choose from.

A second official route on Node

The @lydell distribution of node-pty splits its platform binaries into optionalDependencies sub-packages, and they do not install when the installer skips optional dependencies; in environments you do not control (restricted CI, offline mirrors), that is a failure point.

zigpty does not depend on the installer: zero runtime dependencies, zero install scripts, and the prebuilds for 8 platform tuples (Linux/macOS/Windows × x64/arm64, plus musl variants on Linux) all ship inside the main tarball, about 420 KB unpacked (npm unpackedSize). The install result does not change under --omit=optional and similar flags.

The two routes share one public contract; the differences sit at the engine level. The selection surface:

Dimensionzigptynode-pty (@lydell distribution)
Runtime dependencies0platform sub-packages (optionalDependencies)
Install scriptsnonenone
Distributionprebuilds for 8 platform tuples in the main tarball, ~420 KB unpackedone sub-package per platform, installed per host
Install robustness--omit=optional and similar flags change nothingskipped optional deps mean not installed
Native text outputyes (encoding: "utf8")yes (encoding: "utf8")
Byte writesrejected by default, needs writeDecodeaccepted by default
Windowsruns, buffered output (see below)declared, evidence-gated
Transport read errors surfacedindistinguishable (see below)surfaced as unsupported

zigpty's advantage concentrates on distribution. The costs are in the caveats below and the next two sections: byte writes need an extra option, Windows output is buffered, and the memory bound comes from an option.

One upstream behavior to watch: when a prebuild cannot load, the zigpty library silently falls back to a pipe-based pseudo-PTY, where isatty and kernel geometry both fail. The adapter hard-gates that fallback at readiness; hasNative false means unsupported, so a ready Backend always means a real PTY underneath.

Switching routes changes no code

npm install unipty @unipty/backend-zigpty
import { UniPty } from "unipty";
import { createZigptyBackend } from "@unipty/backend-zigpty";

const backend = await createZigptyBackend();
const unipty = new UniPty({ backend });

const pty = unipty.spawn(["/bin/sh", "-i"], {
  terminal: { cols: 120, rows: 40 },
});
for await (const text of pty.stream({ encoding: "utf8" })) {
  process.stdout.write(text);
}

spawn, stream, write, resize, close, terminate, and exited behave identically to the other three routes; switching routes is a one-line factory change.

The 25-scenario public contract suite passed in full against the installed package artifact: one CI evidence record each from ubuntu and macos entered the release catalog (node 22.20.0, 2026-09-07, see the compatibility catalog), and a local darwin-arm64 run against the installed package passed 25/25.

Windows runs

zigpty's Windows build ships a ConPTY prebuild, but its public pause() / resume() are empty methods there, so consumer-paced output backpressure cannot reach the kernel. The route does not fail closed on Windows: the adapter keeps draining engine callbacks and runs as usual.

"Output backpressure does not reach the kernel on this platform" is declared as a route-level limitation, the same class the Deno route applies to its internal reader thread. UniPty does not discipline upstream engines: third-party developers will bring their own backends, and bridging engine differences in adapter code is the core work of this architecture (change proposal).

No platform branch is needed anywhere:

const backend = await createZigptyBackend(); // same line on Windows and unix

Support claims stay evidence-gated. Windows tuples remain declared-unverified until public contract evidence exists; the compatibility catalog presents them as such. Buffered operation is not a claim of verified support.

Stalled consumers no longer mean unbounded memory

A stalled consumer is the main memory risk. A terminal attached to a browser tab that the user switches away from for ten minutes produces ten minutes of output with nowhere to go, piling up in the adapter's queue; on Windows, where backpressure cannot reach the kernel, the pile grows faster. outputSpool replaces that queue with a disk-backed FIFO whose in-memory head is capped at 1 MiB by default:

const backend = await createZigptyBackend({
  outputSpool: { memoryBytes: 4 * 1024 * 1024 }, // or just `true` for defaults
});

The public stream is byte-identical with and without the spool: text records round-trip per complete record, chunk boundaries are preserved, and replay strictly follows the consumer's pull pace. When the child exits, the completion signal waits for the backlog to drain, so a fast-exit tail is never cut by EOF; an explicit close() still completes synchronously and deletes the temp file. Enable it on Windows: there is no kernel backpressure there, and this is the memory bound for stalled readers.

Mechanism and boundaries
  • Records past the memory head spill to one adapter-owned temp file (0600 permissions, exclusive create), in the OS temp directory by default, configurable via directory

  • Spill IO is synchronous; disk usage while backlogged is unbounded by design, bounded only by the child's own output

  • An abruptly killed process leaves the temp file to OS tmp reaping

  • On platforms where the engine can pause (unix), passing the memory bound propagates pressure back into the kernel, blocking the child instead of piling higher

  • Spill failures (ENOSPC, a missing directory) terminate the output source with a typed error instead of silently lifting the memory cap

Caveats

The full difference surface is the capability matrix in the README. Beyond the two sections above, three things to know up front.

  1. Byte writes need an option. The engine's write accepts strings only, so the Endpoint rejects byte input with unsupported by default. For byte input streams (an xterm.js front end and the like), enable writeDecode; the decoding is stateful and safe across chunk splits.

const backend = await createZigptyBackend({ writeDecode: true });
pty.write(new TextEncoder().encode("echo hi\r"));
  1. Transport read errors are indistinguishable. The engine exposes no transport-layer events. The adapter repossesses the read stream inside the exit window, uses its real end / close as EOF, and keeps a 50 ms quiescence window as fallback (re-armed by every late chunk). A read error cannot be distinguished from clean EOF on this route; that is a declared limitation.

  2. Exit shapes. A signalled death reports { exitCode: 0, signal: "SIGTERM" } (the exit code keeps the engine's original value); a missing executable reports { exitCode: 1, signal: null } as an exit observation, not an exception.

Signalled-death shapes across the four routes
node-pty   { exitCode: null, signal: "SIGTERM" }
zigpty     { exitCode: 0,    signal: "SIGTERM" }
bun        { exitCode: null, signal: "SIGTERM" }
deno-ffi   { exitCode: 1,    signal: null }

Other changes

  • Fixed a killed flooded child never settling its exit observation while paused reads held a backlog: terminate() now resumes master reads after kill (9bac5d7)

  • Fixed output stranded in the kernel when a backpressure pause landed right before child exit: the repossessed stream is resumed inside the exit window (0a9a172)

  • Fixed fast-exit children losing output on ubuntu: the adapter now repossesses the read stream inside the exit window (3d56f71)

  • Fixed writeDecode saturation rejection advancing decoder state (f083127)

  • Malformed outputSpool values (false, numbers, or strings) now fail with a stable invalid-argument instead of silent defaults (9bac5d7)

  • Release catalog registry re-keyed by route identity with dual identity validation (b878ce4, f083127)

  • Bun route: clean EOF is deferred by one macrotask, so late same-turn data chunks survive (a0852e8)

  • Edge-case battery covering memoryBytes extremes, empty records, corrupted records, and mid-backlog cancellation; package tests grew to 70 (f8ad01d)

  • README restructured with a capability matrix for the four official routes, docs synced repo-wide; the docs site moved to the jixoai identity and gained a Chinese mirror (bb807db, 021a82d, 7915ac4, a635df2)

From v0.2.0 to v0.2.2 there are 50 commits: strict conventional-prefix counting gives feat 10, fix 8, docs 18, test 2, chore 5, style 6, ci 1; commits marked ! for breaking changes: 0.

Full list in compare v0.2.0...v0.2.2.

Upgrading

No breaking changes; purely additive. The Backend package's npm version is 0.2.1:

npm i @unipty/backend-zigpty@0.2.1

If you are already on unipty@0.2.0, Core needs nothing.

Thanks

The route is built on zigpty, pinned exactly at 0.2.1. Thanks to pi0 and the UnJS community.

Links