Skip to content

CI: select the compliance suite; add a spine-chunk split-TU probe - #24835

Merged
PurHur merged 5 commits into
masterfrom
ci/nightly-suite-and-spine-probe
Jul 29, 2026
Merged

PurHur merged 5 commits into
masterfrom
ci/nightly-suite-and-spine-probe

Conversation

@PurHur

@PurHur PurHur commented Jul 29, 2026

Copy link
Copy Markdown
Owner

Two independent pieces, both about measuring things that were previously measured by hand and got the wrong answer.

1. compliance-nightly.yml takes a suite input (VMTest | JITTest)

The cron nightly still runs VMTest, the suite with a committed baseline. JITTest (~472 failures) has none, so --suite=JITTest --diff is expected to refuse until a collect run has been reviewed and landed by a human — that is the script doing its job, not a break.

schedule supplies no inputs, so every expression falls back to VMTest. The concurrency group is keyed on the suite too, so a JITTest collect cannot cancel a VMTest nightly on the same ref.

2. script/spine-chunk-probe.sh

Replaces the hand-built fixtures under build/micro/chunk/ used to test whether the spine can be split into separately-compiled TUs (RELEASE-PLAN Phase 3 item 9). This matters because the gen-1 bootstrap compiles all 6,519 spine files as one TU and has now failed four times purely on memory — OOM at 6, 24 and 48 GiB, and a fourth run pinned at a 12 GiB cap for 4h55m in direct reclaim (memory.events max=1426, oom_kill=0 — it survived by swapping, not by fitting). Capacity is not the constraint; unbounded growth in a single TU is.

The hand-built fixtures gave two wrong answers. The script encodes both.

They were not valid PHP programs. A bare require_once list over ext/<name>/ references classes declared elsewhere (ModuleAbstract, VmClassMethod, Func\Internal), so Zend fatals on all seven with rc=255. "The compiler failed to build this chunk" could not be distinguished from "this chunk is not a program" — which undercuts the premise of #24429's title. The generated fixture prepends the autoloader, and the Zend baseline is checked rather than assumed.

rc=0 plus a non-empty binary is not a pass. ext/ds compiles to an 18.5 MB binary that exits 0, while the compiler itself reports 61 method calls — including hexdec, substr, is_string, str_starts_with — lowered to a silent null because their class is not in the module (#579, #24227). Against the bare fixture that binary exits 0 where Zend exits 255. Silent wrong output is the characteristic failure of this project (AGENTS.md §3), so an unbound stub is a failure here, not a warning.

Measured at fd3b1fedc with PHP_COMPILER_SPINE_CHUNK=1 (#24496)

chunk result
ds compiles, 18.5 MB, 61 unbound stubs → HOLLOW
types LLVMAbstract/Module.php exception, rc=255
msgpack unimplemented TYPE_BITWISE binary op
sockets Static call class must be a literal (lib/JIT.php:18097)
vm same, at 202s
spl SIGSEGV (rc=139) at 71s

So #24496 did what it claimed — the chunks get past the old Call to undefined method object::… abort — but no chunk is yet usable, and the blocker moved from "compile aborts" to "compiles, silently wrong", which is the more dangerous state.

One detail worth a follow-up: in the sockets/vm case Block::getOperand() returns null for both the class and name slots, so the class operand is absent rather than dynamic. The throw message describes a different condition than the one occurring.

Validation

Validated by making it fail: exit 1 on a real chunk failure, exit 1 on a bogus --ext, and the pass path exercised via ext/ds.

🤖 Generated with Claude Code

Two independent pieces, both about measuring things that were previously
measured by hand and got the wrong answer.

1. compliance-nightly.yml takes a `suite` input (VMTest | JITTest).

   The cron nightly still runs VMTest, which is the suite with a committed
   baseline. JITTest (~472 failures) has none, so `--suite=JITTest --diff` is
   expected to refuse until a collect run has been reviewed and landed by a
   human. `schedule` supplies no inputs, so every expression falls back to
   VMTest. The concurrency group is keyed on the suite as well, so a JITTest
   collect cannot cancel a VMTest nightly on the same ref.

2. script/spine-chunk-probe.sh replaces the hand-built chunk fixtures under
   build/micro/chunk/ used to test whether the spine can be split into
   separately-compiled translation units (RELEASE-PLAN Phase 3 item 9).

   The hand-built fixtures gave two wrong answers, and the script encodes
   both:

   - They were not valid PHP programs. A bare require_once list over ext/<n>/
     references classes declared elsewhere (ModuleAbstract, VmClassMethod,
     Func\Internal), so Zend fatals on all seven with rc=255. "The compiler
     failed to build this chunk" could not be distinguished from "this chunk
     is not a program". The generated fixture prepends the autoloader and the
     Zend baseline is now checked rather than assumed.

   - rc=0 plus a non-empty binary is not a pass. ext/ds compiles to an
     18.5 MB binary that exits 0 while the compiler itself reports 61 method
     calls — including hexdec, substr, is_string, str_starts_with — lowered
     to a silent null because their class is not in the module (#579,
     #24227). Against the bare fixture that binary exits 0 where Zend exits
     255: silent wrong output, which is the characteristic failure of this
     project (AGENTS.md 3). An unbound stub is therefore a failure here, not
     a warning.

   Measured at fd3b1fe with PHP_COMPILER_SPINE_CHUNK=1 (#24496):
     ds      compiles, 18.5 MB, 61 unbound stubs      -> HOLLOW
     types   LLVMAbstract/Module.php exception, rc=255
     msgpack unimplemented TYPE_BITWISE binary op
     sockets Static call class must be a literal (JIT.php:18097)
     vm      same, at 202s
     spl     SIGSEGV (rc=139) at 71s

   In the sockets/vm case Block::getOperand() returns null for BOTH the class
   and name slots, so the class operand is absent rather than dynamic — the
   throw message describes a different condition than the one occurring.

   Exit 0 only when every chunk compiles with every call bound. Today that is
   never, which is the status quo being tracked; a zero exit is real news.

Validated by making it fail: exit 1 on a real chunk failure, exit 1 on a
bogus --ext, and the pass path exercised via ext/ds.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@PurHur

PurHur commented Jul 29, 2026

Copy link
Copy Markdown
Owner Author

Validated by running it, not by reading it

This PR has no checks — compiler-gate.yml triggers on lib/** and ext/**, and this touches neither. CLEAN here means "no checks configured" (CLAUDE.md §1), so naming the gate I actually ran:

Dispatched the changed workflow from this branch: run 30455848639 — mode=collect suite=JITTest.

  • job names rendered as JITTest shard 0..23, not VMTest — the suite input reaches the matrix
  • all 24 shards succeeded
  • the collect step produced artifact JITTest-baseline-42361f4b… containing 489 failing names, header # shards: 24 failing: 489

So the suite plumbs through the matrix, the shard script, the artifact paths and the baseline collector. This matters because the last CI gate I added failed on arrival for want of exactly this check.

script/spine-chunk-probe.sh was validated by making it fail: exit 1 on a real chunk failure, exit 1 on a bogus --ext, and the pass path exercised via ext/ds (which is how the HOLLOW case was found in the first place).

Not landing a JITTest baseline from this run. 489 names from a single run is one measurement; the two-run protocol requires a second collect to quarantine what flips. Run 2 is dispatched. A single-run baseline was measured wrong in 9 places on VMTest (#24672).

… collects

The two-run baseline protocol needs two collects of the SAME commit. Two
things broke that, and both are fixed here.

cancel-in-progress killed one of any two overlapping manual runs, leaving a
single result and no way to tell flaky from changed. It now applies only to
scheduled runs, which should still supersede each other.

The quarantine landed in #24778 was derived from two collects 15 commits
apart, so every flip it recorded was a CHANGE rather than flakiness:

  0ae0f94 (highlight_string HTML wire format) fixed four highlight_* cases
  and broke three; 6af97e0 (property hooks on the default profile) made six
  property_hook_* cases start executing at all, because VMTest.php gates them
  on CompilerVersion::supportsPropertyHooks() and the provider had never
  yielded them before.

A case that goes from unyielded to failing looks exactly like a flaky one in
a set difference of failing NAMES. 18 real failures were quarantined into
invisibility as a result — including three highlight_* regressions from
0ae0f94 that still fail at b114e97 (#24726).

workflow_dispatch resolves a branch to its head at dispatch time, and master
merges ~4 commits/hour, so two dispatches minutes apart are not the same
tree. Documented: cut a ref that does not move and dispatch both against it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
PurHur added a commit that referenced this pull request Jul 29, 2026
…asurement (#24837)

Docs-only: records fourth gen-1 failure (swap, not OOM) and corrects P3.9 chunk measurements against valid fixtures / hollow-compile failure mode. Companion to #24835 probe and #24429/#24836.
…longer run"

The baseline recorded only failing NAMES. That cannot distinguish a case that
started passing from one the provider stopped yielding, and the two need
opposite responses: regenerate for the first, investigate for the second.

VMTest::providePHPTests() gates cases on CompilerVersion::supports*(), and
the default profile has flipped repeatedly this week — 6af97e0 enabled
property hooks, 9108205 re-rejected them on the 8.2 reference profile,
270fea4 re-gated asymmetric visibility, 7236ecd typed class constants.
Each flip changes which cases exist as far as the diff is concerned.

Measured on the 2026-07-29 nightly (run 30458065584) against a634624, four
cases it reported as FIXED do not execute at all:

  language/asymmetric_visibility_write
  language/asymmetric_visibility_public_protected_set
  language/static_asymmetric_visibility
  language/match_typed_class_const

Regenerating the baseline from that report would have dropped them from
tracking permanently. The same mechanism already buried 18 real failures in
the quarantine (#24726), including three highlight_* regressions.

  shard-compliance.sh    writes <suite>-<i>-of-<N>.executed alongside .failed
  compliance-baseline.sh --collect writes baselines/<suite>.executed
                         --diff splits FIXED / DROPPED / NEWLY EXECUTED

Splitting FIXED from DROPPED needs only the current run's executed set;
flagging NEWLY EXECUTED needs the executed baseline. The two are gated
independently — conflating them printed "executed, passing now" over a list
whose execution had never been checked.

Degrades honestly: with no .executed files the heading becomes "FIXED OR
DROPPED — execution NOT verified" and says not to regenerate from it, rather
than asserting something it did not measure.

Validated on synthetic fixtures covering all four paths: real fix reported as
FIXED, unexecuted case reported as DROPPED and NOT as fixed, newly-executed
failing case flagged before its regression line, and the no-.executed
fallback relabelled. Exit stays 1 on regressions, 0 otherwise. SHARD_DIR /
BASELINE_DIR / QUARANTINE are now overridable so this is testable without
touching committed baselines (#24498 does the same for the corpus).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…d pair

The quarantine had 26 entries. Exactly one of them is flaky.

The previous contents came from two collects taken 15 commits apart
(1d57e9d vs d646332), so every "flip" they recorded was a CHANGE rather
than nondeterminism (#24726). Re-measured from two complete 24-shard CI
collects at the SAME commit a3f3266, on the same runner image — runs
30461221640 and 30461258793:

  8,112 executed cases, and the two runs disagreed about exactly ONE.

Both the failing set and the executed set were otherwise identical between
the runs. Held constant, the suite is deterministic.

Verdict on all 26 quarantined names, from that pair:

  FLAKY        1   stdlib/hrtime_nanosecond_precision (#24870) — kept
  stable-FAIL  6   real failures the quarantine was HIDING — now baselined
  stable-pass 13   never flaky; pure noise — removed
  not executed 6   property_hook_* not yielded at this tree — removed

The six that were hidden:

  language/builtin_attribute_classes
  language/final_hooked_property
  stdlib/array_all_internal_callback
  stdlib/highlight_file_br
  stdlib/highlight_file_multiline_br
  stdlib/highlight_string_pre_wrapper

The last three were introduced by 0ae0f94, which fixed four other
highlight_* cases and broke these three. Quarantining them meant the gate
could never report them again — the exact failure mode the quarantine exists
to prevent, inverted.

Baseline is now 428 names, plus the first VMTest.executed at 8,112 names.

Validated the way that counts: the baseline collected from run 30461258793
diffs CLEAN (exit 0) against run 30461221640 — the run it was not built from.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@PurHur

PurHur commented Jul 29, 2026

Copy link
Copy Markdown
Owner Author

The executed-set change paid for itself immediately

Pushed two more commits and validated the whole thing end-to-end on real CI runs.

The gate had a hole, and the nightly walked into it

Nightly 30458065584 reported a FIXED list. Four of those cases do not execute at all at a634624db — verified by filtering for them directly on a dev box:

language/asymmetric_visibility_write
language/asymmetric_visibility_public_protected_set
language/static_asymmetric_visibility
language/match_typed_class_const

The prescribed response to a FIXED entry is "regenerate the baseline", which would have deleted them from tracking permanently. shard-compliance.sh now writes a .executed set per shard and the collector splits FIXED / DROPPED / NEWLY EXECUTED.

It degrades honestly: with no .executed files the heading becomes FIXED OR DROPPED — execution NOT verified and says not to regenerate from it. The first version of that code printed "executed, passing now" over a list whose execution had never been checked — caught by testing the degraded path rather than only the happy one.

First properly pinned pair, and the quarantine was wrong

Two complete 24-shard collects at the SAME commit a3f32662b on the same image — 30461221640 and 30461258793. Both ran to completion, neither cancelled the other, which is the concurrency fix working.

8,112 executed cases. The two runs disagreed about exactly one.

Verdict on all 26 quarantined names:

verdict n action
genuinely FLAKY 1 stdlib/hrtime_nanosecond_precision kept, issue filed (#24870)
stable FAIL 6 real failures the quarantine was HIDING — now baselined
stable pass 13 never flaky; removed
not executed 6 property_hook_* not yielded at this tree; removed

The six that were hidden include highlight_file_br, highlight_file_multiline_br and highlight_string_pre_wrapper — introduced by 0ae0f941c, which fixed four other highlight_* cases and broke these three. Quarantining them meant the gate could never report them again.

Baseline is now 428 names plus the first VMTest.executed at 8,112.

Gate I ran

This PR gets no CI checks (compiler-gate.yml triggers on lib/** and ext/**). Named explicitly:

  • suite input: dispatched mode=collect suite=JITTest — jobs rendered as JITTest shard 0..23, 489 names collected
  • JITTest twice at one SHA (42361f4be): 489 vs 489, zero flips
  • VMTest twice at one SHA (a3f32662b): 428 vs 428 failing, 8,112 vs 8,112 executed, one flip
  • baseline collected from run 2 diffs clean (exit 0) against run 1 — the run it was not built from
  • compliance-baseline.sh three-way split validated on synthetic fixtures across all four paths
  • spine-chunk-probe.sh validated by making it fail: exit 1 on a real failure, exit 1 on a bogus --ext

…d-spine-probe

# Conflicts:
#	test/compliance/quarantine.txt
@PurHur
PurHur merged commit e7de997 into master Jul 29, 2026
PurHur added a commit that referenced this pull request Jul 29, 2026
#24933)

The executed set I added in #24835 counted skips as executions, so the very
confusion it was built to prevent survived in a second form.

PHPUnit emits testStarted for a skipped case AND then testIgnored. The set
was built from testStarted alone, so once --SKIPIF-- started being honoured
(#24888) a case that became skipped left the failing set, stayed in the
executed set, and read as FIXED.

Measured on the first post-SKIPIF collect (runs 30472500277 / 30472527097 at
90c654c): failing fell 428 -> 370, while executed ROSE 8112 -> 8121 and
exactly ZERO cases stopped executing. The 58 cases that stopped failing were
skips wearing a fix.

  shard-compliance.sh    writes <suite>-<i>-of-<N>.skipped from testIgnored,
                         and .executed is now started MINUS ignored
  compliance-baseline.sh --diff splits FIXED / SKIPPED / DROPPED / NEWLY
                         EXECUTED

SKIPPED and DROPPED are kept apart because they are different problems: a
skipped case declares itself inapplicable to this host/profile, an unyielded
case was gated out by VMTest::providePHPTests(). Both are coverage losses,
neither is a repair, and a skip guard that started firing wrongly would look
identical to a correct one.

The zero-executed guard deliberately still keys on testStarted: it means "the
filter matched nothing", and a shard whose cases all legitimately skip is not
that failure.

Verified on a real mixed teamcity log (4 started, 2 skipped, 2 ran, 1 failed
— the two skips correctly excluded from "ran"), and on synthetic fixtures for
all four classifications. Degrades conservatively: with no .skipped files a
skipped case falls into DROPPED, never into FIXED.

Co-authored-by: PurHur <tedyyyyy@gmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
@PurHur
PurHur deleted the ci/nightly-suite-and-spine-probe branch July 30, 2026 00:44
PurHur added a commit that referenced this pull request Jul 30, 2026
)

The compiler gate has produced no master verdict at all. Measured over the 60
most recent runs of compiler-gate.yml:

  cancelled   31
  in flight   29
  completed    0

Every master push shared one concurrency group with cancel-in-progress, and
master takes ~12 commits/hour against a gate that needs ~25 min — so each run
was cancelled by the next merge before it could reach a conclusion. The gate
appeared to be running constantly and never reported anything.

That is the same failure as a check that reports without running, which this
repo has now hit four times (#24888 dead SKIPIF, #24726 quarantine from two
different commits, #24835 baseline that could not tell fixed from dropped,
#24933 skips counted as executions). Cancelled-forever is absence wearing a
busy spinner.

Master runs are now keyed on the SHA, so each merge gets its own verdict. PR
runs keep superseding each other on the ref, which is correct — once a branch
is pushed again the previous tree is nobody's concern.

Cost: master runs may overlap. With the paths filter (lib, ext, bin, patches,
the sweep scripts) and ~56% of commits touching lowering, that is roughly 7
runs/hour at ~25 min each, so ~3 concurrent. GitHub queues the excess.

Found while investigating why #24988 merged with a red gate. That failure was
`aot-smoke: 7 passed, 1 failed` — `echo` hitting rc=124, a 900s COMPILE
TIMEOUT under CI load, not a wrong result. Verified on current master
(e5bf674, which contains that patch): aot-smoke 8 passed, 0 failed locally,
including echo. So the red was infrastructure slowness, and master is intact —
but nobody could have known that from the gate, because it never finishes.

Co-authored-by: PurHur <tedyyyyy@gmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants