Skip to content

Object returned by one class's method reads corrupted fields (or crashes) after an intervening heavy call to another class #512

Description

@mirchaemanuel

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions