-
Notifications
You must be signed in to change notification settings - Fork 603
rpc: fix rpc_ep_workers_http being stopped on first request #2364
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: develop
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -124,6 +124,9 @@ func (c *Client) newClientConn(conn ServerCodec) *clientConn { | |
| } | ||
|
|
||
| func (cc *clientConn) close(err error, inflightReq *requestOp) { | ||
| // The client-side pool is per-connection (created in newClientConn), so it | ||
| // is owned here and must be stopped to release its metric goroutine. | ||
| cc.handler.executionPool.Stop() | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Potential issue: this execution pool uses the static "rpcclient" service label, so multiple concurrent WS/IPC connections share the same rpc_ep_*_rpcclient metric gauges. This appears pre-existing and is not introduced by this PR, so I don't think it should block this change, but it would be worth tracking separately or adding a follow-up issue to make the per-connection metrics distinct or aggregate them correctly. |
||
| cc.handler.close(err, inflightReq) | ||
| cc.codec.close() | ||
| } | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -334,7 +334,12 @@ func (h *handler) close(err error, inflightReq *requestOp) { | |
| h.callWG.Wait() | ||
| h.cancelRoot() | ||
| h.cancelServerSubscriptions(err) | ||
| h.executionPool.Stop() | ||
|
|
||
| // The execution pool is deliberately not stopped here. On the server side | ||
| // it is shared across all handlers and owned by Server, which stops it in | ||
| // Server.Stop(); stopping it per-handler would kill the shared pool's | ||
| // metric goroutine on the first request (regression introduced in #1005). | ||
| // On the client side the per-connection pool is stopped by clientConn.close. | ||
| } | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Suggestion: could we add a short comment here explaining that executionPool is the shared server-side HTTP pool and is intentionally stopped by Server.Stop() rather than by each handler?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Comment added as suggested. |
||
|
|
||
| // addRequestOp registers a request operation. | ||
|
Comment on lines
334
to
345
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 This is a quality/documentation nit: neither Extended reasoning...This PR correctly separates ownership of two The problem is that this ownership asymmetry is not written down anywhere. Both call sites now look superficially inconsistent: one path stops its pool on close, the other doesn't. Nothing in the code signals why — a reader has to trace back through This is not a hypothetical risk: PR #1005 originally added Per this repo's own commenting guidance in AGENTS.md ("gotchas future developers should know", "why simpler alternatives don't work"), this is precisely the kind of non-obvious invariant that warrants a one-line comment — not because the code is wrong, but because its correctness depends on external, easy-to-forget context (which pool is shared vs. per-connection) that isn't visible at either call site. Concrete walkthrough of the regression this documentation would prevent:
Fix: add a short comment at both |
||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🟣 Pre-existing (not introduced by this PR): every server-side WS/IPC connection creates its own execution pool via
newClientConn→NewExecutionPool(100, 0, "rpcclient", true)(rpc/client.go:122), butnewEpMetricskeys its gauges only by the literal service string "rpcclient" (rpc/metrics.go:53-58). With N concurrent WS/IPC connections, their independent 3s reportMetrics tickers all write to the samerpc/ep/workers/rpcclientgauge, so the value flip-flops between unrelated connections, and the Stop() call this PR relocates toclientConn.closeleaves a stale last-written value once a connection closes. Not caused by this PR, but it sits directly on the per-connection lifecycle path this PR touches.Extended reasoning...
What the bug is:
newEpMetrics(rpc/metrics.go:53-58) builds its gauge names purely from the pool'sservicestring viametrics.GetOrRegisterGauge, which returns the same singleton gauge object for any two pools sharing that string.newClientConn(rpc/client.go:122) hardcodesNewExecutionPool(100, 0, "rpcclient", true)for every non-HTTPclientConnit creates — and sinceinitialSize=100>0andreport=true, each such pool spawns its ownreportMetricsgoroutine (execution_pool.go:52-54, 158-176) that ticks every 3 seconds and callsworkerCount.Update(s.inFlight.Load())on the sharedrpc/ep/workers/rpcclientgauge (plus its queue/processed siblings).The code path that triggers it: the key fact to verify was whether this per-connection
newClientConnpath is actually reachable from the server side for real WS/IPC connections, or only from outboundDial("ws://...")clients. I traced it directly:rpc/websocket.go:66andrpc/ipc.go:42both callServer.ServeCodec, notserveSingleRequest(that function's own doc comment says it is 'used to serve HTTP connections' — confirmed byrpc/http.go:341being its only caller).ServeCodec(rpc/server.go:142-155) callsinitClient(codec, ...), andinitClient(rpc/client.go:247-276) spawnsgo c.dispatch(conn)whenever the codec isn't an*httpConn.dispatchthen callsc.newClientConn(codec)to build the per-connection handler. So every inbound WS or IPC connection to a Bor node's RPC server gets its own independentClient/clientConn/SafePoolinstance, each tagged "rpcclient", each running its own 3s reporter goroutine concurrently with every other open WS/IPC connection.Why existing code doesn't prevent it: the pool's service label is a static string baked in at the call site, with no per-connection suffix (e.g. remote address or connection ID) to disambiguate.
metrics.GetOrRegisterGaugedeliberately returns one shared instance per name, so nothing stops two independently-created pools with the same service string from stomping on each other.Step-by-step proof:
websocket.go→ServeCodec→initClient→dispatch→newClientConncreates poolA =NewExecutionPool(100,0,"rpcclient",true). poolA'sreportMetricsgoroutine starts, registers/fetches gaugerpc/ep/workers/rpcclient, and every 3s writes poolA.inFlight.newEpMetrics("rpcclient")returns the same gauge object poolA is already updating.rpc_ep_workers_rpcclientvalue alternates between A's and B's in-flight counts with no way for an observer to tell which connection it reflects.clientConn.close(the line this PR modifies) callscc.handler.executionPool.Stop(), which stops poolA's ticker via<-s.close. It never resets/zeroes the shared gauge, so if A's ticker happened to write last, the gauge is now permanently stuck at A's stale last value even though only B (or nobody) is live.Impact: this is an observability-only defect — no consensus, correctness, or crash impact (Gauge.Update is atomic, so there's no data race, just a semantic collision). It only affects the accuracy of the
rpc_ep_workers_rpcclient/rpc_ep_queue_rpcclient/rpc_ep_processed_rpcclientmetrics under concurrent WS/IPC server connections.Why pre-existing: the
NewExecutionPool(...,"rpcclient",true)call andnewEpMetrics's naming scheme are both untouched by this diff. Before this PR,Stop()was already invoked at the same point in the per-connection lifecycle, just from insidehandler.close()(called byclientConn.close) instead of directly inclientConn.close. This PR only relocates where Stop() is called for HTTP's shared server pool bug; it does not change when or how the per-connection client-side pool's Stop() fires relative to the collision. That said, it does now sit on the exact code line (cc.handler.executionPool.Stop()) governing this lifecycle, so it's worth flagging here even though it predates the change.Suggested fix (non-blocking): include a connection-unique component (e.g. remote address, or an incrementing connection counter) in the service string passed to
NewExecutionPoolfor server-side per-connection pools, or disablereportfor connections created viaServeCodec/initClientand instead track WS/IPC pool stats in aggregate on the server's own shared pool.