Skip to content

Fix AssertionError crash when audio source finishes while paused#2633

Merged
riccardobl merged 2 commits intomasterfrom
copilot/fix-audio-pause-crash
Mar 3, 2026
Merged

Fix AssertionError crash when audio source finishes while paused#2633
riccardobl merged 2 commits intomasterfrom
copilot/fix-audio-pause-crash

Conversation

Copy link
Contributor

Copilot AI commented Mar 1, 2026

A race condition in ALAudioRenderer.updateInRenderThread() causes an AssertionError when an audio clip finishes playing in the same frame it is paused — OpenAL reports AL_STOPPED while JME still holds Status.Paused, hitting the unhandled mismatch branch. Fixes #2550

Change

Widen the reclaim condition from jmeStatus == Status.Playing to jmeStatus != Status.Stopped, giving OpenAL's AL_STOPPED state final authority over channel reclamation regardless of whether JME considered the source playing or paused:

// Before
if (openALStatus == Status.Stopped && jmeStatus == Status.Playing) {

// After
if (openALStatus == Status.Stopped && jmeStatus != Status.Stopped) {

The existing channel-reclaim logic (stream EOF detection, looping-buffer warning, buffer finished path) is correct for both Playing and Paused JME states — only the guard condition was too narrow.

Original prompt

This section details on the original issue you should resolve

<issue_title>Crash when pausing audio</issue_title>
<issue_description>Code in question from ALAudioRenderer.java:

    /**
     * Internal update logic called from the render thread within the lock.
     * Checks source statuses and reclaims finished channels.
     *
     * @param tpf Time per frame (currently unused).
     */
    public void updateInRenderThread(float tpf) {
        if (audioDisabled) {
            return;
        }

        for (int i = 0; i < channels.length; i++) {
            AudioSource src = channelSources[i];

            if (src == null) {
                continue; // No source on this channel
            }

            int sourceId = channels[i];
            boolean boundSource = i == src.getChannel();
            boolean reclaimChannel = false;

            // Get OpenAL status for the source
            int openALState = al.alGetSourcei(sourceId, AL_SOURCE_STATE);
            Status openALStatus = convertStatus(openALState);

            // --- Handle Instanced Playback (Not bound to a specific channel) ---
            if (!boundSource) {
                if (openALStatus == Status.Stopped) {
                    // Instanced audio (non-looping buffer) finished playing. Reclaim channel.
                    if (logger.isLoggable(Level.FINE)) {
                        logger.log(Level.FINE, "Reclaiming channel {0} from finished instance.", i);
                    }
                    clearChannel(i); // Stop source, detach buffer/filter
                    freeChannel(i);  // Add channel back to the free pool
                } else if (openALStatus == Status.Paused) {
                    throw new AssertionError("Instanced audio source on channel " + i + " cannot be paused.");
                }

                // If Playing, do nothing, let it finish.
                continue;
            }

            // --- Handle Bound Playback (Normal play/pause/stop) ---
            Status jmeStatus = src.getStatus();

            // Check if we need to sync JME status with OpenAL status.
            if (openALStatus != jmeStatus) {
                if (openALStatus == Status.Stopped && jmeStatus == Status.Playing) {

                    // Source stopped playing unexpectedly (finished or starved)
                    if (src.getAudioData() instanceof AudioStream) {
                        AudioStream stream = (AudioStream) src.getAudioData();

                        if (stream.isEOF() && !src.isLooping()) {
                            // Stream reached EOF and is not looping.
                            if (logger.isLoggable(Level.FINE)) {
                                logger.log(Level.FINE, "Stream source on channel {0} finished.", i);
                            }
                            reclaimChannel = true;
                        } else {
                            // Stream still has data or is looping, but stopped.
                            // This indicates buffer starvation. The decoder thread will handle restarting it.
                            if (logger.isLoggable(Level.FINE)) {
                                logger.log(Level.FINE, "Stream source on channel {0} likely starved.", i);
                            }
                            // Don't reclaim channel here, let decoder thread refill and restart.
                        }
                    } else {
                        // Buffer finished playing.
                        if (src.isLooping()) {
                            // This is unexpected for looping buffers unless the device was disconnected/reset.
                            logger.log(Level.WARNING, "Looping buffer source on channel {0} stopped unexpectedly.", i);
                        }  else {
                            // Non-looping buffer finished normally.
                            if (logger.isLoggable(Level.FINE)) {
                                logger.log(Level.FINE, "Buffer source on channel {0} finished.", i);
                            }
                        }

                        reclaimChannel = true;
                    }

                    if (reclaimChannel) {
                        if (logger.isLoggable(Level.FINE)) {
                            logger.log(Level.FINE, "Reclaiming channel {0} from finished source.", i);
                        }
                        src.setStatus(Status.Stopped);
                        src.setChannel(-1);
                        clearChannel(i); // Stop AL source, detach buffers/filters
                        freeChannel(i);  // Add channel back to the free pool
                    }
                } else {
                    // jME3 state does not match OpenAL state.
                    // This is only relevant for bound sources.
                    throw new AssertionError("Unexpected sound status. "
                            + "OpenAL: " + openALStatus + ", JME: " + jmeStatus);
        ...

</details>



<!-- START COPILOT CODING AGENT SUFFIX -->

- Fixes jMonkeyEngine/jmonkeyengine#2550

<!-- START COPILOT CODING AGENT TIPS -->
---

🔒 GitHub Advanced Security automatically protects Copilot coding agent pull requests. You can protect all pull requests by enabling Advanced Security for your repositories. [Learn more about Advanced Security.](https://gh.io/cca-advanced-security)


Fixes https://github.com/jMonkeyEngine/jmonkeyengine/issues/2550

Co-authored-by: riccardobl <4943530+riccardobl@users.noreply.github.com>
Copilot AI changed the title [WIP] Fix crash when pausing audio Fix AssertionError crash when audio source finishes while paused Mar 1, 2026
@riccardobl
Copy link
Member

@copilot detect if this patch can cause side effects in the codebase and write a detailed report

@riccardobl riccardobl marked this pull request as ready for review March 1, 2026 20:47
Copy link
Contributor Author

Copilot AI commented Mar 1, 2026

@copilot detect if this patch can cause side effects in the codebase and write a detailed report

Here is the detailed side-effects analysis for the one-line change (jmeStatus == Status.PlayingjmeStatus != Status.Stopped) in updateInRenderThread.


Side-Effects Analysis

What the change does

The condition that gates the "source has stopped, decide whether to reclaim the channel" block was widened from only matching jmeStatus == Playing to matching any non-Stopped JME status (i.e. Playing or Paused). The only new state it catches is jmeStatus == Paused + openALStatus == Stopped.

The existing channel-reclaim logic inside the block is unchanged.


Case 1 — Non-looping buffer source (most common, the primary bug scenario)

State: jmeStatus = Paused, openALStatus = Stopped

A short clip finishes playing in the same frame it is paused. OpenAL reports AL_STOPPED while JME still holds Paused.

  • Before the fix: throws AssertionError. ❌
  • After the fix: enters the buffer branch → reclaimChannel = truesrc.setStatus(Stopped), src.setChannel(-1), channel freed. ✅

No side effects. The source ends up in Stopped state, exactly what would happen if the natural completion path (Playing → Stopped) had been taken instead.


Case 2 — Looping buffer source

State: jmeStatus = Paused, openALStatus = Stopped

OpenAL should never stop a looping buffer on its own, so this combination requires an external disturbance (device reset, driver bug).

  • Before the fix: throws AssertionError. ❌
  • After the fix: enters the buffer branch → src.isLooping() == true → logs a WARNING, then reclaimChannel = true → channel freed. ✅

The warning (already present for the Playing path) is now also emitted for the Paused path, which is the appropriate reaction to unexpected hardware-level interruption.


Case 3 — Streaming source, EOF reached, not looping

State: jmeStatus = Paused, openALStatus = Stopped, stream.isEOF() == true

  • Before the fix: throws AssertionError. ❌
  • After the fix: reclaimChannel = true → channel freed correctly. ✅

Case 4 — Streaming source, buffer starvation while "paused"

State: jmeStatus = Paused, openALStatus = Stopped, stream has data remaining

This scenario is theoretically reachable if starvation occurs and a pause command lands before updateInDecoderThread can restart playback. In practice it is extremely unlikely because alSourcePause stops buffer consumption and therefore starvation cannot happen while the source is genuinely paused.

  • Before the fix: throws AssertionError. ❌
  • After the fix: the starvation branch (reclaimChannel stays false) is taken — channel is not reclaimed, decoder thread continues filling buffers. The source is left in jmeStatus = Paused / openALStatus = Stopped with a valid channel. The user can then call playSource (which calls alSourcePlay and transitions back to Playing) or stopSource (which cleans up normally). ⚠️ No crash, no data corruption, but the source sits in limbo until the application acts on it — identical in behavior to the already-existing starvation path for jmeStatus = Playing.

updateInDecoderThread — no regression

The decoder thread has its own restart guard:

if (buffersWereFilled && openALStatus == Status.Stopped && jmeStatus == Status.Playing) {
    al.alSourcePlay(sourceId);
}

This is unchanged. A paused source is still not auto-restarted by the decoder, which is correct.


playSource after reclaim — no regression

Whenever reclaimChannel = true, the fix calls src.setStatus(Status.Stopped) before freeing the channel. A subsequent playSource call sees Status.Stopped, enters the channel-allocation branch, and starts cleanly. The intermediate Paused state that triggered the reclaim is fully erased.


Thread-safety — no new concerns

The change sits entirely inside updateInRenderThread, which runs under threadLock. Both pauseSource and playSource also acquire threadLock, so the race condition is the same pre-existing cross-thread timing issue (OpenAL finishes playback vs. application pauses the source) that was already present — the fix merely handles the outcome gracefully instead of crashing.


Verdict

The change is safe with no regressions. The only non-trivial edge case (Case 4) is already present on the Playing path and is handled identically. All other cases are strictly improved: a crash is replaced with correct channel reclamation.

@riccardobl riccardobl merged commit 80015d1 into master Mar 3, 2026
18 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Crash when pausing audio

2 participants