diff --git a/app/Actions/PingHostname.php b/app/Actions/PingHostname.php index 04884fe31..1d1a3d8ac 100644 --- a/app/Actions/PingHostname.php +++ b/app/Actions/PingHostname.php @@ -6,22 +6,36 @@ use Lorisleiva\Actions\Concerns\AsAction; use Spatie\Ping\Ping; use Spatie\Ping\PingResult; +use Throwable; class PingHostname { use AsAction; - public function handle(?string $hostname = null, int $count = 1): PingResult + /** + * Attempt to ping the given hostname. Returns null when the ping binary + * is unavailable or another OS-level error prevents execution. + */ + public function handle(?string $hostname = null, int $count = 1): ?PingResult { $hostname = $hostname ?? config('speedtest.preflight.internet_check_hostname'); // Remove protocol if present $hostname = preg_replace('#^https?://#', '', $hostname); - $ping = (new Ping( - hostname: $hostname, - count: $count, - ))->run(); + try { + $ping = (new Ping( + hostname: $hostname, + count: $count, + ))->run(); + } catch (Throwable $e) { + Log::debug('Ping command unavailable', [ + 'host' => $hostname, + 'error' => $e->getMessage(), + ]); + + return null; + } $data = $ping->toArray(); unset($data['raw_output'], $data['lines']); diff --git a/app/Jobs/CheckForInternetConnectionJob.php b/app/Jobs/CheckForInternetConnectionJob.php index c0f39a61f..2498e6460 100644 --- a/app/Jobs/CheckForInternetConnectionJob.php +++ b/app/Jobs/CheckForInternetConnectionJob.php @@ -11,6 +11,9 @@ use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Queue\Queueable; use Illuminate\Queue\Middleware\SkipIfBatchCancelled; +use Illuminate\Support\Facades\Http; +use Illuminate\Support\Facades\Log; +use Throwable; class CheckForInternetConnectionJob implements ShouldQueue { @@ -46,11 +49,20 @@ public function handle(): void $ping = PingHostname::run(); - if ($ping->isSuccess()) { + if ($ping?->isSuccess()) { return; } - $message = sprintf('Failed to connected to hostname "%s". Error received "%s".', $ping->getHost(), $ping->error()?->value); + Log::debug('Pinged failed, falling back to HTTP connectivity check'); + + // Ping either failed or was unavailable — attempt an HTTP fallback. + if ($this->httpFallbackSucceeds()) { + return; + } + + $message = $ping === null + ? 'Ping command is unavailable and HTTP fallback also failed.' + : sprintf('Failed to connected to hostname "%s". Error received "%s". HTTP fallback also failed.', $ping->getHost(), $ping->error()?->value); $this->result->update([ 'data->type' => 'log', @@ -63,4 +75,39 @@ public function handle(): void $this->batch()->cancel(); } + + /** + * Attempt to verify connectivity via an HTTP GET request as a fallback + * when ping is unavailable or unsuccessful. + */ + protected function httpFallbackSucceeds(): bool + { + $url = config('speedtest.preflight.external_ip_url'); + + try { + $response = Http::retry(3, 100) + ->timeout(5) + ->get(url: $url); + + if ($response->ok()) { + Log::debug('HTTP fallback connectivity check succeeded', ['url' => $url]); + + return true; + } + + Log::debug('HTTP fallback connectivity check received non-OK response', [ + 'url' => $url, + 'status' => $response->status(), + ]); + + return false; + } catch (Throwable $e) { + Log::debug('HTTP fallback connectivity check failed', [ + 'url' => $url, + 'error' => $e->getMessage(), + ]); + + return false; + } + } } diff --git a/config/speedtest.php b/config/speedtest.php index 0782457aa..33493f396 100644 --- a/config/speedtest.php +++ b/config/speedtest.php @@ -6,9 +6,9 @@ /** * General settings. */ - 'build_date' => Carbon::parse('2026-02-03'), + 'build_date' => Carbon::parse('2026-02-04'), - 'build_version' => 'v1.13.6', + 'build_version' => 'v1.13.7', 'content_width' => env('CONTENT_WIDTH', '7xl'), diff --git a/tests/Feature/CheckForInternetConnectionJobTest.php b/tests/Feature/CheckForInternetConnectionJobTest.php new file mode 100644 index 000000000..ebb641524 --- /dev/null +++ b/tests/Feature/CheckForInternetConnectionJobTest.php @@ -0,0 +1,152 @@ +create(['status' => ResultStatus::Started]); + + $successfulPing = PingResult::fromArray(['success' => true, 'host' => 'icanhazip.com']); + app()->bind(PingHostname::class, fn () => new class($successfulPing) + { + public function __construct(private PingResult $ping) {} + + public function handle(?string $hostname = null, int $count = 1): ?PingResult + { + return $this->ping; + } + }); + + [$job, $batch] = (new CheckForInternetConnectionJob($result))->withFakeBatch(); + $job->handle(); + + $this->assertFalse($batch->cancelled()); + $result->refresh(); + expect($result->status)->toBe(ResultStatus::Checking); + Event::assertNotDispatched(SpeedtestFailed::class); + }); + + test('batch continues when ping fails but HTTP fallback succeeds', function () { + $result = Result::factory()->create(['status' => ResultStatus::Started]); + + $failedPing = PingResult::fromArray([ + 'success' => false, + 'error' => 'hostUnreachable', + 'host' => 'icanhazip.com', + ]); + app()->bind(PingHostname::class, fn () => new class($failedPing) + { + public function __construct(private PingResult $ping) {} + + public function handle(?string $hostname = null, int $count = 1): ?PingResult + { + return $this->ping; + } + }); + + Http::fake([ + '*' => Http::response('1.2.3.4', 200), + ]); + + [$job, $batch] = (new CheckForInternetConnectionJob($result))->withFakeBatch(); + $job->handle(); + + $this->assertFalse($batch->cancelled()); + $result->refresh(); + expect($result->status)->toBe(ResultStatus::Checking); + Event::assertNotDispatched(SpeedtestFailed::class); + }); + + test('batch continues when ping is unavailable but HTTP fallback succeeds', function () { + $result = Result::factory()->create(['status' => ResultStatus::Started]); + + app()->bind(PingHostname::class, fn () => new class + { + public function handle(?string $hostname = null, int $count = 1): ?PingResult + { + return null; + } + }); + + Http::fake([ + '*' => Http::response('1.2.3.4', 200), + ]); + + [$job, $batch] = (new CheckForInternetConnectionJob($result))->withFakeBatch(); + $job->handle(); + + $this->assertFalse($batch->cancelled()); + $result->refresh(); + expect($result->status)->toBe(ResultStatus::Checking); + Event::assertNotDispatched(SpeedtestFailed::class); + }); + + test('batch is cancelled when ping fails and HTTP fallback also fails', function () { + $result = Result::factory()->create(['status' => ResultStatus::Started]); + + $failedPing = PingResult::fromArray([ + 'success' => false, + 'error' => 'hostUnreachable', + 'host' => 'icanhazip.com', + ]); + app()->bind(PingHostname::class, fn () => new class($failedPing) + { + public function __construct(private PingResult $ping) {} + + public function handle(?string $hostname = null, int $count = 1): ?PingResult + { + return $this->ping; + } + }); + + Http::fake([ + '*' => Http::response('Service Unavailable', 503), + ]); + + [$job, $batch] = (new CheckForInternetConnectionJob($result))->withFakeBatch(); + $job->handle(); + + $this->assertTrue($batch->cancelled()); + $result->refresh(); + expect($result->status)->toBe(ResultStatus::Failed); + expect($result->data['level'])->toBe('error'); + expect($result->data['message'])->toContain('HTTP fallback also failed'); + Event::assertDispatched(SpeedtestFailed::class); + }); + + test('batch is cancelled when ping is unavailable and HTTP fallback throws', function () { + $result = Result::factory()->create(['status' => ResultStatus::Started]); + + app()->bind(PingHostname::class, fn () => new class + { + public function handle(?string $hostname = null, int $count = 1): ?PingResult + { + return null; + } + }); + + Http::fake([ + '*' => Http::failedConnection(), + ]); + + [$job, $batch] = (new CheckForInternetConnectionJob($result))->withFakeBatch(); + $job->handle(); + + $this->assertTrue($batch->cancelled()); + $result->refresh(); + expect($result->status)->toBe(ResultStatus::Failed); + expect($result->data['message'])->toBe('Ping command is unavailable and HTTP fallback also failed.'); + Event::assertDispatched(SpeedtestFailed::class); + }); +});