Conversation
JsonRPCServerLoop::on_stop() logs "todo" and RpcServerThread::on_stop()
logs "TODO: stop server thread", so neither thread can be stopped at all:
the loop stays in ev_loop() and the workers stay in waitForEvent(). Nothing
even asks them to stop - the module has no destructor and the thread pool
none either.
AmPlugIn::~AmPlugIn() deletes every plug-in factory and then dlclose()s the
modules. While that happens the JSON-RPC loop thread keeps accepting
connections and dispatching events, and the worker threads keep processing
requests, i.e. they execute code out of a mapping that is being unloaded and
touch JsonRPCServerLoop's static connection map after the module is gone.
Make the threads stoppable and stop them in the right order:
* RpcServerThread gets a stop flag which run() honours; request_stop()
sets it and wakes a thread blocked in waitForEvent() via ev_pending.
* RpcServerThreadpool::cleanup() stops, joins and deletes all workers.
* JsonRPCServerLoop::request_stop() breaks ev_loop() through an async
watcher registered by the loop thread itself - only ev_async_send() may
be used from another thread while the loop runs. run() then cleans the
thread pool up (no event can be dispatched to a worker any more) and
closes the listening socket, which was leaked on every error path too.
* The new JsonRPCServerModule destructor stops and joins the loop. It goes
through the singleton on purpose: the factory object AmPlugIn creates and
deletes is not the instance that owns the loop, so server_loop is now
initialised in the constructor as well.
AmThread::stop() detaches the thread, which would make a following join() a
no-op, hence request_stop() next to on_stop() in both classes - same pattern
as the reg_agent dialer thread.
Backported from yeti-switch/sems, which stops the same two threads on
shutdown: RpcServerThread's is_stop flag and RpcServerThreadpool::cleanup()
come from 977d0091, breaking the event loop out of ev_loop() from f6ab75ec,
and the stop-flag-before-ev_pending ordering from 3bd8616d.
Contributor
There was a problem hiding this comment.
🟡 Changes recommended
Unresolved shutdown races, connection cleanup gaps, and a worker-pool deadlock block approval.
Get a fresh assessment by requesting another Copilot review.
Pull request overview
Adds orderly shutdown for the JSON-RPC event loop and worker threads before module unloading.
Changes:
- Adds worker stop signaling and pool cleanup.
- Adds async event-loop shutdown and listening-socket cleanup.
- Stops and joins the loop during module destruction.
File summaries
| File | Reviewed change |
|---|---|
apps/jsonrpc/RpcServerThread.h |
Declares worker stop and cleanup APIs. |
apps/jsonrpc/RpcServerThread.cpp |
Implements worker shutdown and joining. |
apps/jsonrpc/RpcServerLoop.h |
Declares loop stop support. |
apps/jsonrpc/RpcServerLoop.cpp |
Implements async loop shutdown and socket cleanup. |
apps/jsonrpc/JsonRPC.cpp |
Stops and joins the loop during destruction. |
Review details
Suppressed comments (1)
apps/jsonrpc/RpcServerLoop.cpp:193
async_wis never stopped when the loop exits. It remains registered in the default libev loop withasync_cbpointing into this module, so unloading the module leaves a live watcher referencing unmapped code; a later reuse of the default loop (for example during a reload) can call it. Stopasync_win this callback before breaking the loop.
ev_async_stop(EV_A_ w);
ev_io_stop(EV_A_ &ev_accept);
ev_break(EV_A_ EVBREAK_ALL);
- Files reviewed: 5/5 changed files
- Comments generated: 4
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Comment on lines
+55
to
+58
| if (_instance != NULL && _instance->server_loop != NULL) { | ||
| DBG("requesting the JSON-RPC server loop to stop...\n"); | ||
| _instance->server_loop->request_stop(); | ||
| _instance->server_loop->join(); |
Comment on lines
+353
to
+354
| ev_async_init (&async_stop, async_stop_cb); | ||
| ev_async_start (EV_A_ &async_stop); |
Comment on lines
+360
to
+363
| // stopped: no new connection is accepted and no event is dispatched to the | ||
| // server threads any more, so they can be shut down and joined now | ||
| threadpool.cleanup(); | ||
| ::close(listen_fd); |
Comment on lines
+203
to
+214
| threads_mut.lock(); | ||
| DBG("stopping %zu RPC server threads\n", threads.size()); | ||
| for (vector<RpcServerThread*>::iterator it = threads.begin(); | ||
| it != threads.end(); it++) { | ||
| // not stop(): that detaches the thread, which turns join() into a no-op | ||
| (*it)->request_stop(); | ||
| (*it)->join(); | ||
| delete *it; | ||
| } | ||
| threads.clear(); | ||
| t_it = threads.begin(); | ||
| threads_mut.unlock(); |
request_stop() wrote to the async watcher that run() initialises and starts right before entering ev_loop(). ev_async_init() resets the watcher, so a request from before that point, i.e. a shutdown right after the module was loaded, was dropped: the loop ran on and the module destructor's join() never returned. Have request_stop() set a flag first and run() check it once the watcher is live. A request from before then shows in the flag, a later one reaches the watcher. Stop the async watcher that hands events to the loop as well when leaving it, so that no watcher of this module stays registered in the default loop after run() has returned. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
RpcServerThreadpool::cleanup() joined the threads with threads_mut held. A thread still finishing an event hands its connection back through JsonRPCServerLoop::returnConnection(), which dispatches a pending event for that connection to the pool, and dispatch() takes threads_mut: with a message queued for a connection that was being processed, shutdown deadlocked. Move the threads out of the pool under the mutex and stop and join them without it. dispatch() finds the pool empty meanwhile and drops the event, which is all it can do at that point. Ask all threads to stop before joining the first, so that they wind down together. Run cleanup() from the pool's destructor too. The pool is a static of the module and starts a thread as soon as the module is loaded, so that other modules can use JSON-RPC while they initialise. When loading fails before the server loop has run, nothing else stops that thread before the module is unloaded. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
When the event loop has returned and the server threads are joined, the accepted and outgoing connections are still registered, their descriptors open and their read watchers in the default loop, and the events queued for busy connections are still pending. Close and free them, and drop the pending events, before run() returns. For that, ev_is_active() has to be reliable on a connection that never entered the read loop, e.g. an outgoing one whose request is still queued, so the connection constructor now initialises its watchers. Define the thread pool after the other statics of the file, so that it is destroyed before them: its destructor stops threads that use them. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Add a test_jsonrpc suite for the previous commits. The server loop and thread sources of the module are built into sems_tests when libev is found, which is the case in every CI build; JsonRPC.cpp stays out, since it exports the same factory symbol as UACAuth.cpp, so the test defines the two settings the loop reads from it. Without libev the suite is empty. - A server thread asked to stop leaves run(), both when it is waiting for events and when it was asked before it started. - cleanup() stops and joins the threads of a pool within a bound; dispatching to the emptied pool drops the event, and a second cleanup() is harmless. - The loop accepts a client on its port; after request_stop() the loop thread leaves run() and the client sees its connection closed. - A stop requested right after start(), or before it, is not lost. Without the stop flag the first of these hangs. The deadlock in cleanup() needs a server thread to dispatch while it is being joined, and is not reproduced here. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The bug
Neither JSON-RPC thread can be stopped:
RpcServerThread::run()loops onwhile (true), the loop thread sits inev_loop(), and nothing even asks them to stop: the module has no destructor andRpcServerThreadpoolhas no teardown.AmPlugIn::~AmPlugIn()deletes every plug-in factory and thendlclose()s the modules. While that happens the JSON-RPC loop thread keeps accepting connections and dispatching events, and the worker threads keep processing requests: they execute code out of a mapping that is being unloaded and touchJsonRPCServerLoop's static connection map after the module is gone. Same shape as the reg_agent dialer thread fixed in #570.The fix
Make both stoppable and stop them in order:
RpcServerThreadgets a stop flag honoured byrun();request_stop()sets it and then wakes a thread blocked inwaitForEvent()viaev_pending(that ordering matters: the other way round the thread can wake, drain and block again).RpcServerThreadpool::cleanup()stops, joins and deletes all workers.JsonRPCServerLoop::request_stop()breaksev_loop()through an async watcher that the loop thread registers itself; onlyev_async_send()may be called from another thread while the loop runs.run()then cleans up the thread pool, by that point no event can reach a worker any more, and closes the listening socket, which was leaked on every error path too.JsonRPCServerModuledestructor stops and joins the loop. It goes through the singleton deliberately: the factory objectEXPORT_PLUGIN_CLASS_FACTORYhands toAmPlugIn(and whichAmPlugIndeletes) is not the instance that ownsserver_loop, soserver_loopis initialised in the constructor as well.AmThread::stop()detaches the thread, which would turn a followingjoin()into a no-op, hencerequest_stop()alongsideon_stop()in both classes, the pattern already used for the reg_agent dialer.Follow-ups from the review
The review found four gaps in the shutdown sequence. Each is fixed in its own commit:
request_stop()wrote to the async watcher thatrun()initialises right beforeev_loop(), andev_async_init()resets it. A shutdown right after the module was loaded therefore left the loop running and the destructor'sjoin()hanging.request_stop()now sets a flag first, andrun()checks it once the watcher is live; a request from before shows in the flag, a later one reaches the watcher. Leaving the loop also stops the async watcher that hands events to it, so no watcher of the module stays registered in the default loop. (ev_async_send()on a never-started watcher touches nothing invalid, by the way; the request was merely dropped.)cleanup()could deadlock. It joined the workers withthreads_mutheld. A worker finishing an event hands its connection back throughreturnConnection(), which dispatches a pending event for that connection to the pool, anddispatch()takesthreads_mut. The threads are now moved out of the pool under the mutex and joined without it;dispatch()finds the pool empty meanwhile and drops the event.onLoad(),_instanceis still NULL and the destructor guard skipped everything. The pool's own destructor now runscleanup(), and the pool is defined after the other statics of the file so that it is destroyed before the ones its threads use.run()now closes and frees them once the workers are joined. For that,ev_is_active()has to be reliable on a connection that never entered the read loop, so the connection constructor initialises its watchers.One trade-off worth stating: shutdown now waits for the workers. A worker parked in
netstringsBlockingWrite()against a peer that has stopped reading retries every 10 ms without limit and delays it, which is the same unbounded waitAmMediaProcessor::stop()already does for its threads. A bound on those retries would be a separate change.Known failure, not yet fixed
The hardened and ubsan CI jobs fail at process exit:
cleanup()logs throughDBG(), and when it runs from the static destructor of the pool the logging facilities it reaches have already been torn down. Ordering the pool's definition after the other statics of its own translation unit does not cover facilities that live elsewhere. The static-destructor path needs to stop logging, or the pool needs to be torn down before exit rather than by a static destructor.Tests
core/tests/test_jsonrpc.cpp. The server loop and thread sources are built intosems_testswhen libev is found (all CI builds have it);JsonRPC.cppstays out because it exports the same factory symbol asuac_auth, so the test defines the two settings the loop reads from it. The suite is empty in a build without libev.run(), both when it is waiting for events and when it was asked before it started.cleanup()stops and joins the threads of a pool within a bound; dispatching to the emptied pool drops the event, and a secondcleanup()is harmless.request_stop()the loop thread leavesrun()and the client sees its connection closed.start(), or before it, is not lost. Without the flag, the first of these hangs.The
cleanup()deadlock needs a worker to dispatch while being joined and is not reproduced deterministically here.Credit
Backported from yeti-switch/sems, which stops the same two threads on shutdown: the
RpcServerThreadstop flag andRpcServerThreadpool::cleanup()come from977d0091, breaking out ofev_loop()fromf6ab75ec, and the stop-flag-before-ev_pendingordering from3bd8616d. Thanks to the yeti-switch maintainers.