Summary
An object returned from a method of one class becomes unreliable — corrupted
fields, or a worker crash reading a string field — after a subsequent call to
a "heavy" method of a different class runs in between. This reproduces
deterministically (5/5) under --web, independent of what storage backend
produced the object (reproduces identically whether the returned object was
built from PDO or from a from-scratch filesystem-backed store), so it is not
a regression from a storage rewrite. It looks like the same escape-analysis/
arena family as #483/#484/#486/#498 (owned values not correctly promoted out
of the producing function's frame and later "recycled" by the next heavy
call in the same worker) but manifests as corruption/crash on read rather
than only as a leak — worth filing as its own issue given the distinct,
worker-crashing symptom, while cross-linking the family.
Environment
- elephc v0.26.0 (release, commit
892ff162), macOS ARM64
- PHP 8.4: unaffected
Minimal reproducing chain
// inside a controller method; $this->db and $this->view/$this->hl are
// properties holding instances of OTHER classes
$p = $this->db->find($id, time()); // object constructed INSIDE Db::find()
// ... $p->title reads correctly here, right after the return ...
$hl = $this->hl->highlight($p->body, $p->lang); // any "heavy" call interposed
echo $this->view->renderPaste($p, $hl); // CRASH here, reading $p->title
Reproduces even with View::renderPaste reduced to reading a single field
($p->title) and ignoring everything else. Reads of int fields (e.g.
$p->createdAt) don't crash but return a wrong value (e.g. 4294967328
instead of the real timestamp) — so this isn't limited to string fields; the
entire object returned by the producing method becomes unreliable once a
"heavy" call (e.g. a syntax highlighter) runs in between.
Workaround that works (but requires touching the caller)
Reconstructing a fresh object from the fields of the one returned by the
producer, immediately after receiving it and before calling the heavy
method in between, eliminates the crash (verified 5/5):
$p = $this->db->find($id, time());
$fresh = new Paste($p->id, $p->title, $p->lang, $p->body, $p->createdAt, $p->expiresAt);
echo $this->view->renderPaste($fresh, $this->hl->highlight($fresh->body, $fresh->lang));
Workarounds tried inside the producer (Db::find()) that do NOT work
- Defensive copy of every string field with
. '' or sprintf('%s', ...)
before constructing the returned object inside find()
- A static cache array that "pins" the object (avoids the crash but the
field reads back empty — it doesn't repair the value, just changes the
failure mode)
- Static string properties used as a cache (still crashes)
- Allocating and discarding ~10 KB of string right after constructing the
object, to "advance" a hypothetical bump allocator (no effect)
--heap-size from 8 MB up to 256 MB (no effect — not a heap-size problem)
The only fix that works requires the final (re)construction of the object to
happen in the caller's frame, not inside the producing method — consistent
with an escape-analysis/arena bug where values "returned" from a function are
not correctly promoted and remain tied to memory local to that function,
reusable by the next "heavy" call in the same worker.
Cross-links
Same family as the following open leak/ownership issues — the common thread
is "values crossing a function-return boundary aren't given independent,
correctly-owned storage":
This issue's symptom (corruption/crash on read, not just a leaked
allocation) suggests it's not purely a "forgot to release" leak like the
above — it looks like the freed/reused memory from the leak family above is
what a subsequent heavy call then writes into, producing a genuine
use-after-free read rather than only unbounded growth. Filing separately
because the reproducing trigger (cross-class method call sequencing) and the
crash-on-read symptom are distinct enough to need their own repro, but this
is very likely one bug family with a common root cause in escape analysis/
value ownership at function-return boundaries.
Notes
Found building a pastebin single-binary app (--web) for the Discord
contest: GET /p/{id} deterministically returned an empty response
(curl exit 52) until the caller-side reconstruction workaround was applied.
Summary
An object returned from a method of one class becomes unreliable — corrupted
fields, or a worker crash reading a string field — after a subsequent call to
a "heavy" method of a different class runs in between. This reproduces
deterministically (5/5) under
--web, independent of what storage backendproduced the object (reproduces identically whether the returned object was
built from PDO or from a from-scratch filesystem-backed store), so it is not
a regression from a storage rewrite. It looks like the same escape-analysis/
arena family as #483/#484/#486/#498 (owned values not correctly promoted out
of the producing function's frame and later "recycled" by the next heavy
call in the same worker) but manifests as corruption/crash on read rather
than only as a leak — worth filing as its own issue given the distinct,
worker-crashing symptom, while cross-linking the family.
Environment
892ff162), macOS ARM64Minimal reproducing chain
Reproduces even with
View::renderPastereduced to reading a single field(
$p->title) and ignoring everything else. Reads of int fields (e.g.$p->createdAt) don't crash but return a wrong value (e.g.4294967328instead of the real timestamp) — so this isn't limited to string fields; the
entire object returned by the producing method becomes unreliable once a
"heavy" call (e.g. a syntax highlighter) runs in between.
Workaround that works (but requires touching the caller)
Reconstructing a fresh object from the fields of the one returned by the
producer, immediately after receiving it and before calling the heavy
method in between, eliminates the crash (verified 5/5):
Workarounds tried inside the producer (
Db::find()) that do NOT work. ''orsprintf('%s', ...)before constructing the returned object inside
find()field reads back empty — it doesn't repair the value, just changes the
failure mode)
object, to "advance" a hypothetical bump allocator (no effect)
--heap-sizefrom 8 MB up to 256 MB (no effect — not a heap-size problem)The only fix that works requires the final (re)construction of the object to
happen in the caller's frame, not inside the producing method — consistent
with an escape-analysis/arena bug where values "returned" from a function are
not correctly promoted and remain tied to memory local to that function,
reusable by the next "heavy" call in the same worker.
Cross-links
Same family as the following open leak/ownership issues — the common thread
is "values crossing a function-return boundary aren't given independent,
correctly-owned storage":
?->/ ternary arms never releasedmixed_boxof an owned object leaks the producer's referenceThis issue's symptom (corruption/crash on read, not just a leaked
allocation) suggests it's not purely a "forgot to release" leak like the
above — it looks like the freed/reused memory from the leak family above is
what a subsequent heavy call then writes into, producing a genuine
use-after-free read rather than only unbounded growth. Filing separately
because the reproducing trigger (cross-class method call sequencing) and the
crash-on-read symptom are distinct enough to need their own repro, but this
is very likely one bug family with a common root cause in escape analysis/
value ownership at function-return boundaries.
Notes
Found building a pastebin single-binary app (
--web) for the Discordcontest:
GET /p/{id}deterministically returned an empty response(curl exit 52) until the caller-side reconstruction workaround was applied.