Fix iOS broadcast pump mapping churn
The screen-audio pump opened and disposed a memory mapping plus its container lookup and path strings on every 5 ms tick - about 200 mapping pairs per second of steady allocation that churned the GC under long calls. The producer opens the ring with O_CREAT and never replaces it, so the pump now keeps one mapping across ticks and rebuilds it only when the ring file disappears or a drain fails.
This commit is contained in:
@@ -9,12 +9,15 @@ internal sealed class BroadcastAudioPump : IAsyncDisposable
|
|||||||
private const uint Magic = 0x56434252, Version = 1;
|
private const uint Magic = 0x56434252, Version = 1;
|
||||||
private const int Header = 64, Capacity = 96_000, Frame = 960;
|
private const int Header = 64, Capacity = 96_000, Frame = 960;
|
||||||
private readonly CancellationTokenSource stop = new();
|
private readonly CancellationTokenSource stop = new();
|
||||||
|
private readonly short[] scratch = new short[Frame * 2];
|
||||||
private Thread? worker;
|
private Thread? worker;
|
||||||
private VoiceCatClient? client;
|
private VoiceCatClient? client;
|
||||||
|
private MemoryMappedFile? map;
|
||||||
|
private MemoryMappedViewAccessor? view;
|
||||||
|
private string? path;
|
||||||
private uint streamId;
|
private uint streamId;
|
||||||
private bool active;
|
private bool active;
|
||||||
private int generation;
|
private int generation;
|
||||||
private readonly short[] scratch = new short[Frame * 2];
|
|
||||||
|
|
||||||
internal event Action? Changed;
|
internal event Action? Changed;
|
||||||
internal bool IsActive => active;
|
internal bool IsActive => active;
|
||||||
@@ -29,51 +32,92 @@ internal sealed class BroadcastAudioPump : IAsyncDisposable
|
|||||||
private void Run()
|
private void Run()
|
||||||
{
|
{
|
||||||
CancellationToken token = stop.Token;
|
CancellationToken token = stop.Token;
|
||||||
|
try
|
||||||
|
{
|
||||||
while (!token.IsCancellationRequested)
|
while (!token.IsCancellationRequested)
|
||||||
{
|
{
|
||||||
try { DrainAsync(token).GetAwaiter().GetResult(); }
|
try { DrainAsync(token).GetAwaiter().GetResult(); }
|
||||||
catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or InvalidDataException or OperationCanceledException) { }
|
catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or InvalidDataException or OperationCanceledException) { CloseMapping(); }
|
||||||
if (!token.IsCancellationRequested) Thread.Sleep(5);
|
if (!token.IsCancellationRequested) Thread.Sleep(5);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
finally { CloseMapping(); }
|
||||||
|
}
|
||||||
|
|
||||||
private async Task DrainAsync(CancellationToken token)
|
private async Task DrainAsync(CancellationToken token)
|
||||||
{
|
{
|
||||||
NSUrl? root = NSFileManager.DefaultManager.GetContainerUrl(IosConstants.AppGroup);
|
if (!OpenMapping()) return;
|
||||||
if (root?.Path is null) return;
|
MemoryMappedViewAccessor pages = view!;
|
||||||
string path = Path.Combine(root.Path, "voicecat", "broadcast_audio.ring"); if (!File.Exists(path)) return;
|
bool active = pages.ReadUInt32(16) != 0;
|
||||||
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 (!active) { StopStream(); SetActive(false); return; }
|
if (!active) { StopStream(); SetActive(false); return; }
|
||||||
VoiceCatClient owner = client ?? throw new IOException("Client disconnected.");
|
VoiceCatClient owner = client ?? throw new IOException("Client disconnected.");
|
||||||
if (streamId == 0)
|
if (streamId == 0)
|
||||||
{
|
{
|
||||||
int startGeneration = Volatile.Read(ref generation);
|
int startGeneration = Volatile.Read(ref generation);
|
||||||
StreamInfo stream = await owner.StartStreamAsync(StreamKind.StreamScreenAudio, "Screen audio", 2, token).ConfigureAwait(false);
|
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) { }
|
try { owner.StopStream(stream.StreamId); } catch (Exception exception) when (exception is IOException or InvalidOperationException) { }
|
||||||
return;
|
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;
|
if (write - read > Capacity) read = write - Capacity;
|
||||||
while (write - read >= Frame * 2)
|
while (write - read >= Frame * 2)
|
||||||
{
|
{
|
||||||
for (int sample = 0; sample < scratch.Length; sample++)
|
for (int sample = 0; sample < scratch.Length; sample++)
|
||||||
{
|
{
|
||||||
ulong index = (read + (ulong)sample) % Capacity;
|
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;
|
if (!owner.Audio.FeedPcm(streamId, scratch, 2)) break;
|
||||||
read += (ulong)scratch.Length;
|
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()
|
private void StopStream()
|
||||||
{
|
{
|
||||||
uint id = streamId; streamId = 0; if (id == 0 || client?.State != ClientConnectionState.Connected) return;
|
uint id = streamId; streamId = 0; if (id == 0 || client?.State != ClientConnectionState.Connected) return;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
|
||||||
namespace VoiceCat.Tests;
|
namespace VoiceCat.Tests;
|
||||||
|
|
||||||
@@ -117,6 +118,25 @@ public class PublishServerScriptTests
|
|||||||
Assert.DoesNotContain("if (OperatingSystem.IsIOSVersionAtLeast(27)) { model.ToggleScreenAudio();", settings);
|
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]
|
[Fact]
|
||||||
public async Task IosCaptureDoesNotDependOnCoalescedManagedTimers()
|
public async Task IosCaptureDoesNotDependOnCoalescedManagedTimers()
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user