Skip to content

Audio hangs after Windows App disconnect: Weston semaphore wait and PulseAudio blocked socket read #1508

Open

Description

Summary

Disconnecting Windows App on a Mac from a Windows host during WSLg audio playback leaves the WSLg PulseAudio service unresponsive after reconnect. Reproduced with a standalone pacat player, provided here.

Live kernel-stack and socket inspection shows PulseAudio's rdp-sink thread blocked reading four bytes from Weston, and a Weston thread blocked reading an empty eventfd semaphore. The matching source strongly suggests a stalled playback-acknowledgment/latency-response chain; userspace backtraces have not yet confirmed the exact call chains.

Windows build number

10.0.26200.9445

Distribution version

Ubuntu 26.04 LTS, x86_64.

WSL versions

WSL version: 2.7.14.0
Kernel version: 6.18.33.2-2
WSLg version: 1.0.73.2
MSRDC version: 1.2.7214
Direct3D version: 1.611.1-81528511
DXCore version: 10.0.26100.1-240331-1435.ge-release
Windows version: 10.0.26200.9445

From /mnt/wslg/versions.txt:

WSLg (x86_64): 1.0.73.2
Built at: Mon May 18 22:31:53 UTC 2026
Azure Linux: 3.0.20260510
pulseaudio: c33051f2296af748ac5cd13e51abfac4d129e4c7
FreeRDP: c4030980b29322a9cb2190711a5fadeeeb8b6a33
weston: 04d436c7d9a0cd55fa64b6612c7fa678d6fcd077

The outer remote client is Windows App on macOS. Its exact version and the macOS version were not collected. This is a report of the versions above, not a claim of reproduction on the newest available WSL build.

Steps to reproduce

  1. Connect from Windows App on a Mac to the Windows host, with remote audio audible on the Mac.
  2. In Ubuntu/WSL, confirm timeout 5 pactl info succeeds. The server is unix:/mnt/wslg/PulseServer, default sink RDPSink.
  3. Run the standalone Python player included below. It generates a quiet half-second tone every two seconds through a continuous raw PCM pacat playback stream (48 kHz, stereo, signed 16-bit little-endian). It independently probes pactl info with a two-second timeout.
  4. Confirm playback works, then disconnect Windows App without stopping the player.
  5. Reconnect. Audio does not return; subsequent pactl info requests time out. This was observed repeatedly.

The player uses PulseAudio directly, not JUCE or the ALSA PulseAudio adapter. It neither opens a microphone nor automatically restarts playback/services.

Expected behavior

Audio should recover after reconnect, or the stream should fail cleanly while PulseAudio remains responsive to other clients.

Actual behavior and timeline

Latest captured run on 2026-09-13, UTC:

17:03:15.118  pactl info succeeds
17:03:20.126  accepted bytes=931840; last write 0.601 seconds ago
17:03:22.132  pactl info times out after 2.004 seconds
17:03:25.139  accepted bytes=931840; last write 5.614 seconds ago
17:03:35.167  accepted bytes=931840; last write 15.642 seconds ago
17:03:45.192  accepted bytes=931840; last write 25.667 seconds ago

Accepted bytes measure data consumed by pacat, not proof of audible output. The user observed silence after reconnect. Last write progress was approximately 17:03:19.5 UTC. The exact outer disconnect timestamp was not independently recorded.

In an earlier captured reproduction, pacat --verbose repeatedly reported:

pa_stream_update_timing_info() failed: Internal error
Failed to get latency: Timeout

Live blocked-thread and socket evidence

Collected while the latest failure persisted, without restarting the services:

  • PulseAudio PID 78285 main thread: futex_do_wait.
  • PulseAudio rdp-sink thread 78296: syscall read(fd=13, count=4), blocked in unix_stream_read_generic.
  • PulseAudio fd 13 connects to Weston PID 78290 fd 98 at /mnt/wslg/PulseAudioRDPSink.
  • Weston thread 78326: syscall read(fd=83, count=8), blocked in eventfd_read.
  • Weston fd 83: eventfd-count: 0, eventfd-semaphore: 1.

Socket queues from ss -xnp:

State Recv-Q Send-Q Local                               Peer   Process
ESTAB 0      15104  * 268749                            270826 pulseaudio pid=78285 fd=13
ESTAB 10792  0      /mnt/wslg/PulseAudioRDPSink 270826    268749 weston pid=78290 fd=98

Representative kernel stacks:

PulseAudio main:
  futex_do_wait -> __futex_wait -> futex_wait -> do_futex
PulseAudio rdp-sink:
  unix_stream_read_generic -> unix_stream_recvmsg -> sock_recvmsg
  -> sock_read_iter -> vfs_read -> ksys_read
Weston blocked thread:
  do_wait_intr_irq -> eventfd_read -> vfs_read -> ksys_read

Source correlation (hypothesis, not a confirmed userspace backtrace)

At the installed PulseAudio revision, get_latency() sends RDP_AUDIO_CMD_GET_LATENCY then performs a blocking four-byte read without a timeout. The version handshake also reads four bytes; the previously established playing stream makes the latency path the stronger interpretation.

At the installed Weston revision, rdp_audio_handle_transfer() performs a blocking eight-byte read on audioSem. rdp_audio_client_confirm_block() replenishes this semaphore upon the playback acknowledgment. Transfers and latency requests are handled serially on the audio socket thread.

This suggests the following chain: playback acknowledgments cease, Weston exhausts its audio semaphore and stops processing subsequent commands, PulseAudio waits indefinitely for a latency reply, and server-wide requests stop completing. The main thread's precise userspace wait chain and the original reason acknowledgments stop remain unconfirmed.

Potential hardening: bounded/interruptible audio-credit waits and correct disconnect/channel-reset handling in Weston; bounded reply waits plus transport failure handling in the PulseAudio module. A timeout must discard or resynchronize the old connection, so delayed replies cannot corrupt the command protocol.

WSL logs

The player's default-level WSLg log snapshots do not show an explicit audio error at the stall. Weston continues logging client display/layout updates after reconnect (13:03:39.915 local EDT = 17:03:39.915 UTC). Its audio debug scope was not enabled, so acknowledgment traffic is not captured.

Earlier recovery attempts produced version-exchange errors and an audio-input teardown assertion; those occurred during earlier restarts and are not presented as the initiating error for this reproduction. No crash dump or userspace backtrace was collected for the current hang.

Workarounds tested

  • PulseAudio is already launched with --exit-idle-time=-1.
  • Disabling module-switch-on-port-available did not prevent a disconnect failure.
  • A separate standalone reproduction failed with module-rdp-source unloaded (default source RDPSink.monitor). Microphone disabling is not a reliable fix.
  • The full loaded-module list was not captured for every trial; no claim that every combination has been independently verified.
  • Restarting only PulseAudio did not recover audio in observed wedged states.
  • Restarting PulseAudio and Weston recovered responsiveness, but closes Linux GUI apps:
wsl.exe --system --cd / --exec sh -c 'pkill -KILL -x pulseaudio; pkill -TERM -x weston'

Related report

#1468 describes unrecoverable audio after a pause with another application. The trigger differs; I have not established that it has the same root cause.

Standalone logging reproducer

Requires Python 3 and pacat/pactl (pulseaudio-utils on Ubuntu). Save as player.py, run python3 player.py, and leave it running across the disconnect. Ctrl-C stops it. Log output is stored under ~/wsl-audio-logs/. This is the player used in the reported tests.

player.py
#!/usr/bin/env python3
"""A bounded, non-recovering PulseAudio playback probe for WSLg disconnects."""
import argparse
import array
import datetime as dt
import json
import math
import os
from pathlib import Path
import select
import shutil
import signal
import subprocess
import sys
import threading
import time


def main():
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument('--duration', type=float, default=0,
                        help='Seconds to run; 0 runs until Ctrl-C (default).')
    parser.add_argument('--log-dir', type=Path, help='New directory for this run.')
    parser.add_argument('--device', help='Optional PulseAudio sink name.')
    parser.add_argument('--mark', type=Path, help='Add a timestamped marker to an existing run directory, then exit.')
    parser.add_argument('--message', default='User marker')
    args = parser.parse_args()
    if args.mark:
        with (args.mark / 'markers.jsonl').open('a') as f:
            f.write(json.dumps({'utc': dt.datetime.now(dt.timezone.utc).isoformat(),
                                'message': args.message}) + '\n')
        return
    if args.duration < 0:
        parser.error('--duration must be nonnegative')
    for tool in ('pacat', 'pactl'):
        if not shutil.which(tool):
            parser.error(f'{tool} is required (Ubuntu package: pulseaudio-utils)')
    stamp = dt.datetime.now(dt.timezone.utc).strftime('%Y%m%dT%H%M%S.%fZ')
    folder = args.log_dir or Path.home() / 'wsl-audio-logs' / stamp
    folder.mkdir(parents=True, exist_ok=False)
    events = (folder / 'events.jsonl').open('a', buffering=1)
    lock = threading.Lock()
    started = time.monotonic()
    stop = threading.Event()

    def log(event, **fields):
        row = {'utc': dt.datetime.now(dt.timezone.utc).isoformat(),
               'elapsed_s': round(time.monotonic() - started, 3), 'event': event, **fields}
        with lock:
            events.write(json.dumps(row) + '\n')
        if event in ('start', 'health', 'heartbeat', 'player_exit', 'stop', 'error'):
            print(f"{row['utc']} {event}: {fields}", flush=True)

    def snapshot(reason):
        # Read only tails, including rotated newfile logs, so dumps stay bounded.
        root = Path('/mnt/wslg')
        try:
            pulse = sorted(root.glob('pulseaudio.log*'), key=lambda p: p.stat().st_mtime)[-3:]
            for path in pulse + [root / 'weston.log', root / 'stderr.log']:
                try:
                    with path.open('rb') as f:
                        f.seek(0, 2)
                        f.seek(max(0, f.tell() - 12000))
                        content = f.read(12000).decode(errors='replace')
                    log('wslg_log_tail', reason=reason, path=str(path), text=content)
                except OSError as e:
                    log('snapshot_error', path=str(path), error=str(e))
        except OSError as e:
            log('snapshot_error', error=str(e))

    def probe(*cmd):
        before = time.monotonic()
        try:
            result = subprocess.run(cmd, capture_output=True, text=True, timeout=2)
            return {'status': 'ok' if result.returncode == 0 else 'error',
                    'code': result.returncode, 'seconds': round(time.monotonic()-before, 3),
                    'stdout': result.stdout[-24000:], 'stderr': result.stderr[-4000:]}
        except subprocess.TimeoutExpired:
            # subprocess.run kills and reaps the probe on timeout.
            return {'status': 'timeout', 'seconds': round(time.monotonic()-before, 3)}
        except OSError as e:
            return {'status': 'error', 'error': str(e)}

    def health_loop():
        previous = None
        count = 0
        while not stop.is_set():
            result = probe('pactl', 'info')
            log('health', **result)
            changed = result['status'] != previous
            if changed:
                snapshot('health_' + result['status'])
            if result['status'] == 'ok' and (changed or count % 6 == 0):
                for kind in ('sinks', 'sink-inputs'):
                    if stop.is_set():
                        break
                    log('pulse_details', kind=kind, **probe('pactl', 'list', kind))
            previous = result['status']
            count += 1
            stop.wait(5)

    def signal_stop(signum, frame):
        stop.set()

    signal.signal(signal.SIGINT, signal_stop)
    signal.signal(signal.SIGTERM, signal_stop)
    print(f'Logs: {folder}\nQuiet 440 Hz tone: half a second every two seconds. Ctrl-C stops.', flush=True)
    log('start', pulse_server=os.environ.get('PULSE_SERVER'), device=args.device,
        uname=list(os.uname()), note='Writes measure bytes accepted by pacat, not audible output. No automatic recovery.')
    # Smooth the edges of a quiet (-30 dBFS peak) tone. Maintain a continuous
    # stream, including silence, to test device lifetime across disconnection.
    rate = 48000
    pcm = array.array('h')
    for i in range(rate * 2):
        t = i / rate
        envelope = max(0, min(1, t / .01, (.5 - t) / .01))
        sample = int(1000 * envelope * math.sin(2 * math.pi * 440 * t))
        pcm.extend((sample, sample))
    if sys.byteorder != 'little':
        pcm.byteswap()
    wave = pcm.tobytes()
    command = ['pacat', '--playback', '--raw', '--verbose', '--format=s16le',
               '--rate=48000', '--channels=2', '--latency-msec=100',
               '--client-name=WSLg logging audio player', '--stream-name=Disconnect probe']
    if args.device:
        command += ['--device', args.device]
    # pacat's verbose timing output uses carriage returns; retain it verbatim.
    stderr = (folder / 'pacat.log').open('wb')
    player = subprocess.Popen(command, stdin=subprocess.PIPE, stdout=subprocess.DEVNULL, stderr=stderr)
    os.set_blocking(player.stdin.fileno(), False)
    thread = threading.Thread(target=health_loop, daemon=True)
    thread.start()
    total = 0
    last_write = time.monotonic()
    next_heartbeat = last_write
    exited = False
    try:
        while not stop.is_set():
            now = time.monotonic()
            if args.duration and now - started >= args.duration:
                break
            if now >= next_heartbeat:
                log('heartbeat', bytes_accepted=total, audio_seconds_accepted=round(total/(rate*4), 3),
                    seconds_since_write=round(now-last_write, 3), player_returncode=player.poll())
                next_heartbeat = now + 5
            if player.poll() is not None or exited:
                if not exited:
                    log('player_exit', code=player.returncode)
                    snapshot('player_exit')
                    exited = True
                stop.wait(.2)
                continue  # Keep observing the service; never restart the player.
            _, writable, _ = select.select([], [player.stdin], [], .2)
            if writable:
                offset = total % len(wave)
                try:
                    n = os.write(player.stdin.fileno(), wave[offset:offset + 4096])
                    total += n
                    last_write = time.monotonic()
                except BlockingIOError:
                    pass
                except BrokenPipeError:
                    log('player_exit', code=player.poll(), reason='broken pipe')
                    snapshot('broken_pipe')
                    exited = True
    finally:
        stop.set()
        if player.poll() is None:
            player.terminate()
            try:
                player.wait(timeout=2)
            except subprocess.TimeoutExpired:
                player.kill()
                player.wait()
        player.stdin.close()
        stderr.close()
        thread.join(timeout=8)
        snapshot('stop')
        log('stop', player_returncode=player.returncode, bytes_accepted=total)
        events.close()
    return 0


if __name__ == '__main__':
    raise SystemExit(main())

Activity

  1. SebastienGllmt commented on Sep 15, 2026

    @SebastienGllmt

    I have additional source-matched evidence for the same PulseAudio-to-Weston terminal state, this time without disconnecting the outer remote-desktop client. This supports an open-source WSLg liveness problem independent of the exact reason the private client delays played confirmations.

    Confirmation timing at failure

    The test image used:

    • Windows: 10.0.26200.9445
    • WSL: 2.9.8.0
    • WSLg: 1.0.79
    • Weston: a7e35fdab455dce5774b43b8aff682f748307e31, with bounded instrumentation
    • FreeRDP: c4030980b29322a9cb2190711a5fadeeeb8b6a33
    • PulseAudio: PR microsoft/pulseaudio-mirror#5, commit 31bcfe8c1c833d2c389dedf284a428ffff532373

    During a progressive failure:

    • All 256 cBlockNo slots became outstanding.
    • Median send-to-first/receipt confirmation latency remained 0.522 ms.
    • Median first-to-second/played confirmation latency reached 1278.670 ms.
    • Submission continued near the expected sample rate before saturation.
    • The trace had no sequence gap, resume burst, missing-second-confirm diagnostic, or live block-number reuse before saturation.
    • The WSLg PulseServer subsequently accumulated backlog and became unresponsive.

    This is consistent with the two-stage confirmation behavior documented by FreeRDP PR #7910: the first confirmation represents receipt/network progress, while the second represents rendered/played progress.

    microsoft/pulseaudio-mirror#5, commit 31bcfe8c1c833d2c389dedf284a428ffff532373 fixes a separate stale scheduling timestamp after SUSPENDED -> RUNNING. A controlled test verified that fix, but the confirmation-delay recurrence above occurred without the stale-resume burst.

    Windows-side boundary

    A bounded ETW capture covering the onset showed:

    • audiodg maintained fixed 10 ms padding and regular completions.
    • No glitches, dropped buffers, or missed audio-engine deadlines were recorded.
    • The active private msrdc renderer’s WASAPI-side buffer remained approximately 36.6–46.6 ms while Weston’s played-confirm latency grew toward 1.28 s.

    This localizes the additional delay before the observed WASAPI/audiodg boundary. It does not prove that a specific private msrdc queue or policy is the root cause.

    Open-source liveness problem

    At the source revisions above, the PA-to-Weston bridge has the following dependency chain:

    1. Weston initializes a 256-credit eventfd, matching the complete 8-bit cBlockNo namespace.
    2. Each submitted RDP audio block consumes a permit.
    3. A permit is returned only after the second/played confirmation.
    4. Weston waits synchronously for a permit while processing a TRANSFER.
    5. TRANSFER, GET_LATENCY, and other commands are processed serially on the same Unix-socket thread.
    6. PulseAudio performs synchronous socket operations, including a blocking latency-reply read.

    Consequently, delayed played confirmations can consume the entire block-number namespace, block Weston’s command reader, propagate backpressure into the producer, and leave PulseAudio waiting indefinitely for a
    reply Weston cannot process.

    This is a WSLg-owned liveness defect even if delayed played confirmations are legitimate protocol input and even though their private-client trigger remains unknown.

    Experiments with bounded flow control

    I tested two approaches.

    Smaller Weston semaphore only

    Limiting the existing synchronous window to 64 five-millisecond blocks:

    • Bounded the outstanding queue near 320 ms.
    • Prevented use of all 256 block numbers.
    • Caused regular PipeWire underflows and rhythmic choppiness.

    The smaller semaphore propagated synchronous backpressure into the producer, so merely changing the existing constant is not sufficient.

    Paired asynchronous protocol prototype

    I then prototyped a negotiated PA-to-Weston protocol revision with:

    • Explicit bounded credits and v1 fallback.
    • Nonblocking, partial-I/O-safe producer and server state machines.
    • Capacity returned only after genuine second/played progress.
    • Immediate refund when SendSamples emits no RDP block.
    • No reuse of a block number while that slot remains outstanding.
    • Bounded reconnect and negotiation handling.

    An initial live capture with audio playing normally showed:

    • Everything sounded audio at normal pitch.
    • Zero RDP/Pulse socket backlog.
    • 53 of 64 slots outstanding.
    • Played-confirm latency of 255.5 ms median and 262.0 ms p99.
    • First-confirm latency of 0.372 ms median.
    • No protocol diagnostics or sequence gaps.

    This is initial interoperability and invariant evidence (not clear if it's a durable resolution)

  2. MerverliPy commented on Oct 8, 2026

    @MerverliPy

    I reproduced a related WSLg audio-credit stall without disconnecting or
    restarting the Windows RDP client
    . This is a transient output stall, unlike
    the persistent post-disconnect hang reported above, so I do not claim the same
    initiating cause.

    Environment: WSL 3.0.1.0, kernel 6.18.40.1, WSLg 1.0.79, MSRDC 1.2.7214,
    Windows 10.0.19045.7725. WSLg's installed Weston revision is
    a7e35fdab455dce5774b43b8aff682f748307e31; FreeRDP is
    c4030980b29322a9cb2190711a5fadeeeb8b6a33.

    A silent standalone libpulse client (no Godot, microphone, capture, or manual
    server queries) became continuously unwritable for 1.881 s while its sampling
    loop's maximum gap was 8.14 ms. Temporary counter-only uprobes on the matching
    installed WSLg binaries recorded at one sampled second:

    Branch Count
    Wave2 submissions 10,681
    First-confirmation branch 10,681
    Played-confirmation/credit-return branch 10,425

    The difference is exactly 256, matching the initial credit count in the
    matching Weston source. During the stall, the candidate credit eventfd count
    was zero and the verified Pulse-to-Weston socket pair had queued data; the sink
    waited for send space. At the end, first-confirmation and credit-return counts
    both reached 10,820 and the eventfd returned to 256 without a reset. These
    probes count branch entries before calls, so they support aggregate credit
    accounting rather than proving the outcome of each individual write. No audio
    payload, function arguments, process memory, or client traffic was captured.

    This local evidence supports delayed played confirmations that later catch
    up
    , while first confirmations continue. It does not identify why the Windows
    client delays them. The installed Windows RDP sound manifests and existing
    MSRDC auto trace expose no decoded per-wave confirmation event here. Is there
    a supported client-side event source or diagnostic that records Wave Confirm
    creation/sending, with a documented filter, so it can be correlated with the
    Weston branches? A broad, undecoded ETL would not resolve that question.

    The game symptom is intermittent: a current 95/95 full rendered Godot journey
    with default PulseAudio exited cleanly, while a 90-second sound-only run from
    the same game source retained four WAV resources at exit after mixer stalls.
    I have not established that each game exit warning is caused by this credit
    delay, and I have made no WSLg, Windows, or game audio configuration changes.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions