fix(occ): don't let one broken command make occ unusable (#33895)#41642
fix(occ): don't let one broken command make occ unusable (#33895)#41642DeepDiver1975 wants to merge 1 commit into
Conversation
loadCommandsFromInfoXml() loaded each app-provided console command and let any failure propagate out of occ entirely. A command whose class is missing or whose constructor throws (e.g. ArgumentCountError, a PHP \Error rather than a QueryException) therefore aborted the whole command loader, so a single broken app made occ unable to run *any* command — including the maintenance/repair commands an admin needs to recover. Wrap each command load in a try/catch(\Throwable): a broken command is now logged via ILogger::warning (with the command name and error) and skipped, while the remaining commands still load. The existing query → `new $command()` → unknown-class logic and the success path are unchanged, so the same commands are registered when nothing is broken. Adds tests/lib/Console/ApplicationTest.php asserting that a broken command in the list does not abort loading and valid commands are still registered. Fixes #33895 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Signed-off-by: Thomas Müller <1005065+DeepDiver1975@users.noreply.github.com>
|
Thanks for opening this pull request! The maintainers of this repository would appreciate it if you would create a changelog item based on your changes. |
DeepDiver1975
left a comment
There was a problem hiding this comment.
🤖 Automated code review by Claude Code review agent
Overview
Wraps each per-command load in OC\Console\Application::loadCommandsFromInfoXml() in an outer try/catch (\Throwable) so a single broken app command is logged and skipped instead of aborting the whole occ loader. Adds tests/lib/Console/ApplicationTest.php. The change is small, well-scoped, and directly addresses #33895. I verified the fix against the local clone (Symfony Console v7.4.7, PHP >= 8.3).
Correctness
\Throwablecovers\Error: ✅ Correct.\Throwableis the common ancestor of both\Exceptionand\Error(PHP 7+), so it catchesArgumentCountError,TypeError, etc. thrown from a command constructor — exactly the failure class the issue describes.- Success path unchanged: ✅ The original
query→new $command()→ unknown-class block is preserved byte-for-byte as the innertry/catch. On the happy path the inner block never throws, the outer catch is never entered, and the same commands register as before. addCommandis the real method: ✅ Verifiedlib/composer/symfony/console/Application.php(installed v7.4.7) definespublic function addCommand(callable|Command $command). Production code already used$this->application->addCommand($c)and the PR did not change that line, so this is not a regression risk. The test'sRecordingApplication::addCommand($command)matches the production call site.- Logger signature valid: ✅
ILogger::warning($message, array $context = [])— the call passes a message string and a context array (command,message,app).{command}/{message}PSR-3-style placeholders match the context keys. Fine. - Failures not swallowed silently: ✅ Each skip is logged with command name + error message.
Tests
I traced the new testBrokenCommandDoesNotAbortLoading end-to-end against the actual container/test infra:
Test\TestCase::invokePrivate()✅ exists (static,tests/lib/TestCase.php:176), and correctly handles both the private-property set ('application') and the private-method invoke ('loadCommandsFromInfoXml').newInstanceWithoutConstructor()✅ appropriate — avoids needingOC_Defaults/ a realSymfonyApplication; the test then injectsRecordingApplicationinto the private$applicationproperty.- Broken command
OC\This\Command\Does\Not\Exist:query()→resolve()→ReflectionException→ wrappedQueryException→class_exists()false →throw new \Exception(...)→ caught by outer\Throwable→ logged + skipped. ✅ - Valid command
ValidStubCommand::class:Command::__construct(?string $name = null)is fully optional, so the container autowires it (or thenew $command()fallback runs); either way$c instanceof ValidStubCommand. ✅ - Assertions:
assertCount(1, registered)andassertInstanceOf(ValidStubCommand::class, registered[0]). ✅ Both hold — exactly one command survives. No sort-order/ordering assumption issues (single survivor). - Namespace/PSR-4:
Test\→tests/lib/(composerautoload-dev), soTest\Console\ApplicationTest→tests/lib/Console/ApplicationTest.php. ✅ Matches the existingTest\Files\subdir convention. @group DB: justified — the test reaches\OC::$server->getLogger()and the DI container, which need the OC bootstrap. ✅php -lon the production file: clean.
The test is correct and should pass in CI.
Minor
- Three classes in one file (
ValidStubCommand,RecordingApplication,ApplicationTest). Common and acceptable for test fixtures; PHPUnit loads by path so PSR-4 single-class-per-file is not required here. No action needed. RecordingApplicationis a structural duck-type, not a realSymfonyApplication— fine becauseloadCommandsFromInfoXmlonly callsaddCommand(). If the loader ever calls another method on$this->application, the test fixture would need updating; a short comment to that effect already exists.- Optional: the warning context sets
'app' => 'core', but the failing command belongs to an app, not core. Harmless (it identifies the logging subsystem), but the offending app id is not captured. Not blocking.
Verdict
approve-with-nits. The fix is correct, minimal, and safe; the happy path is provably unchanged; and the new test is sound and should pass in CI. No blocking issues. The only suggestions are cosmetic (logging the originating app id). Good defensive fix for a real admin-recovery footgun.
|
This looks good. For each PR like this we should have a changelog entry. Claude is going to really struggle with that convention. Is there some other changelog naming convention that we can be happy to use, and then could Claude also write a suggested changelog entry with each PR? |
Summary
Fixes #33895 — a single broken console command could make all of
occunusable.OC\Console\Application::loadCommandsFromInfoXml()loaded each app-provided command and let any failure propagate out ofocc. The existingtry/catchonly handledQueryException(falling back tonew $command()or throwing for an unknown class), so:ArgumentCountErroror any other PHP\Error/\Throwablethat is not aQueryExceptionbubbled all the way up and aborted the entire command loader. The practical impact: one broken app makes
occunable to run any command — including themaintenance:*/upgrade/ repair commands an administrator needs to recover from that very situation.Change
Wrap each per-command load in an outer
try/catch (\Throwable):ILogger::warning(with the command name and error message) and skipped, and the loop continues loading the remaining commands.query→new $command()→ unknown-class logic is preserved byte-for-byte as the inner block, so the normal/success path registers exactly the same commands as before. The outer catch only changes behavior when a command actually fails.\Throwableis used deliberately so it covers both\Exceptionand\Error(theArgumentCountError-style constructor failures this issue is about).Why this is safe
new $command()was already present; this only broadens error tolerance around it.Tests
Adds
tests/lib/Console/ApplicationTest.php(testBrokenCommandDoesNotAbortLoading): given a command list containing a broken (unknown-class) command followed by a valid one,loadCommandsFromInfoXml()must not throw and the valid command must still be registered.🤖 This PR was prepared by the Claude Code review agent from the analysis of #33895. Please review carefully before merging.