|
| 1 | +<?php |
| 2 | + |
| 3 | +declare(strict_types=1); |
| 4 | + |
| 5 | +namespace Tests\System\CiFeatures; |
| 6 | + |
| 7 | +use Tests\Utils\TestCase\CiFeatureTestCase; |
| 8 | + |
| 9 | +/** |
| 10 | + * End-to-end regression tests for the two memory-budget bugs detected during |
| 11 | + * v3.3 RC QA and closed in commits b9f5ff8 (BUG-001) and d614e4c (BUG-002). |
| 12 | + * |
| 13 | + * Sits in the CiFeatures group so the binary is invoked from the source |
| 14 | + * checkout (no need for a freshly-built .phar) — the same path the matrix |
| 15 | + * CI cell uses for memory-budget feature tests. The unit and integration |
| 16 | + * tests on FlowExecutor / FlowMemoryHandler already cover the contracts at |
| 17 | + * the coordinator level; these tests are the last-line safety net against |
| 18 | + * a refactor that bypasses the unit tests yet ships a broken end-to-end |
| 19 | + * behaviour to users. |
| 20 | + * |
| 21 | + * @group ci-features |
| 22 | + */ |
| 23 | +class MemoryBudgetRegressionTest extends CiFeatureTestCase |
| 24 | +{ |
| 25 | + private string $burnerScript; |
| 26 | + |
| 27 | + protected function setUp(): void |
| 28 | + { |
| 29 | + parent::setUp(); |
| 30 | + $this->burnerScript = 'tests/Fixtures/scripts/memory-burner.php'; |
| 31 | + } |
| 32 | + |
| 33 | + /** |
| 34 | + * Regression for BUG-001 (closed in b9f5ff8 — fix(memory): propagate per-job |
| 35 | + * fail-above to job.success). |
| 36 | + * |
| 37 | + * Symptom before the fix: a job whose observed peak crossed its declared |
| 38 | + * `memory.fail-above` produced `threshold.failed: true` on the JobResult, |
| 39 | + * but `job.success` stayed true, `flow.success` stayed true, and the exit |
| 40 | + * code was 0. The contract said a job that crossed fail-above must fail |
| 41 | + * the flow (mirror of the time-budget per-job `fail-after` contract). |
| 42 | + * |
| 43 | + * Setup: a 64 MB allocator (memory-burner.php) with warn-above=10 and |
| 44 | + * fail-above=20 — the observed peak (~64 MB) crosses both. |
| 45 | + * |
| 46 | + * @test |
| 47 | + */ |
| 48 | + public function bug001_per_job_fail_above_propagates_to_exit_code(): void |
| 49 | + { |
| 50 | + if (PHP_OS_FAMILY === 'Windows') { |
| 51 | + $this->markTestSkipped('Per-job memory threshold requires an active sampler; Windows skipped.'); |
| 52 | + } |
| 53 | + |
| 54 | + $this->configurationFileBuilder |
| 55 | + ->setV3Flows(['qa' => ['jobs' => ['mem_heavy']]]) |
| 56 | + ->setV3Jobs([ |
| 57 | + 'mem_heavy' => [ |
| 58 | + 'type' => 'custom', |
| 59 | + 'script' => "php $this->burnerScript 64 2", |
| 60 | + 'memory' => ['warn-above' => 10, 'fail-above' => 20], |
| 61 | + ], |
| 62 | + ]); |
| 63 | + $this->writeConfig(); |
| 64 | + |
| 65 | + $result = $this->runGithooks("flow qa --format=json --config=$this->configPath"); |
| 66 | + |
| 67 | + $this->assertNotSame( |
| 68 | + 0, |
| 69 | + $result['exitCode'], |
| 70 | + "BUG-001 regression: job that crossed fail-above must produce a non-zero exit code. stderr:\n{$result['stderr']}" |
| 71 | + ); |
| 72 | + |
| 73 | + $decoded = $this->decodeJsonOutput($result['stdout']); |
| 74 | + $this->assertFalse( |
| 75 | + $decoded['success'], |
| 76 | + 'BUG-001 regression: flow.success must be false when a per-job fail-above triggers' |
| 77 | + ); |
| 78 | + |
| 79 | + $job = $this->findJob($decoded, 'mem_heavy'); |
| 80 | + $this->assertFalse( |
| 81 | + $job['success'], |
| 82 | + 'BUG-001 regression: job.success must be false when peak crossed fail-above (was true before b9f5ff8)' |
| 83 | + ); |
| 84 | + $this->assertTrue( |
| 85 | + $job['memoryThreshold']['failed'] ?? false, |
| 86 | + 'memoryThreshold.failed must be true so the threshold reason is observable in the structured output' |
| 87 | + ); |
| 88 | + } |
| 89 | + |
| 90 | + /** |
| 91 | + * Regression for BUG-002 (closed in d614e4c — fix(memory): clamp per-job |
| 92 | + * memory reserve to bin-packing reference in admission). |
| 93 | + * |
| 94 | + * Symptom before the fix: invoking `flow qa --memory-warn-above=N` with |
| 95 | + * N < max(jobs.memory) caused the executeParallel loop to spin at 100% CPU |
| 96 | + * forever. The bin-packing reference (warn-above preferred, fail-above |
| 97 | + * otherwise) was used as a hard admission ceiling, so a single job whose |
| 98 | + * declared `memory:` exceeded that ceiling never satisfied |
| 99 | + * AdmissionContext::fits() and FifoAdmission span the loop indefinitely. |
| 100 | + * |
| 101 | + * Wrapping the binary in `timeout 30` is critical: without it, a regression |
| 102 | + * of this fix would hang the test runner for PHPUnit's full wall-clock |
| 103 | + * budget. With it, an unfinished binary surfaces as exit code 124 — the |
| 104 | + * sentinel we assert against. |
| 105 | + * |
| 106 | + * @test |
| 107 | + */ |
| 108 | + public function bug002_memory_warn_above_below_reserve_does_not_deadlock(): void |
| 109 | + { |
| 110 | + $this->configurationFileBuilder |
| 111 | + ->setV3GlobalOptions(['fail-fast' => false, 'processes' => 1]) |
| 112 | + ->setV3Flows(['qa' => ['jobs' => ['heavy_reservation']]]) |
| 113 | + ->setV3Jobs([ |
| 114 | + 'heavy_reservation' => [ |
| 115 | + 'type' => 'custom', |
| 116 | + 'script' => 'true', |
| 117 | + 'memory' => 300, |
| 118 | + ], |
| 119 | + ]); |
| 120 | + $this->writeConfig(); |
| 121 | + |
| 122 | + $entrypoint = $this->projectRoot . DIRECTORY_SEPARATOR . 'githooks'; |
| 123 | + $cmd = sprintf( |
| 124 | + 'timeout 30 %s %s flow qa --memory-warn-above=200 --format=json --config=%s 2>/dev/null', |
| 125 | + escapeshellarg(PHP_BINARY), |
| 126 | + escapeshellarg($entrypoint), |
| 127 | + escapeshellarg($this->configPath) |
| 128 | + ); |
| 129 | + |
| 130 | + $start = microtime(true); |
| 131 | + $output = shell_exec($cmd . '; echo "EXIT_CODE=$?"'); |
| 132 | + $elapsed = microtime(true) - $start; |
| 133 | + |
| 134 | + // shell_exec returns the combined stdout (with our marker on the last line). |
| 135 | + if (!is_string($output) || preg_match('/EXIT_CODE=(\d+)/', $output, $m) !== 1) { |
| 136 | + $this->fail('Could not extract exit code from binary output: ' . var_export($output, true)); |
| 137 | + } |
| 138 | + $exitCode = (int) $m[1]; |
| 139 | + $jsonOutput = preg_replace('/EXIT_CODE=\d+\s*$/', '', $output); |
| 140 | + |
| 141 | + $this->assertNotSame( |
| 142 | + 124, |
| 143 | + $exitCode, |
| 144 | + 'BUG-002 regression: the executeParallel loop deadlocked — `timeout 30` killed the binary at the 30s sentinel' |
| 145 | + ); |
| 146 | + $this->assertLessThan( |
| 147 | + 10.0, |
| 148 | + $elapsed, |
| 149 | + 'BUG-002 regression: flow took ' . number_format($elapsed, 2) . 's. ' |
| 150 | + . 'After the fix the binary terminates in <2s; >10s indicates the clamp is missing or partially regressed' |
| 151 | + ); |
| 152 | + |
| 153 | + $decoded = json_decode($jsonOutput, true); |
| 154 | + $this->assertNotNull($decoded, 'Output is not valid JSON: ' . $jsonOutput); |
| 155 | + $this->assertSame('qa', $decoded['flow']); |
| 156 | + } |
| 157 | +} |
0 commit comments