diff --git a/clients/apple/VoiceCat.iOS/BroadcastAudioPump.cs b/clients/apple/VoiceCat.iOS/BroadcastAudioPump.cs index 496084a..5e1a135 100644 --- a/clients/apple/VoiceCat.iOS/BroadcastAudioPump.cs +++ b/clients/apple/VoiceCat.iOS/BroadcastAudioPump.cs @@ -9,12 +9,15 @@ internal sealed class BroadcastAudioPump : IAsyncDisposable private const uint Magic = 0x56434252, Version = 1; private const int Header = 64, Capacity = 96_000, Frame = 960; private readonly CancellationTokenSource stop = new(); + private readonly short[] scratch = new short[Frame * 2]; private Thread? worker; private VoiceCatClient? client; + private MemoryMappedFile? map; + private MemoryMappedViewAccessor? view; + private string? path; private uint streamId; private bool active; private int generation; - private readonly short[] scratch = new short[Frame * 2]; internal event Action? Changed; internal bool IsActive => active; @@ -29,51 +32,92 @@ internal sealed class BroadcastAudioPump : IAsyncDisposable private void Run() { CancellationToken token = stop.Token; - while (!token.IsCancellationRequested) + try { - try { DrainAsync(token).GetAwaiter().GetResult(); } - catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or InvalidDataException or OperationCanceledException) { } - if (!token.IsCancellationRequested) Thread.Sleep(5); + while (!token.IsCancellationRequested) + { + try { DrainAsync(token).GetAwaiter().GetResult(); } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or InvalidDataException or OperationCanceledException) { CloseMapping(); } + if (!token.IsCancellationRequested) Thread.Sleep(5); + } } + finally { CloseMapping(); } } private async Task DrainAsync(CancellationToken token) { - NSUrl? root = NSFileManager.DefaultManager.GetContainerUrl(IosConstants.AppGroup); - if (root?.Path is null) return; - string path = Path.Combine(root.Path, "voicecat", "broadcast_audio.ring"); if (!File.Exists(path)) return; - using MemoryMappedFile map = MemoryMappedFile.CreateFromFile(path, FileMode.Open, null, Header + Capacity * sizeof(short), MemoryMappedFileAccess.ReadWrite); - using MemoryMappedViewAccessor view = map.CreateViewAccessor(0, Header + Capacity * sizeof(short), MemoryMappedFileAccess.ReadWrite); - if (view.ReadUInt32(0) != Magic || view.ReadUInt32(4) != Version) throw new InvalidDataException("Unsupported broadcast ring."); - bool active = view.ReadUInt32(16) != 0; + if (!OpenMapping()) return; + MemoryMappedViewAccessor pages = view!; + bool active = pages.ReadUInt32(16) != 0; if (!active) { StopStream(); SetActive(false); return; } VoiceCatClient owner = client ?? throw new IOException("Client disconnected."); if (streamId == 0) { int startGeneration = Volatile.Read(ref generation); StreamInfo stream = await owner.StartStreamAsync(StreamKind.StreamScreenAudio, "Screen audio", 2, token).ConfigureAwait(false); - if (startGeneration != Volatile.Read(ref generation) || view.ReadUInt32(16) == 0 || !ReferenceEquals(client, owner)) + if (startGeneration != Volatile.Read(ref generation) || pages.ReadUInt32(16) == 0 || !ReferenceEquals(client, owner)) { try { owner.StopStream(stream.StreamId); } catch (Exception exception) when (exception is IOException or InvalidOperationException) { } return; } - streamId = stream.StreamId; view.Write(32, view.ReadUInt64(24)); SetActive(true); + streamId = stream.StreamId; pages.Write(32, pages.ReadUInt64(24)); SetActive(true); } - ulong write = view.ReadUInt64(24), read = view.ReadUInt64(32); + ulong write = pages.ReadUInt64(24), read = pages.ReadUInt64(32); if (write - read > Capacity) read = write - Capacity; while (write - read >= Frame * 2) { for (int sample = 0; sample < scratch.Length; sample++) { ulong index = (read + (ulong)sample) % Capacity; - scratch[sample] = view.ReadInt16(Header + checked((long)index * sizeof(short))); + scratch[sample] = pages.ReadInt16(Header + checked((long)index * sizeof(short))); } if (!owner.Audio.FeedPcm(streamId, scratch, 2)) break; read += (ulong)scratch.Length; - view.Write(32, read); + pages.Write(32, read); } } + // The producer opens the ring with O_CREAT and never replaces it, so one mapping covers the + // pump's whole life. Creating and disposing a mapping (plus its container lookup and path + // strings) every 5 ms tick allocated steadily at 200 Hz and churned the GC under long calls; + // the mapping is now built on the ring's transitions and reused until the file disappears or + // a drain fails. + private bool OpenMapping() + { + path ??= ResolvePath(); + if (path is null) return false; + bool present = File.Exists(path); + if (view is not null) + { + if (present) return true; + CloseMapping(); return false; + } + if (!present) return false; + MemoryMappedFile candidate = MemoryMappedFile.CreateFromFile(path, FileMode.Open, null, Header + Capacity * sizeof(short), MemoryMappedFileAccess.ReadWrite); + try + { + MemoryMappedViewAccessor pages = candidate.CreateViewAccessor(0, Header + Capacity * sizeof(short), MemoryMappedFileAccess.ReadWrite); + if (pages.ReadUInt32(0) != Magic || pages.ReadUInt32(4) != Version) + { + pages.Dispose(); throw new InvalidDataException("Unsupported broadcast ring."); + } + map = candidate; view = pages; return true; + } + catch { candidate.Dispose(); throw; } + } + + private static string? ResolvePath() + { + NSUrl? root = NSFileManager.DefaultManager.GetContainerUrl(IosConstants.AppGroup); + return root?.Path is null ? null : Path.Combine(root.Path, "voicecat", "broadcast_audio.ring"); + } + + private void CloseMapping() + { + view?.Dispose(); view = null; + map?.Dispose(); map = null; + } + private void StopStream() { uint id = streamId; streamId = 0; if (id == 0 || client?.State != ClientConnectionState.Connected) return; diff --git a/tests/VoiceCat.Tests/PublishServerScriptTests.cs b/tests/VoiceCat.Tests/PublishServerScriptTests.cs index be44233..e9a1452 100644 --- a/tests/VoiceCat.Tests/PublishServerScriptTests.cs +++ b/tests/VoiceCat.Tests/PublishServerScriptTests.cs @@ -1,4 +1,5 @@ using System.Diagnostics; +using System.Text.RegularExpressions; namespace VoiceCat.Tests; @@ -117,6 +118,25 @@ public class PublishServerScriptTests Assert.DoesNotContain("if (OperatingSystem.IsIOSVersionAtLeast(27)) { model.ToggleScreenAudio();", settings); } + [Fact] + public async Task IosBroadcastPumpKeepsOneRingMappingAcrossTicks() + { + string root = FindRoot(); + string pump = await File.ReadAllTextAsync(Path.Combine( + root, "clients", "apple", "VoiceCat.iOS", "BroadcastAudioPump.cs")); + + // A mapping pair plus its container lookup and path strings per 5 ms tick allocated + // steadily at 200 Hz and churned the GC under long calls. The pump opens one mapping on + // the ring's transitions and reuses it; the producer never replaces the ring file. + Assert.Single(Regex.Matches(pump, "MemoryMappedFile.CreateFromFile")); + Assert.Contains("private bool OpenMapping()", pump); + Assert.Contains("private void CloseMapping()", pump); + Assert.Contains("Thread.Sleep(5)", pump); + Assert.Contains("File.Exists(path)", pump); + Assert.DoesNotContain("using MemoryMappedFile", pump); + Assert.DoesNotContain("using MemoryMappedViewAccessor", pump); + } + [Fact] public async Task IosCaptureDoesNotDependOnCoalescedManagedTimers() {