diff --git a/docs/changelog/v0.2.10.md b/docs/changelog/v0.2.10.md index 20b9f48..3320fa6 100644 --- a/docs/changelog/v0.2.10.md +++ b/docs/changelog/v0.2.10.md @@ -13,6 +13,7 @@ Follow-up patch release for v0.2.9 addressing auto-updater regressions, adding a - Fix pre-update backup failing when a Serilog-held log file is locked — `UpdateBackupService` now enumerates files manually, skips the `logs/` directory and `.log` files, and logs-and-continues on `IOException`/`UnauthorizedAccessException` instead of aborting the whole backup - Simplify update progress dispatch — remove redundant `Application.Invoke` wrappers around progress updates that are already called from the UI thread (introduced while fixing the freeze above) - Fix cursor position being reset to the start of the line when auto-completing commands in the CLI app — insertion point is now moved to the end of the completed text +- Fix notification sounds crashing or being silently dropped when several arrive in quick succession — playback is now serialized through a semaphore that's held for the duration of each sound (using `PlaybackFinished` with a 10s safety timeout) and always released in `finally`, so back-to-back notifications queue up and play in order instead of racing the underlying audio player (fixes #20) ## Refactoring diff --git a/src/EchoHub.Client/Services/NotificationSoundService.cs b/src/EchoHub.Client/Services/NotificationSoundService.cs index 2118d40..e72e0b2 100644 --- a/src/EchoHub.Client/Services/NotificationSoundService.cs +++ b/src/EchoHub.Client/Services/NotificationSoundService.cs @@ -6,7 +6,11 @@ namespace EchoHub.Client.Services; public class NotificationSoundService { + // Safety net: if PlaybackFinished never fires we don't want to block future notifications forever. + private static readonly TimeSpan PlaybackTimeout = TimeSpan.FromSeconds(10); + private readonly Player _player = new(); + private readonly SemaphoreSlim _lock = new(1, 1); private readonly NotificationConfig _config; private string? _resolvedSoundPath; @@ -41,18 +45,31 @@ public class NotificationSoundService private async Task PlayInternal() { + await _lock.WaitAsync(); + + // _player.Play returns as soon as playback starts, so we wait on PlaybackFinished + // to hold the lock for the duration of the sound. A one-shot handler + timeout + // keeps the finally release robust: never-fires → timeout; fires twice → ignored + // (TrySetResult); handler throws → caller's catch still runs finally. + var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + void OnFinished(object? s, EventArgs e) => completion.TrySetResult(); + _player.PlaybackFinished += OnFinished; + try { - if (_player.Playing) - await _player.Stop(); - await _player.SetVolume(_config.Volume); await _player.Play(_resolvedSoundPath!); + await Task.WhenAny(completion.Task, Task.Delay(PlaybackTimeout)); } catch (Exception ex) { Log.Warning(ex, "Failed to play notification sound"); } + finally + { + _player.PlaybackFinished -= OnFinished; + _lock.Release(); + } } private void ResolveSoundPath()