UniPty v0.2.2: wider engine choice on Node, a bound for output memory
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:
| Dimension | zigpty | node-pty (@lydell distribution) |
|---|---|---|
| Runtime dependencies | 0 | platform sub-packages (optionalDependencies) |
| Install scripts | none | none |
| Distribution | prebuilds for 8 platform tuples in the main tarball, ~420 KB unpacked | one sub-package per platform, installed per host |
| Install robustness | --omit=optional and similar flags change nothing | skipped optional deps mean not installed |
| Native text output | yes (encoding: "utf8") | yes (encoding: "utf8") |
| Byte writes | rejected by default, needs writeDecode | accepted by default |
| Windows | runs, buffered output (see below) | declared, evidence-gated |
| Transport read errors surfaced | indistinguishable (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
directorySpill 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.
Byte writes need an option. The engine's
writeaccepts strings only, so the Endpoint rejects byte input withunsupportedby default. For byte input streams (an xterm.js front end and the like), enablewriteDecode; the decoding is stateful and safe across chunk splits.
const backend = await createZigptyBackend({ writeDecode: true });
pty.write(new TextEncoder().encode("echo hi\r"));
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/closeas 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.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
outputSpoolvalues (false, numbers, or strings) now fail with a stableinvalid-argumentinstead 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
Changelog: GitHub Release v0.2.1 · GitHub Release v0.2.2 · compare v0.2.0...v0.2.2
Docs: unipty.jixoai.com · capability matrix · outputSpool option
Upgrade: non-breaking, one
npm iDiscussion: GitHub Issues
npm: @unipty/backend-zigpty · unipty
Previous release: UniPty v0.2.0 (released 2026-08-20)
Chinese version: /zh/blog/2026-09-07-unipty-v0-2-2/
