Skip to content

fix(broker): detect a blackholed node-control connection instead of trusting writes - #1462

Open
khaliqgant wants to merge 1 commit into
mainfrom
fix/node-control-read-idle
Open

fix(broker): detect a blackholed node-control connection instead of trusting writes#1462
khaliqgant wants to merge 1 commit into
mainfrom
fix/node-control-read-idle

Conversation

@khaliqgant

@khaliqgant khaliqgant commented Aug 7, 2026

Copy link
Copy Markdown
Member

Closes #1457.

The bug

Every disconnect path in run_connected_once's select! keyed off send_wire(...).is_err(). That is write-only liveness: on a blackholed /v1/node/ws the kernel keeps accepting 12-second heartbeat frames into the send buffer, so the writes never fail. There was no heartbeat ack, no WS ping/pong, and no read-side deadline — stream.next() waits forever — so the client never left the select! and never reached the reconnect/backoff that already existed below it.

How it showed up

finn-mini sat like this for 80 minutes on 2026-08-07. Engine-side lastHeartbeatAt froze at 11:52:35Z and the node vanished from agent-relay fleet nodes (visible only under --all, as offline), while on the box itself:

{"nodeConnected":true,"nodeDelivery":{"connected":true,"tokenPresent":true},"relaycastConnected":true,"status":"ok"}

…and lsof still showed the TLS socket ESTABLISHED. Broker uptime was 26h, so this was not a startup failure. Nothing recovered it but a full broker restart, which cost 11 agent sessions.

The fix

Each heartbeat tick now also sends a WS ping, so a live peer always owes us a frame even when the engine has nothing to say, and any inbound frame refreshes a last-seen stamp. Silence past four heartbeat intervals (48s) returns ControlRunResult::Disconnected and lets the existing backoff reconnect.

The engine is not guaranteed to send unsolicited traffic, so an application-level ack would not have been sufficient on its own — the socket needs its own keepalive. #1450 notes the events WebSocket is already pinged every 30s; node-control was the one that wasn't.

Proof the test bites

Neutralizing the guard to false && idle > read_idle_timeout:

test node_control::tests::node_control_reconnects_when_peer_goes_silent_but_writes_still_succeed ... FAILED
panicked at: client never reconnected after the peer went silent: Elapsed(())
test result: FAILED. 0 passed; 1 failed;  finished in 20.01s

With the guard restored: ok ... finished in 1.42s.

Two things worth knowing about the test:

  • The silent server holds the socket without polling it. A server that keeps calling next() is not silent — tungstenite answers pings automatically, so the first version of this test failed with the fix in place.
  • The window is injectable via FleetControlConfig::read_idle_timeout (400ms in test, 48s in production). #[tokio::test(start_paused = true)] was tried first and rejected: virtual time raced past the real TCP handshake, producing a 0.01s spurious failure.

Test plan

cargo test -p agent-relay-broker --lib      860 passed, 0 failed, 4 ignored
cargo fmt -p agent-relay-broker -- --check  clean
cargo clippy -p agent-relay-broker --lib --tests -- -D warnings

Clippy reports 3 errors, all pre-existing and in files this branch does not touch (snippets.rs:1500, runtime/api.rs:2488, runtime/worker_events.rs:38) — the same three documented in 5c2ad8ee3's test plan.

Scope

This fixes detection, not the underlying cause of the blackhole, which was never identified. The misleading nodeConnected: true that made this hard to spot is also still there — that is #1386's stated secondary defect and is left alone here.

🤖 Generated with Claude Code

Review in cubic

@cursor

cursor Bot commented Aug 7, 2026

Copy link
Copy Markdown

Bugbot is not enabled for your account, so this pull request was not reviewed.

Enable Bugbot in the Cursor dashboard to get automatic reviews on future PRs.

@coderabbitai

coderabbitai Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 21867c7b-6458-4ef5-baa3-5b6ec142c9d7

📥 Commits

Reviewing files that changed from the base of the PR and between a9e50e8 and f5877a6.

📒 Files selected for processing (3)
  • CHANGELOG.md
  • crates/broker/src/node_control.rs
  • crates/broker/src/runtime/init.rs
🚧 Files skipped from review as they are similar to previous changes (2)
  • crates/broker/src/runtime/init.rs
  • crates/broker/src/node_control.rs

📝 Walkthrough

Walkthrough

The broker now detects inbound-idle periods on node-control WebSocket connections, sends WebSocket pings with heartbeats, reconnects after prolonged silence, updates configuration and tests, and documents the fix.

Changes

Node-control WebSocket liveness

Layer / File(s) Summary
Read-idle timeout configuration
crates/broker/src/node_control.rs, crates/broker/src/runtime/init.rs
Adds the production timeout and FleetControlConfig.read_idle_timeout. Runtime initialization sets the field to None.
Heartbeat and disconnect logic
crates/broker/src/node_control.rs
Heartbeat scheduling uses one quarter of the configured idle timeout when it is shorter than the normal interval. Each tick sends a heartbeat and a WebSocket ping. Inbound frames refresh activity. The connection disconnects after the idle window expires.
Reconnection validation and release note
crates/broker/src/node_control.rs, CHANGELOG.md
Test configurations initialize the new field. An integration test verifies reconnection from a silent peer. The changelog records the fix.

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

Possibly related PRs

Suggested reviewers: willwashburn

Poem

A rabbit watches the silent wire,
Then sends a ping through frost and fire.
If no frame returns in time,
The broker reconnects in a hop and rhyme. 🐇

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the primary fix for blackholed node-control connections and write-only liveness.
Description check ✅ Passed The description explains the bug, fix, scope, and test results; the omitted screenshots section is not applicable.
Linked Issues check ✅ Passed The changes satisfy issue #1457 by adding WebSocket pings, inbound-frame tracking, timeout detection, reconnect behavior, and a regression test.
Out of Scope Changes check ✅ Passed The changes remain within the linked issue scope and explicitly leave the unrelated health-reporting defect unchanged.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ 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/node-control-read-idle

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: 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 `@CHANGELOG.md`:
- Line 8: Update the root changelog heading from “## [Unreleased - Patch]” to
“## [Unreleased]”, keeping the existing pending entry under its “### Fixed”
section.

In `@crates/broker/src/node_control.rs`:
- Around line 1718-1719: Update the idle timeout comparison in the heartbeat
handling logic around last_inbound and read_idle_timeout to use a
greater-than-or-equal check, ensuring clients disconnect exactly when the
configured deadline is reached while preserving the existing timeout handling.
🪄 Autofix

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: 33f5a438-1650-4010-bb9d-074edfbe69a0

📥 Commits

Reviewing files that changed from the base of the PR and between 6466b84 and a9e50e8.

📒 Files selected for processing (3)
  • CHANGELOG.md
  • crates/broker/src/node_control.rs
  • crates/broker/src/runtime/init.rs

Comment thread CHANGELOG.md
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]
## [Unreleased - Patch]

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.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Use the required [Unreleased] heading.

Line 8 uses ## [Unreleased - Patch]. Put pending entries under ## [Unreleased] and retain this item in ### Fixed.

As per coding guidelines, “Curate the root CHANGELOG.md under [Unreleased] using Keep a Changelog and SemVer conventions.”

Proposed fix
-## [Unreleased - Patch]
+## [Unreleased]
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
## [Unreleased - Patch]
## [Unreleased]
🤖 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 `@CHANGELOG.md` at line 8, Update the root changelog heading from “##
[Unreleased - Patch]” to “## [Unreleased]”, keeping the existing pending entry
under its “### Fixed” section.

Source: Coding guidelines

Comment thread crates/broker/src/node_control.rs Outdated

@chatgpt-codex-connector chatgpt-codex-connector Bot 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: a9e50e8b9d

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread CHANGELOG.md Outdated

### Fixed

- A fleet node no longer drops out of the roster indefinitely when its `/v1/node/ws` connection is blackholed. The broker pings each heartbeat interval and reconnects when no frame arrives for 48s, instead of trusting writes that keep succeeding into a dead socket while `agent-relay fleet nodes` shows the node offline and `/health` still reports `nodeConnected: true`.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Shorten the changelog entry to the user-visible impact

This bullet goes beyond the user-visible fix and includes the internal WebSocket route, timeout, write-liveness mechanism, CLI state, and health-field details. Keep it to a short impact-first statement—such as the broker now reconnecting blackholed fleet-node connections—so the release narrative follows the repository requirement to omit implementation backstory.

AGENTS.md reference: AGENTS.md:L45-L49

Useful? React with 👍 / 👎.

@cubic-dev-ai cubic-dev-ai 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.

All reported issues were addressed across 3 files

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread crates/broker/src/node_control.rs Outdated
Comment thread CHANGELOG.md Outdated
…rusting writes

Closes #1457.

Every disconnect path in run_connected_once's select! keyed off
send_wire(...).is_err(), which is write-only liveness. On a blackholed
/v1/node/ws the kernel keeps accepting 12-second heartbeat frames into the
send buffer, so the writes never fail; there was no heartbeat ack, no WS
ping/pong, and no read-side deadline, so stream.next() waited forever and the
client never reached the reconnect/backoff that already existed below it.

finn-mini sat like this for 80 minutes on 2026-08-07: engine-side
lastHeartbeatAt frozen at 11:52:35Z and the node hidden from `fleet nodes`,
while the broker's own /health still reported nodeConnected: true and the TLS
socket was still ESTABLISHED.

Each heartbeat tick now also sends a WS ping, so a live peer always owes us a
frame even when the engine has nothing to say, and any inbound frame refreshes
a last-seen stamp. Silence past four heartbeat intervals (48s) returns
ControlRunResult::Disconnected and lets the existing backoff reconnect. The
window is injectable via FleetControlConfig::read_idle_timeout so the
regression test covers it in 400ms rather than 48s.

Proven to bite: neutralizing the idle check to `false && idle > ...` makes
node_control_reconnects_when_peer_goes_silent_but_writes_still_succeed hang
its full 20s bound and fail with "client never reconnected after the peer went
silent"; with the check it reconnects in 1.42s. The test's silent server holds
the socket open without polling it, because a server that keeps calling next()
makes tungstenite answer pings automatically and would not model a blackhole.

cargo test -p agent-relay-broker --lib: 860 passed, 0 failed, 4 ignored.
cargo fmt -p agent-relay-broker -- --check: clean.
cargo clippy -p agent-relay-broker --lib --tests -- -D warnings: 3 pre-existing
errors in snippets.rs / runtime/api.rs / runtime/worker_events.rs, untouched by
this change and already documented in 5c2ad8e's test plan.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@khaliqgant
khaliqgant force-pushed the fix/node-control-read-idle branch from a9e50e8 to f5877a6 Compare August 10, 2026 13:45
@khaliqgant

Copy link
Copy Markdown
Member Author

Rebased onto main (was 41 behind, now on top of 11.5.0) and addressed review feedback in f5877a604.

Applied — >>= at node_control.rs (@coderabbitai and @cubic-dev-ai, independently). Valid: the check runs at the top of each tick, so on a blackholed connection idle lands exactly on the window at the fourth tick, and the strict > deferred the disconnect a whole interval (60s / five intervals) — not the four-interval budget the constant documents. Comment added explaining why the boundary is inclusive.

Applied — shortened the changelog entry (@chatgpt-codex-connector, P1). Correct per AGENTS.md L45-49: the entry carried the route, timeout, write-liveness mechanism and health-field detail, which is implementation backstory. Now one impact-first line.

Not applied — [Unreleased - Patch][Unreleased] (@coderabbitai). This contradicts the guideline it cites. AGENTS.md L33-43:

An empty post-release changelog starts with [Unreleased]. The first pending user-visible change must set the heading to [Unreleased - Patch], [Unreleased - Minor], or [Unreleased - Major] according to its SemVer impact.

11.5.0 was cut on 2026-08-10 leaving an empty [Unreleased], and this is the first pending change on top of it — a bug fix, so [Unreleased - Patch] is the required heading. Leaving as-is.

Verification after the change

cargo test -p agent-relay-broker --lib      869 passed, 0 failed, 4 ignored
cargo fmt -p agent-relay-broker -- --check  clean

The regression test still bites with >= in place — neutralizing the guard to false && idle >= read_idle_timeout hangs the full 20s bound and fails; with it, the reconnect lands in ~1.5s.

@chatgpt-codex-connector

Copy link
Copy Markdown

To use Codex here, create an environment for this repo.

@coderabbitai

coderabbitai Bot commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Tip

For best results, initiate chat on the files or code changes.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- Focused commit diff ---'
git show --format=fuller --find-renames --find-copies -- CHANGELOG.md crates/broker/src/node_control.rs AGENTS.md f5877a604

printf '%s\n' '--- Changelog policy ---'
if [ -f AGENTS.md ]; then
  sed -n '30,55p' AGENTS.md
else
  fd -HI '^AGENTS\.md$' . -x sh -c 'printf "\n--- %s ---\n" "$1"; sed -n "30,55p" "$1"' sh {}
fi

printf '%s\n' '--- Current idle-timeout guard context ---'
rg -n -C 6 'idle\s*(>=|>)\s*read_idle_timeout|inclusive|four heartbeat' crates/broker/src/node_control.rs

printf '%s\n' '--- Current changelog heading and entry ---'
sed -n '1,35p' CHANGELOG.md

Length of output: 20355


@khaliqgant Confirmed.

crates/broker/src/node_control.rs now uses idle >= read_idle_timeout. The comment correctly explains why the boundary must be inclusive.

I also verified AGENTS.md. [Unreleased - Patch] is required for the first pending user-visible patch after a release. My earlier [Unreleased] feedback was incorrect. I withdraw it.

The changelog entry is concise and impact-first. The reported test and formatting results are sufficient for this follow-up.


✏️ Learnings added
Learnt from: khaliqgant
URL: https://github.com/AgentWorkforce/relay/pull/1462

Timestamp: 2026-08-10T13:46:15.418Z
Learning: In this repository, `AGENTS.md` requires the first pending user-visible change after a release to change `CHANGELOG.md` from `[Unreleased]` to `[Unreleased - Patch]`, `[Unreleased - Minor]`, or `[Unreleased - Major]` according to SemVer impact. An empty `[Unreleased]` heading is only restored when a release is cut.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.

You are interacting with an AI system.

@cubic-dev-ai

cubic-dev-ai Bot commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Rebased onto main (was 41 behind, now on top of 11.5.0) and addressed review feedback in f5877a604.

Applied — >>= at node_control.rs (@coderabbitai and @cubic-dev-ai, independently). Valid: the check runs at the top of each tick, so on a blackholed connection idle lands exactly on the window at the fourth tick, and the strict > deferred the disconnect a whole interval (60s / five intervals) — not the four-interval budget the constant documents. Comment added explaining why the boundary is inclusive.

Applied — shortened the changelog entry (@chatgpt-codex-connector, P1). Correct per AGENTS.md L45-49: the entry carried the route, timeout, write-liveness mechanism and health-field detail, which is implementation backstory. Now one impact-first line.
...

@khaliqgant The >>= change is right, and the reasoning holds. The tick interval is HEARTBEAT_INTERVAL.min(read_idle_timeout / 4), so in production (12s interval, 48s window) the fourth tick lands idle exactly on 48s — a strict > would defer the disconnect to 60s/five intervals, which contradicts the "three lost pings tolerated" budget the constant documents. In the test (100ms tick, 400ms window) it's the same pattern, so the regression test stays tight. Good call.

The changelog heading decision also matches your repo rule — first pending change on top of a release that left an empty [Unreleased] sets the versioned patch heading, and a bug fix is Patch. And the shortened entry is consistent with the impact-first convention. All three resolutions look consistent with what the code and AGENTS.md actually say; nothing here to push back on.

The only mild note: the >= boundary relies on the check running at the top of the tick before the write. That's true today, so the fourth tick fires idle == window exactly. It's a small coupling to ordering, but the comment you added already documents the reasoning, which is enough.

Tip: get faster answers by chatting with cubic’s review copilot in the review UI

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.

Node-control WS has write-only liveness: a blackholed connection strands the node offline for hours while /health reports connected

1 participant