Skip to content

Race condition during application close can lead to a crash #82

Open

Description

@gfr-g

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:

  1. The GUI enters terminate(), holds the iterator and sends -1.
  2. The backend disconnects, the listener reads EOF and enters terminate().
  3. The listener erases the entry
  4. 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

  1. omnimaxxing commented on Nov 7, 2026

    @omnimaxxing

    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:

    [AppLoad]: Unloading frontends of piano
    [AppLoad]: Failed to send message header to ID:piano
    [AppLoad]: Application piano terminated.
    double free or corruption (out)
    systemd[1]: xochitl.service: Main process exited, code=dumped, status=6/ABRT
    

    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. Its terminate() (management.cpp:205) goes on to closeUIInstance() (:79) and coordinator->unload() (AppLoadCoordinator.cpp:29), which emit the coordinator's unloading(), loadedChanged() and applicationQMLRootChanged() 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 around sockets alone 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 in management.cpp), with test results. Happy to rework it toward shared ownership if you'd rather go that way.

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions