pty

Cross-platform PTY (pseudo-terminal) spawning for MoonBit native targets, integrated with moonbitlang/async.

pty
pseudo-terminal
async
native
moon add moonbit-community/pty@0.4.0
Download zip
Version
0.4.0
License
Apache-2.0
Last updated
2 days ago
Downloads
9K

Dependencies

README

#moonbit-community/pty

Cross-platform PTY (pseudo-terminal) spawning for MoonBit native targets, integrated with moonbitlang/async so reads and writes go through the async event loop instead of blocking the thread.

#API

@async.with_task_group(group => {
let pty = @pty.spawn(group, ["/bin/sh", "-c", "echo hello"])
defer pty.close()

let reader = pty.reader() // @raw_fd.RawFd
pty.write(@utf8.encode("ls\n")) // async
pty.resize(cols=120, rows=40)
let pid : Int = pty.pid()
let exit_code : Int = pty.wait()
})

@pty.spawn follows moonbitlang/async/process.spawn: it is attached to a task group, registers the master fd with the async event loop, and returns a handle that can be used while the child is running. On Unix, argv[0] is resolved via PATH using execvp; on Windows, the command is launched through CreateProcessA.

The deprecated method form Pty::spawn is kept for compatibility; prefer @pty.spawn in new code.

Pty::wait waits for the child process and returns its exit code. Pty::close only releases PTY resources; if the child is still running, it first requests child cancellation. Call wait explicitly when the exit code matters.

#Errors

Failures are reported as @moonbitlang/async/os_error.OSError(code, context~), where code is errno on Unix or GetLastError() on Windows. Use the @os_error predicates such as is_EACCES, is_ENOENT, and is_nonblocking_io_error to branch on specific kinds:

try {
@async.with_task_group(group => {
@pty.spawn(group, ["/bin/missing"])
})
} catch {
err is @os_error.OSError if err.is_ENOENT() => ...
err => raise err
}

#Platform strategies

PlatformMethodWhy
macOSopenpty() + moonbitlang/async self-spawn helperavoids fork() with mimalloc
Linuxopenpty() + moonbitlang/async self-spawn helpershares the async process spawn path
WindowsConPTY + CreateProcessA()No fork involved

#Windows: ConPTY process startup

Windows uses the ConPTY API instead of Unix-style PTYs:

  1. Create two synchronous pipes:
    • input pipe: parent writes keyboard input to inputWriteSide; ConPTY reads from inputReadSide
    • output pipe: ConPTY writes screen output to outputWriteSide; parent reads from outputReadSide
  2. Call CreatePseudoConsole(size, inputReadSide, outputWriteSide, ...).
  3. Prepare STARTUPINFOEX with PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE.
  4. Call CreateProcessA(..., EXTENDED_STARTUPINFO_PRESENT, ...).
  5. After CreateProcessA succeeds, close inputReadSide and outputWriteSide in the parent. The parent keeps only inputWriteSide and outputReadSide for async I/O.

The child process is launched with bInheritHandles=FALSE; the ConPTY handle is passed through the process-thread attribute list rather than inherited as a raw handle.

#Why STARTF_USESTDHANDLES is set

When the parent process has redirected stdio, for example inside GitHub Actions or a daemon/logging setup, Windows may otherwise copy those redirected stdio handles into the child process. In that state, child output can bypass ConPTY and go directly to the parent's stdout/stderr instead of the PTY output pipe.

To avoid that, the Windows startup path explicitly sets STARTF_USESTDHANDLES with zero-initialized stdio handles. This follows the same practical pattern used by established ConPTY implementations:

  • microsoft/node-pty sets STARTF_USESTDHANDLES, passes null stdio handles, and creates the child with bInheritHandles=false.
  • wezterm/portable-pty sets STARTF_USESTDHANDLES and uses invalid stdio handles to prevent the child from inheriting redirected parent output handles.

This is separate from the core ConPTY attachment. The actual PTY association is still made by PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE; STARTF_USESTDHANDLES only prevents inherited stdio redirection from competing with that attachment.

#Current Windows argv limitations

The Windows path currently joins argv into a CreateProcessA command line with simple space separation. This works for ordinary commands such as ["cmd.exe", "/c", "echo", "READY"], but it does not yet implement full Windows command-line quoting. Arguments containing spaces, quotes, or backslash-quote sequences may be parsed differently by the child process.

Because the implementation calls CreateProcessA, non-ASCII executable paths and arguments also depend on the process ANSI code page. A future Windows path should switch to CreateProcessW and proper Windows command-line escaping.

#macOS: the mimalloc + fork problem

MoonBit's release builds ship with mimalloc as the default allocator. On macOS, mimalloc registers itself as a custom malloc zone. When fork() is called, libSystem_atfork_child iterates all registered malloc zones in the child and calls their introspection callbacks. mimalloc's mi_introspect struct has a NULL function pointer for one of these callbacks, causing the child to segfault (signal 11, exit code 139) before it ever reaches exec().

