From b5071b04f6341c7f973d7f59d2196df28d229bf1 Mon Sep 17 00:00:00 2001 From: Imani Manyara Date: Fri, 13 Nov 2026 13:00:55 -0500 Subject: [PATCH 1/2] Track phpseclib 4.0.3's curTimeout type and raise the floor to match --- composer.json | 4 ++-- src/Tunnels/Phpseclib/ForwardingSsh2.php | 10 +++++++--- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/composer.json b/composer.json index 629f8e2..6fd43f1 100644 --- a/composer.json +++ b/composer.json @@ -65,7 +65,7 @@ "pestphp/pest": "^4.0", "pestphp/pest-plugin-arch": "^4.0", "pestphp/pest-plugin-laravel": "^4.0", - "phpseclib/phpseclib": "^4.0.1", + "phpseclib/phpseclib": "^4.0.3", "phpstan/phpstan": "^2.0", "rector/rector": "^2.5.8", "roave/security-advisories": "dev-latest", @@ -78,7 +78,7 @@ "laravel/passport": "OAuth2 guard for the optional REST API (service-to-service access).", "laravel/sanctum": "Default token guard for the optional REST API.", "laravel/slack-notification-channel": "Sends notifications and alerts to Slack (notifications.channels with slack).", - "phpseclib/phpseclib": "Required by the phpseclib SSH tunnel driver (^4.0.1), for hosts without an ssh binary.", + "phpseclib/phpseclib": "Required by the phpseclib SSH tunnel driver (^4.0.3), for hosts without an ssh binary.", "spatie/laravel-permission": "Delegates console RBAC storage to Spatie when rbac.driver is set to spatie." }, "autoload": { diff --git a/src/Tunnels/Phpseclib/ForwardingSsh2.php b/src/Tunnels/Phpseclib/ForwardingSsh2.php index 976206e..337278a 100644 --- a/src/Tunnels/Phpseclib/ForwardingSsh2.php +++ b/src/Tunnels/Phpseclib/ForwardingSsh2.php @@ -32,9 +32,13 @@ final class ForwardingSsh2 extends SSH2 * ran out. While polling, the wait is never raised past the poll's own. * The hook applies to writes from phpseclib's code too, which is the point. */ - protected int|float|null $curTimeout = null { - set(int|float|null $value) { - $this->curTimeout = $this->polling && $value !== null && $value > self::POLL_TIMEOUT + protected int|float|false $curTimeout = false { + set(int|float|false $value) { + // `false` is phpseclib's "no timeout", as `null` was before 4.0.3. It is excluded from + // the comparison for the same reason null was: it is not a duration, so `> POLL_TIMEOUT` + // would be meaningless. An overriding property MUST repeat the parent's type exactly, + // so this declaration tracks SSH2::$curTimeout rather than expressing a preference. + $this->curTimeout = $this->polling && $value !== false && $value > self::POLL_TIMEOUT ? self::POLL_TIMEOUT : $value; } From 7067235436f8309062956cd9dd2d985ae2263b76 Mon Sep 17 00:00:00 2001 From: Imani Manyara Date: Fri, 13 Nov 2026 13:17:01 -0500 Subject: [PATCH 2/2] Type the clamp closure honestly and exempt the rule that cannot see hooks --- rector.php | 12 ++++++++++++ tests/Unit/Tunnels/ForwardingSsh2Test.php | 2 +- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/rector.php b/rector.php index 5968edd..5165a57 100644 --- a/rector.php +++ b/rector.php @@ -4,6 +4,7 @@ use Rector\Config\RectorConfig; use Rector\Set\ValueObject\SetList; +use Rector\DeadCode\Rector\FunctionLike\NarrowWideUnionReturnTypeRector; return RectorConfig::configure() ->withPaths([__DIR__ . '/src', __DIR__ . '/tests']) @@ -17,4 +18,15 @@ SetList::TYPE_DECLARATION, SetList::EARLY_RETURN, ]) + // NarrowWideUnionReturnTypeRector cannot see a property hook. ForwardingSsh2::$curTimeout has a + // `set` hook that rewrites 5 to POLL_TIMEOUT (0.005) while polling, so the closure in + // ForwardingSsh2Test genuinely returns int|float -- and the test asserts both, 0.005 and 5. + // Rector infers `int` from the literal assignment alone; applying that suggestion throws + // "Return value must be of type int, float returned", verified 2026-11-13 by making the change + // and watching the test fail. Scoped to the one file, so the rule still runs everywhere else. + ->withSkip([ + NarrowWideUnionReturnTypeRector::class => [ + __DIR__ . '/tests/Unit/Tunnels/ForwardingSsh2Test.php', + ], + ]) ->withImportNames(removeUnusedImports: true); diff --git a/tests/Unit/Tunnels/ForwardingSsh2Test.php b/tests/Unit/Tunnels/ForwardingSsh2Test.php index 047674f..a4e8353 100644 --- a/tests/Unit/Tunnels/ForwardingSsh2Test.php +++ b/tests/Unit/Tunnels/ForwardingSsh2Test.php @@ -15,7 +15,7 @@ // Lazy: the constructor connects nothing until a method needs the socket. $ssh = new ForwardingSsh2('ssh.invalid', 22, 10); - $timeoutAfterClose = Closure::bind(function (bool $polling): int|float|null { + $timeoutAfterClose = Closure::bind(function (bool $polling): int|float { $this->polling = $polling; $this->curTimeout = 5; // what get_channel_packet() does on a CHANNEL_CLOSE