Airplay_emulation: stop() is unconditional, so any unrelated volumioStop() kills a live AirPlay stream

Volumio Information

Volumio Version: 4.119 (build Tue Mar 24 17:20:52 UTC 2026)
Hardware: Raspberry Pi 4 Model B Rev 1.1
DAC: generic USB DAC — shows up as 8086:0808 Intel Corp. USB PnP Sound Device in lsusb, that’s the only descriptor there is, even with lsusb -v. No idea what the actual brand is under that, sorry — cheap unbranded dongle, no markings I can go by either.

Debug Log

http://logs.volumio.org/volumio/dnrN9Gn.html

Steps to Reproduce

  1. Stream AirPlay from a source (in my case OwnTone/forked-daapd) to two zones at once — a real AirPlay speaker plus this Volumio box.
  2. Have any other plugin on the Volumio box call coreCommand.volumioStop() for its own unrelated reasons while that AirPlay stream is playing. I hit this because a beta Spotify Connect plugin (Soloist Connect) was calling it spuriously every 2-5 minutes on its own, nothing to do with AirPlay — but the trigger doesn’t matter, any volumioStop() call does it.
  3. Within 1-3 seconds the AirPlay stream to this Volumio zone dies — shairport-sync gets killed and restarted, confirmed against the source’s own logs (“closed RTSP connection” the moment the unrelated volumioStop() fires).
  4. Proved it with a clean A/B: disable the other plugin → zero drops over a long multiroom test. Re-enable it → reproduced twice within minutes.

Additional Information

Dug into app/plugins/music_service/airplay_emulation/index.js to figure out why this kept happening, found two things:

  1. unsetVol() has a stray line that fires regardless of the branch it’s in:
AirPlayInterface.prototype.unsetVol = function () {
  var self = this;

  if (!onDemand) {
    var state = self.commandRouter.stateMachine.getState();
    if (state && state.service && state.service !== 'airplay_emulation' && self.commandRouter.stateMachine.isVolatile) {
      return self.sendShairportStopCommand();
    } else {
      setTimeout(() => {
        return libQ.resolve();
      }, 1500);
    }
    return self.sendShairportStopCommand();   // <- this. Runs no matter which branch above ran.
  } else {
    ...

That last line isn’t inside either branch, it just falls through — so the else case, which is clearly meant to be a no-op (why else the pointless 1500ms setTimeout?), ends up killing shairport-sync anyway. I patched this locally by just deleting that line, couldn’t find any reason it needed to be there. It’s a one-line fix, happy to open a PR if that’s useful.

2. The actual dominant cause — stop() doesn’t check if AirPlay is even the active service:

AirPlayInterface.prototype.stop = function () {
  var self = this;
  var defer = libQ.defer();
  self.logger.info('Airplay Stop');
  if (!onDemand) {
    return self.sendShairportStopCommand();
  } else {
    defer.resolve('');
  }
  return defer.promise;
};

Not a typo, it does what it’s written to do. The issue is it gets called from CoreStateMachine::stop()/serviceStop() any time any plugin calls volumioStop() — not just when AirPlay is what should actually be stopped. So any plugin on the box, for any reason of its own, can unconditionally kill someone else’s live AirPlay stream on a totally different zone. Given how many things on a Volumio box can call volumioStop(), this seems worth a guard — e.g. only actually run sendShairportStopCommand() if AirPlay was the active/last playback service.

Side note, not asking for a fix here: SHAIRPORT_SYNC_ON_DEMAND=true (env var in volumio.service, no UI toggle) makes shairport-sync only start on a genuine AirPlay connection and exit after — helps some, but not if the spurious stop() lands while a connection is already active. Mentioning it in case it’s worth exposing as an actual setting.