Skip to content

[v5] πŸ› bug: contributor WebSocket flaps every 30–90s, and every close is silent by construction (1006, no reason, discarded by the relay)Β #5090

Description

@Danathar

Summary

  • A contributor ("clanker") holds one long-lived WebSocket to the hive hub for
    the whole session: it carries the task assignment, the scoped GitHub token,
    progress reports, and a 30-second application-level ping/pong in both
    directions.
  • On hosted-kubestellar-hive-ojv6 that socket is not staying up. Over 32
    minutes on 2026-08-29 one contributor's connection died and re-established
    seven times, with connected lifetimes of 76s, 84s, 30s, 991s, 30s, 300s
    and 30s. Three of the seven lasted exactly 30 seconds. The relay reconnects
    1s later every time and resumes the task, so work does complete β€” this is not
    a stall.
  • The cause is not established. It is not the application heartbeat: both
    the relay (HEARTBEAT_TIMEOUT_MS = 90000) and the hub
    (wsHeartbeatTimeout = 90s) require 90 seconds of silence before they close
    anything, so neither can produce a 30-second lifetime, and the relay's
    distinctive Heartbeat timeout on … β€” reconnecting line does not appear in
    the log at all. That points below the application β€” the ingress/network path
    in front of the hosted spoke β€” but nothing on either side records enough to
    confirm it.
  • Which is the second half of this report: every hub-side close is
    indistinguishable from a network drop, and the relay throws away the only
    evidence there is.
    Both are one-line fixes and both are prerequisites for
    diagnosing the first half.
  • Blast radius: every contributor on a hosted hub. Work still completes, but
    each flap books a release cooldown on the in-flight issue, re-mints a GitHub
    token, and writes a joined/left pair into the shared public activity feed.

Evidence

GET /api/contribute/activity, filtered to one contributor, 2026-08-29:

11:12:52  joined     ┐ 76s
11:14:08  left       β”˜
11:15:22  joined     ┐ 84s
11:16:46  left       β”˜
11:17:18  joined     ┐ 30s
11:17:48  left       β”˜
11:22:02  joined     ┐ 991s
11:38:33  left       β”˜
11:38:34  joined     ┐ 30s      (+1s reconnect = BASE_RECONNECT_DELAY_MS)
11:39:04  left       β”˜
11:39:05  joined     ┐ 300s
11:44:05  left       β”˜
11:44:06  joined     ┐ 30s
11:44:36  left       β”˜

19 joined and 19 left events for this one contributor in the retained feed.
The +1s gaps are the relay's BASE_RECONNECT_DELAY_MS; the backoff never
grows past 1s because a successful connect resets it
(bin/contributor-relay.sh:2318), so the log line is always
Reconnecting in 1000ms.

Matching relay output, one full cycle, repeated verbatim four times:

Connected to wss://hosted-kubestellar-hive-ojv6…/api/contribute/ws
Authenticated … as c-ca8187f7480c (tier: contributor)
Hub protocol 1.2; capabilities: token_refresh, …
Reconnected while working on kubestellar/hive#5055 β€” resuming
GitHub token refreshed
Connection to wss://…/api/contribute/ws closed. Reconnecting in 1000ms...

Why nobody can tell what closed it

1. The hub never sends a WebSocket Close frame. Every hub-side close is a
bare conn.Close() / c.ws.Close() β€” the auth timeout
(contribute_ws.go:2766), the auth-failure paths (:2845, :2861, :2867),
the heartbeat loop's timeout and failed-ping branches (:3639, :3647), and
the stale sweep in cleanupLoop (:3929). None calls
WriteControl(websocket.CloseMessage, …), so the client sees 1006 (abnormal
closure), empty reason
β€” byte-identical to a yanked cable.

Verified live against the hub with an unauthenticated probe:

 0.1s open
 0.1s msg: {"type":"auth_challenge","seq":1,"nonce":"…"}
30.1s msg: {"type":"auth_failed","reason":"Authentication timeout"}
30.1s CLOSE code= 1006 reason= ""

The hub knew exactly why it was hanging up, said so in a JSON message, and then
closed the socket in a way that carries none of it. A JSON auth_failed is at
least readable; the heartbeat and cleanup closes send nothing at all first.

2. The relay discards the close code and reason.

hub.ws.on('close', () => {
  console.log(`Connection to ${hub.url} closed. Reconnecting in ${hub.reconnectDelay}ms...`);

(bin/contributor-relay.sh:2553)

ws hands that callback (code, reason). Logging them would immediately
separate "1006, no frame" (network or a bare Close()) from a deliberate
1000/1001/1011 with text β€” which is the single fact needed to decide whether
this is the ingress or the hub, and it is currently thrown away on every flap.

What each flap costs

  • The in-flight issue is briefly withdrawn. The disconnect path calls
    bookReleaseCooldown (contribute_ws.go:2814) so the issue is not instantly
    re-admissible β€” correct as designed (it closes the /contribute sending "dupe" PRsΒ #2356 duplicate-PR race),
    but it means a flapping session repeatedly pulls its own issue out of the
    ready-work queue.
  • A GitHub token is re-minted every cycle β€” GitHub token refreshed appears
    in all four logged reconnects, against a wsTokenRefreshPeriod of 50 minutes.
  • The shared activity feed fills with churn. joined/left are ordinary
    activity entries, so one flapping contributor crowds real picked up /
    completed rows out of the operator's view and out of /api/contribute/activity.
  • fleet_size samples become a coin flip β€” the hourly metric reads 1 or 0
    depending on which side of a flap the sample lands.
  • Each dial re-occupies a slot against the hub-wide maxWSConnections = 50.

Suggested order of work

  1. Log (code, reason) in the relay's close handler. One line, no behavior change.
  2. Have hub-side closes send a real Close frame with a code and a reason before
    closing β€” at minimum the heartbeat/cleanup paths, which today say nothing at all.
  3. With 1 and 2 in place, one flap tells you whether the hub closed it (and why)
    or something in the ingress path did. Fix that.

Reported from a just contribute-hive claude local session on Linux against
hosted-kubestellar-hive-ojv6, 2026-08-29, working #5055.

Activity

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

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions