Skip to content

fix(host-service): force tunnel reconnect on inbound silence without waiting for onclose - #6236

Closed
andyst-dev wants to merge 4 commits into
ParadevAI:mainfrom
andyst-dev:fix/tunnel-watchdog-force-reconnect
Closed

andyst-dev wants to merge 4 commits into
ParadevAI:mainfrom
andyst-dev:fix/tunnel-watchdog-force-reconnect

Conversation

@andyst-dev

@andyst-dev andyst-dev commented Aug 6, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #6229

Bug

A persistent Linux host repeatedly goes offline in the Superset control plane while the VPS, network, host-service process, and terminals all stay running. The host logs no inbound traffic for 77s, forcing reconnect — and then never reconnects until systemctl --user restart superset-host.

Root cause (confirmed by the triage bot and a contributor in the issue thread):

tunnel-client.ts detects relay silence via a watchdog, but its only recovery mechanism was a graceful WebSocket.close(4002, …) — and every reconnect after an established connection was scheduled exclusively from the onclose handler. On a blackholed TCP path (packets dropped rather than reset — the stuck ESTAB with a ~39 KB unacked send queue seen in the report), the close frame can never be flushed, the closing handshake never completes, onclose never fires, and scheduleReconnect() is never called. The client parks in CLOSING forever.

Fix

forceReconnect(socket, code, reason) in tunnel-client.ts:

  • orphans the stale socket immediately — this.socket = null, connecting = false, stopWatchdog(), cleanupChannels() — and calls scheduleReconnect() directly, without waiting for onclose;
  • mirrors the existing connect-timeout path (connect()'s deadline already did close + null + scheduleReconnect);
  • the watchdog now calls it instead of the bare socket.close();
  • the identity guard at the top of onclose (if (this.socket !== socket) return) means the stale socket's late close (if it ever arrives) is ignored — no double-scheduling, no teardown of a newer socket.

Validation

Note: tunnel-client-v2.ts may share the pattern; this PR scopes to the v1 client the report and triage identified.


Summary by cubic

Force reconnects the tunnel on inbound silence and ignores events from orphaned sockets. Previously the watchdog waited for onclose after close(4002); on blackholed TCP, onclose never fired and the client stayed in CLOSING.

  • Adds forceReconnect() to orphan the current socket, stop the watchdog, clean channels, and scheduleReconnect() immediately; the watchdog now calls this on inbound silence.
  • Guards onopen, onmessage, onerror, and onclose by socket identity so late events from orphaned sockets don’t touch state or double-schedule.
  • Adds tests that drive the real connect() path with a global FakeWebSocket, stub scheduleReconnect to avoid timer leaks, verify reconnect without onclose, ignore stale sockets and late messages, and close the client to suppress the pending connect deadline.

Written for commit 1b873b0. Summary will update on new commits.

Review in cubic

Summary by CodeRabbit

  • Bug Fixes

    • Improved automatic reconnection when a WebSocket becomes unresponsive.
    • Replaced stale connections promptly, even when the close handshake does not complete.
    • Prevented outdated connections from interfering with active sessions or reconnection attempts.
    • Ensured cleanup is skipped after the client has been closed.
  • Tests

    • Added regression coverage for unresponsive WebSockets, stale callbacks, reconnect scheduling, and late messages from orphaned connections.

@coderabbitai

coderabbitai Bot commented Aug 6, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: bb2f1616-bd46-4f2f-a134-47d60bcc3b56

📥 Commits

Reviewing files that changed from the base of the PR and between e266b01 and 15fe31a.

📒 Files selected for processing (2)
  • packages/host-service/src/tunnel/tunnel-client.test.ts
  • packages/host-service/src/tunnel/tunnel-client.ts
🚧 Files skipped from review as they are similar to previous changes (2)
  • packages/host-service/src/tunnel/tunnel-client.test.ts
  • packages/host-service/src/tunnel/tunnel-client.ts

📝 Walkthrough

Walkthrough

The tunnel watchdog now uses forceReconnect to recover stale sockets without waiting for onclose. Callbacks from orphaned sockets are ignored. Tests cover socket replacement, client closure, and late messages.

Changes

Tunnel watchdog recovery

Layer / File(s) Summary
Forced stale-socket recovery
packages/host-service/src/tunnel/tunnel-client.ts
The watchdog calls forceReconnect. The helper validates socket ownership, closes and detaches the socket, resets connection state, cleans channels, stops monitoring, and schedules reconnection. Stale onopen, onmessage, and onerror callbacks are ignored.
Reconnect regression coverage
packages/host-service/src/tunnel/tunnel-client.test.ts
Fake WebSocket tests verify immediate socket orphaning, ignored replaced sockets, ignored late messages, and no action after client closure.

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

Mergeability Score: ⚪ Minimal · up to 15fe3

This change forces a stalled tunnel to reconnect instead of waiting indefinitely for socket closure and ignores events from the stale connection. No actionable merge-blocking risk remains beyond normal checks and review.

Possibly related PRs

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The changes address #6229 by forcing reconnection without waiting for onclose and by ignoring stale socket events.
Out of Scope Changes check ✅ Passed The implementation and regression tests are limited to the v1 tunnel reconnect failure described in #6229.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Title check ✅ Passed The title uses conventional commit format and clearly identifies the tunnel reconnect fix.
Description check ✅ Passed The description explains the bug, root cause, fix, testing, scope, and linked issue, with only checklist statuses left unchecked.
✨ 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: 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 `@packages/host-service/src/tunnel/tunnel-client.test.ts`:
- Around line 105-116: Update makeClient to expose the TunnelClient.close
method, then change the “forceReconnect is a no-op after close()” test to invoke
close() before forceReconnect instead of directly setting the private closed
flag. Preserve the existing scheduled-reconnect assertion and socket setup.

In `@packages/host-service/src/tunnel/tunnel-client.ts`:
- Around line 417-421: Update the socket event handlers in the tunnel client to
return immediately when the handler’s captured socket no longer matches
this.socket, applying the guard before state updates, message dispatch, or sends
in both onmessage and onopen. Add a regression test covering stale.onmessage
after socket replacement and verify lastInboundAt remains unchanged.
🪄 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: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: a83c99c6-03b1-4cf8-a428-2152549038e0

📥 Commits

Reviewing files that changed from the base of the PR and between c920d52 and 6fcdc8e.

📒 Files selected for processing (2)
  • packages/host-service/src/tunnel/tunnel-client.test.ts
  • packages/host-service/src/tunnel/tunnel-client.ts

@greptile-apps

greptile-apps Bot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

The PR makes the v1 host tunnel recover from inbound silence without relying on a potentially stuck WebSocket closing handshake.

  • Adds forceReconnect to orphan the silent socket, clean up channels and watchdog state, and schedule reconnection immediately.
  • Adds focused tests for blackholed close behavior, stale sockets, and closed clients.
  • The production lifecycle change appears sound; the new test contains an unnecessary process-wide WebSocket mutation.

Confidence Score: 4/5

The PR appears safe to merge, with only a non-blocking test-isolation cleanup recommended.

The forced-reconnect path consistently orphans the stale socket, clears tunnel-owned state, and schedules one reconnect under existing identity and timer guards; the remaining concern is an unnecessary process-wide WebSocket test stub.

Files Needing Attention: packages/host-service/src/tunnel/tunnel-client.test.ts

Important Files Changed

Filename Overview
packages/host-service/src/tunnel/tunnel-client.ts Introduces immediate, identity-guarded recovery from a blackholed tunnel while preventing stale onclose events from scheduling twice.
packages/host-service/src/tunnel/tunnel-client.test.ts Covers the intended forced-reconnect behavior, but unnecessarily mutates globalThis.WebSocket during module evaluation.

Sequence Diagram

sequenceDiagram
  participant W as Watchdog
  participant C as TunnelClient
  participant S as Stale WebSocket
  participant R as Reconnect Timer
  W->>C: inbound silence timeout
  C->>S: close(4002)
  C->>C: "socket = null"
  C->>C: stop watchdog and cleanup channels
  C->>R: scheduleReconnect()
  Note over S,C: Late onclose is ignored by socket identity guard
  R->>C: connect()
Loading
Prompt To Fix All With AI
### Issue 1
packages/host-service/src/tunnel/tunnel-client.test.ts:23-27
**Avoid the unnecessary global stub**

These tests instantiate `FakeWebSocket` directly and never use `globalThis.WebSocket`, so replacing the process-wide implementation during module evaluation creates avoidable cross-test coupling until the first `afterEach` restores it.

```suggestion
const { TunnelClient } = await import("./tunnel-client");
```

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Reviews (1): Last reviewed commit: "fix(host-service): force tunnel reconnec..." | Re-trigger Greptile

Comment on lines +23 to +27
// Stub global WebSocket with the fake.
const originalWebSocket = globalThis.WebSocket;
(globalThis as Record<string, unknown>).WebSocket = FakeWebSocket;

const { TunnelClient } = await import("./tunnel-client");

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.

P2 Avoid the unnecessary global stub

These tests instantiate FakeWebSocket directly and never use globalThis.WebSocket, so replacing the process-wide implementation during module evaluation creates avoidable cross-test coupling until the first afterEach restores it.

Suggested change
// Stub global WebSocket with the fake.
const originalWebSocket = globalThis.WebSocket;
(globalThis as Record<string, unknown>).WebSocket = FakeWebSocket;
const { TunnelClient } = await import("./tunnel-client");
const { TunnelClient } = await import("./tunnel-client");
Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/host-service/src/tunnel/tunnel-client.test.ts
Line: 23-27

Comment:
**Avoid the unnecessary global stub**

These tests instantiate `FakeWebSocket` directly and never use `globalThis.WebSocket`, so replacing the process-wide implementation during module evaluation creates avoidable cross-test coupling until the first `afterEach` restores it.

```suggestion
const { TunnelClient } = await import("./tunnel-client");
```

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

@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.

1 issue found across 2 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="packages/host-service/src/tunnel/tunnel-client.test.ts">

<violation number="1" location="packages/host-service/src/tunnel/tunnel-client.test.ts:29">
P3: The comment here claims the tests drive the watchdog timer manually ("we drive the timer manually by stubbing timers"), but none of the added tests actually start, observe, or stop the watchdog — they all assign `socket` directly and call `forceReconnect(...)` on it. Consequently the `startWatchdog`/`stopWatchdog`/`watchdogTimer`/`connect` members exposed by `makeClient` are unused dead scaffolding, and the comment is misleading for future maintainers. Consider either trimming the exposed surface and correcting the comment to reflect that the watchdog path is exercised by calling `forceReconnect` directly, or actually driving the timer if the goal was to test the interval-triggered path.</violation>
</file>
Architecture diagram
sequenceDiagram
    participant Host as Linux Host
    participant Process as host-service Process
    participant WS as WebSocket Connection
    participant Relay as Relay Server
    participant Superset as Superset Control Plane
    
    Note over Host,Superset: Normal Operation (happy path)
    Process->>WS: open connection
    WS->>Relay: establish WebSocket
    Relay->>Superset: host online
    loop Watchdog Interval
        Process->>Process: check lastInboundAt
        alt Inbound traffic received
            Process->>WS: reset watchdog timer
        else Inbound silence detected
            Process->>WS: close(4002, "Inbound silence timeout")
            WS->>Relay: closing handshake
            Relay-->>WS: close handshake ack
            WS-->>Process: onclose event
            Process->>Process: scheduleReconnect()
            Process->>WS: new connection attempt
        end
    end
    
    Note over Host,Superset: Failure Mode - Blackholed TCP Path (#6229)
    loop Watchdog Interval
        Process->>Process: check lastInboundAt
        alt Inbound silence detected
            Process->>WS: close(4002, "Inbound silence timeout")
            Note over WS,Relay: close frame NEVER reaches relay<br/>packets dropped, TCP stuck in ESTAB
            WS-->>Process: onclose NEVER fires<br/>client parks in CLOSING forever
            Note over Process: No reconnect scheduled<br/>Host appears offline
        end
    end
    
    Note over Host,Superset: Fixed Flow - forceReconnect()
    loop Watchdog Interval
        Process->>Process: check lastInboundAt
        alt Inbound silence detected
            Process->>Process: forceReconnect(socket, 4002, "Inbound silence timeout")
            Process->>WS: socket.close(code, reason)
            Process->>Process: this.socket = null
            Process->>Process: this.connecting = false
            Process->>Process: stopWatchdog()
            Process->>Process: cleanupChannels()
            Process->>Process: scheduleReconnect()
            Note over Process: Reconnect scheduled IMMEDIATELY<br/>without waiting for onclose
            Process->>WS: new connection attempt
            alt Late onclose from stale socket arrives
                WS-->>Process: onclose event
                Process->>Process: identity guard: this.socket !== socket<br/>→ ignore (no double-schedule)
            end
        end
    end
    
    opt forceReconnect with stale socket already replaced
        Note over Process: stale socket from previous watchdog cycle
        Process->>Process: forceReconnect(staleSocket, 4002, ...)
        Process->>Process: identity guard: this.socket !== staleSocket<br/>→ immediate return (no-op)
        Note over Process: Current socket and connection<br/>remain unchanged
    end
    
    opt forceReconnect after client close()
        Note over Process: client.closed = true
        Process->>Process: forceReconnect(socket, 4002, ...)
        Process->>Process: this.closed check → immediate return
        Note over Process: No action taken
    end
Loading

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

Re-trigger cubic


const { TunnelClient } = await import("./tunnel-client");

// Speed up the watchdog for tests: reach into the module via the constants

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.

P3: The comment here claims the tests drive the watchdog timer manually ("we drive the timer manually by stubbing timers"), but none of the added tests actually start, observe, or stop the watchdog — they all assign socket directly and call forceReconnect(...) on it. Consequently the startWatchdog/stopWatchdog/watchdogTimer/connect members exposed by makeClient are unused dead scaffolding, and the comment is misleading for future maintainers. Consider either trimming the exposed surface and correcting the comment to reflect that the watchdog path is exercised by calling forceReconnect directly, or actually driving the timer if the goal was to test the interval-triggered path.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/host-service/src/tunnel/tunnel-client.test.ts, line 29:

<comment>The comment here claims the tests drive the watchdog timer manually ("we drive the timer manually by stubbing timers"), but none of the added tests actually start, observe, or stop the watchdog — they all assign `socket` directly and call `forceReconnect(...)` on it. Consequently the `startWatchdog`/`stopWatchdog`/`watchdogTimer`/`connect` members exposed by `makeClient` are unused dead scaffolding, and the comment is misleading for future maintainers. Consider either trimming the exposed surface and correcting the comment to reflect that the watchdog path is exercised by calling `forceReconnect` directly, or actually driving the timer if the goal was to test the interval-triggered path.</comment>

<file context>
@@ -0,0 +1,119 @@
+
+const { TunnelClient } = await import("./tunnel-client");
+
+// Speed up the watchdog for tests: reach into the module via the constants
+// is not possible (not exported), so we drive the timer manually by
+// stubbing timers.
</file context>

@andyst-dev

Copy link
Copy Markdown
Contributor Author

Thanks — all round-2 feedback is addressed in 81c3798:

  • Ignore events from detached sockets (coderabbit): onopen, onmessage, and onerror now all carry the if (this.socket !== socket) return identity guard, mirroring the existing onclose guard — a late frame from an orphaned socket can no longer touch lastInboundAt/watchdog state or dispatch through handleMessage. New regression test: late messages from an orphaned socket are ignored (4/4 pass).
  • Post-close test via real close() (coderabbit): the no-op test now calls the actual close() method instead of setting closed by hand.
  • No unnecessary global stub (greptile + cubic): the tests instantiate FakeWebSocket directly and never read globalThis.WebSocket, so the process-wide replacement at module scope is gone.
  • Misleading timer comment removed (cubic): tests exercise forceReconnect/socket identity directly, not the 10s watchdog interval — the comment no longer claims otherwise.

tsc --noEmit: 0 errors · bun test: 4/4 pass · Biome: clean.

@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 2 files (changes from recent commits).

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

Re-trigger cubic

@andyst-dev

Copy link
Copy Markdown
Contributor Author

Thanks — both points are addressed in 6806919:

  • Test no longer a no-op (cubic P2): the late-messages test previously assigned socket state by hand, so onmessage stayed null and the assertion passed regardless of the fix. The fake WebSocket is now registered before the module import (TunnelClient resolves WebSocket at module-evaluation time) and the test drives the real connect() path, so it exercises the actual guarded handlers. Verified: a late inbound from an orphaned socket leaves lastInboundAt untouched.
  • No leaked reconnect timer (cubic P2): scheduleReconnect is stubbed in that test so forceReconnect's scheduling is observed without a real timer firing against relay.example.com after the test; the global stub is restored in afterEach.

bun test: 4/4 pass · tsc --noEmit: 0 errors · Biome: clean.

@andyst-dev

Copy link
Copy Markdown
Contributor Author

Thanks — addressed in 3a60b61: the orphaned-socket test now calls client.close() after the assertions, so the 20s deadline connect() left pending on the never-opening fake socket becomes a no-op instead of firing after the test (cubic P2).

bun test: 4/4 pass · tsc --noEmit: 0 errors · Biome: clean.

@andyst-dev

Copy link
Copy Markdown
Contributor Author

Thanks — this comment is stale: it points at commit 6806919/81c3798 and was re-anchored onto the current head, but the concern was already addressed in 3a60b61 (pushed before this comment's review run):

  • The orphaned-socket test calls client.close() after its assertions, which marks the client closed so connect()'s pending 20s deadline becomes a no-op and no real timer fires against relay.example.com. scheduleReconnect is also stubbed in that test (observed via a counter, not executed).

bun test: 4/4 pass · tsc --noEmit: 0 errors · Biome: clean.

@andyst-dev
andyst-dev force-pushed the fix/tunnel-watchdog-force-reconnect branch from 3a60b61 to 15fe31a Compare August 13, 2026 07:22
@coderabbitai

coderabbitai Bot commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

Note

GitHub couldn't provide a complete incremental comparison for this pull request, so CodeRabbit is performing a full review instead. This review may take a little longer.

…waiting for onclose

The watchdog detected a silent relay connection and called
socket.close(4002). On a blackholed TCP path (packets dropped, not
reset) the WebSocket closing handshake never completes, onclose never
fires, and the only reconnect path (scheduled from onclose) never ran —
the client parked in CLOSING forever while the process stayed alive but
the host showed offline (ParadevAI#6229).

forceReconnect() now orphans the stale socket (null + stopWatchdog +
cleanupChannels) and schedules the reconnect immediately, mirroring the
existing connect-timeout path. The onclose identity guard already
ignores the stale socket's late close, so no double-scheduling.

3 unit tests with a fake WebSocket whose close() never fires onclose.
forceReconnect nulls this.socket so the stale socket's late onclose is
ignored, but onopen/onmessage/onerror could still run on the orphaned
socket and touch state (lastInboundAt, watchdog, handleMessage dispatch)
or use a replacement socket. Add the identity guard to all three
handlers, mirroring the existing onclose guard.

Tests: drop the unnecessary globalThis.WebSocket stub (tests instantiate
FakeWebSocket directly), drive close() instead of setting closed by hand,
and add a regression test that late messages from an orphaned socket are
ignored. 4/4 pass.
The late-messages test previously assigned socket state by hand, so
onmessage stayed null and the assertion was a no-op that passed
regardless of the fix. The fake WebSocket is now registered before the
module import (TunnelClient resolves WebSocket at eval time) and the
test calls connect() to get the REAL guarded handlers, then verifies a
late inbound from an orphaned socket is ignored and that reconnect is
scheduled exactly once. scheduleReconnect is stubbed so no real timer
leaks; the global stub is restored in afterEach.
connect() leaves a 20s deadline pending on the fake socket (which never
opens); closing the client after the assertions marks it closed so the
deadline becomes a no-op instead of firing post-test (cubic P2).
@andyst-dev
andyst-dev force-pushed the fix/tunnel-watchdog-force-reconnect branch from 15fe31a to 1b873b0 Compare August 13, 2026 08:20
@andyst-dev

Copy link
Copy Markdown
Contributor Author

Closing as a duplicate: main already ships this via un-wedgeable tunnel supervision + SDK relay resolution (#6724) — the watchdog on tunnel-client.ts already forces a socket teardown on inbound silence (abandonSocket("Inbound silence timeout")) without waiting for the close handshake. That commit is a stricter superset of this PR's forceReconnect intent (stuck-socket grace, v1_dead_reconnect_chain rescue, etc.), so rebasing would be redundant against the maintainers' newer design. No action needed.

@andyst-dev andyst-dev closed this Aug 22, 2026
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.

Remote Linux host stays alive but becomes offline after relay WebSocket stalls

1 participant