PHP FFI on Apple Silicon: your ioctl call is lying to you
PHP FFI on Apple Silicon: your ioctl call is lying to you
PHP FFI 在 Apple Silicon 上的陷阱:你的 ioctl 调用在撒谎
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. 我花了一个晚上为 PHP 构建伪终端(pseudo-terminal)支持,结果在一个谎报“成功”的 Bug 上浪费了一个小时。如果你在生产环境中使用 FFI 和 ioctl,且你的 CI(持续集成)仅在 Linux 上运行,那么这篇文章值得你花十分钟阅读。
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.
环境设置:PHP 本身已经可以打开伪终端。proc_open() 接受 ['pty'] 描述符,在 macOS 上你会得到一个真实的 /dev/ttysNNN。但你无法控制窗口大小。PHP 标准库中没有 ioctl(),因此也就没有 TIOCSWINSZ,进而没有 SIGWINCH 信号。交互式终端程序在启动时只能猜测窗口几何尺寸,且永远无法感知窗口大小的变化。对于任何绘制全屏 UI 的程序(如 top、vim 或代理 CLI)来说,这就是“可用”与“不可用”的区别。正是因为这个缺口,那些需要驱动终端的 PHP 项目最终不得不引入一个 Node.js 辅助进程来使用 node-pty。而 ext-ffi 本应填补这个空白。
openpty(), login_tty() and ioctl() are all sitting in libc. So I wrote the obvious binding:
openpty()、login_tty() 和 ioctl() 都存在于 libc 中。所以我写了显而易见的绑定:
$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);
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.
然后设置大小、fork、调用 login_tty()、执行 exec,并询问子进程它认为自己的终端是什么样子的。症状是:我要求 30 行 120 列,但子进程打印出:/dev/ttys018 0 2046。
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.
TTY 是真实的,但尺寸不是。2046 根本不可能是列数——它不是 120 的截断,不是字节序交换,也不是字段顺序错误。它是垃圾数据。最让我耗费一小时的部分是:ioctl() 返回了 0,表示成功。没有 errno,没有异常,没有任何可检查的错误。唯一知道出问题的方法就是去问子进程。
Isolating it
问题排查
Three hypotheses, in decreasing order of comfort:
- The
struct winsizelayout is wrong. - The
ioctlcall itself is wrong. - Something is wrong with the fork/exec path.
三个假设,按心理舒适度递减排序:
struct winsize的布局错误。ioctl调用本身错误。- fork/exec 路径有问题。
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.
有一个干净的方法来区分前两个假设。openpty() 的第五个参数接受一个 struct winsize * —— 你可以在创建时设置初始大小,而无需调用 ioctl。而且 openpty() 不是变参函数。所以:相同的结构体、相同的子进程、相同的一切,三种路径。
// A — size set by openpty(winp), no ioctl at all
// B — ioctl declared with fixed arity
// C — ioctl declared variadic
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:
基准测试是让连接到 pty 的子进程运行 stty size —— 特意不使用 TIOCGWINSZ,因为回读它会经过同样的嫌疑调用。环境:PHP 8.5.8, Darwin, arm64。要求 30 行 120 列:
-
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.
-
A — openpty(winp),非变参 → 子进程报告 30 120。正确。返回值为 0。
-
B — ioctl 声明为固定参数 → 子进程报告 0 2046。垃圾数据。返回值为 0。
-
C — ioctl 声明为变参 → 子进程报告 30 120。正确。返回值为 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.
三次调用都报告成功。但只有一个是真实的。结构体没问题,openpty 没问题,问题出在 ioctl 的声明上。
Why ioctl is variadic in C:
为什么 C 语言中的 ioctl 是变参函数:
int ioctl(int fildes, unsigned long request, ...);
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.
几乎你在网上能找到的每一个 PHP + FFI 代码片段都将其声明为固定参数 —— 即将 void *arg 作为第三个参数。在 Linux x86-64 上这无伤大雅:变参和非变参的调用约定对于整数和指针是一致的,所以值会落在被调用者读取的寄存器中。
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.
Apple 的 ARM64 ABI 则不同。Apple 在这里偏离了标准的 AAPCS64:在变参函数中,所有变参都通过栈传递,尽管固定参数仍然通过寄存器传递。因此,当你将 ioctl 声明为非变参时,libffi 会构建一个非变参的调用帧,并将你的指针放在寄存器 x2 中。而真正的 ioctl(编译为变参)会去栈上寻找它。它会找到栈上的任意数据,将其视为 struct winsize *,并从中复制 8 个字节。
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.
如果该地址恰好未映射,你会得到 EFAULT,至少你知道出错了。如果它恰好可读(这种情况很常见),调用就会成功并写入乱码。这就是 0 2046 的由来。它不是损坏的值,而是完全不同的内存片段。
This is not PHP-specific. Chez Scheme hit the same wall on arm64 macOS (issue #745); any FFI over libffi can reproduce it. 这不是 PHP 特有的问题。Chez Scheme 在 arm64 macOS 上也遇到了同样的障碍(issue #745);任何基于 libffi 的 FFI 都会重现此问题。
The fix
修复方法
One line: 一行代码:
- int ioctl(int fd, unsigned long request, void *arg);
+ int ioctl(int fd, unsigned long request, ...);
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.
PHP 的 FFI 解析器接受 ...,libffi 随后会使用带有正确固定参数计数的 ffi_prep_cif_var(),从而生成符合 Darwin 标准的调用帧。
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.
如果你的同一个文件中同时有 FFI::cdef 和 ioctl:搜索该声明。如果第三个参数是具体的类型而不是 ...,那么你在 Apple Silicon 上就存在这个 Bug。不要相信返回值,它永远是 0。编写一个断言,让子进程观察效果,并在 macos-latest 的 CI 中运行它。仅在 Ubuntu 上测试会给你一个虚假的通过结果 —— 因为在那上面两种声明的行为是一样的。
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.
最后一点是我要强调的。这类 Bug 在大多数 CI 运行的平台上是不可见的,而在大多数 PHP 开发者编写代码的平台上又是静默的。同样的逻辑适用于你绑定的任何变参 libc 函数:open、fcntl、printf 等等。如果 C 头文件以 ... 结尾,你的 cdef 也必须如此。