Skip to content

Bound the connect dial and handshake separately from operations - #46

Merged
abnegate merged 3 commits into
mainfrom
fix/bounded-handshake-timeout
Jul 31, 2026
Merged

Bound the connect dial and handshake separately from operations#46
abnegate merged 3 commits into
mainfrom
fix/bounded-handshake-timeout

Conversation

@abnegate

@abnegate abnegate commented Jul 31, 2026

Copy link
Copy Markdown
Member

What

Adds a connectTimeout constructor parameter to Client: a deadline covering the TCP dial and the SCRAM handshake, separate from the steady-state receive $timeout. Defaults to $timeout, so existing callers keep their behaviour.

Why

The two timeouts answer different questions. A steady-state operation may legitimately wait a long time for a large or slow response. Establishing a connection may not: Pools salvages a connection whose operation just failed by calling connect() via reconnect(), and behind a proxy that accepts instantly while its backend is unreachable, the dial always "succeeds" — the first handshake reply is what stalls. That salvage attempt then costs a second full receive timeout on top of the failure it was recovering from.

Measured in Appwrite Cloud CI (dedicated MongoDB behind the edge TCP proxy, during a resize): a 10s operation deadline produced 21.8s of API stall per dead pooled connection — the operation's 10s plus a 10s handshake wait inside recovery.

How

  • connect() dials with connectTimeout ?? timeout.
  • A handshaking flag arms the same bound for the SCRAM receives, scoped with finally so a slow first real query never inherits it and a failed handshake never leaves it armed on a reused client.

Tests

  • testConnectDialsAndHandshakesUnderTheConnectDeadline — the dial carries the connect deadline; the flag clears after a completed handshake.
  • testHandshakeSilenceFailsAtTheConnectDeadline — a silent handshake fails at the connect deadline instead of the receive timeout (verified red: without the fix it waits the full receive timeout).

Downstream: consumed by appwrite-labs/cloud#5047's stack to bound dedicated-database recovery.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added an optional connection timeout that can be configured separately from the standard socket timeout.
    • Connection establishment and authentication now use the configured connection timeout.
    • Normal timeout behavior is automatically restored after a successful connection.
  • Bug Fixes

    • Improved handling of stalled connections and silent authentication handshakes, allowing them to fail promptly.

A steady-state operation may legitimately wait a long time for a large or
slow response; establishing a connection may not. The pool salvages a
connection whose operation just failed by calling connect() through
reconnect(), and behind a proxy that accepts instantly while its backend
is unreachable, the dial always "succeeds" — the first SCRAM reply is
what stalls. That salvage attempt then cost a second full receive timeout
on top of the failure it was recovering from: a 10s operation deadline
produced a measured 21.8s outage per dead pooled connection.

The new connectTimeout bounds both the TCP dial and the handshake
receives, and only those: the flag arming the shorter deadline is scoped
to connect() with a finally, so a slow first real query never inherits it
and a failed handshake never leaves it armed on a reused client. Defaults
to $timeout, so existing callers keep their behaviour.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings July 31, 2026 21:27

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 reached their quota limit.

@coderabbitai

coderabbitai Bot commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Warning

Review limit reached

@abnegate, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 31 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 7fa5415f-4e42-416c-8732-dbbca391737b

📥 Commits

Reviewing files that changed from the base of the PR and between 11bc00a and ef33138.

📒 Files selected for processing (2)
  • src/Client.php
  • tests/ClientTest.php
📝 Walkthrough

Walkthrough

The client now accepts an optional connection timeout. Dialing and SCRAM authentication use this timeout, while normal receives use the standard timeout after handshake completion or failure.

Changes

Connection timeout

Layer / File(s) Summary
Timeout configuration and handshake state
src/Client.php
The constructor accepts and validates connectTimeout. The client stores the timeout and tracks SCRAM handshake state.
Connection and receive timeout execution
src/Client.php, tests/ClientTest.php
Connection dialing and SCRAM authentication use the connect timeout. Receive deadlines select the handshake or normal timeout. Tests cover timeout propagation, restoration, and silent-handshake failure cleanup.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Suggested reviewers: copilot, chiragagg5k

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant Socket
  participant SCRAM
  Client->>Socket: Dial using connectTimeout
  Client->>SCRAM: Authenticate with handshake timeout
  SCRAM-->>Client: Authentication result
  Client->>Socket: Receive using normal timeout
Loading
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: separate timeouts for connection dialing and handshake operations.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/bounded-handshake-timeout

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@greptile-apps

greptile-apps Bot commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

Adds a separate connection deadline and applies it across dialing and SCRAM authentication.

  • Tracks one absolute deadline throughout connection establishment.
  • Passes the remaining deadline to receive operations and restores steady-state timeout behavior afterward.
  • Invalidates sockets when authentication fails.
  • Adds tests for shared connection budgets, handshake timeout behavior, socket invalidation, and transport timeout configuration.

Confidence Score: 4/5

The PR is not yet safe to merge because the synchronous receive path can still block beyond the configured connection deadline.

