Skip to content

fix(rpc-gateway): tie the upstream pubsub socket to the client connection - #412

Open
KishiTheMechanic wants to merge 1 commit into
mainfrom
fix/rpc-gateway-pubsub-upstream-leak
Open

fix(rpc-gateway): tie the upstream pubsub socket to the client connection#412
KishiTheMechanic wants to merge 1 commit into
mainfrom
fix/rpc-gateway-pubsub-upstream-leak

Conversation

@KishiTheMechanic

Copy link
Copy Markdown
Contributor

症状

slv-rpc-gateway がクライアント 1 本につき 1 本開く upstream pubsub WebSocket が、クライアントが去っても閉じない。

〔実測 2026-09-04 indexed-ty6-1〕クライアント接続 3 本・validator 側の実サブスクリプション 13 件に対して、127.0.0.1:7212 への upstream が 343 本。プロセスの fd の 97% がこれで、RSS 600 MB。全接続が bytes_sent:356 bytes_acked:357 で以後無通信〔ss -Htnim〕= WS upgrade と 1 メソッドだけ送って、あとは何もしないまま閉じない。増加率 0.85 本/分 で、LimitNOFILE=65536 まで約 55 日。

機序

connect_loopwriterreader のどちらかが終わったときにしか終了しないが、どちらも終わらない条件が普通に起きる。

  • writerup_rx.recv() で待つ。この channel の sender は connect_loop 自身が持っている — ローカル束縛の up_tx と、自分が握る Arc<Inner> 経由で State::Open { tx } に入っているクローン。PubsubForward を drop しても 3 本目の参照が消えるだけで、channel は開いたまま。
  • reader は upstream からフレームが来たときにしか起きない。静かな購読をしてから去ったクライアントではフレームが来ないので stream.next() が永久にブロックする。

tokio::select! が発火せず、task と TCP socket が漏れる。

修正

PubsubForwardoneshot::Sender<()> を持たせ、その receiver を connect_loopselect! に足す。クライアント接続が終わって ConnectionState がスコープを抜けると PubsubForward が drop され、sender が落ちて receiver が解決し、socket が畳まれる。

検証

回帰テスト upstream_pubsub_socket_closes_when_client_disconnects を追加。upstream モックは subscribe に応答したあと沈黙し、read 側が close を観測したら oneshot で報告する。クライアントを unsubscribe せずに drop し、5 秒以内に upstream が閉じることを要求する。

  • 修正あり: 60 passed; 0 failed
  • 陰性対照select! から _ = closed_rx => {} の 1 行だけ外して同じテストを撃つ → panicked ... upstream socket must close when the client disconnects: Elapsed(()) / test result: FAILED

🤖 Generated with Claude Code

…tion

`PubsubForward` opens one outbound WebSocket per client, but nothing
made that socket's lifetime follow the client's. `connect_loop` ended
only when `writer` or `reader` finished, and neither can be relied on:

- `writer` waits on `up_rx.recv()`, and the senders are owned by
  `connect_loop` itself (the local `up_tx` binding plus the clone it
  keeps alive through its own `Arc<Inner>`). Dropping `PubsubForward`
  releases a third reference and changes nothing.
- `reader` only wakes when the upstream sends a frame. A client that
  subscribes to something quiet and then leaves produces no frame, so
  `stream.next()` blocks forever.

The task and its TCP socket then leak. Measured in production on
2026-09-04 (indexed-ty6-1): 343 upstream sockets against 3 live client
connections and 13 real subscriptions, each socket showing
`bytes_sent:356 bytes_acked:357` and no traffic afterwards, growing at
~0.85/min and holding 600 MB RSS.

Give `PubsubForward` a `oneshot::Sender` whose receiver is selected on
inside `connect_loop`. Dropping the struct — which is what happens when
the client connection ends and `ConnectionState` goes out of scope —
resolves the receiver and tears the socket down.

The regression test spawns an upstream that answers the subscribe and
then stays silent, drops the client, and requires the upstream to
observe the close. Without the select arm it fails with `Elapsed`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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.

1 participant