JUCE Threading Model & Component Lifecycle

SkillAI & models

JUCE threading model and component lifecycle safety. Use when writing UI components, async callbacks, timers, or any code that crosses thread boundaries. Covers destruction ordering, SafePointer, LookAndFeel cleanup, and common crash patterns.

Available today. Use it from your connected AI after setup.

Connect ahel once, and every AI you use reads what you have installed.

Then ask your AI: use the JUCE Threading Model & Component Lifecycle skill

What this skill tells your AI

The instructions your AI receives, as published by conceptual-machines/magda-core in .claude/skills/juce-threading/SKILL.md and read by ahel’s review.

This skill covers thread safety and component lifecycle patterns critical for avoiding crashes in MAGDA.

Threading Model

Three Thread Contexts

MessageThread (UI)     — Component::paint, resized, mouse events, Timer callbacks, callAsync lambdas
Audio Thread (RT)      — processBlock, no allocations, no locks, no MessageManager calls
Background Threads     — juce::Thread, juce::ThreadPool, long-running tasks

Check Which Thread You're On

// Assert you're on the message thread
jassert(juce::MessageManager::getInstance()->isThisTheMessageThread());

// Check without asserting
if (juce::MessageManager::getInstance()->isThisTheMessageThread())
    component.repaint();

Cross-Thread Communication

// Audio thread → UI thread (fire-and-forget)
juce::MessageManager::callAsync([this] {
    // Runs on message thread. WARNING: `this` may be dead — see SafePointer below.
    label.setText("Done", juce::dontSendNotification);
});

// Audio thread → UI thread (coalesced, no allocation)
struct MyComp : juce::Component, juce::AsyncUpdater {
    void audioCallback() {
        latestValue.store(newVal);
        triggerAsyncUpdate();  // Safe from any thread, coalesces multiple calls
    }
    void handleAsyncUpdate() override {
        // Runs on message thread, once per coalesced batch
        slider.setValue(latestValue.load());
    }
    std::atomic<float> latestValue { 0.0f };
};

// Periodic polling from UI thread
struct MyComp : juce::Component, juce::Timer {
    MyComp() { startTimerHz(30); }
    ~MyComp() override { stopTimer(); }  // MUST stop in destructor
    void timerCallback() override {
        // Runs on message thread
        repaint();
    }
};

// Delayed one-shot on message thread
juce::MessageManager::callAsync([] {
    juce::Timer::callAfterDelay(500, [] {
        // Runs on message thread after 500ms
    });
});

Audio Thread Rules

void processBlock(juce::AudioBuffer<float>& buffer, juce::MidiBuffer& midi) override
{
    // NEVER do any of these on the audio thread:
    // - new / delete / malloc
    // - juce::String construction or concatenation
    // - MessageManager::callAsync (allocates — use AsyncUpdater instead)
    // - Lock acquisition (mutex, CriticalSection)
    // - File I/O
    // - DBG() in release builds

    // SAFE on audio thread:
    // - std::atomic reads/writes
    // - Lock-free queues (juce::AbstractFifo)
    // - Raw pointer/buffer arithmetic
    // - triggerAsyncUpdate() (lock-free)
}

Component Lifecycle & Destruction Safety

Destruction Ordering

struct ParentComp : juce::Component {
    ~ParentComp() override
    {
        stopTimer();            // 1. Stop all timers
        removeListener(this);   // 2. Remove all listener registrations
        // 3. Children are destroyed automatically after this (member order, reverse)
    }

    ChildComp child;  // Destroyed BEFORE ParentComp destructor body runs?
                      // NO — members are destroyed AFTER destructor body.
                      // So stopTimer() in destructor body is safe.
};

Rule: Stop timers and remove listeners in the destructor body, before members are destroyed.

SafePointer for Async Safety

// DANGEROUS — `this` may be deleted before lambda runs
juce::MessageManager::callAsync([this] {
    setText("done");  // CRASH if component was deleted
});

// SAFE — SafePointer becomes nullptr if component is deleted
juce::MessageManager::callAsync([safeThis = juce::Component::SafePointer<MyComp>(this)] {
    if (auto* self = safeThis.getComponent())
        self->setText("done");
});

// SAFE — shorter form
auto safeThis = juce::Component::SafePointer<MyComp>(this);
juce::MessageManager::callAsync([safeThis]() mutable {
    if (safeThis != nullptr)
        safeThis->setText("done");
});

Timer Safety

struct MyComp : juce::Component, juce::Timer {
    MyComp() { startTimerHz(30); }

    ~MyComp() override {
        stopTimer();  // MANDATORY — timer callback must not fire after destruction
    }

    void timerCallback() override {
        // Safe to access members here — guaranteed on message thread,
        // and stopTimer() in destructor prevents post-destruction calls
        repaint();
    }
};

LookAndFeel Cleanup

// DANGEROUS — components reference L&F that's already destroyed
struct MyApp {
    CustomLookAndFeel lnf;       // Destroyed SECOND (after mainWindow)
    std::unique_ptr<MainWindow> mainWindow;  // Destroyed FIRST — but its children
                                              // may still reference lnf during teardown
};

// SAFE — clear default L&F before destroying components
struct MyApp {
    ~MyApp() {
        juce::LookAndFeel::setDefaultLookAndFeel(nullptr);  // Clear global L&F
        mainWindow.reset();  // Now safe to destroy components
    }
    CustomLookAndFeel lnf;
    std::unique_ptr<MainWindow> mainWindow;
};

// SAFE — per-component L&F cleanup
struct MyComp : juce::Component {
    ~MyComp() override {
        setLookAndFeel(nullptr);  // Detach before L&F may be destroyed
    }
};

Listener Removal

struct MyComp : juce::Component, juce::Value::Listener {
    MyComp(juce::Value& v) : value(v) {
        value.addListener(this);
    }

    ~MyComp() override {
        value.removeListener(this);  // MUST remove before destruction
    }

    void valueChanged(juce::Value&) override {
        repaint();
    }

    juce::Value value;
};

JUCE Leak Detector

class MyComponent : public juce::Component {
public:
    MyComponent();
    ~MyComponent() override;

private:
    // Place at the END of the class — detects leaked instances on shutdown
    JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR(MyComponent)
};

Common Crash Patterns & Fixes

1. Timer fires after component deleted

// BUG: no stopTimer() in destructor
struct Bad : juce::Component, juce::Timer {
    ~Bad() override { /* timer still running! */ }
};

// FIX:
struct Good : juce::Component, juce::Timer {
    ~Good() override { stopTimer(); }
};

2. callAsync lambda captures dead this

// BUG:
juce::MessageManager::callAsync([this] { repaint(); });

// FIX:
juce::MessageManager::callAsync(
    [safe = juce::Component::SafePointer<MyComp>(this)] {
        if (safe != nullptr) safe->repaint();
    });

3. LookAndFeel destroyed before components

// BUG: member destruction order destroys L&F while components still reference it
struct App {
    CustomLookAndFeel lnf;
    std::unique_ptr<MainWindow> window;  // window's children still use lnf during destruction
};

// FIX: explicit teardown order
struct App {
    ~App() {
        juce::LookAndFeel::setDefaultLookAndFeel(nullptr);
        window.reset();
    }
    CustomLookAndFeel lnf;
    std::unique_ptr<MainWindow> window;
};

4. Listener not removed before object destroyed

// BUG: broadcaster calls dead listener
struct Bad : juce::Button::Listener {
    Bad(juce::Button& b) : button(b) { button.addListener(this); }
    ~Bad() { /* forgot removeListener! */ }
    juce::Button& button;
};

// FIX:
struct Good : juce::Button::Listener {
    Good(juce::Button& b) : button(b) { button.addListener(this); }
    ~Good() { button.removeListener(this); }
    juce::Button& button;
};

5. Component deleted during its own callback

// BUG: deleting yourself inside a button callback
void buttonClicked(juce::Button*) override {
    owner.removeChildComponent(this);
    delete this;  // CRASH — stack still unwinding through JUCE event dispatch
}

// FIX: defer deletion
void buttonClicked(juce::Button*) override {
    juce::MessageManager::callAsync([this, &owner = this->owner] {
        owner.removeAndDeleteChild(this);
    });
}

6. Accessing MessageManager from audio thread

// BUG: called from processBlock
void processBlock(...) {
    if (clipping)
        component.repaint();  // WRONG — repaint() posts to message thread internally,
                               // but is not safe from audio thread
}

// FIX: use atomic flag + timer or AsyncUpdater
void processBlock(...) {
    if (clipping)
        clipFlag.store(true);  // Lock-free
}

void timerCallback() override {
    if (clipFlag.exchange(false))
        clipIndicator.repaint();
}

Prefer RAII Over Manual Resource Management

Always use RAII classes instead of manual acquire/release patterns. This prevents resource leaks when exceptions occur or early returns are taken.

Smart Pointers Over Raw new/delete

// BAD — leak if exception or early return between new and delete
auto* comp = new MyComponent();
addAndMakeVisible(comp);
// ... if something throws or returns, comp is leaked

// GOOD — ownership is automatic
auto comp = std::make_unique<MyComponent>();
addAndMakeVisible(comp.get());
ownedComponents.push_back(std::move(comp));

Scoped Locks

// BAD — manual lock/unlock, easy to forget unlock on early return
mutex.lock();
doWork();
if (error) return;  // BUG: mutex not unlocked!
mutex.unlock();

// GOOD — RAII lock, always released on scope exit
{
    const juce::ScopedLock sl(mutex);
    doWork();
    if (error) return;  // mutex automatically unlocked
}

Tracktion Engine RAII Helpers

// Prevent expensive playback graph rebuilds during batch operations
{
    te::TransportControl::ReallocationInhibitor inhibitor(transport);
    // ... make many changes to tracks/plugins ...
}  // Single graph rebuild happens here

// Restore playback state after temporary stop
{
    te::TransportControl::ScopedPlaybackRestarter restarter(transport);
    transport.stop(false, false);
    // ... do work that requires transport stopped ...
}  // Playback automatically resumes

// Group undo operations
{
    te::Edit::UndoTransactionInhibitor inhibitor(*edit);
    // ... multiple operations won't be grouped into one undo step ...
}

RAII for Listener Registration

// Pattern: register in constructor, unregister in destructor
struct ScopedListener {
    ScopedListener(juce::Value& v, juce::Value::Listener* l)
        : value(v), listener(l) { value.addListener(listener); }
    ~ScopedListener() { value.removeListener(listener); }
    juce::Value& value;
    juce::Value::Listener* listener;
};

RAII for Timer Management

// If you inherit from Timer, always stop in destructor:
~MyComp() override { stopTimer(); }

// Or use a helper that auto-stops:
struct ScopedTimer : juce::Timer {
    std::function<void()> callback;
    ~ScopedTimer() override { stopTimer(); }
    void timerCallback() override { if (callback) callback(); }
};

Thread-Safe Data Patterns

// Atomic for simple values
std::atomic<float> gain { 1.0f };
std::atomic<bool> shouldStop { false };

// Lock-free FIFO for messages (audio → UI)
juce::AbstractFifo fifo { 512 };
std::array<float, 512> buffer;

// SpinLock for very short critical sections (avoid on audio thread if possible)
juce::SpinLock lock;
{
    const juce::SpinLock::ScopedLockType sl(lock);
    // Very brief work only
}

Signals

GitHub stars
206
Forks
23
Last commit
Sep 2026
Advanced
Catalog kind
skill
Gateway key
juce-threading
Source
github.com/conceptual-machines/magda-core