Reproduction results (linking a minimal C forkpty() program with/without libmoonbitrun.o):

VariantWithout runtimeWith libmoonbitrun.o
forkpty() on main threadworkschild exits 139 (SIGSEGV)
forkpty() in a pthreadworkschild exits 139 (SIGSEGV)
posix_spawn() self-helperworksworks
MoonBit release without mimallocworksn/a

A pthread does not help, because pthread_atfork child handlers are process-wide — the child inherits the same malloc zones with the same NULL function pointer regardless of which thread called fork().

#Unix: the self-helper pattern

On macOS and Linux, this package leaves process creation to moonbitlang/async/process.spawn. The C side only owns PTY setup and the constructor that runs in helper mode before main().

Plumbing between parent and helper uses one env var plus stdio redirection:

  1. openpty() creates a PTY pair (master + slave fd).
  2. The parent creates two pipes — argv_pipe (parent → helper) and err_pipe (helper → parent) — and a dummy pipe write end whose fd is rebound to the PTY slave with dup2(slave, dummy_write.fd()).
  3. spawn launches the current command inside the caller's task group with:
    • stdin=argv_pipe.read
    • stdout=dummy_write (now the PTY slave fd)
    • stderr=err_pipe.write
    • MOONBIT_PTY_EXEC=stdio
  4. The constructor detects helper mode, duplicates stderr to keep the error pipe, treats fd 0 as argv input and fd 1 as the PTY slave, then calls login_tty(1).
  5. The parent streams the target argv (flattened as arg0\0arg1\0…argN\0) into argv_pipe and closes its write end.
  6. The constructor reads argv from argv_pipe until EOF, arms FD_CLOEXEC on its copy of the error pipe, and execvp()s. A successful exec auto-closes the error pipe so the parent sees EOF; any pre-exec failure writes the errno to the pipe and _exit()s.
  7. The parent reads from the error pipe: 4 bytes → failure with that errno, EOF → success.

#Why argv must be copied

The parent also re-spawns with its own executable path and original OS argv as helper-process arguments (separate from the target argv that gets streamed through the pipe). This is required because in MoonBit debug mode, the current executable can be tcc (MoonBit's native runner), not the test/application binary. Re-spawning tcc without the original arguments means it doesn't know which compiled module to load; the constructor would never run.

In release mode, MoonBit compiles to a standalone native binary, so the self command is the actual binary with the constructor baked in. The extra argv is harmless in that case because the constructor fires before main() parses them.

#
Pty

type Pty

Handle to a spawned PTY plus its event-loop registration.

On Unix the PTY master fd is used for both reading and writing, so read_io and write_io are the same RawFd wrapper around the same IoHandle. The two fields exist so that reader and writer code paths can use intent-revealing names. On Windows they point at the separate ConPTY input/output pipe HANDLEs.

#
Pty::cancel

fn Pty::cancel(self : Pty) -> Unit

Request cancellation of the child process.

#
Pty::close

fn Pty::close(self : Pty) -> Unit

Close the PTY, releasing all resources.

Closes the owned RawFd wrapper(s) and then tears down the underlying OS PTY handle.

Resource lifetime is explicit: call this when the PTY is no longer needed. The native handle finalizer does not close OS resources.

Safe to call multiple times because the underlying close paths are idempotent.

#
Pty::pid

fn Pty::pid(self : Pty) -> Int

Get the spawned child PID when available.

#
Pty::reader

Get the async reader for this PTY.

The returned RawFd is owned by the Pty; do not call close() or detach() on it. Use Pty::close to release the registration.

#
Pty::resize

fn Pty::resize(self : Pty, cols~ : Int, rows~ : Int) -> Unit raise
OSError

Resize the PTY window.

Raises @os_error.OSError if the underlying syscall fails.

#
Pty::spawn

#deprecated("Use @pty.spawn directly")
#as_free_fn
async fn[X] Pty::spawn(group :
TaskGroup
[X], argv : Array[String], cols? : Int, rows? : Int, no_wait? : Bool) -> Pty

Spawn a new PTY running the given program.

argv[0] is the program to execute (resolved via PATH by execvp), and argv[1..] are its arguments.

cols and rows default to 80x24 if not specified.

Must be called from inside an async event-loop context — the PTY's master fd is registered with the event loop on construction so that reads and writes can suspend instead of blocking the thread.

Raises @os_error.OSError wrapping errno (Unix) or GetLastError() (Windows) if the underlying syscall fails.

#
Pty::wait

async fn Pty::wait(self : Pty) -> Int

Wait for the spawned child process to exit and return its exit code.

This is backed by moonbitlang/async/process, so waiting/reaping lives in the async process layer rather than in Pty::close.

#
Pty::write

async fn Pty::write(self : Pty, data : Bytes) -> Unit

Write data to the PTY stdin.

Suspends the current coroutine via the async event loop until all bytes are written, so a stuck child that stops draining its input buffer won't block other concurrent tasks in the runtime.

Raises @os_error.OSError if the underlying write fails.

Source Files