Build1 distinct publisher3 min readUpdated
A missing ellipsis in one FFI declaration turns a terminal-resize call into a no-op on arm64 macOS. The call still returns 0, and Linux-only CI never sees it.
The Engineer · Build desk
Compiled by The EngineerSomething wrong?How this is made
A developer building pseudo-terminal support for PHP found that an FFI-bound `ioctl()` call to set a terminal window size returns 0 on arm64 macOS while leaving the geometry as garbage, and spent an hour on it because nothing in the call reports failure [1][9]. The same declaration works on Linux x86-64 [17], which means a Linux-only CI matrix will green-light terminal control code that is broken on every Apple Silicon machine the team develops on [22].
The gap being closed is real. PHP's `proc_open()` already accepts `['pty']` descriptors and hands back a genuine `/dev/ttysNNN` on macOS [2], but there is no `ioctl()` in the standard library, so no `TIOCSWINSZ` and no `SIGWINCH` [3]. Full-screen programs render at whatever size they guessed at startup and never learn the window changed [4]. According to the writeup, that single missing call is why PHP projects that need to drive a terminal end up shipping a Node sidecar for `node-pty` [5].
The binding looked correct. Asking for 30 rows by 120 columns, the child process reported `/dev/ttys018` and a size of `0 2046` [7]. The author notes 2046 is not a truncation of 120, not a byte swap, and not a field-order mistake [8]. `ioctl()` returned 0 with no errno and no exception [9].
The isolation is the useful part. `openpty()` accepts a `struct winsize *` as its fifth argument and is not variadic [10], which separates a bad struct layout from a bad call. On PHP 8.5.8, Darwin, arm64 [12], setting the size through `openpty()` produced 30 by 120 [13]; `ioctl()` declared with fixed arity produced 0 by 2046 [14]; `ioctl()` declared variadic produced 30 by 120 [15]. All three returned 0 [16], so the return value carries no information. Ground truth came from `stty size` in a child attached to the pty, deliberately not `TIOCGWINSZ`, because reading the value back would travel through the same suspect call [11].
The mechanism is an ABI divergence, not a PHP bug. `ioctl` is variadic in C [18], yet most PHP FFI snippets in circulation declare a fixed `void *arg` third parameter [19]. On Linux x86-64 the variadic and non-variadic conventions agree for integers and pointers, so the value lands where the callee looks [17]. Apple's arm64 ABI diverges from AAPCS64: fixed arguments still travel in registers, but every variadic argument is passed on the stack [20]. Declared non-variadic, libffi builds a non-variadic frame and puts the pointer in x2, while the real `ioctl` reads the stack, treats whatever is there as a `struct winsize *`, and copies eight bytes [21]. If that address is unmapped you get EFAULT and a signal something is wrong; if it is readable, which is common, the call succeeds and writes nonsense [6]. The fix is declaring the ellipsis, after which PHP's parser and `ffi_prep_cif_var()` do the right thing [25].
This is not confined to PHP or to `ioctl`. Chez Scheme hit the same wall on arm64 macOS in issue #745, and any FFI layered over libffi can reproduce it [23]. Because the defect is in how variadic frames are built, any binding to a symbol whose C prototype ends in `...` but is declared with fixed arity is suspect on Apple Silicon [24].
Two things to check. First, whether your FFI declarations match libc prototypes exactly for variadic symbols, rather than the shape that happens to compile. Second, whether your test oracle is the return code or an out-of-band observation; here only the second caught it [16][11].
Follow any of these and your For You feed starts watching them — no settings page required.
Ranked by verification strength, evidence, and original report placement.
The author spent an evening building pseudo-terminal support for PHP and lost an hour to a bug that reports success.
PHP's proc_open() accepts ['pty'] descriptors, and on macOS returns a real /dev/ttysNNN device.
There is no ioctl() in PHP's standard library, so no TIOCSWINSZ and therefore no SIGWINCH, leaving no control over pty window size.
Interactive terminal programs render at whatever geometry they guess at startup and never find out the window changed.
If the stack address read by ioctl is unmapped, the call returns EFAULT; if it is readable, which is common, the call succeeds and writes nonsense.
After requesting 30 rows by 120 columns, the child process printed /dev/ttys018 and a size of 0 2046.
Evidence-backed comparisons of source perspectives and observed adoption signals. Read the methodology
Which Builder, Operator, and Investor concerns the observed source mix emphasized—not a truth score.
Evidence, demonstrated adoption, hype gap, incentives, and confidence are assessed independently, each on its own current evidence. How these are measured.
Concrete single-author reproduction, no independent corroboration
The core technical claim is unusually well evidenced for a single blog post: a controlled three-path comparison holding struct, child and environment constant, an explicitly stated platform triple (PHP 8.5.8, Darwin, arm64), an out-of-band ground truth (stty size rather than TIOCGWINSZ), and an ABI-level mechanism that predicts exactly the observed 0 2046. What is missing is any second party: no upstream PHP or libffi issue, no reproduction by another engineer in the cluster, and only a pointer to Chez Scheme issue #745 that the supplied material does not itself verify. Two ancillary assertions (prevalence of fixed-arity snippets, Node sidecars in PHP projects) carry no supporting data at all.
No usage data beyond the author's own release
The only adoption-shaped signals supplied are the author's own release of php-pty and the author's own test run. There are no download counts, dependent projects, third-party deployments, or reports of other codebases discovering or fixing the fixed-arity declaration, and the referenced Chez Scheme issue is mentioned without detail. Estimating an adoption level would require inventing facts the sources do not contain.
Core finding matches evidence; scope claims run slightly ahead
The central assertion — a fixed-arity ioctl cdef silently no-ops on arm64 macOS while returning 0 — is stated no more strongly than the reproduction supports, and the headline framing ('lying to you') is literally what the 0 return value with 0 2046 geometry shows. The overshoot is at the edges: 'almost every PHP + FFI snippet you will find online' and the claim that this gap is why PHP projects ship Node sidecars are broad prevalence statements with no evidence, and 'any FFI over libffi can reproduce it' extends one referenced Chez Scheme issue into a general rule. The article also lands on the author's own library, which nudges scope framing upward. Net: mildly overstated, not inflated.
Author promotes own library, disclosed and permissively licensed
The post is authored by the maintainer of php-pty and closes by presenting that library as the productised answer to the problem the article documents, so there is a clear promotional interest in the gap being seen as widespread and painful. Mitigating factors: the interest is stated openly rather than concealed, the library is MIT with no pricing or gating disclosed, the actionable fix is a one-line change readers can apply without adopting anything, and the reproduction is described in enough detail to be checked independently. No vendor sponsorship, funding round or commercial relationship appears in the supplied material.
Mechanism convincing, corroboration thin
Confidence is held up by the internal coherence of the account — a named platform, a controlled matrix, an ABI explanation that predicts the specific observed symptom, and a fix whose effect is verified in the same run — and held down by the cluster's structure: one publisher, one author, one machine, no upstream tracker link, and no external verification of the Chez Scheme parallel. The technical core is likely right and easy for a reader to test in minutes; the ecosystem-scale claims should be treated as unverified.
invest
Your Landed Cost Is Being Litigated By Companies With $306,000 Problems1 distinct publisher
security
Apple dates its EU app rewrite: October 1, 2026, and the install fee becomes a 5% commission1 distinct publisher
product
watchOS 27 demotes the honeycomb: five most-used apps plus Siri is the new default1 distinct publisher
build
Once the question needs a cube, you own the parser1 distinct publisher
Distinct publishers with included, body-backed reporting in this cluster.
dev.to
1 article · August 15, 2026