Skip to content

symfony/demo compiles and serves every page identically to php - #599

Merged
blacktrs merged 83 commits into
mainfrom
examples/symfony-demo
Nov 8, 2026
Merged

blacktrs merged 83 commits into
mainfrom
examples/symfony-demo

Conversation

@blacktrs

@blacktrs blacktrs commented Nov 8, 2026

Copy link
Copy Markdown
Contributor

Goal

Run the full symfony/demo application — Symfony 8.1, Doctrine ORM 3.6, Twig, Symfony UX, security, 30 locales — as one native binary, rendering what php renders.

Result

From a single long-lived process, every page with a php reference is byte-identical to php 8.5 (render timestamps and the port aside):

Page Status
/ 200, identical
/en/blog/, /en/blog/page/2, /uk/blog/ 200, identical (incl. the "Show code" modal)
/en/blog/posts/{slug} 200, identical (Doctrine lazy entities, comment counts)
/en/blog/rss.xml 200, identical
/en/login 200, identical
/en/admin/post/ 302 to login, identical
missing asset / unknown route 404 page, identical
/en/blog/search 200 natively; php fails on that page under the comparison harness, so no reference

examples/symfony-demo/ holds only the overlay (entry, kernel, manifest, warm-up scripts, compat shims) and a setup.sh that fetches upstream at a pinned commit. Its README explains the requirements, the build-time php steps and why they exist, the run-time environment, and what differs from upstream.

What changed in the compiler and runtime

New capabilities

  • Lazy ghosts: ReflectionClass::newLazyGhost, initializeLazyObject, markLazyObjectAsInitialized, isUninitializedLazyObject, setRawValueWithoutLazyInitialization. The initializer runs on the first read, write, isset or ?? of an unset slot. Candidate classes are listed by the new manifest key lazy (documented in docs/modules.md); only those pay for the check.
  • Dom\HTMLDocument::createFromString — a tokenizer and tree builder for ordinary markup (html-sanitizer).
  • debug_backtrace provides each method frame's object (DEBUG_BACKTRACE_* constants added).
  • Each backtrace frame (and Throwable::getTrace/getFile) names its own call site's file in a multi-file build; frames called from built-in code leave file/line out, as php does.
  • ReflectionMethod::getEndLine (the parser records the closing line).
  • http_build_query walks objects by their public properties; fopen reports its failure to the installed error handler.

Correctness and memory fixes (each with a test or a promoted repro)

  • A static local's initial array was allocated in the frame arena and overwritten by the next call's temporaries.
  • A shared virtual dispatcher was reused by a site writing more arguments, which lost its array argument.
  • A callable returned from a cell property was released without a count (crashed the second request); a callable compared unequal to its own cell.
  • A generator did not own a bare-array parameter; a yielding foreach over an erased base walked arrays only.
  • $o->$name, get_object_vars and is_iterable/is_countable now follow php's visibility and Traversable/Countable rules.
  • The HTTP server presents itself as the front controller /index.php (Symfony derived its base URL from ./bin/app).
  • Plus the earlier work on this branch: private-property shadowing, date (de)serialization, enums, unions, references to typed properties, Twig context cycles, and more (see the commit list).

Fixes #566, fixes #569, fixes #576, fixes #587. New issues filed along the way: #588, #589, #593, #594.

Verification done

  • New and touched AOT cases pass, as do the -k subsets for every area touched (generators, foreach, callables, closures, dispatch, reflection, lazy, traces, fibers, exceptions, io, http, dom, static, arena).
  • tests/aot/xfail.sh: no XPASS left (the one that appeared was promoted).
  • setup.sh run from scratch on an overlay-only copy reproduces the warmed caches.
  • macOS arm64 only.

Still owed before merge

  • Full AOT suite and the Linux gate (tools/docker/run_tests.sh --gate): the changes touch ownership, generators, dispatch and the backtrace runtime.
  • Known leak: a raw array callable held in a closure-typed local is never released.

…ne name chain re-emitted the receiver per candidate (3M IR lines in one Doctrine method)
…d), narrowing skips them, $cls::cases()/from()/tryFrom() resolve enum classes
… table, so a class declared in a guarded if gets reflection, var_dump, serialize arms (Fixes #566)
…y name, not by a variadic first declarer that trims them (lost Definition::addTag's attributes)
…nt of its own, so $v = $f($v) no longer frees the result (Fixes #569)
…in (same display name), which sent a name-keyed lookup in circles
…ser TokenPolyfill) takes the eager path, so the return ends the file, not the program
…n the class-isa table (autoconfigure by ServiceSubscriberInterface matched nothing)
…e*/newInstanceArgs build ArrayObject, DateTime, Spl*
…lves (Symfony service factory 'array_first')
…r, call the static symbol (the receiver shifted every argument; corrupted Request::getPreferredLanguage)
…][] = x) lands in the stored value (Twig ExpressionParsers::getPrecedenceChanges crashed)
…riadic under their names (LogRecord::with(message:, context:) lost the keys)
…ature (names were dropped when 'with' was ambiguous by name)
…path (the element was released but left in the array: Twig Node::removeNode)
…ect's first property became the bag pointer: Twig's getAttribute) and stringifies a non-string name
…le turned every iterator into [] and the blog rendered empty; array union reads an entry kind only on a hashed source
…rite throws; Twig read private properties directly and never called the getter
…ys did, so a Twig template's loop over an ArrayIterator rendered its empty branch
…al to its own cell: an array callable was released once per listener and freed under its owner, crashing the second request
…ect's runtime class: UrlGenerator's caster walked private properties across the object graph and crashed the login redirect
…te or test of an unset slot, for the classes a manifest's lazy lists or every user class
…ordinary markup, over the DOM classes; html-sanitizer's parser needs it
…in the request attributes made the login redirect throw
…ROVIDE_OBJECT, php's default); defines the DEBUG_BACKTRACE_* constants
…file they were written in, trace records carry it per frame, and built-in frames leave file/line out as php does
…obal referenced from another global was missing from the split part that held the record
…a: the next arena allocation overwrote it and Doctrine's date hydration crashed in __mc_dt_slot_store
…d the HTTP server presents itself as the front controller /index.php: Symfony's cache retries on "File exists", and its base URL came from the CLI's ./bin/app
…, a setup script that fetches the rest at a pinned commit, and the requirements and steps to build and run it; document the manifest's lazy key
…er: the filler returns its own, and every call leaked one
…he hidden slot owns, and leave a pre-evaluated value to its owner (WeakMap $m[$k][$j] = fresh leaked per write)
@blacktrs
blacktrs force-pushed the examples/symfony-demo branch from 4bcb526 to 7cc3942 Compare November 8, 2026 13:47
…er enum cases answered a cell for an ordinal and crashed (array_endpoint_objects)
…1.3.2 (Alpine) counts the dictionary header in total_in at Z_NEED_DICT, 1.2.x does not
@blacktrs
blacktrs merged commit 8c95ce8 into main Nov 8, 2026
55 checks passed
@blacktrs
blacktrs deleted the examples/symfony-demo branch November 8, 2026 16:45
blacktrs added a commit that referenced this pull request Nov 9, 2026
…, cycle collection through untyped properties, #599 regressions (#602)

* Cut clang memory and IR volume of large builds: no MachineOutliner (a Laravel part 13.7 GB -> 4.65 GB), one cold raise per guard site, table dispatch for $o->$name on a known class, nounwind runtime helpers called not invoked, cell_int_bad slow path out of line

* Release per-request leaks found in Laravel: a minting builtin's discarded result, and a cell or array union read as borrowed in a conditional arm or a local

* Seed the init map of a fresh instance with its untyped properties: in a program that makes lazy ghosts every untyped read before a write was the uninitialized-property Error (Laravel Application::$appPath at boot)

* A closure generator owns its env in the frame (env@56), so an invoke releases the +1 a conditional or literal callee holds: Laravel's call_user_func($cb ?? $default, ...) leaked the closure and all it captured per request

* Walk an object in an untyped (cell) property as a cycle-collector child and release a collected node's other cell slots: no cycle through an untyped property was ever collected, which kept Laravel's per-request Request/Route/closure graph and every string it held

* Promote the 8 known-bug repros that pass now (4 already had identical cases; spread_into_variadic_closure lands as _repro beside a different case of that name)

Fixes #541
Fixes #375
Fixes #517
Fixes #463
Fixes #476
Fixes #477
Fixes #565
Fixes #464

* is_a() with a runtime string subject releases a minted subject after the compares: the strval() an erased subject is unboxed by leaked one class name per Laravel container make()

* Release three more per-request Laravel leaks: an owned call result used as a callee (call_user_func($this->getRouteResolver())), a closure stored raw in a reference box whose cell drop skipped it ($default in Router::substituteImplicitBindings), and (array) over a minting operand retained again (Router::resolveMiddleware)

* Release a callable-typed call result invoked as a callee by its runtime representation: call_user_func($this->getRouteResolver()) with @return \Closure leaked Laravel's route resolver and its Route per request

* Keep one representation for an inherited property: once any class of the slot holds a bare cell, every class of it does. Model::$attributes celled through the parent while Note kept vec[cell], so Note's drop released a tagged word as an array buffer and every Laravel /api/notes request crashed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment