PTY & Shell

forkpty() on macOS: The Login Shell Setup

TermOnMac creates terminal sessions using forkpty(), the POSIX call that forks a child process and connects its stdin/stdout/stderr to a new PTY. The setup in PTYSession.swift handles several macOS-specific details that affect shell behavior.

The forkpty() Call

Swift marks fork() as unavailable, so we can’t invoke it directly. The usual forkpty() wrapper in <util.h> would be one option, but TermOnMac instead assembles the equivalent from openpty() + fork() in a C helper — same result, but explicit about each step. It lives in the CPosixHelpers module:

// mac_agent/Sources/CPosixHelpers/posix_helpers.c
#include <util.h>

// Swift marks fork() as unavailable, so we wrap it in C.
int c_forkpty(int *masterfd, struct winsize *ws) {
    int master, slave;
    if (openpty(&master, &slave, NULL, NULL, ws) < 0) return -1;

    pid_t pid = fork();
    if (pid < 0) { close(master); close(slave); return -1; }

    if (pid == 0) {
        // Child
        close(master);
        setsid();
        ioctl(slave, TIOCSCTTY, 0);
        dup2(slave, STDIN_FILENO);
        dup2(slave, STDOUT_FILENO);
        dup2(slave, STDERR_FILENO);
        if (slave > STDERR_FILENO) close(slave);
        return 0;
    }

    // Parent
    close(slave);
    *masterfd = master;
    return pid;
}

setsid() detaches the child from the parent’s controlling terminal, and ioctl(slave, TIOCSCTTY, 0) makes the slave PTY the child’s new controlling terminal. Without these two calls, the child would inherit the daemon’s (nonexistent) session, and job control signals wouldn’t flow to the shell.

The Swift side calls c_forkpty:

// mac_agent/Sources/MacAgentLib/PTYSession.swift
func start(command: String = "/bin/zsh", workDir: String? = nil,
           rows: UInt16 = 24, cols: UInt16 = 80, sessionId: String? = nil) throws {
    var masterFD: Int32 = 0
    var ws = winsize(ws_row: rows, ws_col: cols, ws_xpixel: 0, ws_ypixel: 0)

    let pid = c_forkpty(&masterFD, &ws)
    guard pid >= 0 else {
        throw PTYError.forkFailed
    }

After the fork, pid == 0 in the child and pid > 0 in the parent (the daemon).

Login Shell via argv[0]

The child process uses -{shellName} as argv[0]:

// Child process setup
let shellName = (command as NSString).lastPathComponent  // e.g. "zsh"
let loginArg = "-\(shellName)".withCString { strdup($0)! }  // e.g. "-zsh"
let args: [UnsafeMutablePointer<CChar>?] = [loginArg, nil]
execvp(cmd, args)

The code comment explains why:

// Use "-shell" as argv[0] to start a login shell, matching Terminal.app behavior.
// This ensures /etc/zprofile (path_helper) and ~/.zprofile are sourced,
// so PATH includes Homebrew and other user-configured paths.

On macOS, zsh checks argv[0][0] == '-' at startup. If true, it sources login shell files: /etc/zprofile (which runs path_helper to build PATH from /etc/paths and /etc/paths.d/) and ~/.zprofile (user customizations). Terminal.app and iTerm2 use this same convention.

Environment Variables

Four environment variables are set in the child before execvp:

setenv("TERM", "xterm-256color", 1)
setenv("LANG", "en_US.UTF-8", 1)
setenv("SHELL_SESSIONS_DISABLE", "1", 1)
if let sid = sessionId { setenv("TERMONMAC_SESSION", sid, 1) }

TERM=xterm-256color — declares the terminal type to the shell and programs running in it. Programs that query terminal capabilities (via ncurses or terminfo) use this to determine what escape sequences to emit.

LANG=en_US.UTF-8 — ensures correct handling of UTF-8 characters in the terminal output stream.

SHELL_SESSIONS_DISABLE=1 — disables macOS shell session saving. Since macOS Sierra, zsh saves session state to ~/.zsh_sessions so Terminal.app can restore history across app relaunches. This is not appropriate for a programmatic PTY, so it’s disabled.

TERMONMAC_SESSION={sessionId} — passes the session ID into the shell environment. The Mac CLI uses this to detect nested attaches: if a user runs termonmac attach from within an already-managed PTY, the CLI reads TERMONMAC_SESSION and warns about the nested context.

Parent: Non-Blocking Master FD

After the fork, the parent (daemon) configures the master file descriptor:

// Parent process
self.masterFD = masterFD
self.childPID = pid
if let name = ptsname(masterFD) { self.slavePath = String(cString: name) }

let flags = fcntl(masterFD, F_GETFL)
_ = fcntl(masterFD, F_SETFL, flags | O_NONBLOCK)

O_NONBLOCK makes reads non-blocking. Combined with a GCD DispatchSource, the daemon can read PTY output asynchronously without blocking any thread:

let source = DispatchSource.makeReadSource(fileDescriptor: masterFD,
                                           queue: .global(qos: .userInteractive))
source.setEventHandler { [weak self] in
    var buf = [UInt8](repeating: 0, count: 8192)
    let n = read(self.masterFD, &buf, buf.count)
    if n > 0 {
        self.outputQueue.async {
            self.outputBuffer.append(Data(buf[0..<n]))
            if self.outputBuffer.count >= 32768 {
                self.flushOutput()
            } else if self.flushTimer == nil {
                // Schedule 200ms flush timer
            }
        }
    }
}

Output is buffered: it flushes either when the buffer reaches 32,768 bytes or after 200ms, whichever comes first.

Slave Path

The slave PTY device path (e.g. /dev/ttys003) is captured via ptsname() and stored in slavePath. This is used by the nested attach detection logic — a CLI attach command checks whether the current PTY slave path matches an existing session, indicating it’s running inside a managed session.

Process Cleanup

On stop, the PTY sends SIGHUP first (the standard signal for terminal hangup), waits briefly, then SIGKILL if the process hasn’t exited:

kill(pid, SIGHUP)
var status: Int32 = 0
if waitpid(pid, &status, WNOHANG) == 0 {
    kill(pid, SIGKILL)
    if waitpid(pid, &status, WNOHANG) == 0 {
        // Reap in background to avoid zombie
        DispatchQueue.global().async {
            var s: Int32 = 0
            waitpid(pid, &s, 0)
        }
    }
}

The background waitpid handles processes that don’t exit immediately after SIGKILL — prevents zombie processes from accumulating.