Skip to content

fix: make Core::pointerAtAddress allocation-free (leaked ~116 B per call) - #135

Merged
lisachenko merged 2 commits into
8.4from
claude/fix-pointer-at-address-leak
Aug 7, 2026
Merged

fix: make Core::pointerAtAddress allocation-free (leaked ~116 B per call)#135
lisachenko merged 2 commits into
8.4from
claude/fix-pointer-at-address-leak

Conversation

@lisachenko

Copy link
Copy Markdown
Owner

Fixes a process-lifetime memory leak in Core::pointerAtAddress(): it minted an owned T*[1] FFI slot on every call and returned $slot[0] — a view that pins the owning slot forever, so ~116 B leaked per call for every caller. Introduced in a56d59d when HashTable::uninitializedBucketData() switched to this primitive, which made every hashtable construction leak.

Fix: FFI::cast() reinterprets a plain integer as a pointer value with no backing buffer, so the method collapses to a single allocation-free cast. The assert($pointer instanceof CData) is gone because the guarantee is now static (cast() returns CData; PHPStan level max rejects the redundant runtime check).

Regression tests (tests/CoreMemoryTest.php): an addressOf() round-trip, and a 50k-call loop asserting RSS growth stays under 1 MB — verified to fail (+5.4 MB) with the fix reverted.

Measured, PHP 8.4.19, 50 000 calls: request memory +5 832 192 B → +0 B; RSS +5 556 kB → +0 kB.

Verification on this branch: full phpunit 396/396, --group opcache --fail-on-skipped 26/26, --group internal --process-isolation 138/138, PHPStan clean, cs clean.

Downstream proof (lisachenko/php-shared-data-extension#13, which surfaced this): with this fix overlaid on tag 8.4.0, its failing "Reclaiming soak" goes from +9 211 080 B FAIL to +32 048 B PASS (budget 65 536), resident 3.73 kB/cycle (budget 6 kB).

Release note: master (8.5) has the same bug via the cascade lineage — the 8.4 → master merge-up will carry this fix. A 8.4.1 tag after merge lets the ~8.4.0-pinned dependents pick it up.

Note: branch 8.4 was auto-deleted when cascade PR #133 merged ("automatically delete head branches" acted on the PR head); it has been restored at its previous SHA. Consider excluding version branches from auto-delete, or the next cascade merge will delete it again.

🤖 Generated with Claude Code

https://claude.ai/code/session_01HRDZ2XsoVuB5uG4qXKL3ny


Generated by Claude Code

pointerAtAddress() built its result by allocating a fresh `{$type}[1]` slot,
writing the address through an integer view of it and returning `$slot[0]`.
That element view pins its owning slot for as long as it lives, so the owned
allocation was never released: every call retained ~116 bytes for the rest of
the request.

It affects every caller, and the hot one is hashtable construction -
uninitializedBucketData() calls it once per table, so any workload that mints
persistent hashtables in a loop grew without bound. Introduced in a56d59d,
which moved that helper from pure cast arithmetic to pointerAtAddress().

FFI::cast() reinterprets a plain integer as a pointer value, so the typed
pointer needs no backing buffer at all. Semantics are unchanged: the result is
still a `T*` CData carrying exactly the requested address, verified by an
addressOf() round trip. The instanceof assert goes with the old body - it
existed to narrow the untyped `$slot[0]`, while cast() is declared to return
CData, and PHPStan rejects it as provably redundant.

Measured over 50k calls (PHP 8.4.19, release build):
  before  +5832192 request bytes (116.6 B/call), RSS +5556 kB
  after   +0 request bytes, RSS +0 kB

Downstream php-shared-data-extension, whose reclamation soak this was failing:
  tools/soak-drop.php 5000  request +9211080 -> +32048 bytes (budget 65536),
                            resident 5.63 -> 3.73 kB/cycle (budget 6)
  tools/soak.php 5000       stays flat at -1728 bytes

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HRDZ2XsoVuB5uG4qXKL3ny
@lisachenko

Copy link
Copy Markdown
Owner Author

Please make all branches 8.x not deletable after merge.

Copy link
Copy Markdown
Owner Author

I can't change repository rules with the tooling available in this session (no ruleset/branch-protection API access), so this one needs a minute of your admin UI — two equivalent options:

Option A — ruleset (recommended): Settings → Rules → Rulesets → New branch ruleset

  • Name: protect-version-branches, Enforcement: Active
  • Target branches → Add target → Include by pattern8.* (add 0.x too if you want it kept)
  • Under Rules, check Restrict deletions only (leave the rest off so cascade merges and fix pushes keep working)

Option B — classic protection rule: Settings → Branches → Add branch protection rule → pattern 8.*, save with no checkboxes enabled — matching branches become non-deletable and non-force-pushable by default.

Either one makes "Automatically delete head branches" skip 8.x branches when a cascade PR (like #133) merges, while feature branches still get cleaned up. 8.4 is already restored at its pre-deletion SHA, so once the rule is in place this can't recur.


Generated by Claude Code

The pull_request run for this branch (31123959841) has been wedged
pre-queue in the GitHub Actions incident backlog for 7 hours - it cannot
be cancelled (409) or re-run. A fresh head mints a fresh run.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HRDZ2XsoVuB5uG4qXKL3ny
@lisachenko
lisachenko merged commit 7b2a2e2 into 8.4 Aug 7, 2026
5 checks passed
@lisachenko
lisachenko deleted the claude/fix-pointer-at-address-leak branch August 7, 2026 04:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants