DEV Community

Cover image for PHP FFI on Apple Silicon: your ioctl call is lying to you
Baptiste Bouillot
Baptiste Bouillot

Posted on

PHP FFI on Apple Silicon: your ioctl call is lying to you

I spent an evening building pseudo-terminal support for PHP and lost an hour of it to a bug that reports success. If you use FFI and ioctl anywhere near production, and your CI only runs on Linux, this one is worth ten minutes of your time.

The setup

PHP can already open a pseudo-terminal. proc_open() accepts ['pty'] descriptors, and on macOS you get a real /dev/ttysNNN back.

What you do not get is any control over the window size. There is no ioctl() in PHP's standard library, so no TIOCSWINSZ, so no SIGWINCH. Interactive terminal programs render at whatever geometry they guess at startup, and they never find out the window changed. For anything that draws a full-screen UI — top, vim, an agent CLI — that is the difference between usable and useless.

That single gap is why PHP projects that need to drive a terminal end up shipping a Node sidecar just to get node-pty.

ext-ffi should close it. openpty(), login_tty() and ioctl() are all sitting in libc. So I wrote the obvious binding:

$ffi = FFI::cdef(<<<'C'
    struct winsize {
        unsigned short ws_row;
        unsigned short ws_col;
        unsigned short ws_xpixel;
        unsigned short ws_ypixel;
    };
    int openpty(int *amaster, int *aslave, char *name, void *termp, void *winp);
    int login_tty(int fd);
    int ioctl(int fd, unsigned long request, void *arg);
    int close(int fd);
C);
Enter fullscreen mode Exit fullscreen mode

Then set the size, fork, login_tty(), exec, and ask the child what it thinks its terminal looks like.

The symptom

I asked for 30 rows by 120 columns. The child printed:

/dev/ttys018
0 2046
Enter fullscreen mode Exit fullscreen mode

The tty is real. The size is not. And 2046 is not a plausible number of columns for anything — it is not a truncation of 120, not a byte-swap, not a field-order mistake. It is garbage.

The part that cost me the hour: ioctl() returned 0. Success. No errno, no exception, nothing to check. The only way to know something went wrong was to ask the child.

Isolating it

Three hypotheses, in decreasing order of comfort:

The struct winsize layout is wrong.
The ioctl call itself is wrong.
Something is wrong with the fork/exec path.

There is a clean way to separate the first two. openpty() takes a struct winsize * as its fifth parameter — you can set the initial size at creation time without ever calling ioctl. And openpty() is not variadic.

So: same struct, same child, same everything, three paths.

// A — size set by openpty(winp), no ioctl at all
$rc = $ffi->openpty(FFI::addr($m), FFI::addr($s), null, null, FFI::addr($ws));

// B — ioctl declared with fixed arity
// int ioctl(int fd, unsigned long request, struct winsize *arg);
$ffi->ioctl($master, TIOCSWINSZ, FFI::addr($ws));

// C — ioctl declared variadic
// int ioctl(int fd, unsigned long request, ...);
$ffi->ioctl($master, TIOCSWINSZ, FFI::addr($ws));
Enter fullscreen mode Exit fullscreen mode

Ground truth is stty size run by a child attached to the pty — deliberately not TIOCGWINSZ, because reading it back would go through the exact same suspect call.

PHP 8.5.8, Darwin, arm64. Asking for 30 rows by 120 columns:

A — openpty(winp), not variadic → child reports 30 120. Correct. Return value 0.
B — ioctl declared with fixed arity → child reports 0 2046. Garbage. Return value 0.
C — ioctl declared variadic → child reports 30 120. Correct. Return value 0.

All three calls report success. Only one of them is telling the truth.

The struct is fine. openpty is fine. The declaration of ioctl is not.

Why

ioctl is variadic in C:

int ioctl(int fildes, unsigned long request, ...);
Enter fullscreen mode Exit fullscreen mode

Almost every PHP + FFI snippet you will find online declares it with fixed arity instead — void *arg as the third parameter. On Linux x86-64 that is harmless: the variadic and non-variadic calling conventions agree for integers and pointers, so the value lands in the register the callee reads.

Apple's ARM64 ABI does not agree. Apple diverges from the standard AAPCS64 here: in a variadic function, every variadic argument is passed on the stack, even though fixed arguments still travel in registers.

So when you declare ioctl non-variadic, libffi builds a non-variadic call frame and places your pointer in register x2. The real ioctl — compiled as variadic — goes looking for it on the stack. It finds whatever was there, treats it as a struct winsize *, and copies eight bytes from it.

If that address happens to be unmapped you get EFAULT and at least you know. If it happens to be readable — which is common — the call succeeds and writes nonsense. That is the 0 2046. It is not a corrupted value; it is a different piece of memory entirely.

This is not PHP-specific. Chez Scheme hit the same wall on arm64 macOS (issue #745); any FFI over libffi can reproduce it.

The fix

One line:

- int ioctl(int fd, unsigned long request, void *arg);
+ int ioctl(int fd, unsigned long request, ...);
Enter fullscreen mode Exit fullscreen mode

PHP's FFI parser accepts ... and libffi then uses ffi_prep_cif_var() with the correct fixed-argument count, which produces a Darwin-correct call frame.

How to check your own code

If you have FFI::cdef and ioctl in the same file:

Grep for the declaration. If the third parameter is typed rather than ..., you have this bug on Apple Silicon.
Do not trust the return value. It will be 0.
Write an assertion that a child process observes the effect, and run it in CI on macos-latest. Ubuntu alone gives you a false green — both declarations behave identically there.

That last point is the one I would underline. This class of bug is invisible on the platform most CI runs on and silent on the platform most PHP developers write code on.

The same reasoning applies to any variadic libc function you bind: open, fcntl, printf and friends. If the C header ends in ..., your cdef must too.

The package

The pty work became php-pty — node-pty for PHP, MIT, nothing to compile:

use Croustibat\Pty\Pty;

$session = Pty::spawn(['top'], rows: 30, cols: 120);

$session->resize(40, 100);   // real TIOCSWINSZ, real SIGWINCH
echo $session->read();
$session->write('q');

$session->stream();          // non-blocking, for stream_select()
$session->wait();            // exit code
Enter fullscreen mode Exit fullscreen mode

Three classes, about 300 lines. login_tty() so the child gets a real controlling terminal, which is what makes job control and Ctrl-C work — the thing proc_open cannot give you. CI on Ubuntu and macOS, PHP 8.2 to 8.4.

CLI SAPI only, and it refuses to load elsewhere: pcntl_fork() duplicates the whole process, open database connections included. No Windows either — Windows has no pty, ConPTY is a different API.

Three more things that cost me time

Written down in case they save you some:

PHP retries stream writes internally. It buffers writes in userspace and loops on the flush. On a pty master whose buffer is full, fwrite() then never returns, and no application-level timeout helps because you are stuck inside the call. stream_set_write_buffer($stream, 0) makes each fwrite() map to exactly one write(2) and hand EAGAIN straight back.

Partial writes are the normal case, not an edge case. Pushing 1 MB through a pty master in 8 KB calls took 1,677 fwrite() calls instead of the 128 a full write would need — roughly 625 bytes accepted per call. Code that ignores the return value loses data thirteen times out of fourteen. When the truncation lands inside an escape sequence, your terminal prints the tail as literal text: a stray 7G on screen where a cursor move was meant.

stty -echo is not stty raw. The first only silences the echo; the line discipline stays canonical and holds at most MAX_CANON bytes while it waits for a newline. Push half a megabyte with no \n through it and it jams solid.

If you are doing anything with FFI and libc from PHP, check your variadic declarations. The bug that returns 0 is always the expensive one.

Top comments (0)