Skip to content

pty.exited never resolves when PTY is the only async work in flight #4

@privatenumber

Description

@privatenumber

Bug

pty.exited never resolves — and pty.onExit never fires — when the PTY is the only async operation keeping the event loop alive.

Minimal repro:

import { spawn } from 'zigpty';

const pty = spawn('uv', ['--help'], { cols: 80, rows: 40 });
const exitCode = await pty.exited; // never resolves — Node exits first
console.log('exit code:', exitCode);
$ node zigpty-repro.mjs
Warning: Detected unsettled top-level await at zigpty-repro.mjs:3
const exitCode = await pty.exited;
                 ^
$ echo $?
13

Same failure with pty.onExit(cb) or terminal: { data() {} }. Any command: uv --help, tsc --help, pnpm add --help.

Root cause

zig/pty_unix.zig, line 125:

_ = napi.napi_unref_threadsafe_function(env, tsfn);

The exit-callback tsfn is unref'd immediately after creation. Once the child exits and the tty.ReadStream closes, no ref'd handles remain → Node fires beforeExit and exits before the Zig exit-monitor thread can dispatch napi_call_threadsafe_function.

Proof — adding any ref'd timer makes it work:

const keepAlive = setInterval(() => {}, 100);
const pty = spawn('uv', ['--help'], { cols: 80, rows: 40 });
pty.exited.then((code) => {
    clearInterval(keepAlive);
    console.log('exit code:', code); // → 0
});

Suggested fix

Remove the early napi_unref_threadsafe_function call. Let the tsfn keep the event loop alive and release it only after the exit callback is delivered. The exit-monitor thread already calls napi_release_threadsafe_function in its defer block — removing line 125 should be sufficient.

Workaround

const keepAlive = setInterval(() => {}, 100);
const pty = spawn('uv', ['--help'], { cols: 80, rows: 40 });
const exitCode = await pty.exited;
clearInterval(keepAlive);

Environment

  • Node: v24.11.0
  • zigpty: 0.1.6
  • OS: macOS 25.2.0 (darwin-arm64)

Happy to test a fix or send a PR.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions