fix(legacy): handle a null exit code in Shell::runProcess() - #177
Conversation
If a process fails to start, Symfony throws ProcessStartFailedException (a ProcessFailedException subclass) and getExitCode() returns null. With $mustRun disabled, runProcess() returned that null from an int method, causing a TypeError. This happened in Config::getVersion(), which runs `git describe` with CLI_ROOT as the working directory. Inside a phar, CLI_ROOT is a phar:// path: it passes is_dir() but proc_open() cannot use it. - Return 1 when the exit code is null. - Skip the git version lookup when CLI_ROOT is a phar:// path. - Remove the now-fixed PHPStan baseline entry. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
Warning
Changes suggested — 🟡 1 warning · 🔵 1 minor point
🔍 Full review · 4 files reviewed
Verification
$process->getExitCode() ?? 1keeps the declaredintreturn type, andexecute()'s$exitCode > 0check turns it into the documentedfalseresult.- The removed phpstan-baseline entry matches exactly the
runProcess() should return int but returns int|nullerror the change fixes; no other baseline entry for that method is orphaned. HasTempDirTrait::tearDown()uses!empty($this->tempDir), which is isset-safe, so the other two tests in the class that never calltempDirSetUp()do not hit an uninitialized typed property.- The new guard in
getVersion()is evaluated in the same branch that previously ran the git lookup, so non-phar installs keep thegit describe --tagsfallback unchanged.
The diff adds ShellServiceTest::testExecuteStartFailure(), which builds a phar:// tar directory and asserts execute() returns false; it runs in CI's legacy-php job via ./scripts/test/unit.sh, and that job also runs make lint-phpstan, which is what the removed baseline entry affects. No test covers the $mustRun = true start-failure path or the new phar:// branch in Config::getVersion().
Review details
- Commit: 722028e
- Model: claude-opus-5
Review 1 of 10 for this pull request · View the full run
When a process failed to start and $mustRun was true, runProcess() wrapped it in the CLI's ProcessFailedException, whose constructor reads the process output. For a process that never started, that throws a LogicException and loses the original error. Rethrow Symfony's exception instead. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
Note
Reviewed — No new blocking findings · 🔵 1 minor point · 1 still open
🔁 Incremental · 2 files reviewed
🔵 Minor point
legacy/src/Service/Shell.php:223— Rethrowing the rawProcessStartFailedExceptionavoids the LogicException at the throw site, but hands a never-startedProcessto the handlers that catch Symfony'sProcessFailedExceptionand then read the process.Shell::exceptionMeansCommandDoesNotExist()(Shell.php ~283) does$process->getExitCode() === 127(null for a start failure, so false) and then, on Windows,$process->getErrorOutput(), which callsrequireProcessIsStarted()and throwsLogicException: Process must be started before calling getErrorOutput(). On Windows Symfony starts processes withbypass_shell, so a missing executable makesproc_open()itself fail:Git::ensureInstalled()(Git.php:61-62) on a Windows machine without git now reaches that catch and dies with the opaque LogicException instead of the intendedDependencyMissingException('Git must be installed'). Same forShell::findWhere()'s fallback attempt (Shell.php:259-261). GuardingexceptionMeansCommandDoesNotExist()with!$process->isStarted() ? true/false(or checking$e instanceof ProcessStartFailedException) would complete the fix.
Outstanding from earlier reviews:
- 🔵 #4099111704 —
legacy/src/Service/Config.php:550: Same latent failure remains for every other caller passing a phar path. — Declined by the author; Shell::setupProcess() still accepts anyphar://dir passing is_dir(), so the guard remains per-call-site in Config::getVersion().
Verification
Process::start()leaves status READY whenproc_open()fails, so!$process->isStarted()correctly selects the start-failure case for the rethrow.- The
!$mustRunearly return still precedes the new guard, soexecute()keeps returning false (exit code 1) for a start failure. ProcessStartFailedExceptionexists in the locked symfony/process v7.4.19, so the new test import resolves.findWhere()'scatch (ProcessFailedException)uses Symfony's base class, so the rethrown start-failure exception is still caught there.
The diff adds mustExecute() coverage to testExecuteStartFailure in legacy/tests/Service/ShellServiceTest.php, asserting ProcessStartFailedException; CI's php job runs ./scripts/test/unit.sh plus make lint-phpstan (.github/workflows/ci.yml). No test covers the Windows exceptionMeansCommandDoesNotExist() path described in the finding.
Review 2 of 10 for this pull request · View the full run
Shell::exceptionMeansCommandDoesNotExist() read the process's error output on Windows, which throws a LogicException for a process that never started. Return false in that case: a start failure (e.g. an unusable working directory) does not show that the command is missing. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Re the minor point in the latest review: fixed in 65bbefe. One correction to the scenario: on Windows, Symfony wraps every command in 🤖 Addressed by Claude Code |
There was a problem hiding this comment.
Warning
Changes suggested — 🟡 1 warning
🔁 Incremental (head + base moved) · 2 files reviewed
Verification
- The
!$process->isStarted()guard is placed beforegetExitCode()/getErrorOutput(), so the LogicException fromrequireProcessIsStarted()can no longer be reached from this helper. ProcessStartFailedExceptionexists in the pinned symfony/process v7.4.19 (composer.lock) and extendsProcessFailedException, so the test's catch andrunProcess()'s catch both match.- The rewritten test asserts a real return value rather than relying on
expectException, so the assertion after the throw is actually executed. - Base-branch movement (#107) touched only Go files under
internal/andcommands/; nothing it renamed is referenced by this PHP change.
Covered by ShellServiceTest::testExecuteStartFailure(), which is run by the legacy-php job's "Run PHPUnit tests" step in .github/workflows/ci.yml; that job runs on Linux only, so the Windows bypass_shell start-failure path the new guard mainly affects is not exercised by any test.
Review 3 of 10 for this pull request · View the full run
If a process can't start, Symfony throws
ProcessStartFailedException, which is a subclass ofProcessFailedException, andgetExitCode()returns null. When$mustRunwas false,Shell::runProcess()returned that null from a method declared to returnint. The result was aTypeError.In CI this came from
Config::getVersion(). Whenapplication.versionis still the placeholder, it runsgit describe --tagswithCLI_ROOTas the working directory. Inside a phar,CLI_ROOTis aphar://path. That path passes Symfony'sis_dir()check, butproc_open()can't use it.Changes:
runProcess()returns 1 when the exit code is null.getVersion()skips the git lookup whenCLI_ROOTis aphar://path.phar://tar directory as the working directory. It reproduced theTypeErrorbefore the fix.🤖 Generated with Claude Code