Skip to content

fix: return reaper handshake errors - #3828

Closed
drawliin wants to merge 1 commit into
testcontainers:mainfrom
drawliin:fix/reaper-handshake-error
Closed

fix: return reaper handshake errors#3828
drawliin wants to merge 1 commit into
testcontainers:mainfrom
drawliin:fix/reaper-handshake-error

Conversation

@drawliin

Copy link
Copy Markdown

Related issues

What does this PR do?

This PR makes Reaper.connect perform the Ryuk handshake synchronously before returning a successful connection.

If the handshake fails, the connection is closed and the handshake error is returned to the caller. The existing retry path can then handle the failure instead of treating the reaper connection as successful.

It also adds a regression test using a local TCP listener that returns an invalid ACK, verifying that connect returns an error and no termination channel.

Why is it important?

Previously, handshake failures were only logged inside the connection goroutine. Reaper.connect still returned a non-nil termination channel and nil error, so callers could believe the reaper was connected even though Ryuk had rejected or failed the handshake.

Returning the error makes reaper startup failures visible and retryable.

How to test this PR

go test ./ -run "TestReaperConnectReturnsHandshakeError|TestSpawnerRetryError|TestSpawnerBackoff"

@drawliin
drawliin requested a review from a team as a code owner July 30, 2026 17:23
@netlify

netlify Bot commented Jul 30, 2026

Copy link
Copy Markdown

Deploy Preview for testcontainers-go ready!

Name Link
🔨 Latest commit 07891a3
🔍 Latest deploy log https://app.netlify.com/projects/testcontainers-go/deploys/6a6b88a2186c530008e7c078
😎 Deploy Preview https://deploy-preview-3828--testcontainers-go.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

@coderabbitai

coderabbitai Bot commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Summary by CodeRabbit

  • Bug Fixes
    • Connection setup now reports handshake failures immediately instead of silently continuing.
    • Failed connections are closed promptly, improving reliability and error visibility.

Walkthrough

Reaper.connect now completes the TCP handshake before returning a termination channel, closes failed connections, and propagates handshake errors. Tests cover unexpected responses and use the public connection path for successful handshakes.

Changes

Reaper handshake handling

Layer / File(s) Summary
Synchronous handshake handling
reaper.go
Reaper.connect performs the handshake before starting its termination-waiting goroutine and returns formatted errors after closing failed connections.
Handshake result validation
reaper_test.go
Adds coverage for unexpected handshake responses and updates the success helper to call Reaper.Connect().

Estimated code review effort: 2 (Simple) | ~10 minutes

Suggested labels: bug

Suggested reviewers: mdelapenya

Poem

I’m a rabbit with ears held high,
Handshakes now fail instead of slyly passing by.
A bad “NOPE” closes the door,
Tests hop through the success path once more.
Thump, thump—errors now arrive!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 25.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title matches the main change: making reaper handshake errors propagate instead of being swallowed.
Description check ✅ Passed The description clearly describes the synchronous handshake error handling and regression test.
Linked Issues check ✅ Passed The change addresses #3827 by failing connect on handshake error and adding a regression test.
Out of Scope Changes check ✅ Passed The diff stays focused on reaper handshake behavior and its test coverage, with no obvious unrelated changes.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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.

@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: 1

🤖 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 `@reaper.go`:
- Around line 545-548: Update Connect around the r.handshake(conn) call to apply
the connection context’s deadline to conn before starting the synchronous
handshake, ensuring the existing five-second timeout bounds io.ReadFull. After a
successful handshake, clear the connection deadline before returning; preserve
the current close-and-wrapped-error behavior when the handshake fails.
🪄 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: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: acf12f59-28b9-4f3e-9b93-27143c37a785

📥 Commits

Reviewing files that changed from the base of the PR and between ea854ec and 07891a3.

📒 Files selected for processing (2)
  • reaper.go
  • reaper_test.go

Comment thread reaper.go
Comment on lines +545 to +548
if err := r.handshake(conn); err != nil {
conn.Close()
return nil, fmt.Errorf("handshake reaper %s: %w", r.Endpoint, err)
}

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.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf 'Files containing reaper.go:\n'
fd -a 'reaper\.go$' . || true

printf '\nGit status/stat:\n'
git diff --stat || true

printf '\nInspect reaper.go outline around Connect/handshake:\n'
if [ -f reaper.go ]; then
  wc -l reaper.go
  ast-grep outline reaper.go --match Connect --view expanded || true
  ast-grep outline reaper.go --match handshake --view expanded || true
  sed -n '500,580p' reaper.go | cat -n
fi

printf '\nSearch for SetDeadline and Connect context usage:\n'
rg -n "SetDeadline|DialContext|context\.(WithTimeout|WithDeadline)|func \(.*Reaper.*\) (Connect|connect|handshake)|ReadFull|Write\(" reaper.go || true

Repository: testcontainers/testcontainers-go

Length of output: 3604


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf 'Inspect Connect and connect usage loop:\n'
sed -n '450,536p' reaper.go | cat -n

printf '\nImports for time/io/types needed by proposed fix:\n'
sed -n '1,60p' reaper.go | cat -n

printf '\nCheck current connection deadline handling across reaper.go:\n'
rg -n "SetDeadline|SetReadDeadline|SetWriteDeadline|Deadline|context\.Context" reaper.go || true

