Repository navigation
Releases: Particle-Academy/fancy-pty
Release list
v0.2.0
Four process-lifetime fixes asked for by Genie 2.0.0-beta.33. Every one is
proven by test/live/lifecycle.live.mjs on real CI runners. That suite was red
against 0.1.4 first and is green with these fixes.
Changed
-
BEHAVIOUR CHANGE: a pty now dies with the process that opened it. If the
controlling process exits, crashes or is killed outright, every helper it
owned hard-kills its whole tree and exits. On Windows the job object is
terminated at once. On POSIX the process group gets SIGHUP, up to 250 ms to
leave, then SIGKILL. Before this, on Linux and macOS, every pty and
everything it started kept running with nobody left to read or stop it, so a
restarted app started duplicates. CI measured 10 helpers, 10 children and 10
grandchildren all still alive 2 s after the controller was SIGKILLed on
0.1.4. Now it is 0 / 0 / 0 on all four live platforms.What you must DO: nothing, unless you relied on terminals surviving the
process that opened them. There was never a supported detached mode, so
that only ever worked by accident, and only on POSIX. If you want terminals
to outlive your UI, open them from a long-lived host process, as
fancy-term-host's pty host already does. That host stays their controller,
so this change does not affect it.On Windows the old behaviour was already this one, but only by accident.
CI measured 0 survivors on 0.1.4 there, because a Node controller put the
helper in libuv's kill-on-close job. Spawning the helper detached (next entry)
removes that, and the helper now does it itself. -
Windows: the helper no longer has a console of its own. It is spawned
detached (DETACHED_PROCESS) instead of with a hidden console, which saves
oneconhost.exe(about 8 MB) per pty. Measured in CI: 2 conhosts per helper
on 0.1.4, 1 now (the pseudoconsole's own). Nothing to do. The helper's stdio
is still the three pipes, and no console window can appear.
Added
-
pty.childPid: the pid of the command itself (argv[0]), sent by the
helper right after the command is created and before its first output. So it
is set by the firstonData, and staysundefinedif the command never
started.pty.pidis unchanged: it is still the helper's pid, and
killing it still takes the whole tree. -
pty.kill({ graceMs }), a graceful stop. On Windows it types Ctrl-C into
the console. On POSIX it sends SIGTERM to the process group. If the whole
tree, grandchildren included, has not exited withingraceMs, it is
hard-killed.pty.killedBy("graceful"|"forced"|null) and a
new second argument toonExit,{ killedBy }, report which happened.
kill()with no argument is unchanged: an immediate hard kill, now reported
as"forced". ExistingonExit(code => …)listeners keep working. Note
that an interactive shell at its prompt ignores both Ctrl-C and SIGTERM, so
a bare shell always ends"forced". The graceful path is for the workers
running inside it."graceful"means the tree exited by itself after the polite signal. It
does not mean the program finished its work. Whether Ctrl-C or SIGTERM
lets a job complete is up to the program. One case worth checking before you
rely on it: PHP on Windows has nopcntl, and as far as we know Laravel's
queue:workcannot trap Ctrl-C there, so it would stop mid-job and still be
reported"graceful". That is not measured. On POSIX,queue:worktreats
SIGTERM as "finish the current job, then stop". -
Protocol: a
PIDframe (0x84), aKILLEDframe (0x85), and an optional
[graceMs u32]payload onKILL. Seenative/proto.md. The emptyKILLis
the 0.1.x frame and keeps its meaning.
Fixed
- Windows: a process the command starts in its first instant can no longer
escape the tree kill. The command used to be assigned to the job object
only after it had started running. Anything it launched in that window was
born outside the job, so neitherkill()nor the helper's death could reach
it. It is now created suspended, assigned, then resumed. The window was too
narrow to hit on demand, so the test holds it open with a test-only helper
setting (FANCY_PTY_TEST_ONLY_ASSIGN_DELAY_MS): red on 0.1.4, green now.
Only a descendant that is not attached to the pseudoconsole could escape.
One that stays on the console dies when the console closes anyway, which the
first red run showed.
v0.1.4
Fixed
-
A write to a helper that has already exited no longer throws an uncaught
EPIPE.kill()(orwrite/resize) in the window between the helper's
death and thecloseevent wrote into a pipe whose reader was gone. Node
reports that as anerrorevent on the helper's stdin, which had no listener,
so it was thrown fromprocess.nextTick. In Electron that is the main-process
"A JavaScript error occurred" dialog, a synchronous GTK dialog on the main
thread: measured in Genie 2 on Linux, where a front-door Caddy that could not
bind its port exited and Genie's immediate stop hit the window. Genie froze
behind a dialog nobody could see.The helper's stdin now has an
errorlistener.EPIPEand
ERR_STREAM_DESTROYEDmean the helper is gone, whichclose(or the Exit
frame) already reports with what actually happened, so they are dropped. Any
other stdin error goes toonErrorlike every other fault (and to stderr when
nobody listens, #1). Nothing is thrown. A caller observes no change except
that the crash is gone.Test:
src/__tests__/stdin-epipe.test.tsuses a helper that closes its stdin
and stays alive, so the window is reproduced every run. Its control first shows
that a plain write getsEPIPE; the check counts uncaught exceptions. It is red
on 0.1.3 ([ 'write EPIPE' ]) and green with the fix, run in a Linux VM. -
A helper that cannot start now names fancy-pty and its path on every
platform, not only on Windows. A non-executable helper is a synchronous
throw on Windows (EFTYPE), which 0.1.0 already wrapped. On POSIX it is an
errorevent (EACCES), and a missing path is ENOENT everywhere. Those arrived
as a barespawn EACCESnaming neither the package nor the file. An error
before the helper'sspawnevent is now worded "fancy-pty: could not start
the helper at ()", with the original ascause. Afterspawn,
errors pass through unchanged. The test that covered this asserted only the
Windows half, so it was red on the Linux CI runner for 0.1.3. It now asserts
the same promise on every platform.
Added
-
npm run verify:flash — a live Windows check that no console window ever
APPEARS during a spawn/kill cycle (fancy-term-host #4), plus the probe it
runs on. No consumer-visible change; this measures a claim that was previously
only argued.
The claim was architectural and sound — this package ships no agent process,
so there is no second executable to pop a window the way node-pty's legacy
ConPTY agent did. But fancy-term-host's 0.10.0-rc.1 notes said #4 was "in
the Windows VM acceptance set" and "re-proved rather than assumed", and when
that was checked against the acceptance record it was not there — the
smoke covered the window and the helper was spawned with windowsHide, but
nothing asserted "no console flashed" as its own control. A confident sentence
stood in for a measurement.
Measured now, on win32-x64, 8 spawn/kill cycles against 0.1.3: 8
PseudoConsoleWindows created, 0 ever shown. The discriminator is the
EVENT_OBJECT_SHOW, not the window — ConPTY creates one per pty regardless,
so counting windows would have produced a false positive on correct code.
--verify runs a positive control first and reports VOID rather than PASS
if the probe cannot see a console it opened itself: a flash lasts tens of
milliseconds, and an instrument that cannot catch one is indistinguishable
from a package that never flashes. Proven to go red as well — a deliberately
flashing subject gives 6 visible shows and exit 1.
Known limit, stated rather than rounded off: this runs on an interactive
desktop, while #4 appeared under the windowless detached service host.
v0.1.3
Fixed
-
A spawn failure is now visible to the HOST, not only to a terminal that may
not exist (#1).onErrorwas the only channel, andfancy-term-host
forwards it into the terminal data stream — correct for a pty that opened and
then broke, useless for one that never opened. In a packaged Electron app the
0.1.2 asar bug therefore leftpid -1and nothing else; it was diagnosed from
the shape of the failure rather than from anything this package said.An
errorwith no registered listener now also goes toprocess.stderr,
which is Node's own convention for an unhandlederror: do not swallow it.
It deliberately does not throw the wayEventEmitterdoes — crashing an
Electron main process over one failed terminal is worse than a loud line —
and the check happens at emit time, not atopen()time, so a caller that
registersonErroron the line afteropen()is never second-guessed and
never printed over.This changes nothing a caller can observe except that a fault nobody listened
for becomes visible. The alternatives all alteredopen()'s synchronous,
node-pty-shaped contract, which exists so consumers need no adapter change. -
A synchronous spawn failure now names the package and the path.
spawn
fails two different ways, measured rather than assumed: a directory or a
nonexistent path emits an asyncerror(ENOENT), while a non-executable
file throws synchronously (EFTYPE on Windows). The sync half already
reached the caller, but as a barespawn EFTYPEnaming neither this package
nor the file — and whoever reads that is debugging someone else's library.
Added
src/__tests__/unheard-error.test.ts. Its fixture is a directory, which
is the only construct that reproduces the real shape: theexistsSync
pre-flight PASSES and the spawn then fails asynchronously — exactly what the
asar bug did, where Electron's patchedfssaid yes and the unpatched
spawnsaid no. The first draft used a.tsfile and failed for the wrong
reason, which is how the two failure modes came to be measured.
v0.1.2
Fixed
-
The helper could not be spawned from inside a packaged Electron app. This
is the one that mattered: in a packaged app the module lives at
resources/app.asar/node_modules/@particle-academy/fancy-pty/, so
packageRoot()resolved INTO the archive and the helper path named the
archive — while the executable sat correctly on disk under
resources/app.asar.unpacked/.../bin/<platform>-<arch>/.Electron patches
fsto redirect reads of unpacked files; it does not patch
child_process.spawn. So the pre-flightexistsSyncSUCCEEDED and the
spawn then failed on a path the OS cannot execute. A terminal came back
pid -1, no helper process ever appeared, and the error message blamed
asarUnpack— advice for a problem the consumer did not have, since their
asarUnpackwas correct.Any path segment ending
.asaris now rewritten to.asar.unpacked
(exported asunpackedPath). Not justapp.asar: Electron allows
arbitrarily named archives andasarUnpackalways produces
<name>.asar.unpacked, so hardcoding the common name would have fixed it for
the reporter and nobody else. The match is anchored to the segment end, so it
is idempotent and leavesapp.asar.unpackedandmy.asar.backupalone.node-ptynever hit this because its whole package is unpacked and its
.nodeloads throughprocess.dlopen, which Electron does patch.Found by
claude · tynnon an INSTALLED build. Nothing in this repo could
have caught it: the live suite, 21 unit tests and all six CI targets were
green throughout, because every one of them runs from an unpacked tree.
Packaging is a different environment rather than a different configuration.
src/__tests__/asar.test.tsis the cheapest stand-in — the rewrite is pure
string work, so it needs no Electron, no build and no installer.An explicit
helperPath({ dir })is deliberately NOT rewritten: that is the
consumer's own claim about where the binary is, and silently changing it would
make their override mean something they did not say.
Added
-
The live suite asserts issue #4 directly: the pty's console exists but is
NOT VISIBLE. The child reports its ownGetConsoleWindow/
IsWindowVisiblefrom inside the pty, so the check needs no desktop, no
interactive session and nobody watching a screen — and it runs on every
Windows CI pass rather than once by eye.It closes a gap
claude · tynnnamed honestly in their Windows VM
acceptance: that run is headless over CDP in session 0, so "no window
appeared" is not something the VM can observe. This checks the same fact from
inside the process instead.Note the shape, because the obvious assertion is wrong: a ConPTY child ALWAYS
has a non-null console HWND, since conhost creates a window and never shows
it. Non-null is correct and expected; VISIBLE is the failure. An earlier probe
asserted non-null was the bug and invented one.
v0.1.1
Changed
-
The helper binaries are stripped, cutting the package from 13.64 MB to a
fraction of it. Measured from the published 0.1.0 tarball: the Linux
helpers were 5.60 MB and 5.48 MB against Windows' 0.30 MB — 18x — because
ELF and Mach-O carry debug symbols INSIDE the binary where Windows puts them
in a separate.pdb, which was already excluded. Built with
-C strip=symbols.Nothing a consumer does changes. The trade is that a crash backtrace loses
function names; panic messages keep their file and line regardless, since
those compile in as static strings, and this is ~700 lines of straight-line
syscalls with no recursion.It took shipping 0.1.0 to see this: the arch gate checks the binaries are
correct, and had no opinion on whether they were fat.
v0.1.0
Added
- The Windows backend, end to end. ConPTY through the in-box
kernel32
pseudoconsole API: spawn withargv/cwd/env, read, write, resize, kill
via a job object, and exit reporting. Noconpty.dll, noOpenConsole.exe,
no redistributable of any kind. - The per-pty helper executable, built by
rustcalone — noCargo.toml,
no crates, Rust std with hand-writtenextern "system"declarations. Linked
with-C target-feature=+crt-staticso it runs on a bare Windows with no
Visual C++ redistributable installed; a missing redistributable makes every
.nodeaddon fail asCannot find native binding, which reads like a missing
file rather than a missing runtime. - The framed stdin/stdout protocol (
native/proto.md) and its JS codec,
withstderrleft free for diagnostics so a crashing helper can still say why
without corrupting the stream. npm run verify:pty— the live suite: real shells, the execution oracle,
resize, kill, a failed spawn, an orphan sweep, and the leak bar. It opens with
a positive control (hold 20 ptys, confirm the gauges rise), because a leak
check that has never seen its gauges move cannot go red.prepublishOnlyreads every shipped binary's own PE / ELF / Mach-O header and
compares the machine field with the directory it sits in. A present-files
check is what let a host-archconpty.dllship green on a cross-arch build.
Notes
-
The helper resolves IN PLACE under the install directory and is never
extracted to a temp path. A hard constraint, not an implementation detail:
Genie's upgrade teardown kills and names everything running from the install
dir, so a helper inos.tmpdir()would be invisible to that sweep and one
stuck helper could jam an upgrade with nothing able to name it. -
ESM with an
exports.importcondition. Measured rather than chosen: a
built ESM Electron main bundle leaves packages external, and Node refuses a
named import from a CJS package with getter-defined exports. CJS is carried
too, for everyone else. -
The POSIX backend —
posix_openpt/grantpt/unlockpt/ptsname,
thenfork+setsid+ioctl(TIOCSCTTY)+dup2+execvp, with a
CLOEXEC errno pipe so a failed exec is reported rather than appearing as a
shell that exited 127. Verified by the live suite on real CI runners:platform per pty threads handles / fds memory win32-x6432 ms 0.000 0.000 OS handles 0.009 MB linux-x643 ms 0.000 0.000 fds 0.036 MB linux-arm642 ms 0.000 0.000 fds 0.021 MB darwin-arm6412 ms 0.000 0.000 fds 0.044 MB darwin-x64andwin32-arm64cross-compile clean but have not run — nothing
in CI hosts them natively, and "builds" is not "works". -
The leak check measures the bar's own units — threads, OS handles/fds and
RSS (test/live/process-stats.mjs). An earlier version gaugedlibuvhandle
counts, which are Node's event-loop handles rather than OS handles, and did
not measure threads at all; it reported a pass against a threshold it was not
measuring. An unavailable metric is now skipped out loud rather than scored
as zero. -
npm run linttypechecks the helper for all six targets, not just the
host.c_charis unsigned onaarch64-linuxand signed elsewhere, so
hardcodedi8C-string pointers compiled on four targets and failed on one;
acfg-heavy file is only as checked as the targets you compiled it for, and
the arms you did not compile are unparsed rather than merely untested.