The shared absolute deadline fixes phase-by-phase renewal, and coroutine receives receive the remaining budget directly. However, the synchronous path relies on an error-suppressed post-connect timeout update that the implementation itself acknowledges may not be honored; in that case, the blocking receive retains the longer steady-state timeout and the deadline cannot be rechecked until afterward.

Files Needing Attention: src/Client.php

Important Files Changed

Filename Overview
src/Client.php Introduces a configurable connection deadline, shared dial-and-handshake budgeting, deadline-aware receives, and failed-handshake invalidation.
tests/ClientTest.php Adds transport instrumentation and coverage for connection deadlines, deadline cleanup, shared budgets, failed authentication, and timeout propagation.

Reviews (3): Last reviewed commit: "fix(client): bound the socket-level wait..." | Re-trigger Greptile

Comment thread src/Client.php

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/Client.php`:
- Around line 307-320: Update the SCRAM handshake sequence around auth->start(),
query(), and auth->continue() so any exception, including decoded authentication
command errors, invalidates the transport before being rethrown; retain the
existing handshaking cleanup in finally. Add coverage for an ok: 0
authentication response that verifies the transport closes and a subsequent
connect() performs a new dial.
- Around line 294-295: Update the connection setup around Client::connect() so
it uses $this->timeout as the initial Swoole receive timeout, while retaining
$connectTimeout for the dial timeout if the API supports separate configuration.
After SCRAM completes successfully, update the client receive timeout to
$this->timeout using the existing receiveTimeout() flow.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 9dbae8dc-3583-4927-814b-33258d0612ae

📥 Commits

Reviewing files that changed from the base of the PR and between 78818fd and 11bc00a.

📒 Files selected for processing (2)
  • src/Client.php
  • tests/ClientTest.php

Comment thread src/Client.php Outdated
Comment thread src/Client.php Outdated
Review findings on the connect deadline, both real. The dial and each
SCRAM receive were each granted a fresh connectTimeout, so a phase that
consumed most of its allowance left the next a full one — the complete
attempt could take a multiple of the documented bound. The deadline is
now absolute: set once before the dial, every handshake receive gets the
remainder, and chunk-arrival renewals shrink toward it instead of
extending past it.

A failed SCRAM exchange also left the transport dialed and reporting
connected, so a later connect() early-returned without ever completing
authentication. The handshake now invalidates the socket on any failure
before rethrowing: the next connect() dials fresh.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@abnegate

Copy link
Copy Markdown
Member Author

Both findings addressed in c023ef1:

  • Connect deadline resets per phase (P1): the deadline is now absolute — set once before the dial; every handshake receive gets the remainder, and chunk-arrival renewals inside receive() shrink toward it instead of extending past it. Pinned by testDialAndHandshakeConsumeOneSharedConnectBudget (verified red against the per-phase-reset behaviour: 0.2 is not less than 0.2).
  • Invalidate the socket when SCRAM fails: the handshake now invalidates on any failure before rethrowing, so a later connect() dials fresh instead of early-returning on a dialed-but-unauthenticated transport. Pinned by testFailedHandshakeInvalidatesTheDialedSocket (verified red: without the invalidate, the transport stays open and connect() reuses it).

Comment thread src/Client.php
Third finding on the same seam, and the root of all three: the deadline
arithmetic in receive() means nothing while the blocking primitive
answers only to the constructor-time steady-state timeout. A peer that
accepts and then goes silent — a proxy fronting an unreachable backend,
the exact case connect() is being bounded for — parks the first recv()
for the full steady-state window before any deadline is rechecked.

Every socket wait now takes the remaining budget of the loop it serves:
per-call on the coroutine client, via a per-wait refresh of the client
timeout option on the synchronous one. Where a synchronous build applies
options only at connect(), the behaviour degrades to exactly what shipped
before — the loop-level deadline still bounds the total — and where
honoured, the wait matches the budget.

The contract tests observe the socket layer directly: every handshake
wait stays within the remaining connect budget, steady-state waits return
to the full receive timeout, and the synchronous transport sees its
option refreshed per wait.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@abnegate

Copy link
Copy Markdown
Member Author

ef33138 addresses the root the three findings circled: the client has two timeout layers, and the fixes so far steered only the loop-level deadline while each blocking recv() answered to the constructor-time steady-state socket option. Every socket wait now takes the remaining budget of the loop it serves — per-call on the coroutine client, via a per-wait option refresh on the synchronous one (degrading to exactly the shipped behaviour where a build applies options only at connect, with the loop deadline still bounding the total).

Contract tests observe the socket layer directly: handshake waits never exceed the remaining connect budget (verified red: 0.0 is not greater than 0.0 with the per-call bound removed), steady-state waits return to the full receive timeout, and the sync transport sees its option refreshed per wait.

@abnegate
abnegate merged commit 1fd4d96 into main Jul 31, 2026
5 checks passed
@abnegate
abnegate deleted the fix/bounded-handshake-timeout branch July 31, 2026 22:07
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