Repository navigation
Description
Problem
In the latest version (v0.6.0, possibly earlier but didn't confirm), frontend termination and the backend listener can both enter appload::management::terminate() at the same time and this can lead to the situation where one caller erases an entry while another still holds its iterator.
I encountered this on a custom app deployed on the Remarkable Paper Pure and I also reproduced in the emulator with a minimal QML frontend whose unloading() calls endpoint.terminate() and a backend that exits immediately upon receiving the termination message
What can happen is basically:
- The GUI enters
terminate(), holds the iterator and sends-1. - The backend disconnects, the listener reads EOF and enters
terminate(). - The listener erases the entry
- The GUI resumes its erase using the invalidated iterator and crashes
Possible approaches to solve the problem
There are probably different ways to solve the problem, below I propose two with different tradeoffs. I can assist with the resolution but I wanted to discuss first to be aligned with your (maintainer/contributors) vision of the project
1. Track active users and wait for completion
Store connection state and usage in the map. Termination disables new acquisitions under synchronization, notify the listener and waits for existing users and the listener to finish before closing descriptors.
This guarantuees that cleanup has completed before returning but there is a bit of bookkeeping to do. There might also be other tricky problems of deadlocks I'm not foreseeing right now.
2. Use shared ownership and deferred cleanup
Store something like shared_ptr<Connection> values in the map with each Connection object holding the descriptors and closing them automatically when the last reference is released. Senders acquire a reference under a lock and the listener holds one while running. Termination removes the entry preventing new acquisitions and notifies the listener. Descriptors close when the last reference is released.
This has the advantage that we would have lifetime tracking without explicit usage counters and the cleanup can happen asynchronously. The tradeoff is that termination can return before the descriptors are confirmed closed and retained references delay cleanup.
Happy to discuss this further
EDIT: confirmed against last commit df974a6d641f37492df04013c53df2e5d6403488
Activity
omnimaxxing commentedon Nov 7, 2026
I'm hitting this on a reMarkable Paper Pure too (firmware 3.29.1.0, AppLoad 0.6.0 from Vellum). Closing an app whose backend has just gone away crashes xochitl. From the journal, all in the same second:
With your recipe (the backend exits the moment it gets a message, and the frontend sends one and closes in the same instant), stock 0.6.0 crashed on the first close (SIGSEGV this time), and the log line showed both threads in
terminate()together:Failed to send message header to ID:close-race[AppLoad]: Failed to send message header to ID:close-race.One more hazard on the same path: the listener thread doesn't only edit
sockets. Itsterminate()(management.cpp:205) goes on tocloseUIInstance()(:79) andcoordinator->unload()(AppLoadCoordinator.cpp:29), which emit the coordinator'sunloading(),loadedChanged()andapplicationQMLRootChanged()and so tear down the app's QML from a non-GUI thread.unloadFrontend()unregisters resources there as well. Even on a normal Close, the listener can run this a second time after the GUI already did: "Unloading frontends of …" is often logged twice for one close. A lock aroundsocketsalone wouldn't cover that part.So I went with a smaller rule than either option above: only the GUI thread tears an app down. The listener queues its teardown with
QMetaObject::invokeMethod(qApp, …, Qt::QueuedConnection)and skips it if the GUI already terminated the app. That's #84 (15 lines inmanagement.cpp), with test results. Happy to rework it toward shared ownership if you'd rather go that way.