printf '\nBehavioral probe: net.Conn deadlines are ignored by DialContext and cancel writes in a mock-like scenario is not possible here but demonstrate Go semantics with read-only source:\n'
python3 - <<'PY'
from pathlib import Path
p = Path("reaper.go")
s = p.read_text()
checks = {
    "Connect creates 5s context": "context.WithTimeout(context.Background(), time.Second*5)" in s,
    "connect Dial uses context": 'd.DialContext(ctx, "tcp", r.Endpoint)' in s,
    "handshake follows DialContext": ") != nil" in s.split("if err := d.DialContext(ctx, \"tcp\",")[1].split("if err := r.handshake(conn)")[0],
    "handshake Write unbounded": "conn.Write(filters)" in s and "SetWriteDeadline" not in s,
    "handshake ReadFull unbounded": "io.ReadFull(conn, buf)" in s and "SetReadDeadline" not in s,
    "current timeout applied after Dial": "SetDeadline" in s.split("conn, err := d.DialContext(ctx, \"tcp\",")[1].split("if err := r.handshake(conn)")[0].split("return terminationSignal")[0],
}
for k, v in checks.items():
    print(f"{k}: {v}")
PY

Repository: testcontainers/testcontainers-go

Length of output: 6809


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf 'Inspect newReaper retry behavior:\n'
sed -n '380,480p' reaper.go | cat -n

printf '\nBehavioral probe of source invariants:\n'
python3 - <<'PY'
from pathlib import Path
s = Path("reaper.go").read_text()
try:
    connect = s[s.index("func (r *Reaper) connect(ctx context.Context)"):s.index("func (r *Reaper) handshake(conn net.Conn)", s.index("func (r *Reaper) connect(ctx context.Context)"))]
except ValueError:
    connect = s[s.index("func (r *Reaper) connect(ctx context.Context)"):s.index("func (r *Reaper) handshake(conn net.Conn)", s.index("func (r *Reaper) connect(ctx context.Context)"))]
print("connect source contains Dial then handshake:", connect.find("DialContext") != -1 and connect.index("DialContext") < connect.index("handshake"))
print("connect source does not contain SetDeadline before handshake:", "SetDeadline" not in connect)
print("current timeout applied after Dial:", "SetDeadline" not in connect.split("conn, err := d.DialContext(ctx, \"tcp\", r.Endpoint)")[1].split("handshake(conn)")[0].rstrip())

connect_fn = s[s.index("func (r *Reaper) connect(ctx context.Context)"):s.index("func (r *Reaper) handshake(conn net.Conn)", s.index("func (r *Reaper) connect(ctx context.Context)") + 1)]
print("connect source line count approximately:", len(connect_fn.splitlines()))
PY

Repository: testcontainers/testcontainers-go

Length of output: 4156


🌐 Web query:

Go net TCPConn SetDeadline io.ReadFull context cancellation documentation

💡 Result:

In Go, the net.TCPConn deadline methods and io.ReadFull interact in a specific way due to the design of network deadlines in the net package [1][2]. Deadlines are absolute points in time [1]. When you call SetReadDeadline or SetDeadline on a net.TCPConn, you are setting a threshold that applies to all future I/O operations and any currently-blocked I/O operations on that connection [1][2][3]. Interaction with io.ReadFull: 1. io.ReadFull is a utility function that performs multiple calls to the underlying Read method of the connection until the provided buffer is filled or an error occurs [4]. 2. Because a deadline applies to all future reads until explicitly changed or cleared, a single SetReadDeadline call effectively covers the entire duration of an io.ReadFull operation [4]. If the deadline is reached while io.ReadFull is still waiting for more data, the internal Read call will return an error that wraps os.ErrDeadlineExceeded [1][2]. 3. Deadlines do not automatically reset after an I/O operation [5][6]. If you set a deadline before calling io.ReadFull, that deadline remains in effect for any subsequent reads until you either set a new deadline or clear it by passing a zero time value (time.Time{}) [1][5][7]. Context Cancellation: The net.Conn interface (including TCPConn) does not natively support context cancellation for I/O operations [1][8]. To cancel an ongoing read operation using a context, you must implement a mechanism to monitor the context and, upon cancellation, trigger the deadline to expire immediately [8][3]. This is typically done by calling conn.SetReadDeadline(time.Now) from a separate goroutine when the context is canceled [3]. This forces any currently blocked Read call (including those inside io.ReadFull) to return immediately with a timeout error [1][3]. Summary of Best Practices: - Always set a new deadline before an operation if you want it to have a specific timeout [5][6]. - Clear the deadline (SetReadDeadline(time.Time{})) if you no longer want the connection to time out [1][7]. - To support context-based cancellation, use a select block with the context's Done channel to call SetReadDeadline(time.Now) as soon as the context is canceled [8][3].

Citations:


Bound the synchronous handshake by the connection timeout.

DialContext only bounds dialing; once the TCP connection is established, io.ReadFull can block indefinitely if the peer accepts the socket but never ACKs. Apply the context deadline to conn before the handshake and clear it after success so Connect respects its five-second timeout.

🤖 Prompt for 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.

In `@reaper.go` around lines 545 - 548, Update Connect around the
r.handshake(conn) call to apply the connection context’s deadline to conn before
starting the synchronous handshake, ensuring the existing five-second timeout
bounds io.ReadFull. After a successful handshake, clear the connection deadline
before returning; preserve the current close-and-wrapped-error behavior when the
handshake fails.

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.

[Bug]: Reaper.connect swallows a failed handshake, leaving the process silently unregistered with Ryuk

1 participant