Audio hangs after Windows App disconnect: Weston semaphore wait and PulseAudio blocked socket read
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.9445From /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: 04d436c7d9a0cd55fa64b6612c7fa678d6fcd077The 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
- Connect from Windows App on a Mac to the Windows host, with remote audio audible on the Mac.
- In Ubuntu/WSL, confirm
timeout 5 pactl infosucceeds. The server isunix:/mnt/wslg/PulseServer, default sinkRDPSink. - Run the standalone Python player included below. It generates a quiet half-second tone every two seconds through a continuous raw PCM
pacatplayback stream (48 kHz, stereo, signed 16-bit little-endian). It independently probespactl infowith a two-second timeout. - Confirm playback works, then disconnect Windows App without stopping the player.
- Reconnect. Audio does not return; subsequent
pactl inforequests 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 agoAccepted 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: TimeoutLive 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-sinkthread 78296: syscallread(fd=13, count=4), blocked inunix_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 ineventfd_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=98Representative 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_readSource 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-availabledid not prevent a disconnect failure. - A separate standalone reproduction failed with
module-rdp-sourceunloaded (default sourceRDPSink.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.
#!/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())Source: microsoft/wslg