Ensure resilience over disconnect, restarts, hibernation and (re)boot #24
Labels
No labels
Agent
Ignore
Agent
In Progress
Agent
Planned
Agent
Planning
Agent
Ready
Agent
Triage
Compat
Breaking
Kind
Bug
Kind
Documentation
Kind
Enhancement
Kind
Feature
Kind
Security
Kind
Testing
Priority
Critical
Priority
High
Priority
Low
Priority
Medium
Reviewed
Confirmed
Reviewed
Duplicate
Reviewed
Invalid
Reviewed
Won't Fix
Status
Abandoned
Status
Blocked
Status
Need More Info
No milestone
No assignees
2 participants
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
lucas/nanoKontroller2#24
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Already observed issue: restarting the application via the systemd user service produces a crash dump.
Generally, the expected behavior is that the app eventually always settles into a working state, regardless of what happens with the system or in which order things happen.
app starts first -> wait for controller to be connected
controller connected, then app starts -> start working as expected
restart at any time -> reconcile connected controllers or wait for it, reconcile state
system boots or hibernates -> reconcile connected controllers or wait for it, reconcile state
Triage
The issue bundles one observed crash and one missing behaviour. They sit in different subsystems, so I'll separate them.
There are four independent lifecycles involved, and the code treats them very differently:
on_core_disconnectedinsrc/main.cpp:48resetsAudioServiceand retrieswp_core_connectevery 500 ms1. The MIDI side has no lifecycle at all
MidiManager::open()is called exactly once (src/main.cpp:127). It enumeratesRtMidiIn/RtMidiOutports, picks the first name match, opens it, and that is the end of device management. There is no re-enumeration, no subscription to ALSA sequencer client announce/remove events, andMidiManager::isOpen()has no caller anywhere in the tree. Consequences:mainprints andreturn 1(src/main.cpp:128-129). The waiting is done byRestart=on-failure+RestartSec=5inpackaging/nanokontroller2.service— i.e. the process flaps until the device shows up. That works by accident of the exit code rather than by design, and it also means every restart re-parses the config and re-builds all actions.m_openstaystrue,MidiManager::sendControlChangekeeps catching the resultingRtMidiErrorand returningfalse, and thatfalseis discarded byLedManager::Set's callers (Action_Connection::Refresh_LED_State). The process stays alive, exit code never happens, so systemd never restarts it. The app is up and inert — which is the exact state the issue says must never persist.2. The teardown path is the mirror image of the reconnect path, and it is missing
on_core_disconnectedis careful: it callsAudioService::Reset(), which disconnects every per-node signal handler bound tothisbefore dropping the cache (src/AudioService.cpp:78-89). Normal shutdown does none of that.mainquits the loop, callsm_MidiManager.close(), and lets~AppStaterun. There is nowp_core_disconnectanywhere in the tree and noAudioService::Reset()on the exit path.Member destruction order in
AppStateisActions → AudioService → LedManager → MidiManager → Core → Loop, soAudioServiceis destroyed while the core it registered object managers with is still alive, and the core's own teardown happens afterwards.WpCore's finalize callswp_core_disconnect(), and its dispose runswp_registry_clear()before chaining to GObject's dispose (wireplumber 0.5.8lib/wp/core.c) — that is, registry teardown and proxy destruction run at a point where handlers are still connected andAudioServiceis already gone. Everyg_signal_connect(..., this)inAudioService::Initand every per-node handler thatReset()would have removed is a callback into freed memory in that window.on_core_disconnecteditself is registered with&State, which has the same problem one level up.That is my primary hypothesis for the crash dump: shutdown runs the destruction in the opposite order from the one the reconnect path established as correct, and nothing severs the callbacks first. [INFERENCE] — I have not reproduced it; see the questions below.
Two further candidates I cannot rule out without the backtrace, both in
MidiManager::handleRawMessage(src/MidiManager.cpp:140-178):m_handler(ev)is called unconditionally, but the RtMidi callback is installed insideopen()(src/MidiManager.cpp:70) while the handler is only assigned several statements later inmain(src/main.cpp:132) — afterLedManager::ResetState()has pushed ~30 CC messages at the device. Any inbound byte in that window is an emptystd::functioncall → uncaughtstd::bad_function_call→abort()→ core dump. A restart is exactly when that window is most likely to be hit, because the user may still be touching a control.msg[1]andmsg[2]are read without checkingmsg.size().ignoreTypes(true, true, true)filters sysex, timing clock and active sensing, but not 1-byte start/continue/stop/reset (0xFA–0xFC, 0xFF) nor 2-byte program change / channel pressure. Those are out-of-boundsstd::vectorreads.3. "Reconcile state" has an asymmetry worth naming
After any gap, the two directions are not equally recoverable:
LedManagerholds a write-through cache (state_) that is only seeded by the singleResetState()at startup. A device that disappears and comes back arrives with all LEDs dark while the cache still claims they are lit, andLedManager::Togglereads that cache — so the first press after a reconnect does the opposite of what the LED shows. The actions do re-push LED state, but only in response to WirePlumber events (on_node_available,on_default_nodes_changed); a MIDI-only reconnect triggers none of those.Action_Volume::m_LastVolumestarts out unset (m_bHasVolume == false) while the hardware sits at some arbitrary position; the divergence is invisible until the user moves the control.So "reconcile state" means two different mechanisms, and only one of them is a pure software problem.
4. Smaller findings in the same area
AudioService::Initguards the links object manager withif (!ObjectManager)(src/AudioService.cpp:22) — the node manager's pointer, notLinksObjectManager. Harmless today only becauseReset()nulls both; it breaks the momentInitruns twice without aResetin between.on_core_disconnectedadds a new 500 ms retry timeout (and ag_newallocation) per emission. If "disconnected" fires more than once before a reconnect succeeds, the timers stack andAudioService::Initruns repeatedly.packaging/nanokontroller2.servicehasRestart=on-failure, so the inert-after-unplug state in §1 is explicitly not covered, and a missing config file produces a process that runs forever with zero actions (AppSettingsManager::GetAppSettingsreturns empty settings andmaincarries on).g_timeout_add(1, consume_queue_timeout, …)polls the MIDI queue at 1 kHz, i.e. a wakeup every millisecond for the life of the process.Questions
coredumpctl infofor that dump, plus the journal lines around it)? It discriminates directly between the three candidates above: WirePlumber teardown is a SIGSEGV deep in glib/wp frames, the empty-handler case is a SIGABRT withstd::bad_function_callin an RtMidi thread, the short-message case is a SIGSEGV insideMidiManager::handleRawMessage.systemctl --user restartregardless points at teardown.Related issues
Regarding your questions, I don't have the dump or log at hand. But I remember the stack trace listing some form of "abort" and/or "kill" functions, and I'm fairly certain it was the stopped process.
It happened as part of a
systemctl --user restart nanokontroller2and the newly started process worked fine. I also only saw one "stopped" and "started" line each insystemctl --user status. I believe the controller was plugged in at the time.That narrows it usefully, even without the dump.
What "abort" plus "kill" tells us
That frame pair is the standard glibc abort chain —
abort()→raise()→__pthread_kill_implementation. It means the process killed itself: it was not SIGSEGV, and it was not systemd escalating to SIGKILL. Combined with "it was the stopped process" and "the new one worked fine", the window is the shutdown path of the old process, betweeng_main_loop_quitandmainreturning.What that eliminates
Both MidiManager candidates from my first comment are out:
std::bad_function_call) exists only betweenMidiManager::open()installing the RtMidi callback andmainassigningsetEventHandler. That is startup code. The stopped process had its handler installed since startup, and the new process started cleanly — so this is not it.msg[1]/msg[2]read is an out-of-bounds read in the RtMidi thread; it produces SIGSEGV or silent garbage, not an abort, and it would fire at arbitrary moments rather than at stop. (Still a latent defect, just not this one.)I can also rule out a race with the MIDI thread during teardown:
MidiManager::close()runs before~AppState, andm_in.reset()there runs~MidiInAlsa, which writes the trigger pipe and joins the input thread (rtmidiRtMidi.cpp). By the time any destructor inAppStateruns, that thread is gone. RtMidi's own teardown is also a poor abort candidate:MidiInAlsa::~MidiInAlsaonly callsclosePort()and ALSA cleanup, and the paths that do report problems there go througherror()with WARNING severity, which prints instead of throwing.What is left
The WirePlumber teardown, and the abort signature actually fits it better than a segfault would. A use-after-free that lands inside an allocator — freeing an already-freed pointer, or unreffing a GObject whose memory has been reused — is normally caught by glibc's own heap consistency check, which prints
free(): invalid pointer/double free detectedand callsabort(). PipeWire's SPA assertions and an uncaught C++ exception reachingstd::terminateend in the same three frames. All three are symptoms of the same structural fact.The fact itself is in the member order of
AppState(src/main.cpp:23-34). Destruction runsActions → AudioService → LedManager → MidiManager → Core → Loop, soAudioServiceis destroyed first and theWpCoreit registered everything with is destroyed last. At that last step:wp_core_disposecallswp_registry_clear()and only then chains to GObject's dispose, which is what destroys signal handlers (wireplumber 0.5.8lib/wp/core.c:403-411). So the whole registry teardown — globals dropped, proxies destroyed,object-removedemitted — happens while every handler is still connected.this: the two object managers (src/AudioService.cpp:26-27, 36-37) and three per-node handlers attached inon_node_added(src/AudioService.cpp:456-458). The only code that severs them isAudioService::Reset()(src/AudioService.cpp:78-89), and the exit path never calls it. There is nowp_core_disconnect()anywhere in the tree either.So the reconnect path is careful about exactly this hazard and the exit path ignores it.
on_core_disconnectedresets the service before anything else touches it; shutdown does the reverse.One detail I could not settle from the published sources:
wp_registry_install_object_managerlives in a private registry translation unit I couldn't fetch, so I can't say whether the registry holds a strong reference to an installedWpObjectManageror a weak one. It matters only for which way the corruption goes, not whether it exists — strong ref means the manager outlives~AudioServiceand emits into freed memory; weak means the registry keeps an entry for a manager that~AudioServicealready unreffed. [INFERENCE] on which of the two.A secondary contributor in the same window:
Actionsare destroyed first, and~IActionBaseunconditionally dereferencesm_audioService(src/Actions/IAction.cpp:4-8). That ordering happens to be correct today, but it is correct by declaration order alone — nothing expresses the dependency.Two things that would still sharpen this
coredumpctl listkeeps them for a while by default), the first line of the abort message is decisive:free(): …/malloc(): …means the allocator caught a double or invalid free,terminate called after throwing …means an uncaught exception, and anything withspa_assertorg_assertion_messagepoints at a library invariant instead. Those three sit on different code paths inside the same teardown.systemctl --user stop nanokontroller2abort the same way? If yes, the restart is irrelevant and this is purely the exit path. If it only happens onrestart, something about the two processes overlapping is involved and my reasoning above is incomplete.Here is the dump:
omp-deck is holding this event: the model provider's usage was at 90% as of 2026-11-14T17:18:57.628Z, at or above the 90% ceiling omp-deck keeps for scheduled work. The quota window resets at 2026-11-16T08:00:00.410Z. The turn starts automatically once usage drops, and this comment is removed then.