Skip to content

Track phpseclib 4.0.3's curTimeout type and raise the floor to match - #77

Open
imanimanyara wants to merge 2 commits into
mainfrom
fix/phpseclib-4.0.3-curtimeout
Open

imanimanyara wants to merge 2 commits into
mainfrom
fix/phpseclib-4.0.3-curtimeout

Conversation

@imanimanyara

Copy link
Copy Markdown
Member

Summary

phpseclib changed a typed property in a patch release, and PHP requires an overriding property to repeat its parent's type exactly. ForwardingSsh2 therefore fatals against 4.0.3.

phpseclib SSH2::$curTimeout
4.0.2 protected int|float|null $curTimeout = null;
4.0.3 protected int|float|false $curTimeout = false;
PHP Fatal error: Type of Simtabi\Laranail\DBConsole\Tunnels\Phpseclib\ForwardingSsh2::$curTimeout
must be int|float|null (as in class phpseclib4\Net\SSH2)

This is not a lint finding. It is a class that will not load.

There is no version-agnostic declaration, which is why the floor moves

Verified in both directions on this machine:

  • the old int|float|null declaration fatals on 4.0.3 (the error above);
  • the new int|float|false declaration fatals on 4.0.2 — "Type of parameter $value of hook … must be compatible with property type".

One class cannot satisfy both parents, so phpseclib/phpseclib goes ^4.0.1 → ^4.0.3 in require-dev and in the suggest string. The suggest line matters as much as the constraint: it is the instruction a consumer follows when installing the tunnel driver, and left at ^4.0.1 it would keep sending them to a resolution that fatals.

The hook's semantics move with it

false is phpseclib's "no timeout", as null was before 4.0.3, so the guard that excluded it from the numeric comparison moves with it:

-$this->curTimeout = $this->polling && $value !== null && $value > self::POLL_TIMEOUT
+$this->curTimeout = $this->polling && $value !== false && $value > self::POLL_TIMEOUT

The intent is unchanged — a sentinel is not a duration, so comparing it against POLL_TIMEOUT is meaningless either way. The three assignments inside the class pass int|float and are unaffected.

How this surfaced

Not from a commit. This repository tracks no lockfile, so CI resolves fresh every run and picked up 4.0.3 the moment it published. The local vendor/ was still on 4.0.2, which is why it passed locally and failed in CI — a plain composer install does not upgrade a package that already satisfies the range.

Verified

  • Class loads against 4.0.3; reverting the declaration fatals; restored.
  • Suite: 919 passed, 96 skipped, 2408 assertions.
  • laranail-pint --test passes.
  • PHPStan was not run locally — it binds a TCP port, which this environment refuses with EPERM. CI is the verifier for that check; it is the one that reported the mismatch in the first place.

Not in this PR

Two further failures on db-console#76 are unrelated to this and are being looked at separately: pest · prefer-stable logging "GitHub refused the token on all 3 attempts", and live SSH tunnels failing to install the PostgreSQL 18 client tools with dpkg error 100.

Copilot AI balanced review requested due to automatic review settings November 13, 2026 18:01

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has insufficient remaining quota. Try requesting a review at a lower effort level.

@imanimanyara

Copy link
Copy Markdown
Member Author

Follow-up: PHPStan now passes; Rector was wrong and is exempted for one file

With the property fixed, PHPStan is green. Rector then asked for a change that breaks the test:

-$timeoutAfterClose = Closure::bind(function (bool $polling): int|float|null {
+$timeoutAfterClose = Closure::bind(function (bool $polling): int {

NarrowWideUnionReturnTypeRector infers int from the literal $this->curTimeout = 5 — it cannot see the property hook, which rewrites 5 to POLL_TIMEOUT (0.005) while polling. The test asserts both values:

expect($timeoutAfterClose(true))->toBe(0.005)
    ->and($timeoutAfterClose(false))->toBe(5);

Applying Rector's suggestion verbatim, verified on this machine 2026-11-13:

TypeError: …{closure}(): Return value must be of type int, float returned
Tests: 1 failed, 1 passed

So the honest type is int|float — null is dropped because the property is now int|float|false and that branch is unreachable — and the rule is skipped for that one file only, with the reason and the measurement in the config. It still runs everywhere else in src/ and tests/.

This is the second time in this change that a tool's static view disagreed with the runtime, and in both cases the runtime was right: PHPStan's intl-style stub narrowing in chrono, and now Rector's inference past a property hook. A gate that cannot model a language feature is not evidence about code that uses it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants