Skip to content

test(count): lock the null-container sentinel path after the #585 normalization#616

Open
mirchaemanuel wants to merge 1 commit into
illegalstudio:mainfrom
mirchaemanuel:fix/602-count-sentinel-typeerror
Open

test(count): lock the null-container sentinel path after the #585 normalization#616
mirchaemanuel wants to merge 1 commit into
illegalstudio:mainfrom
mirchaemanuel:fix/602-count-sentinel-typeerror

Conversation

@mirchaemanuel

Copy link
Copy Markdown
Contributor

Summary

count() on a boxed mixed receiver whose payload is the null-container sentinel segfaulted
(exit 139) instead of raising PHP's TypeError. A missed array read forwarded through a
ternary merge ($argc == 1 ? $rows[5] : ["a", "b"]) is boxed as an indexed-array mixed
cell whose payload pointer is the in-band NULL_SENTINEL; __rt_mixed_count dereferenced
that payload past a plain-zero guard.

Repro

<?php
$rows = [[1, 2]];
$r = $argc == 1 ? $rows[5] : ["a", "b"];
echo count($r), "\n";

Run with no arguments (missed-read arm):

  • Before: Warning: Undefined array key 5, then SIGSEGV (exit 139).
  • After: the same warning, then Fatal error: Uncaught TypeError: count(): Argument #1 ($value) must be of type Countable|array, null given (exit 1) — matching PHP's diagnostic
    in elephc's uncaught-exception format, and catchable as a TypeError.

Root cause

The count() lowering for a boxed Mixed receiver delegated to __rt_mixed_count, whose
tag-4/5 payload read (ldr x0, [x9] / mov rax, [r10]) was guarded only against a zero
pointer. The non-zero in-band NULL_SENTINEL slipped through and was dereferenced — the same
hole family as #526/#533 (read-side hardening), #585/PR #591 (get/set helpers), and
#592/PR #600 (append path). The concrete-array count() arm already raised this exact
TypeError via emit_type_error; the Mixed arm lacked the equivalent guard.

