Skip to content

Crash: pending QTimer::singleShot fires into a destroyed CClientDlg after an audio-device exception #3845

Description

@mcfnord

MY LLM WROTE:

Describe the bug

If an audio-device error throws while the client is running, the client crashes — not when the error happens, but up to a minute later, while the error dialog is on screen.

This is not #3754. I found it while investigating that issue and checked the connection specifically. #3754 reports BUG IN CLIENT OF LIBMALLOC: not an allocated block at __cxa_finalize_ranges → exit; this is a use-after-scope at timer dispatch. I ran the same chain in stock builds under macOS MallocErrorAbort/MallocGuardEdges and glibc MALLOC_CHECK_=3 and never got an allocator message, which follows once you notice the dangling object is on the stack, so no allocator check can fire. Same code region and same intermittent character, different crash.

CClientDlg is a stack object in main() (main.cpp:1008). A CGenErr thrown from the asynchronous audio-reinit path unwinds main()'s try (main.cpp:978) and destroys it. The handler at main.cpp:1107 then calls QMessageBox::critical (main.cpp:1113), which runs QDialog::exec() — a nested event loop that keeps dispatching timers, and which stays open until the user clicks Quit.

Meanwhile clientdlg.cpp:869 holds a pending timer:

QTimer::singleShot ( 60000, [this]() { lblUpdateCheck->hide(); } );

That is the 2-argument overload. It captures this but registers no context object, so Qt cannot auto-cancel it when CClientDlg is destroyed. When it comes due inside the nested loop it dereferences the destroyed dialog, and the client crashes.

The timer is only armed after connecting to a server that reports a strictly newer, suffix-free version, which is why this is intermittent rather than constant.

It is the only bare call site of its kind in the tree — serverdlg.cpp:924 and :930 already use the 3-argument context-aware form.

To Reproduce

Two things have to overlap: the update-check label must be showing, and an audio-device exception must escape within the timer's 60 seconds.

The second needs no special setup. On JACK, stopping JACK under a running client runs jack_on_shutdownCSound::shutdownCallback (sound/jack/sound.cpp:463) → EmitReinitRequestSignal ( RS_ONLY_RESTART_AND_INIT )CClient::OnSndCrdReinitRequest (client.cpp:849) → Init()CSound::Init()throw CGenErr (sound/jack/sound.cpp:338). On CoreAudio the same slot reaches throw CGenErr at sound/coreaudio-mac/sound.cpp:917 and :925 on a buffer-size mismatch. I observed the JACK route happening on its own, with no instrumentation of the throw: the reinit slot entered, both warnings printed, and the dialog destroyed by the unwind.

To measure it rather than wait for luck, I built with two test-only hooks — one driving the real OnCLVersionAndOSReceived slot with version 99.9.9, one throwing from the reinit slot — and shortened the timer. Nothing about the two defects themselves was modified.

Expected behavior

No crash. A timer whose callback touches a widget should not outlive that widget.

Screenshots

Not applicable.

Operating system

Reproduced on both, built from source:

  • Ubuntu Linux, Qt 5.15.13, offscreen QPA
  • macOS 12.7.6, x86_64, Qt 5.15.2

Version of Jamulus

3.12.2dev. All line numbers above are against current main (9d1eca4e), where both defects are unchanged. The singleShot line has been as it is since e9865ff2 (2026-05-14).

Additional context

28/28 crashes under ASan (Linux 20/20, macOS 8/8), with identical stacks on both platforms:

<lambda>  clientdlg.cpp:869
QSingleShotTimer::timerEvent
QTimerInfoList::activateTimers
QEventLoop::exec
QDialog::exec              <- the QMessageBox

ASan classifies it stack-use-after-scope, READ of size 8 — stack rather than heap, which follows from the dialog living in main()'s frame. Stock builds without ASan crash just as reliably: 15/15 SIGSEGV on Linux, 8/8 EXC_BAD_ACCESS on macOS, with QWidget::hide() on top.

This does not fire on the normal quit path, which is presumably why it has gone unnoticed. I measured that rather than assuming it: a 2 ms heartbeat timer outliving the dialog shows Qt stops dispatching timers within ±2 ms of close() being called, with roughly 110 further ticks due and none delivered, 3/3 on each platform. The error path is what makes the bug reachable, because a modal dialog inside an exception handler is a fully pumping event loop running after stack objects have been unwound.

Two independent one-line fixes:

// clientdlg.cpp:869 - Qt auto-cancels when the context object is destroyed
QTimer::singleShot ( 60000, this, [this]() { lblUpdateCheck->hide(); } );
// client.cpp:856 / 891 - releases the mutex even if an exception unwinds
QMutexLocker locker ( &MutexDriverReinit );

The first stops this crash. The second stops the exception escaping in the first place, and fixes a second problem on that path: the throw skips MutexDriverReinit.unlock() at client.cpp:891, so the mutex is left locked permanently. Every trial prints both Qt has caught an exception thrown from an event handler and QMutex: destroying locked mutex. There are three catch ( CGenErr ) sites — main.cpp:1107, clientdlg.cpp:1254, recorder/jamrecorder.cpp:432 — and none of them covers this asynchronous path.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    Status
    Triage

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions