From e49e540ec0d67f47ab1c67df0561e13a486aec54 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Sat, 24 Oct 2026 00:45:13 +0200 Subject: [PATCH] fix(applications): report a failed stop instead of failing silently - Do not ignore errors of the docker stop and rm commands, so a failed SSH call does not mark a running application as exited. - Log the failure and broadcast ApplicationStopFailed; the application page shows an error toast. Closes #11063 Co-Authored-By: Claude Opus 5.5 --- .ai/lessons.md | 3 + app/Actions/Application/StopApplication.php | 17 ++- app/Events/ApplicationStopFailed.php | 27 +++++ app/Livewire/Project/Application/Heading.php | 15 +++ tests/Feature/StopApplicationFailureTest.php | 111 +++++++++++++++++++ 5 files changed, 170 insertions(+), 3 deletions(-) create mode 100644 app/Events/ApplicationStopFailed.php create mode 100644 tests/Feature/StopApplicationFailureTest.php diff --git a/.ai/lessons.md b/.ai/lessons.md index 1ea84dab0f..00dc3cbd89 100644 --- a/.ai/lessons.md +++ b/.ai/lessons.md @@ -107,6 +107,9 @@ - Host test runs share `storage/` with the dev container. Fake the `ssh-keys`/`ssh-mux` disks and `Process` in tests that run seeders or write SSH files, or the test deletes/chowns the dev instance's SSH keys and SSH breaks for `www-data`. - Dev QEMU servers from `dev:qemu` are seeded, not validated: they have no `coolify` Docker network, and Alpine has no bash until `InstallPrerequisites` runs. +## Report failures of queued actions +- A value that an action returns when it runs as a queued job (`::dispatch()`) goes nowhere. Do not report a failure only with a return string or `throwError: false`; log it and broadcast it to the page that started the action. + ## Format only your own files - `pint --dirty` also rewrites uncommitted files that belong to other work in the tree. When the tree has unrelated changes, pass your changed paths to Pint. diff --git a/app/Actions/Application/StopApplication.php b/app/Actions/Application/StopApplication.php index 9ac26b243c..8f8b4bd4f2 100644 --- a/app/Actions/Application/StopApplication.php +++ b/app/Actions/Application/StopApplication.php @@ -3,8 +3,10 @@ namespace App\Actions\Application; use App\Actions\Server\CleanupDocker; +use App\Events\ApplicationStopFailed; use App\Events\ServiceStatusChanged; use App\Models\Application; +use Illuminate\Support\Facades\Log; use Lorisleiva\Actions\Concerns\AsAction; class StopApplication @@ -50,7 +52,11 @@ class StopApplication $commands[] = "docker rm -f {$escapedContainerName}"; } - instant_remote_process(command: $commands, server: $server, throwError: false); + try { + instant_remote_process(command: $commands, server: $server); + } catch (\Exception $e) { + $errors[] = $e->getMessage(); + } } if ($removeContainers && $application->build_pack === 'dockercompose') { @@ -64,8 +70,13 @@ class StopApplication $errors[] = $e->getMessage(); } } + $teamId = $application->environment->project->team->id; if ($errors !== []) { - return implode(' ', $errors); + $message = implode(' ', $errors); + Log::warning('Failed to stop application', ['application_uuid' => $application->uuid, 'error' => $message]); + ApplicationStopFailed::dispatch($teamId, $application->uuid, $message); + + return $message; } $status = [ @@ -82,7 +93,7 @@ class StopApplication } $application->update($status); - ServiceStatusChanged::dispatch($application->environment->project->team->id); + ServiceStatusChanged::dispatch($teamId); return null; } diff --git a/app/Events/ApplicationStopFailed.php b/app/Events/ApplicationStopFailed.php new file mode 100644 index 0000000000..37884978c0 --- /dev/null +++ b/app/Events/ApplicationStopFailed.php @@ -0,0 +1,27 @@ +teamId}")]; + } +} diff --git a/app/Livewire/Project/Application/Heading.php b/app/Livewire/Project/Application/Heading.php index 7c3aac02cd..46711750c2 100644 --- a/app/Livewire/Project/Application/Heading.php +++ b/app/Livewire/Project/Application/Heading.php @@ -37,6 +37,7 @@ class Heading extends Component ...$this->teamChannelListeners([ 'ServiceStatusChanged' => 'checkStatus', 'ServiceChecked' => '$refresh', + 'ApplicationStopFailed' => 'stopFailed', ]), ]; } @@ -77,6 +78,20 @@ class Heading extends Component } } + /** + * @param array{applicationUuid?: mixed, message?: mixed} $event + */ + public function stopFailed(array $event): void + { + if (($event['applicationUuid'] ?? null) !== $this->application->uuid || ! is_string($event['message'] ?? null)) { + $this->skipRender(); + + return; + } + + $this->dispatch('error', 'Failed to stop the application.', $event['message']); + } + public function manualCheckStatus() { $this->checkStatus(); diff --git a/tests/Feature/StopApplicationFailureTest.php b/tests/Feature/StopApplicationFailureTest.php new file mode 100644 index 0000000000..b7ad6b4b56 --- /dev/null +++ b/tests/Feature/StopApplicationFailureTest.php @@ -0,0 +1,111 @@ + 0]); + config([ + 'app.maintenance.store' => 'array', + 'constants.ssh.mux_enabled' => false, + ]); + + $this->team = Team::factory()->create(); + $this->server = Server::factory()->create([ + 'team_id' => $this->team->id, + 'private_key_id' => PrivateKey::factory()->create(['team_id' => $this->team->id])->id, + ]); + $this->server->settings->update(['is_reachable' => true, 'is_usable' => true, 'force_disabled' => false, 'docker_version' => '28.0.0']); + $destination = StandaloneDocker::query()->where('server_id', $this->server->id)->firstOrFail(); + $project = Project::factory()->create(['team_id' => $this->team->id]); + $environment = Environment::factory()->create(['project_id' => $project->id]); + + $this->application = Application::factory()->create([ + 'environment_id' => $environment->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + 'status' => 'running:healthy', + ]); +}); + +/** + * `docker ps` finds one running container; the stop commands give the result in $stopResult. + */ +function fakeRunningContainerWithStopResult(ProcessResult $stopResult): void +{ + Process::fake(function ($process) use ($stopResult) { + if (str_contains($process->command, 'docker ps -a --filter')) { + return Process::result(output: json_encode(['Names' => 'app-container', 'Labels' => '', 'State' => 'running'])); + } + + return str_contains($process->command, 'docker stop') ? $stopResult : Process::result(output: ''); + }); +} + +test('a stop that the server rejects keeps the status and reports the error', function () { + fakeRunningContainerWithStopResult(Process::result(errorOutput: 'Error response from daemon: cannot stop container', exitCode: 1)); + + $result = StopApplication::run($this->application, dockerCleanup: false); + + expect($result)->toContain('cannot stop container') + ->and($this->application->fresh()->status)->toBe('running:healthy'); + Event::assertDispatched(ApplicationStopFailed::class, fn (ApplicationStopFailed $event) => $event->teamId === $this->team->id + && $event->applicationUuid === $this->application->uuid + && str_contains($event->message, 'cannot stop container')); + Event::assertNotDispatched(ServiceStatusChanged::class); +}); + +test('a stop on a server that is not functional reports the error', function () { + $this->server->settings->update(['is_reachable' => false]); + Process::fake(); + + StopApplication::run($this->application->fresh(), dockerCleanup: false); + + expect($this->application->fresh()->status)->toBe('running:healthy'); + Event::assertDispatched(ApplicationStopFailed::class, fn (ApplicationStopFailed $event) => str_contains($event->message, 'is not functional')); + Process::assertNothingRan(); +}); + +test('a successful stop marks the application exited and reports no error', function () { + fakeRunningContainerWithStopResult(Process::result(output: 'app-container')); + + expect(StopApplication::run($this->application, dockerCleanup: false))->toBeNull() + ->and($this->application->fresh()->status)->toStartWith('exited'); + Event::assertNotDispatched(ApplicationStopFailed::class); + Event::assertDispatched(ServiceStatusChanged::class); +}); + +test('the application heading shows a stop failure only for its own application', function () { + $user = User::factory()->create(); + $user->teams()->attach($this->team, ['role' => 'admin']); + $this->actingAs($user); + session(['currentTeam' => $this->team]); + + Livewire::test(Heading::class, ['application' => $this->application]) + ->call('stopFailed', ['applicationUuid' => 'another-application', 'message' => 'Server is down.']) + ->assertNotDispatched('error') + ->call('stopFailed', ['applicationUuid' => $this->application->uuid, 'message' => 'Server is down.']) + ->assertDispatched('error', 'Failed to stop the application.', 'Server is down.'); +});