Fix

  • __rt_mixed_count now recognizes every null form of the receiver — a null pointer, the
    in-band sentinel as the cell or as a tag-4/5 payload (checked before the dereference, per
    fix(codegen): guard container consumers against the null-container sentinel (chained-read miss segfault) #533), and a boxed null cell (tag 8) — and returns NULL_SENTINEL as a "receiver was a
    null container" signal. Other non-countable tags still return 0.
  • The count() call site turns that signal into PHP's TypeError via the existing
    emit_type_error, matching the concrete-array path. A plain 0 result (an empty container)
    passes through unchanged, so the sentinel signal never collides with a legitimate count.
  • Guards land on both AArch64 and x86_64.

Real null (count(json_decode("null")), tag 8) previously returned 0 silently; it now
raises the identical TypeError, so the sentinel and real null behave the same way and both
match PHP. Non-null non-countable scalars (e.g. count(json_decode("42"))) still return 0,
unchanged (tracked separately as a quiet PHP-parity divergence).

Tests

New regression file tests/codegen/regressions/mixed_count_sentinel.rs:

  • uncaught sentinel count() prints the PHP TypeError fatal and exits with failure;
  • the same error is catchable and execution continues;
  • a real null cell (tag 8) raises the identical TypeError;
  • the same ternary merge delivering a real array counts correctly;
  • an empty container in a mixed cell still counts as 0;
  • the catchable error path is heap-clean under heap debug.

Focused slices green: mixed_count_sentinel (6), count (67), mixed (286), sentinel
(43), miss (119, including the #526 array-miss controls), sentinels lib unit tests (5).

Fixes #602

https://claude.ai/code/session_01HRvbcjPHa3kAojdCjNZL5L

@mirchaemanuel

Copy link
Copy Markdown
Contributor Author

The remaining count() parity gap mentioned in the description (non-null scalars in a mixed cell silently counting as 0, where PHP raises TypeError with the scalar type in the message) is now tracked as #617 — the machinery from this PR makes it a small follow-up, but it changes a test-locked behavior so it's kept separate.

@github-actions github-actions Bot added area:codegen Touches target-aware assembly or backend lowering. area:runtime Touches runtime helpers, GC, ownership, or bridge runtimes. size:s Small pull request. type:fix Corrects broken or incompatible behavior. labels Jul 23, 2026
@mirchaemanuel

Copy link
Copy Markdown
Contributor Author

CI caught a real interaction here: extending the TypeError to plain null receivers breaks the deliberate BUG-0 CLI convention that off-web superglobals count as 0 (bug0_cli_read_of_server_superglobal_before_assignment_does_not_crash). Correcting to raise the TypeError for the sentinel forms only (the actual #602 crash) and restoring the legacy 0 for plain null / boxed-null receivers — that residual PHP-parity gap moves to #617's scope. The docs-sync failure is just line-number drift from the builtins.rs edit; regenerating. Fix incoming on this branch.

@mirchaemanuel

Copy link
Copy Markdown
Contributor Author

Correction pushed (f16c4b668 + docs sync ac606f34c). Final routing: only values carrying the in-band NULL_SENTINEL (as the cell or as a tag-4/5 payload) raise the TypeError — i.e. exactly the forms that segfaulted; a plain null cell (off-web superglobals), a null tag-4/5 payload, and a boxed real null (tag 8) all keep their legacy 0, so the BUG-0 CLI convention (count($_SERVER) === 0) is preserved and bug0_cli_read_of_server_superglobal_before_assignment_does_not_crash passes again. PHP parity for real-null/scalar receivers stays tracked in #617. Docs desync was the lower_empty embedded line shifting 1077→1099 from the new guard; regenerated with the CI sequence, idempotent.

@github-actions github-actions Bot added the area:tooling-ci Touches CI, development tooling, Docker, or repository scripts. label Jul 23, 2026
…tudio#585 normalization

count() on a Mixed receiver carrying the null-container sentinel used to
dereference it and segfault (issue illegalstudio#602). The normalization added for issue
illegalstudio#585 turns that sentinel into a real null before count() sees it, so the crash
is gone on main without a dedicated guard.

These tests lock the resulting behavior: the sentinel path no longer crashes,
reports the missed read and counts as 0, and stays heap-clean; a real null cell
counts the same way; a populated merge and an empty container are unaffected.
One test records that no TypeError is raised where PHP raises one, so the
divergence tracked in issue illegalstudio#617 cannot be changed silently.

Claude-Session: https://claude.ai/code/session_01124pGFSEbzYQcGbNykWN7V
@mirchaemanuel
mirchaemanuel force-pushed the fix/602-count-sentinel-typeerror branch from ac606f3 to 6d88381 Compare July 25, 2026 21:08
@mirchaemanuel mirchaemanuel changed the title fix: raise count() TypeError on a null-container Mixed receiver instead of segfaulting test(count): lock the null-container sentinel path after the #585 normalization Jul 25, 2026
@mirchaemanuel

Copy link
Copy Markdown
Contributor Author

This PR no longer needs to change any runtime code — your #585 normalization already fixed #602. I've rewritten it as tests only and retitled it accordingly.

Rebasing it onto today's main conflicted in mixed_count.rs against bd354a5ee, and the conflict turned out to be semantic rather than textual: both changes decide how count() treats a null container. Resolving it textually produced a build where the sentinel branch was simply unreachable — the PR's own tests failed, returning 0 instead of the TypeError.

So I checked whether the original crash still exists. On afee4cb47 with no patch of mine:

$rows = [[1, 2]];
$r = $argc == 1 ? $rows[5] : ["a", "b"];
echo count($r), "\n";
Warning: Undefined array key 5
0                                  ← exit 0, was SIGSEGV 139

Normalization turns the sentinel into a real null before count() ever sees it, so there's nothing left to guard. The runtime change here had become dead code, and keeping it would have implied a fix that isn't mine to claim.

What remains is the PHP-parity gap: php raises count(): Argument #1 ($value) must be of type Countable|array, null given where we return 0. That was always out of scope here and is tracked in #617 — and it's now the only thing left, since sentinel and real null are no longer distinguishable at that point. Which also means the narrow version this PR originally shipped (TypeError for sentinel forms only, legacy 0 everywhere else, preserving count($_SERVER) == 0 off-web) can't be expressed any more. Worth knowing before #617 gets picked up: it's now an all-or-nothing decision against the off-web convention.

The six tests lock what main actually guarantees — no crash, warning plus 0, heap-clean on that path, real-null cell counting the same way, populated merge and empty container unaffected — and one of them records that no TypeError is raised where PHP raises one, so #617's divergence can't be changed silently.

Verified on 6d88381e9 (macOS aarch64): test target compiles clean, mixed_count_sentinel 6 passed / 0 failed. Diff is two test files, nothing else — no CHANGELOG entry, since nothing user-facing changes.

@github-actions github-actions Bot added area:triage No primary component could be inferred from changed paths. type:test Changes tests or test infrastructure only. and removed type:fix Corrects broken or incompatible behavior. area:codegen Touches target-aware assembly or backend lowering. area:runtime Touches runtime helpers, GC, ownership, or bridge runtimes. area:tooling-ci Touches CI, development tooling, Docker, or repository scripts. labels Jul 25, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:triage No primary component could be inferred from changed paths. size:s Small pull request. type:test Changes tests or test infrastructure only.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

count() on a null-container sentinel mixed cell segfaults instead of raising PHP's TypeError

1 participant