You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
fix: clone/serialize double-free crash across every FFI handle class, plus a leak in the fix
Every handle-owning class (KeyPair, Session, StreamHandle, PendingCall,
Event, CallResponse, StreamOpenInfo, StreamReply, UcanPayload,
StreamItem) held a private raw cgo.Handle int with no __clone() guard.
PHP's clone shallow-copies private properties before running
__clone(), so `clone $keyPair` produces a second object holding the
identical handle; whichever of the two destructs second calls the Go
free function on an already-deleted runtime/cgo.Handle, which panics --
and an unrecovered panic inside any cgo-exported function is fatal to
the ENTIRE host PHP process, not a catchable exception. Reproduced
before fixing (KeyPair, no network needed):
$copy = clone $original; unset($original); unset($copy);
// panic: runtime/cgo: misuse of an invalid Handle -- SIGABRT, exit 134
A naive `__clone() { throw ...; }` with no other change still crashed:
PHP has already completed the shallow property copy before __clone()
runs, so the doomed clone (never assigned to a variable, since the
throw aborts the assignment) still holds a live handle copy, and ITS
OWN __destruct() fires normally when that temporary is discarded,
stealing the original's handle. Fixed by nulling the clone's own
handle copy before throwing, on all 10 classes.
Fable's review of that fix found the identical bug has a second door:
serialize()/unserialize() copies $handle by value too, and reaches
every consumer's private cache/queue/$_SESSION with no clone() call
anywhere in sight. Fixed with __serialize()/__unserialize() guards on
the same 10 classes, throwing LogicException (aligned with __clone(),
which was inconsistently RuntimeException -- nothing here is in
production yet, so the one-word cleanup across all 10 files is free).
As defense in depth (whatever the PHP-side guards don't catch),
cabi/main.go gained a safeDeleteHandle helper wrapping every _free
export's Delete() in recover() -- same principle as this morning's
macula-go pool.go fix (a double-free must cost nothing, never the
whole process). That recover() itself introduced a real leak Fable
caught: macula_session_close's single top-level recover() meant a
panic from validating identityHandle skipped the trailing
Delete(sessionHandle) entirely, leaking the session (and its live QUIC
connection) forever, silently, on exactly the path the recover() was
added for. Fixed by deferring safeDeleteHandle FIRST (runs LAST per
Go's LIFO defer order, so it fires regardless of where a panic
happened above it), with early returns replacing the nested ifs.
Verified with a standalone Go probe reproducing the exact control-flow
shape of both the old (leaks) and new (doesn't leak) code across every
handle-validity combination.
Added regression coverage in tests/KeyPairTest.php (clone throws and
leaves the original usable; serialize()/unserialize() of a live
instance and of a hand-crafted blob both throw). Full suite (24 tests)
green, composer validate clean, gofmt/vet/build clean, and every live
primitive re-verified against station-de-frankfurt.macula.io (session
close is exercised by all of them): handshake, call, pubsub, content,
UCAN, RPC provider, stream provider.
Fable-reviewed in two rounds (initial finding + a follow-up pass
verifying both fixes' completeness); second round returned zero
required items.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Khkcfi5pTAy2uA2ErjL57Q
0 commit comments