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.
@async.with_task_group(group => {
let pty = @pty.spawn(
group,
["/bin/sh", "-c", "echo hello"],
cwd="/workspace",
)
defer pty.close()
let reader = pty.reader() // @raw_fd.RawFdStream, implements @io.Reader
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
CreateProcessW. The optional cwd argument sets the child's initial working
directory; when omitted, the child inherits the parent's working directory.
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.
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 | Method | Why |
|---|---|---|
| macOS | openpty() + moonbitlang/async self-spawn helper |
avoids fork() with mimalloc |
| Linux | openpty() + moonbitlang/async self-spawn helper |
shares the async process spawn path |
| Windows | ConPTY + CreateProcessW() |
No fork involved |
Windows uses the ConPTY API instead of Unix-style PTYs:
- Create two synchronous pipes:
- input pipe: parent writes keyboard input to
inputWriteSide; ConPTY reads frominputReadSide - output pipe: ConPTY writes screen output to
outputWriteSide; parent reads fromoutputReadSide
- input pipe: parent writes keyboard input to
- Call
CreatePseudoConsole(size, inputReadSide, outputWriteSide, ...). - Prepare
STARTUPINFOEXwithPROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE. - Call
CreateProcessW(..., EXTENDED_STARTUPINFO_PRESENT, ...). - After
CreateProcessWsucceeds, closeinputReadSideandoutputWriteSidein the parent. The parent keeps onlyinputWriteSideandoutputReadSidefor 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.
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-ptysetsSTARTF_USESTDHANDLES, passes null stdio handles, and creates the child withbInheritHandles=false.wezterm/portable-ptysetsSTARTF_USESTDHANDLESand 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.
Windows receives one command-line string rather than an argv array. The
Windows path quotes each argument while constructing that command line, then
passes the command line and optional working directory to CreateProcessW as
UTF-16. Non-ASCII executable paths, arguments, and working directories
therefore do not depend on the process ANSI code page.
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):
| Variant | Without runtime | With libmoonbitrun.o |
|---|---|---|
forkpty() on main thread |
works | child exits 139 (SIGSEGV) |
forkpty() in a pthread |
works | child exits 139 (SIGSEGV) |
posix_spawn() self-helper |
works | works |
| MoonBit release without mimalloc | works | n/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().
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:
openpty()creates a PTY pair (master + slave fd).- The parent creates two pipes —
argv_pipe(parent → helper) anderr_pipe(helper → parent) — and a dummy pipe write end whose fd is rebound to the PTY slave withdup2(slave, dummy_write.fd()). spawnlaunches the current command inside the caller's task group with:stdin=argv_pipe.readstdout=dummy_write(now the PTY slave fd)stderr=err_pipe.writeMOONBIT_PTY_EXEC=stdio
- 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). - The parent streams the target
argv(flattened asarg0\0arg1\0…argN\0) intoargv_pipeand closes its write end. - The constructor reads argv from
argv_pipeuntil EOF, armsFD_CLOEXECon its copy of the error pipe, andexecvp()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. - The parent reads from the error pipe: 4 bytes → failure with that errno, EOF → success.
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.