Files
clide/docs/macos-pty-problem.md
jpmschweitzerandClaude Opus 4.7 dd3b38ba85 fix onboarding docs to match current architecture (T-101)
Rewrite README's Architecture section: drop the `ptyc/` description
and `make ptyc-build` line, fix the broken `decisions/` link
(governance moved it), add pointers to current docs.

Banner docs/initial-plan.md as historical with a redirect to a new
docs/architecture.md that describes today's shape (single Flutter
package at the repo root; in-process IPC + dispatcher + subsystems;
PTY via Dart FFI posix_openpt + posix_spawn).

Also banner two ptyc-era forensic notes (docs/pty-proposition.md,
docs/macos-pty-problem.md) as historical so a contributor reading
them isn't misled into thinking the C helper still exists.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 21:01:43 +02:00

4.2 KiB

macOS PTY Problem — Diagnosis Complete

⚠ HISTORICAL — ptyc is retired. PTY spawning moved to Dart FFI posix_openpt() + posix_spawn() (D-5 amendments, T-96). The race described below was specific to the old C helper's SCM_RIGHTS hand-off and no longer applies.

Status: Root cause found

The PTY master fd is valid (isatty=1), SCM_RIGHTS transfer is correct, all struct layouts are correct. The problem is timing: the reader isolate starts too late and the shell has already exited by the time read() is called on the master fd. macOS returns EOF (n=0) immediately when the slave side is closed — unlike Linux which buffers data.

The timing sequence (what happens)

1. Dart: socketpair()
2. Dart: Process.start(ptyc)
3. ptyc: posix_openpt + grantpt + unlockpt + open(slave)
4. ptyc: fork() → child starts zsh
5. ptyc: sendmsg(SCM_RIGHTS, master_fd)  ← master fd sent
6. ptyc: printf(ok) + exit (or block if diagnostic)
7. Dart: recvmsg() → receives master_fd    ← fd is valid, isatty=1
8. Dart: PtySession._() constructor
9. Dart: _startReader() → Isolate.spawn()  ← ASYNC, does not start immediately
10. ... event loop yields ...
11. Reader isolate starts, calls read(masterFd)
12. But zsh already exited → slave closed → read() returns 0 (EOF)

The gap between step 9 (Isolate.spawn scheduled) and step 11 (reader actually runs) is where the data is lost. On macOS, the kernel doesn't buffer PTY master data after the slave closes.

Why is zsh exiting?

zsh (/bin/zsh) is an interactive shell. It should NOT exit immediately — it should show a prompt and wait for input. But in the test, it does exit. Possible reasons:

  1. No controlling terminal at spawn time. ptyc's child does setsid() + TIOCSCTTY + dup2(slave, 0/1/2) — this should work. But if the slave fd is already closed or invalid by the time dup2 runs, zsh gets no tty and exits.

  2. Environment. The env passed to ptyc may be empty or missing TERM, HOME, SHELL etc. Without TERM, zsh may fail to initialize and exit.

  3. The PTYC_SOCK_FD inherits open fds. When ptyc forks, the child inherits ALL open fds (master, slave, socket, exec-failure pipe). ptyc closes master and pipe in the child, but the socket fd stays open. This shouldn't cause an exit, but it's worth checking.

  4. stdin is connected to the slave. After dup2(slave, 0), zsh reads from the PTY slave. If Dart hasn't written anything to the master AND there's no PTY echo (because the master isn't being read), zsh might get SIGHUP or detect a broken pipe.

Verified facts

Test Result
ptyc compiles on macOS
socketpair fd inherited by ptyc ✓ (verified with test binary)
ptyc creates PTY and forks ✓ ({"ok":true,"pid":N})
SCM_RIGHTS transfer ✓ (correct cmsg layout)
cmsghdr struct (Dart) ✓ (CmsghdrDarwin, 12 bytes, correct offsets)
msghdr struct (Dart) ✓ (MsghdrDarwin, correct field sizes)
SOL_SOCKET ✓ (0xffff on macOS)
TIOCSWINSZ ✓ (0x80087467 on macOS)
Received fd is a tty ✓ (isatty=1)
Reader isolate starts ✓ (prints "started, fd=N")
Reader gets data ✗ — EOF immediately (n=0)
Keeping ptyc alive helps ✗ — still EOF
close(master) in ptyc is the cause ✗ — disproven

Next steps

  1. Investigate why zsh exits immediately. Add logging to ptyc's child process to verify it reaches execvp. Check if the child gets a signal (SIGHUP, SIGTERM) right after exec.

  2. Check the environment passed to ptyc. If env is empty, the child shell has no TERM, HOME, etc. and may exit immediately.

  3. Try a long-running command instead of zsh — e.g. sleep 10 — to rule out shell-specific init failures.

  4. Consider forkpty() on macOS — eliminates the timing gap entirely. forkpty() creates the PTY, forks, and returns the master fd all in one call from the same process. The reader can start before the child is even exec'd.

Environment

  • macOS 26.3.1 (Darwin 25.3.0), Apple Silicon (arm64)
  • Flutter 3.41.7, Developer ID signed (no sandbox)
  • pql 1.4.4, dugite-native git 2.53.0
  • ptyc compiled with: cc -std=c11 -Wall -Wextra -Wpedantic -Werror -O2 -D_FORTIFY_SOURCE=2 -D_DARWIN_C_SOURCE