From 661930f671c4cb610533f3ca80ca30bd8505393f Mon Sep 17 00:00:00 2001 From: Agash Date: Wed, 23 Sep 2026 11:04:50 +0200 Subject: [PATCH 1/4] style: format with csharpier --- .config/dotnet-tools.json | 13 + .editorconfig | 8 + .github/workflows/build.yml | 5 + Directory.Packages.props | 12 +- PipeWire.NET.slnx | 38 +- .../GraphCommands.cs | 124 ++- .../PipeWire.NET.SampleConsole.csproj | 3 +- samples/PipeWire.NET.SampleConsole/Program.cs | 39 +- .../ServeCommands.cs | 88 +- samples/PipeWire.NET.SampleConsole/Session.cs | 27 +- .../StreamCommands.cs | 46 +- src/PipeWire.NET.Media/AudioFrame.cs | 45 +- .../AudioSampleFormatExtensions.cs | 25 +- src/PipeWire.NET.Media/BorrowedVideoFrame.cs | 3 +- src/PipeWire.NET.Media/Descriptors.cs | 29 +- src/PipeWire.NET.Media/DeviceIdNegotiation.cs | 150 ++- src/PipeWire.NET.Media/DrmDevice.cs | 21 +- src/PipeWire.NET.Media/DrmFormat.cs | 99 +- src/PipeWire.NET.Media/DrmSyncobj.cs | 39 +- src/PipeWire.NET.Media/OwnedAudioFrame.cs | 3 +- src/PipeWire.NET.Media/OwnedVideoFrame.cs | 3 +- src/PipeWire.NET.Media/PeerCapabilities.cs | 8 +- .../PipeWireAudioCapture.cs | 150 ++- src/PipeWire.NET.Media/PipeWireAudioOutput.cs | 137 ++- src/PipeWire.NET.Media/PipeWireBufferType.cs | 3 + .../PipeWireRateController.cs | 8 +- .../PipeWireStreamControl.cs | 3 +- src/PipeWire.NET.Media/PipeWireStreamCore.cs | 639 ++++++++---- .../PipeWireVideoCapture.cs | 413 ++++++-- src/PipeWire.NET.Media/PipeWireVideoOutput.cs | 621 ++++++++--- src/PipeWire.NET.Media/PulledVideoFrame.cs | 6 +- src/PipeWire.NET.Media/SpaFormatPod.cs | 558 ++++++---- src/PipeWire.NET.Media/StreamCategory.cs | 1 + src/PipeWire.NET.Media/StreamMediaType.cs | 1 + src/PipeWire.NET.Media/StreamProperties.cs | 11 +- src/PipeWire.NET.Media/StreamSignal.cs | 9 +- src/PipeWire.NET.Media/SyncRelease.cs | 7 +- src/PipeWire.NET.Media/SyncTimeline.cs | 3 +- src/PipeWire.NET.Media/VideoColorInfo.cs | 3 +- src/PipeWire.NET.Media/VideoColorMatrix.cs | 4 + src/PipeWire.NET.Media/VideoColorPrimaries.cs | 2 + src/PipeWire.NET.Media/VideoColorRange.cs | 2 + src/PipeWire.NET.Media/VideoCursor.cs | 13 +- src/PipeWire.NET.Media/VideoFrame.cs | 56 +- src/PipeWire.NET.Media/VideoPlane.cs | 1 - src/PipeWire.NET.Media/VideoSyncTimeline.cs | 7 +- .../VideoTransferFunction.cs | 4 + src/PipeWire.NET/AssemblyInitializer.cs | 12 +- .../CompatibilitySuppressions.xml | 7 +- src/PipeWire.NET/Core/PipeWireContext.cs | 256 +++-- src/PipeWire.NET/Core/PipeWireMediaClass.cs | 38 +- src/PipeWire.NET/Graph/MetadataReconciler.cs | 33 +- src/PipeWire.NET/Graph/PipeWireClient.cs | 3 +- src/PipeWire.NET/Graph/PipeWireClientProxy.cs | 181 +++- src/PipeWire.NET/Graph/PipeWireCore.cs | 3 +- src/PipeWire.NET/Graph/PipeWireDevice.cs | 3 +- .../Graph/PipeWireDeviceProvider.cs | 195 ++-- src/PipeWire.NET/Graph/PipeWireDeviceProxy.cs | 201 ++-- .../Graph/PipeWireExportedFormat.cs | 70 +- src/PipeWire.NET/Graph/PipeWireFactory.cs | 3 +- src/PipeWire.NET/Graph/PipeWireFilter.cs | 361 +++++-- src/PipeWire.NET/Graph/PipeWireFilterPort.cs | 49 +- .../Graph/PipeWireGlobalParser.cs | 216 +++- src/PipeWire.NET/Graph/PipeWireGraphClock.cs | 3 +- .../Graph/PipeWireGraphExtensions.cs | 32 +- .../Graph/PipeWireGraphSnapshot.cs | 116 ++- src/PipeWire.NET/Graph/PipeWireLatency.cs | 32 +- src/PipeWire.NET/Graph/PipeWireLink.cs | 3 +- src/PipeWire.NET/Graph/PipeWireLinkBuilder.cs | 22 +- src/PipeWire.NET/Graph/PipeWireLinkProxy.cs | 94 +- src/PipeWire.NET/Graph/PipeWireMetadata.cs | 3 +- .../Graph/PipeWireMetadataEntry.cs | 10 +- .../Graph/PipeWireMetadataProvider.cs | 144 ++- .../Graph/PipeWireMetadataProxy.cs | 184 +++- src/PipeWire.NET/Graph/PipeWireModule.cs | 3 +- src/PipeWire.NET/Graph/PipeWireModuleProxy.cs | 58 +- src/PipeWire.NET/Graph/PipeWireNode.cs | 3 +- src/PipeWire.NET/Graph/PipeWireNodeBuilder.cs | 29 +- .../Graph/PipeWireNodeProvider.cs | 426 ++++++-- src/PipeWire.NET/Graph/PipeWireNodeProxy.cs | 259 +++-- .../Graph/PipeWireObjectOptions.cs | 21 +- .../Graph/PipeWireObjectPermission.cs | 5 +- .../Graph/PipeWireParameterInfo.cs | 6 +- .../Graph/PipeWireParameterObject.cs | 200 ++-- src/PipeWire.NET/Graph/PipeWirePort.cs | 3 +- src/PipeWire.NET/Graph/PipeWirePortConfig.cs | 15 +- src/PipeWire.NET/Graph/PipeWirePortProxy.cs | 133 ++- .../Graph/PipeWireProcessLatency.cs | 21 +- src/PipeWire.NET/Graph/PipeWireProfiler.cs | 4 +- .../Graph/PipeWireProfilerProxy.cs | 83 +- src/PipeWire.NET/Graph/PipeWireProperties.cs | 35 +- src/PipeWire.NET/Graph/PipeWireRegistry.cs | 960 +++++++++++++----- .../Graph/PipeWireSecurityContext.cs | 4 +- .../Graph/PipeWireSecurityContextProxy.cs | 90 +- src/PipeWire.NET/Graph/PipeWireStreamQueue.cs | 3 +- src/PipeWire.NET/Graph/PipeWireTag.cs | 26 +- src/PipeWire.NET/Graph/PipeWireVideoCycle.cs | 3 +- src/PipeWire.NET/Graph/SpaFactoryExport.cs | 28 +- src/PipeWire.NET/Interop/BoundProxy.cs | 36 +- src/PipeWire.NET/Interop/CoreSync.cs | 121 ++- src/PipeWire.NET/Interop/DaemonText.cs | 3 +- src/PipeWire.NET/Interop/FdInterop.cs | 30 +- src/PipeWire.NET/Interop/Native.Extensions.cs | 228 ++++- .../Interop/NativeLibc.Imports.cs | 9 +- .../Interop/NativeLibdrm.Imports.cs | 16 +- .../Interop/NativeObjectCreation.cs | 156 ++- src/PipeWire.NET/Interop/NativeReaper.cs | 10 +- .../Interop/PipeWireContextHandle.cs | 6 +- .../Interop/PipeWireCoreHandle.cs | 24 +- .../Interop/PipeWireFilterHandle.cs | 26 +- .../Interop/PipeWireImplMetadataHandle.cs | 6 +- .../Interop/PipeWireLoopHandle.cs | 15 +- .../Interop/PipeWireProxyHandle.cs | 24 +- .../Interop/PipeWireStreamHandle.cs | 26 +- src/PipeWire.NET/Interop/SafeCallback.cs | 30 +- src/PipeWire.NET/Interop/SpaHookList.cs | 11 +- src/PipeWire.NET/PipeWire.NET.csproj | 1 - .../PipeWireConnectFailedException.cs | 26 +- .../PipeWireConnectionClosedException.cs | 26 +- .../PipeWireConnectionException.cs | 26 +- src/PipeWire.NET/PipeWireException.cs | 110 +- src/PipeWire.NET/PipeWireInteropException.cs | 26 +- .../PipeWireRequestRefusedException.cs | 26 +- src/PipeWire.NET/SafeDescriptorHandle.cs | 17 +- src/PipeWire.NET/Spa/SpaDictBuilder.cs | 44 +- src/PipeWire.NET/Spa/SpaIdValue.cs | 10 +- src/PipeWire.NET/Spa/SpaKey.cs | 10 +- src/PipeWire.NET/Spa/SpaPod.cs | 210 ++-- src/PipeWire.NET/Spa/SpaPodBuilder.cs | 198 ++-- src/PipeWire.NET/Spa/SpaPodCompare.cs | 80 +- src/PipeWire.NET/Spa/SpaPodProjection.cs | 98 +- src/PipeWire.NET/Spa/SpaPodReader.cs | 163 ++- src/PipeWire.NET/Spa/SpaValue.cs | 80 +- src/PipeWire.NET/Spa/SpaValueEquality.cs | 9 +- .../PipeWire.NET.Tests/AudioRoundTripTests.cs | 237 +++-- tests/PipeWire.NET.Tests/AudioTimingTests.cs | 74 +- tests/PipeWire.NET.Tests/AvSyncTests.cs | 203 +++- .../BorrowedFrameGpuTests.cs | 84 +- .../BorrowedVideoFrameTests.cs | 46 +- .../BuilderAndExceptionContractTests.cs | 58 +- tests/PipeWire.NET.Tests/ChaosSoakTests.cs | 307 ++++-- tests/PipeWire.NET.Tests/CliTool.cs | 45 +- .../ControlContractTests.cs | 215 +++- .../PipeWire.NET.Tests/ControlSurfaceTests.cs | 233 +++-- .../ConversionBoundaryTests.cs | 214 +++- .../CoreSyncContractTests.cs | 207 ++-- .../CoreSyncOrderingTests.cs | 113 ++- .../CreationHostileTests.cs | 170 +++- .../CrossProcessOrderingTests.cs | 65 +- .../DescriptorAndExceptionTests.cs | 78 +- .../DeviceAndMetadataWriteTests.cs | 347 +++++-- .../DeviceIdNegotiationEndToEndTests.cs | 221 +++- .../DeviceIdNegotiationTests.cs | 255 ++++- .../PipeWire.NET.Tests/DeviceProviderTests.cs | 550 +++++++--- .../PipeWire.NET.Tests/DmaBufLifetimeTests.cs | 268 +++-- .../DmaBufRoundTripTests.cs | 43 +- .../DocumentationExampleTests.cs | 128 ++- tests/PipeWire.NET.Tests/DrmFormatTests.cs | 49 +- tests/PipeWire.NET.Tests/DrmSyncobjTests.cs | 66 +- tests/PipeWire.NET.Tests/ExplicitSyncTests.cs | 286 ++++-- .../PipeWire.NET.Tests/ExportedFormatTests.cs | 131 ++- tests/PipeWire.NET.Tests/ExportedNodeTests.cs | 624 ++++++++---- tests/PipeWire.NET.Tests/FdOwnershipTests.cs | 69 +- tests/PipeWire.NET.Tests/FilterTests.cs | 168 ++- .../GStreamerIntegrationTests.cs | 387 +++++-- tests/PipeWire.NET.Tests/GStreamerSupport.cs | 141 ++- tests/PipeWire.NET.Tests/GbmAllocator.cs | 93 +- .../GeneratedKeyRoundTripTests.cs | 118 ++- .../PipeWire.NET.Tests/GeneratedKeysTests.cs | 65 +- .../GraphCreationOptionsTests.cs | 408 ++++++-- .../GraphIntegrationTests.cs | 113 ++- .../PipeWire.NET.Tests/GraphInvariantTests.cs | 86 +- .../PipeWire.NET.Tests/GraphLifetimeTests.cs | 256 +++-- tests/PipeWire.NET.Tests/GraphModelTests.cs | 107 +- .../PipeWire.NET.Tests/GraphScenarioTests.cs | 310 ++++-- tests/PipeWire.NET.Tests/GraphStressTests.cs | 332 ++++-- tests/PipeWire.NET.Tests/GraphSurfaceTests.cs | 206 ++-- .../PipeWire.NET.Tests/GraphThreadingTests.cs | 341 +++++-- .../PipeWire.NET.Tests/HostDrivenLoopTests.cs | 28 +- .../HostileConditionsTests.cs | 315 ++++-- .../PipeWire.NET.Tests/HostileControlTests.cs | 458 ++++++--- tests/PipeWire.NET.Tests/HostileInputTests.cs | 161 ++- .../PipeWire.NET.Tests/InProcessGraphTests.cs | 49 +- .../PipeWire.NET.Tests/LatencyAndTagTests.cs | 146 ++- tests/PipeWire.NET.Tests/LibraryErrorGuard.cs | 55 +- .../PipeWire.NET.Tests/LifecycleLockTests.cs | 76 +- .../LifetimeInvariantTests.cs | 187 +++- tests/PipeWire.NET.Tests/LinkStateTests.cs | 320 ++++-- .../LiveTimingAndRateTests.cs | 119 ++- tests/PipeWire.NET.Tests/LocalV4l2Tests.cs | 53 +- .../MetadataProtocolTests.cs | 170 +++- .../MetadataProviderTests.cs | 113 ++- .../MetadataReconcilerTests.cs | 105 +- tests/PipeWire.NET.Tests/MetadataRelay.cs | 24 +- .../MultiClientHarmonyTests.cs | 510 +++++++--- tests/PipeWire.NET.Tests/NativeAbiTests.cs | 110 +- .../NativeCallbackSignatureTests.cs | 55 +- tests/PipeWire.NET.Tests/NativeDict.cs | 14 +- tests/PipeWire.NET.Tests/NativeHelperTests.cs | 78 +- tests/PipeWire.NET.Tests/NativeLibcTests.cs | 87 +- .../PipeWire.NET.Tests/NativeLifetimeTests.cs | 220 ++-- .../PipeWire.NET.Tests/ObjectIdentityTests.cs | 34 +- .../ParameterAndMetadataTests.cs | 290 ++++-- tests/PipeWire.NET.Tests/PenHarness.cs | 173 +++- .../PermissionRefusalTests.cs | 111 +- tests/PipeWire.NET.Tests/PipeWireLogTests.cs | 13 +- .../PipeWireRateControllerTests.cs | 38 +- .../PipeWire.NET.Tests/PipeWireStreamTests.cs | 531 +++++++--- .../PipeWire.NET.Tests/PodComparisonTests.cs | 123 ++- .../PipeWire.NET.Tests/PublicSurfaceTests.cs | 110 +- .../PulledVideoFrameTests.cs | 17 +- tests/PipeWire.NET.Tests/PwDump.cs | 32 +- tests/PipeWire.NET.Tests/PwToolsSupport.cs | 152 ++- tests/PipeWire.NET.Tests/PwTop.cs | 48 +- .../RealProducerMatrixTests.cs | 151 ++- .../RealWorldScenarioTests.cs | 345 +++++-- tests/PipeWire.NET.Tests/ReentrancyTests.cs | 114 ++- .../RegistryObjectKindLiveTests.cs | 193 ++-- .../RegistryObjectKindTests.cs | 123 ++- .../RegistryParsingTests.cs | 427 ++++++-- .../RegistryRefusalTests.cs | 42 +- .../RegistryRobustnessTests.cs | 247 +++-- .../RoutingAndSettingsTests.cs | 94 +- .../PipeWire.NET.Tests/SandboxInteropTests.cs | 90 +- .../SequenceAndPortConfigTests.cs | 65 +- tests/PipeWire.NET.Tests/SessionGates.cs | 44 +- tests/PipeWire.NET.Tests/SessionHygiene.cs | 31 +- .../PipeWire.NET.Tests/SpaDictBuilderTests.cs | 21 +- tests/PipeWire.NET.Tests/SpaFormatTests.cs | 330 ++++-- tests/PipeWire.NET.Tests/SpaKeyTests.cs | 55 +- .../SpaPodChoiceRoundTripTests.cs | 99 +- tests/PipeWire.NET.Tests/SpaPodChoiceTests.cs | 51 +- tests/PipeWire.NET.Tests/SpaPodFilterTests.cs | 400 ++++++-- tests/PipeWire.NET.Tests/SpaPodFuzzTests.cs | 56 +- .../SpaPodHardeningTests.cs | 25 +- .../SpaPodMalformedTests.cs | 122 ++- .../SpaPodProjectionTests.cs | 214 +++- tests/PipeWire.NET.Tests/SpaPodReaderTests.cs | 86 +- tests/PipeWire.NET.Tests/SpaPodValueTests.cs | 69 +- .../PipeWire.NET.Tests/SpaValueModelTests.cs | 142 ++- .../StreamCoreThreadingTests.cs | 21 +- .../StreamTransportContractTests.cs | 157 ++- .../StreamingSurfaceTests.cs | 126 ++- tests/PipeWire.NET.Tests/SurfaceGuardTests.cs | 192 +++- .../PipeWire.NET.Tests/SurfaceParityTests.cs | 249 +++-- tests/PipeWire.NET.Tests/SyncLoopTests.cs | 160 ++- tests/PipeWire.NET.Tests/TestLogging.cs | 18 +- .../ThirdPartyGraphTests.cs | 488 ++++++--- .../PipeWire.NET.Tests/ThreadAffinityTests.cs | 79 +- tests/PipeWire.NET.Tests/ToolOracleTests.cs | 405 ++++++-- .../TransportEndToEndTests.cs | 245 +++-- .../TransportLifetimeTests.cs | 114 ++- .../TransportRoundTripTests.cs | 143 ++- .../UpstreamExampleTests.cs | 317 ++++-- .../VideoMetadataTypeTests.cs | 26 +- tests/PipeWire.NET.Tests/VirtualCamera.cs | 67 +- 256 files changed, 21808 insertions(+), 8396 deletions(-) create mode 100644 .config/dotnet-tools.json diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json new file mode 100644 index 0000000..9e028ef --- /dev/null +++ b/.config/dotnet-tools.json @@ -0,0 +1,13 @@ +{ + "version": 1, + "isRoot": true, + "tools": { + "csharpier": { + "version": "1.3.0", + "commands": [ + "csharpier" + ], + "rollForward": false + } + } +} diff --git a/.editorconfig b/.editorconfig index a5ce21d..5dc3322 100644 --- a/.editorconfig +++ b/.editorconfig @@ -113,6 +113,14 @@ csharp_space_between_method_declaration_parameter_list_parentheses = false csharp_space_between_parentheses = false csharp_space_between_square_brackets = false +# Formatting follows CSharpier; `dotnet csharpier check` enforces it in CI. These keys keep the IDE +# formatter consistent with it, and IDE0055 is off because some layouts, such as a multi-line +# constructor initializer before an empty body, cannot be expressed to it. +dotnet_diagnostic.IDE0055.severity = none +csharp_preserve_single_line_blocks = true +csharp_preserve_single_line_statements = false +csharp_space_around_declaration_statements = do_not_ignore + [*.{csproj,vbproj,vcxproj,vcxproj.filters,proj,nativeproj,locproj}] indent_size = 2 diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 198a629..a07165e 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -76,6 +76,11 @@ jobs: - name: Restore run: dotnet restore PipeWire.NET.slnx + - name: Check formatting + run: | + dotnet tool restore + dotnet csharpier check . + - name: Build run: dotnet build PipeWire.NET.slnx --no-restore -c Release diff --git a/Directory.Packages.props b/Directory.Packages.props index 96ab58e..4d5c937 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -7,8 +7,16 @@ - - + + diff --git a/PipeWire.NET.slnx b/PipeWire.NET.slnx index 7b7a828..5aaddad 100644 --- a/PipeWire.NET.slnx +++ b/PipeWire.NET.slnx @@ -1,21 +1,21 @@ - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + diff --git a/samples/PipeWire.NET.SampleConsole/GraphCommands.cs b/samples/PipeWire.NET.SampleConsole/GraphCommands.cs index 660212b..469f33c 100644 --- a/samples/PipeWire.NET.SampleConsole/GraphCommands.cs +++ b/samples/PipeWire.NET.SampleConsole/GraphCommands.cs @@ -1,7 +1,7 @@ using System.Globalization; using System.Runtime.Versioning; -using PipeWire.NET.Graph; using PipeWire.NET; +using PipeWire.NET.Graph; namespace PipeWire.NET.SampleConsole; @@ -12,14 +12,17 @@ internal static class GraphCommands { public static async Task ListAsync(CancellationToken cancellationToken) { - await using var session = await Session.ConnectAsync( - "sample-list", cancellationToken).ConfigureAwait(false); + await using var session = await Session + .ConnectAsync("sample-list", cancellationToken) + .ConfigureAwait(false); PipeWireGraphSnapshot graph = session.Registry.Current; - Console.WriteLine($"Graph version {graph.Version}: " + - $"{graph.Nodes.Length} nodes, {graph.Ports.Length} ports, " + - $"{graph.Links.Length} links, {graph.Devices.Length} devices, " + - $"{graph.Clients.Length} clients."); + Console.WriteLine( + $"Graph version {graph.Version}: " + + $"{graph.Nodes.Length} nodes, {graph.Ports.Length} ports, " + + $"{graph.Links.Length} links, {graph.Devices.Length} devices, " + + $"{graph.Clients.Length} clients." + ); Console.WriteLine("Nodes:"); foreach (PipeWireNode node in graph.Nodes) @@ -28,17 +31,23 @@ public static async Task ListAsync(CancellationToken cancellationToken) foreach (PipeWirePort _ in graph.GetPortsForNode(node.NodeId)) ports++; - Console.WriteLine($" [{node.NodeId,4}] {node.Description ?? node.NodeName ?? ""}"); - Console.WriteLine($" class={node.MediaClass ?? "?"} " + - $"media={node.Media} flow={node.Flow} ports={ports}"); + Console.WriteLine( + $" [{node.NodeId, 4}] {node.Description ?? node.NodeName ?? ""}" + ); + Console.WriteLine( + $" class={node.MediaClass ?? "?"} " + + $"media={node.Media} flow={node.Flow} ports={ports}" + ); } if (graph.Devices.Length > 0) { Console.WriteLine("Devices:"); foreach (PipeWireDevice device in graph.Devices) - Console.WriteLine($" [{device.Id,4}] {device.Description ?? device.DeviceName} " + - $"({device.Api ?? "?"})"); + Console.WriteLine( + $" [{device.Id, 4}] {device.Description ?? device.DeviceName} " + + $"({device.Api ?? "?"})" + ); } if (graph.Links.Length > 0) @@ -47,9 +56,11 @@ public static async Task ListAsync(CancellationToken cancellationToken) foreach (PipeWireLink link in graph.Links) { (PipeWirePort? output, PipeWirePort? input) = graph.GetEndpoints(link); - Console.WriteLine($" [{link.LinkId,4}] " + - $"{output?.NodeId}:{output?.PortName ?? "?"} -> " + - $"{input?.NodeId}:{input?.PortName ?? "?"}"); + Console.WriteLine( + $" [{link.LinkId, 4}] " + + $"{output?.NodeId}:{output?.PortName ?? "?"} -> " + + $"{input?.NodeId}:{input?.PortName ?? "?"}" + ); } } @@ -65,35 +76,46 @@ public static async Task MonitorAsync(CancellationToken cancellationToken) // a graph that can no longer change. using var lost = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - await using var session = await Session.ConnectAsync( - "sample-monitor", lost, cancellationToken).ConfigureAwait(false); + await using var session = await Session + .ConnectAsync("sample-monitor", lost, cancellationToken) + .ConfigureAwait(false); Console.WriteLine("Watching the graph; Ctrl+C stops."); // A watcher hears about every global going away. A proxy hears about its own object only, // which is the signal you get when you hold one object and never built a snapshot. Bound // here against whatever sink is present so the sample shows both in one run. - PipeWireNode? watched = session.Registry.Current.Nodes - .FirstOrDefault(n => n.MediaClass == "Audio/Sink"); + PipeWireNode? watched = session.Registry.Current.Nodes.FirstOrDefault(n => + n.MediaClass == "Audio/Sink" + ); PipeWireNodeProxy? proxy = null; if (watched is not null) { proxy = session.Registry.BindNode(watched.NodeId); proxy.Removed += () => - Console.WriteLine($" the daemon destroyed node {watched.NodeId} ('{watched.NodeName}')"); + Console.WriteLine( + $" the daemon destroyed node {watched.NodeId} ('{watched.NodeName}')" + ); - Console.WriteLine($"Also watching node {watched.NodeId} ('{watched.NodeName}') for removal."); + Console.WriteLine( + $"Also watching node {watched.NodeId} ('{watched.NodeName}') for removal." + ); } try { - await foreach (PipeWireGraphSnapshot graph in session.Registry - .WatchAsync(lost.Token).ConfigureAwait(false)) + await foreach ( + PipeWireGraphSnapshot graph in session + .Registry.WatchAsync(lost.Token) + .ConfigureAwait(false) + ) { - Console.WriteLine($"[{graph.Version}] nodes={graph.Nodes.Length} " + - $"ports={graph.Ports.Length} links={graph.Links.Length} " + - $"devices={graph.Devices.Length} clients={graph.Clients.Length}"); + Console.WriteLine( + $"[{graph.Version}] nodes={graph.Nodes.Length} " + + $"ports={graph.Ports.Length} links={graph.Links.Length} " + + $"devices={graph.Devices.Length} clients={graph.Clients.Length}" + ); } } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) @@ -104,7 +126,8 @@ public static async Task MonitorAsync(CancellationToken cancellationToken) { if (proxy is not null) { - if (proxy.IsRemoved) Console.WriteLine(" the watched node is gone."); + if (proxy.IsRemoved) + Console.WriteLine(" the watched node is gone."); await proxy.DisposeAsync().ConfigureAwait(false); } } @@ -122,9 +145,17 @@ public static async Task VolumeAsync(string[] args, CancellationToken cance float setLevel = 0; if (setRaw is not null) { - if (!float.TryParse(setRaw, NumberStyles.Float, - CultureInfo.InvariantCulture, out setLevel) - || float.IsNaN(setLevel) || setLevel < 0 || setLevel > 1) + if ( + !float.TryParse( + setRaw, + NumberStyles.Float, + CultureInfo.InvariantCulture, + out setLevel + ) + || float.IsNaN(setLevel) + || setLevel < 0 + || setLevel > 1 + ) { Console.Error.WriteLine($"--set takes a volume from 0 to 1, not '{setRaw}'."); return 2; @@ -137,17 +168,20 @@ public static async Task VolumeAsync(string[] args, CancellationToken cance return 2; } - await using var session = await Session.ConnectAsync( - "sample-volume", cancellationToken).ConfigureAwait(false); + await using var session = await Session + .ConnectAsync("sample-volume", cancellationToken) + .ConfigureAwait(false); PipeWireNode? node = target is null ? await DefaultSinkNodeAsync(session, cancellationToken).ConfigureAwait(false) : FindNode(session, target); if (node is null) { - Console.Error.WriteLine(target is null - ? "No default sink in this session; name a node explicitly." - : $"No node matches '{target}'."); + Console.Error.WriteLine( + target is null + ? "No default sink in this session; name a node explicitly." + : $"No node matches '{target}'." + ); return Program.NothingToDo; } @@ -179,13 +213,16 @@ public static async Task VolumeAsync(string[] args, CancellationToken cance public static async Task DefaultsAsync(CancellationToken cancellationToken) { - await using var session = await Session.ConnectAsync( - "sample-defaults", cancellationToken).ConfigureAwait(false); + await using var session = await Session + .ConnectAsync("sample-defaults", cancellationToken) + .ConfigureAwait(false); PipeWireMetadataProxy? store = session.Registry.BindMetadata("default"); if (store is null) { - Console.Error.WriteLine("No 'default' metadata store; this session has no session manager."); + Console.Error.WriteLine( + "No 'default' metadata store; this session has no session manager." + ); return Program.NothingToDo; } @@ -196,7 +233,9 @@ public static async Task DefaultsAsync(CancellationToken cancellationToken) Console.WriteLine($"Default source: {store.DefaultAudioSource?.NameValue ?? ""}"); Console.WriteLine($"Clock rate: {Render(store.ClockRate)} Hz"); Console.WriteLine($"Clock quantum: {Render(store.ClockQuantum)}"); - Console.WriteLine($"Quantum range: {Render(store.ClockMinQuantum)}..{Render(store.ClockMaxQuantum)}"); + Console.WriteLine( + $"Quantum range: {Render(store.ClockMinQuantum)}..{Render(store.ClockMaxQuantum)}" + ); Console.WriteLine($"Forced rate: {Render(store.ClockForcedRate)}"); Console.WriteLine($"Forced quantum: {Render(store.ClockForcedQuantum)}"); } @@ -206,7 +245,9 @@ public static async Task DefaultsAsync(CancellationToken cancellationToken) // The default sink node: metadata names the node, the registry resolves it. internal static async Task DefaultSinkNodeAsync( - Session session, CancellationToken cancellationToken) + Session session, + CancellationToken cancellationToken + ) { PipeWireMetadataProxy? store = session.Registry.BindMetadata("default"); if (store is null) @@ -226,8 +267,7 @@ public static async Task DefaultsAsync(CancellationToken cancellationToken) // Numeric id first, then a case-insensitive fragment of the node or description. internal static PipeWireNode? FindNode(Session session, string target) { - if (uint.TryParse(target, NumberStyles.Integer, - CultureInfo.InvariantCulture, out uint id)) + if (uint.TryParse(target, NumberStyles.Integer, CultureInfo.InvariantCulture, out uint id)) { foreach (PipeWireNode node in session.Registry.Nodes) { diff --git a/samples/PipeWire.NET.SampleConsole/PipeWire.NET.SampleConsole.csproj b/samples/PipeWire.NET.SampleConsole/PipeWire.NET.SampleConsole.csproj index b58415f..123b98d 100644 --- a/samples/PipeWire.NET.SampleConsole/PipeWire.NET.SampleConsole.csproj +++ b/samples/PipeWire.NET.SampleConsole/PipeWire.NET.SampleConsole.csproj @@ -4,7 +4,8 @@ net10.0;net11.0 - 10.0.11 + 10.0.11 linux-x64;linux-arm64 false - + CP0001 T:PipeWire.NET.AudioFrame @@ -2442,4 +2445,4 @@ lib/net11.0/PipeWire.NET.dll true - \ No newline at end of file + diff --git a/src/PipeWire.NET/Core/PipeWireContext.cs b/src/PipeWire.NET/Core/PipeWireContext.cs index 1d9541c..d362730 100644 --- a/src/PipeWire.NET/Core/PipeWireContext.cs +++ b/src/PipeWire.NET/Core/PipeWireContext.cs @@ -1,10 +1,10 @@ +using System.Globalization; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; using System.Runtime.Versioning; using System.Text; -using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Logging; -using System.Runtime.CompilerServices; -using System.Globalization; -using System.Runtime.InteropServices; +using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Win32.SafeHandles; using PipeWire.NET.Interop; using PipeWire.NET.Spa; @@ -39,7 +39,15 @@ public sealed partial class PipeWireContext : IDisposable, IAsyncDisposable // A Lock scope held across Dispose is a contract violation, and the drain is what keeps that // violation a hang at the call site rather than an unlock against a destroyed loop. Do not // hold a scope across Dispose. - private enum LifecycleState { Created, Starting, Running, Stopping, Disposed } + private enum LifecycleState + { + Created, + Starting, + Running, + Stopping, + Disposed, + } + private readonly Lock _lifecycle = new(); private volatile LifecycleState _state; private int _activeLeases; @@ -51,12 +59,12 @@ private enum LifecycleState { Created, Starting, Running, Stopping, Disposed } // disposal is about to stop, so a caller waiting on one with no cancellation of its own would // otherwise wait for a reply that can never come. private readonly CancellationTokenSource _shutdown = new(); - private PipeWireLoopHandle? _loopHandle; + private PipeWireLoopHandle? _loopHandle; private PipeWireContextHandle? _contextHandle; - private PipeWireCoreHandle? _coreHandle; + private PipeWireCoreHandle? _coreHandle; private readonly string _name; - private volatile bool _started; - private volatile bool _disposed; + private volatile bool _started; + private volatile bool _disposed; // The connection's own listener, distinct from the per-request one a round trip attaches. // A connection can die between requests - the daemon destroys our client, or the socket goes - @@ -115,7 +123,8 @@ internal void ReportRefusedUnlock(int result) internal unsafe void UnlockLoop(pw_thread_loop* loop) { int rc = Native.pw_thread_loop_unlock_checked(loop); - if (rc < 0) ReportRefusedUnlock(rc); + if (rc < 0) + ReportRefusedUnlock(rc); } private long _refusedUnlocks; @@ -156,7 +165,9 @@ public PipeWireContext(string name = "PipeWire.NET", ILoggerFactory? loggerFacto /// /// private static readonly Lazy ProcessInit = new( - InitOnce, LazyThreadSafetyMode.ExecutionAndPublication); + InitOnce, + LazyThreadSafetyMode.ExecutionAndPublication + ); private static unsafe bool InitOnce() { @@ -183,16 +194,19 @@ private static unsafe bool InitOnce() private static unsafe Version? ParseLibraryVersion() { string? text = Marshal.PtrToStringUTF8((IntPtr)Native.pw_get_library_version()); - if (string.IsNullOrEmpty(text)) return null; + if (string.IsNullOrEmpty(text)) + return null; // "1.6.8", sometimes with a suffix. Only the leading two numbers are compared. int firstDot = text.IndexOf('.', StringComparison.Ordinal); - if (firstDot <= 0) return null; + if (firstDot <= 0) + return null; int secondDot = text.IndexOf('.', firstDot + 1); string minorText = secondDot < 0 ? text[(firstDot + 1)..] : text[(firstDot + 1)..secondDot]; - return int.TryParse(text[..firstDot], CultureInfo.InvariantCulture, out int major) + return + int.TryParse(text[..firstDot], CultureInfo.InvariantCulture, out int major) && int.TryParse(minorText, CultureInfo.InvariantCulture, out int minor) ? new Version(major, minor) : null; @@ -212,12 +226,14 @@ private static unsafe bool InitOnce() private static void RequireASupportedLibrary() { Version? runtime = ParseLibraryVersion(); - if (runtime is null || runtime >= MinimumLibraryVersion) return; + if (runtime is null || runtime >= MinimumLibraryVersion) + return; throw new PipeWireException( $"libpipewire {runtime} is older than the {MinimumLibraryVersion} these bindings are " - + "generated against, and the combination hangs rather than failing. Upgrade PipeWire, " - + "or use a build of this library generated against the installed release."); + + "generated against, and the combination hangs rather than failing. Upgrade PipeWire, " + + "or use a build of this library generated against the installed release." + ); } /// @@ -258,21 +274,26 @@ public void LoadModule(string name, string? args = null) ArgumentException.ThrowIfNullOrEmpty(name); if (_started) - throw new InvalidOperationException("modules must be loaded before the context starts."); + throw new InvalidOperationException( + "modules must be loaded before the context starts." + ); _modules.Add((name, args)); } private unsafe void LoadQueuedModules() { - if (_modules.Count == 0) return; + if (_modules.Count == 0) + return; pw_context* context = _contextHandle!.Context; foreach ((string name, string? args) in _modules) { ReadOnlySpan nameUtf8 = Encoding.UTF8.GetBytes(name + '\0'); - ReadOnlySpan argsUtf8 = args is null ? default : Encoding.UTF8.GetBytes(args + '\0'); + ReadOnlySpan argsUtf8 = args is null + ? default + : Encoding.UTF8.GetBytes(args + '\0'); fixed (byte* n = nameUtf8) fixed (byte* a = argsUtf8) @@ -280,7 +301,9 @@ private unsafe void LoadQueuedModules() if (Native.pw_context_load_module(context, (sbyte*)n, (sbyte*)a, null) is null) { throw new PipeWireInteropException( - $"pw_context_load_module({name})", -NativeLibc.ENOENT); + $"pw_context_load_module({name})", + -NativeLibc.ENOENT + ); } } } @@ -316,7 +339,8 @@ public unsafe int LoopDescriptor get { PipeWireLoopHandle? loop = _loopHandle; - if (loop is null || loop.IsInvalid || loop.IsClosed) return -1; + if (loop is null || loop.IsInvalid || loop.IsClosed) + return -1; return Native.pw_loop_get_fd(Native.pw_thread_loop_get_loop(LoopHandle)); } @@ -365,7 +389,8 @@ private void RequireExternalDriver() { throw new InvalidOperationException( "this context runs its own loop thread; set DriveExternally before starting it to " - + "drive the loop from your own."); + + "drive the loop from your own." + ); } } @@ -387,7 +412,8 @@ private unsafe void InitializeNative(string name) pw_context* context = Native.pw_context_new( Native.pw_thread_loop_get_loop(loop), props: null, - user_data_size: 0); + user_data_size: 0 + ); if (context is null) { @@ -405,7 +431,8 @@ private unsafe void InitializeNative(string name) // reaches zero. private bool Admit() { - if (_state is LifecycleState.Stopping or LifecycleState.Disposed) return false; + if (_state is LifecycleState.Stopping or LifecycleState.Disposed) + return false; Interlocked.Increment(ref _activeLeases); _leasesDrained.Reset(); return true; @@ -528,8 +555,11 @@ private Task StartAsyncCore(SafeHandle? handle, int rawFd, CancellationToken can lock (_lifecycle) { ObjectDisposedException.ThrowIf( - _disposed || _state is LifecycleState.Stopping or LifecycleState.Disposed, this); - if (_state == LifecycleState.Running) return Task.CompletedTask; + _disposed || _state is LifecycleState.Stopping or LifecycleState.Disposed, + this + ); + if (_state == LifecycleState.Running) + return Task.CompletedTask; if (_state == LifecycleState.Created) { _state = LifecycleState.Starting; @@ -550,7 +580,8 @@ private Task StartAsyncCore(SafeHandle? handle, int rawFd, CancellationToken can _startSettled.Wait(cancellationToken); lock (_lifecycle) { - if (_state == LifecycleState.Running) return Task.CompletedTask; + if (_state == LifecycleState.Running) + return Task.CompletedTask; if (_state is LifecycleState.Stopping or LifecycleState.Disposed) throw new ObjectDisposedException(nameof(PipeWireContext)); if (_state == LifecycleState.Created && Admit()) @@ -574,7 +605,8 @@ private Task StartAsyncCore(SafeHandle? handle, int rawFd, CancellationToken can // Back to Created so a later start can retry: StartNative stops the loop thread // it started, so nothing is left running. The settle event wakes any waiter, // which re-verifies the state and takes over. - if (_state == LifecycleState.Starting) _state = LifecycleState.Created; + if (_state == LifecycleState.Starting) + _state = LifecycleState.Created; } _startSettled.Set(); ReleaseLease(); @@ -633,9 +665,10 @@ private unsafe void StartNative(SafeHandle? handle, int rawFd) // The descriptor the connect runs over: the duplicate's number, the caller's raw // number, or none of them for the default daemon socket. - int connectFd = duplicate is not null ? (int)duplicate.DangerousGetHandle() - : rawFd >= 0 ? rawFd - : -1; + int connectFd = + duplicate is not null ? (int)duplicate.DangerousGetHandle() + : rawFd >= 0 ? rawFd + : -1; pw_core* core; @@ -655,13 +688,32 @@ private unsafe void StartNative(SafeHandle? handle, int rawFd) pw_properties* props = Native.pw_properties_new_dict(&native); if (props is null) - throw new PipeWireInteropException("pw_properties_new_dict", -NativeLibc.ENOMEM); - - core = connectFd < 0 - ? (RunInProcess - ? Native.pw_context_connect_self(_contextHandle!.Context, props, user_data_size: 0) - : Native.pw_context_connect(_contextHandle!.Context, props, user_data_size: 0)) - : Native.pw_context_connect_fd(_contextHandle!.Context, connectFd, props, user_data_size: 0); + throw new PipeWireInteropException( + "pw_properties_new_dict", + -NativeLibc.ENOMEM + ); + + core = + connectFd < 0 + ? ( + RunInProcess + ? Native.pw_context_connect_self( + _contextHandle!.Context, + props, + user_data_size: 0 + ) + : Native.pw_context_connect( + _contextHandle!.Context, + props, + user_data_size: 0 + ) + ) + : Native.pw_context_connect_fd( + _contextHandle!.Context, + connectFd, + props, + user_data_size: 0 + ); } finally { @@ -675,15 +727,19 @@ private unsafe void StartNative(SafeHandle? handle, int rawFd) // disposal closes it. A raw descriptor stays the caller's; nothing here closes it. throw connectFd < 0 ? new PipeWireConnectFailedException( - "pw_context_connect", -NativeLibc.ENOENT, + "pw_context_connect", + -NativeLibc.ENOENT, objectId: null, - "ensure the PipeWire daemon is running (pipewire.service / wireplumber.service)") + "ensure the PipeWire daemon is running (pipewire.service / wireplumber.service)" + ) : new PipeWireConnectFailedException( - "pw_context_connect_fd", -NativeLibc.ENOENT, + "pw_context_connect_fd", + -NativeLibc.ENOENT, objectId: null, "the fd must be a connected PipeWire socket (as returned by a portal " - + "OpenPipeWireRemote request), not a plain file, and the daemon must be " - + "reachable on it"); + + "OpenPipeWireRemote request), not a plain file, and the daemon must be " + + "reachable on it" + ); } // A successful connect hands the descriptor to PipeWire: pw_context_connect_fd puts @@ -741,7 +797,8 @@ public unsafe bool TryLock(out LoopLock scope) // admitted here, and the recheck after locking observes a disposal that began in between // without taking the gate while the native lock is held. PipeWireLoopHandle? loop = _loopHandle; - if (loop is null) return false; + if (loop is null) + return false; // A reference, not a validity check. Testing the handle and then reading its pointer is two // steps, and disposal between them hands pw_thread_loop_lock a null pointer - which is a @@ -758,7 +815,8 @@ public unsafe bool TryLock(out LoopLock scope) return false; } - if (!referenced) return false; + if (!referenced) + return false; lock (_lifecycle) { @@ -823,7 +881,8 @@ internal unsafe bool IsOnLoopThread get { PipeWireLoopHandle? loop = _loopHandle; - if (loop is null || loop.IsInvalid) return false; + if (loop is null || loop.IsInvalid) + return false; bool referenced = false; try @@ -837,7 +896,8 @@ internal unsafe bool IsOnLoopThread } finally { - if (referenced) loop.DangerousRelease(); + if (referenced) + loop.DangerousRelease(); } } } @@ -871,7 +931,10 @@ internal unsafe pw_thread_loop* LoopHandle /// refuses to bind one of these through this connection: module-metadata would stop reading the /// connection until it answered a ping it can then never be heard answering. /// - internal System.Collections.Concurrent.ConcurrentDictionary ServedStores { get; } = new(); + internal System.Collections.Concurrent.ConcurrentDictionary< + Graph.PipeWireMetadataProvider, + byte + > ServedStores { get; } = new(); /// /// Changes this client's own properties on a live connection. @@ -894,7 +957,8 @@ internal unsafe pw_thread_loop* LoopHandle public unsafe int UpdateProperties(IReadOnlyDictionary properties) { ArgumentNullException.ThrowIfNull(properties); - if (properties.Count == 0) return 0; + if (properties.Count == 0) + return 0; // Sized from the input rather than a fixed scratch: a long value would otherwise lose its // tail. UTF-8 is at most 4 bytes per char, plus a terminator for each key and value. @@ -906,14 +970,16 @@ public unsafe int UpdateProperties(IReadOnlyDictionary propertie var items = new spa_dict_item[properties.Count]; var builder = new SpaDictBuilder(scratch, items); - foreach (KeyValuePair kv in properties) builder.Add(kv.Key, kv.Value); + foreach (KeyValuePair kv in properties) + builder.Add(kv.Key, kv.Value); spa_dict native = builder.Build(); using (Lock()) { pw_core* core = CoreHandle; - if (core is null) throw new InvalidOperationException("the context is not connected."); + if (core is null) + throw new InvalidOperationException("the context is not connected."); return Native.pw_core_update_properties(core, &native); } @@ -948,7 +1014,14 @@ private unsafe void WatchConnection(pw_core* core) _watchEvents->error = &OnConnectionError; _watchHook = (spa_hook*)NativeMemory.AllocZeroed((nuint)sizeof(spa_hook)); - if (Native.pw_core_add_listener(core, _watchHook, _watchEvents, (void*)GCHandle.ToIntPtr(_watchSelf)) >= 0) + if ( + Native.pw_core_add_listener( + core, + _watchHook, + _watchEvents, + (void*)GCHandle.ToIntPtr(_watchSelf) + ) >= 0 + ) return; ReleaseConnectionWatch(); @@ -967,8 +1040,14 @@ private unsafe void ReleaseConnectionWatch() if (_loopHandle is { IsInvalid: false } loop) { Native.pw_thread_loop_lock(loop.Loop); - try { Native.spa_hook_remove(_watchHook); } - finally { UnlockLoop(loop.Loop); } + try + { + Native.spa_hook_remove(_watchHook); + } + finally + { + UnlockLoop(loop.Loop); + } } else { @@ -981,8 +1060,13 @@ private unsafe void ReleaseConnectionWatch() _watchHook = null; } - if (_watchEvents is not null) { NativeMemory.Free(_watchEvents); _watchEvents = null; } - if (_watchSelf.IsAllocated) _watchSelf.Free(); + if (_watchEvents is not null) + { + NativeMemory.Free(_watchEvents); + _watchEvents = null; + } + if (_watchSelf.IsAllocated) + _watchSelf.Free(); } /// The daemon reporting an error against the connection, from the loop thread. @@ -991,21 +1075,35 @@ private unsafe void ReleaseConnectionWatch() /// an object that is gone - is an answer, and the connection carries on serving. /// [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] - private static unsafe void OnConnectionError(void* data, uint id, int seq, int res, sbyte* message) + private static unsafe void OnConnectionError( + void* data, + uint id, + int seq, + int res, + sbyte* message + ) { // A native callback frame: an escaping exception aborts the process. try { - if (data is null) return; - if (GCHandle.FromIntPtr((nint)data).Target is not PipeWireContext self) return; - if (!IsConnectionFatal(res)) return; + if (data is null) + return; + if (GCHandle.FromIntPtr((nint)data).Target is not PipeWireContext self) + return; + if (!IsConnectionFatal(res)) + return; var fault = new PipeWireConnectionClosedException( - "connection", res, id, message is null ? null : DaemonText.String(message)); + "connection", + res, + id, + message is null ? null : DaemonText.String(message) + ); // First one wins: the errno that ended it is the useful one, and a closing connection // can report several. - if (Interlocked.CompareExchange(ref self._fault, fault, null) is not null) return; + if (Interlocked.CompareExchange(ref self._fault, fault, null) is not null) + return; SafeCallback.Raise(self.ConnectionLost, h => h(fault), _ => { }); } @@ -1017,11 +1115,12 @@ private static unsafe void OnConnectionError(void* data, uint id, int seq, int r } /// - private static bool IsConnectionFatal(int result) => result is - -NativeLibc.EPIPE or - -NativeLibc.ECONNABORTED or - -NativeLibc.ECONNRESET or - -NativeLibc.ENOTCONN; + private static bool IsConnectionFatal(int result) => + result + is -NativeLibc.EPIPE + or -NativeLibc.ECONNABORTED + or -NativeLibc.ECONNRESET + or -NativeLibc.ENOTCONN; private void DisposeCore() { @@ -1034,13 +1133,15 @@ private void DisposeCore() { throw new InvalidOperationException( "A PipeWire context cannot be disposed from its own loop thread: stopping the loop " - + "joins that thread, so it would wait for itself. Dispose from the thread that " - + "created it, or hand the disposal to another thread from the callback."); + + "joins that thread, so it would wait for itself. Dispose from the thread that " + + "created it, or hand the disposal to another thread from the callback." + ); } lock (_lifecycle) { - if (_disposed) return; + if (_disposed) + return; _disposed = true; _state = LifecycleState.Stopping; } @@ -1104,8 +1205,11 @@ private unsafe void DisposeNative() _loopHandle = null; } - [LoggerMessage(EventId = 34700, Level = LogLevel.Error, - Message = "the loop refused an unlock ({Result}) on managed thread {ThreadId}; the loop mutex is still held by that thread and the next thread that needs it will wait for ever. At: {StackTrace}")] + [LoggerMessage( + EventId = 34700, + Level = LogLevel.Error, + Message = "the loop refused an unlock ({Result}) on managed thread {ThreadId}; the loop mutex is still held by that thread and the next thread that needs it will wait for ever. At: {StackTrace}" + )] private partial void LogRefusedUnlock(int threadId, int result, string stackTrace); /// @@ -1156,11 +1260,13 @@ internal void Release() // however many copies were made and however often Dispose runs. An unbalanced unlock // corrupts the native mutex the loop - and every scope after it - depends on, and a // missing lease release wedges disposal's drain: it waits for a scope that is gone. - if (Interlocked.Exchange(ref _released, 1) != 0) return; + if (Interlocked.Exchange(ref _released, 1) != 0) + return; try { int rc = Native.pw_thread_loop_unlock_checked(_loop); - if (rc < 0) _context.ReportRefusedUnlock(rc); + if (rc < 0) + _context.ReportRefusedUnlock(rc); _handle.DangerousRelease(); } finally diff --git a/src/PipeWire.NET/Core/PipeWireMediaClass.cs b/src/PipeWire.NET/Core/PipeWireMediaClass.cs index d7baff6..f74fdc9 100644 --- a/src/PipeWire.NET/Core/PipeWireMediaClass.cs +++ b/src/PipeWire.NET/Core/PipeWireMediaClass.cs @@ -24,19 +24,23 @@ public static class PipeWireMediaClass public static PipeWireMediaKind ParseKind(string? mediaClass) { ReadOnlySpan s = mediaClass; - if (s.IsEmpty) return PipeWireMediaKind.Unknown; + if (s.IsEmpty) + return PipeWireMediaKind.Unknown; // "Stream/Output/Audio" names its medium last; everything else names it first. ReadOnlySpan head = NextSegment(ref s); if (head.Equals("Stream", StringComparison.Ordinal)) { - _ = NextSegment(ref s); // Output | Input + _ = NextSegment(ref s); // Output | Input head = NextSegment(ref s); } - if (head.Equals("Audio", StringComparison.Ordinal)) return PipeWireMediaKind.Audio; - if (head.Equals("Video", StringComparison.Ordinal)) return PipeWireMediaKind.Video; - if (head.Equals("Midi", StringComparison.Ordinal)) return PipeWireMediaKind.Midi; + if (head.Equals("Audio", StringComparison.Ordinal)) + return PipeWireMediaKind.Audio; + if (head.Equals("Video", StringComparison.Ordinal)) + return PipeWireMediaKind.Video; + if (head.Equals("Midi", StringComparison.Ordinal)) + return PipeWireMediaKind.Midi; return PipeWireMediaKind.Unknown; } @@ -44,7 +48,8 @@ public static PipeWireMediaKind ParseKind(string? mediaClass) public static PipeWireMediaFlow ParseFlow(string? mediaClass) { ReadOnlySpan s = mediaClass; - if (s.IsEmpty) return PipeWireMediaFlow.Unknown; + if (s.IsEmpty) + return PipeWireMediaFlow.Unknown; ReadOnlySpan head = NextSegment(ref s); @@ -52,27 +57,34 @@ public static PipeWireMediaFlow ParseFlow(string? mediaClass) { // An app's Output is the graph's Source, and vice versa. ReadOnlySpan dir = NextSegment(ref s); - if (dir.Equals("Output", StringComparison.Ordinal)) return PipeWireMediaFlow.Source; - if (dir.Equals("Input", StringComparison.Ordinal)) return PipeWireMediaFlow.Sink; + if (dir.Equals("Output", StringComparison.Ordinal)) + return PipeWireMediaFlow.Source; + if (dir.Equals("Input", StringComparison.Ordinal)) + return PipeWireMediaFlow.Sink; return PipeWireMediaFlow.Unknown; } // "Audio/Source", "Audio/Source/Virtual", "Video/Sink", "Midi/Bridge", ... ReadOnlySpan role = NextSegment(ref s); - if (role.Equals("Source", StringComparison.Ordinal)) return PipeWireMediaFlow.Source; - if (role.Equals("Sink", StringComparison.Ordinal)) return PipeWireMediaFlow.Sink; - if (role.Equals("Duplex", StringComparison.Ordinal)) return PipeWireMediaFlow.Duplex; + if (role.Equals("Source", StringComparison.Ordinal)) + return PipeWireMediaFlow.Source; + if (role.Equals("Sink", StringComparison.Ordinal)) + return PipeWireMediaFlow.Sink; + if (role.Equals("Duplex", StringComparison.Ordinal)) + return PipeWireMediaFlow.Duplex; // Bridge is treated as duplex. PipeWire does not say so directly, but a bridge exists to // carry data both ways (Midi/Bridge is the common one), and it publishes input and output // ports. Anything relying on this should check the ports rather than the class. - if (role.Equals("Bridge", StringComparison.Ordinal)) return PipeWireMediaFlow.Duplex; + if (role.Equals("Bridge", StringComparison.Ordinal)) + return PipeWireMediaFlow.Duplex; return PipeWireMediaFlow.Unknown; } /// Takes the next /-separated segment, advancing . private static ReadOnlySpan NextSegment(ref ReadOnlySpan rest) { - if (rest.IsEmpty) return default; + if (rest.IsEmpty) + return default; int slash = rest.IndexOf('/'); if (slash < 0) diff --git a/src/PipeWire.NET/Graph/MetadataReconciler.cs b/src/PipeWire.NET/Graph/MetadataReconciler.cs index e4f582c..425ec05 100644 --- a/src/PipeWire.NET/Graph/MetadataReconciler.cs +++ b/src/PipeWire.NET/Graph/MetadataReconciler.cs @@ -1,6 +1,5 @@ using System.Collections.Concurrent; - namespace PipeWire.NET.Graph; /// @@ -28,7 +27,10 @@ namespace PipeWire.NET.Graph; /// internal sealed class MetadataReconciler(TimeSpan window, TimeProvider? time = null) { - private readonly ConcurrentDictionary<(uint Subject, string Key), List> _outstanding = new(); + private readonly ConcurrentDictionary< + (uint Subject, string Key), + List + > _outstanding = new(); private readonly TimeProvider _time = time ?? TimeProvider.System; private readonly TimeSpan _windowSpan = window; @@ -82,8 +84,10 @@ internal PendingWrite NoteWrite(uint subject, string key, string? type, string? lock (bucket) { - if (_outstanding.TryGetValue((subject, key), out List? current) - && ReferenceEquals(current, bucket)) + if ( + _outstanding.TryGetValue((subject, key), out List? current) + && ReferenceEquals(current, bucket) + ) { bucket.RemoveAll(e => e.Expired(now, _window)); bucket.Add(pending); @@ -96,9 +100,11 @@ internal PendingWrite NoteWrite(uint subject, string key, string? type, string? /// Drops a write that never landed, so it cannot suppress an echo that will not come. internal void Forget(uint subject, string key, PendingWrite pending) { - if (!_outstanding.TryGetValue((subject, key), out List? bucket)) return; + if (!_outstanding.TryGetValue((subject, key), out List? bucket)) + return; - lock (bucket) bucket.Remove(pending); + lock (bucket) + bucket.Remove(pending); DropIfEmpty(subject, key, bucket); } @@ -136,7 +142,8 @@ internal EchoAction Classify(uint subject, string key, string? type, string? val // Our newest write coming home. The store raised it when it applied the write, so // raising again here would report one change twice. - if (bucket[^1].Matches(type, value)) return EchoAction.AlreadyKnown; + if (bucket[^1].Matches(type, value)) + return EchoAction.AlreadyKnown; // Records are deliberately not cleared when the newest is acknowledged: clearing // discards the older entries, and echoes still in flight for those then match nothing @@ -159,9 +166,11 @@ private void DropIfEmpty(uint subject, string key, List bucket) // the bucket still present or re-adds it through GetOrAdd. lock (bucket) { - if (bucket.Count != 0) return; + if (bucket.Count != 0) + return; _outstanding.TryRemove( - new KeyValuePair<(uint, string), List>((subject, key), bucket)); + new KeyValuePair<(uint, string), List>((subject, key), bucket) + ); } } @@ -185,7 +194,8 @@ private void DropIfEmpty(uint subject, string key, List bucket) /// internal void Settle(uint subject, string key) { - if (!_outstanding.TryGetValue((subject, key), out List? bucket)) return; + if (!_outstanding.TryGetValue((subject, key), out List? bucket)) + return; // The round trip proves every request issued before it was processed, so this is where the // records in this bucket start ageing. Anything still unacknowledged after it was never @@ -193,7 +203,8 @@ internal void Settle(uint subject, string key) long now = Now(); lock (bucket) { - foreach (PendingWrite entry in bucket) entry.Acknowledge(now); + foreach (PendingWrite entry in bucket) + entry.Acknowledge(now); } DropIfEmpty(subject, key, bucket); diff --git a/src/PipeWire.NET/Graph/PipeWireClient.cs b/src/PipeWire.NET/Graph/PipeWireClient.cs index 788501e..da958ad 100644 --- a/src/PipeWire.NET/Graph/PipeWireClient.cs +++ b/src/PipeWire.NET/Graph/PipeWireClient.cs @@ -35,7 +35,8 @@ internal PipeWireClient( string? SecurityLabel = null, string? SecurityAppId = null, string? SecurityInstanceId = null, - PipeWireProperties? Properties = null) + PipeWireProperties? Properties = null + ) { this.Properties = Properties ?? PipeWireProperties.Empty; this.ObjectSerial = this.Properties.Serial; diff --git a/src/PipeWire.NET/Graph/PipeWireClientProxy.cs b/src/PipeWire.NET/Graph/PipeWireClientProxy.cs index 688ff96..b956693 100644 --- a/src/PipeWire.NET/Graph/PipeWireClientProxy.cs +++ b/src/PipeWire.NET/Graph/PipeWireClientProxy.cs @@ -49,8 +49,9 @@ public sealed partial class PipeWireClientProxy : IDisposable, IAsyncDisposable private readonly PipeWireContext _ctx; private readonly ILogger _logger; - private readonly TaskCompletionSource _ready = - new(TaskCreationOptions.RunContinuationsAsynchronously); + private readonly TaskCompletionSource _ready = new( + TaskCreationOptions.RunContinuationsAsynchronously + ); private BoundProxy? _bound; private volatile bool _disposed; @@ -74,12 +75,25 @@ private PipeWireClientProxy(PipeWireContext ctx, uint id, ILogger logger) public uint Id { get; } internal static unsafe PipeWireClientProxy Bind( - PipeWireContext ctx, pw_registry* registry, uint id, uint version, ILogger logger, - Action? propertiesObserved = null) + PipeWireContext ctx, + pw_registry* registry, + uint id, + uint version, + ILogger logger, + Action? propertiesObserved = null + ) { - var control = new PipeWireClientProxy(ctx, id, logger) { PropertiesObserved = propertiesObserved }; + var control = new PipeWireClientProxy(ctx, id, logger) + { + PropertiesObserved = propertiesObserved, + }; control._bound = BoundProxy.Bind( - ctx, registry, id, PipeWireKeys.PW_TYPE_INTERFACE_Client, version, NativeConstants.PW_VERSION_CLIENT, + ctx, + registry, + id, + PipeWireKeys.PW_TYPE_INTERFACE_Client, + version, + NativeConstants.PW_VERSION_CLIENT, sizeof(pw_client_events), events => { @@ -88,9 +102,15 @@ internal static unsafe PipeWireClientProxy Bind( table->info = &OnInfoCallback; table->permissions = &OnPermissionsCallback; }, - static (proxy, hook, events, data) => Native.pw_client_add_listener( - (pw_client*)proxy, (spa_hook*)hook, (pw_client_events*)events, (void*)data), - control); + static (proxy, hook, events, data) => + Native.pw_client_add_listener( + (pw_client*)proxy, + (spa_hook*)hook, + (pw_client_events*)events, + (void*)data + ), + control + ); control._bound.Removed = control.RaiseRemoved; @@ -111,14 +131,19 @@ internal static unsafe PipeWireClientProxy Bind( private void RaiseRemoved() { Action? handler = Removed; - if (handler is null) return; + if (handler is null) + return; // A native callback frame, so nothing may escape it. - try { handler(); } - catch (Exception) { /* a subscriber that throws must not reach the daemon */ } + try + { + handler(); + } + catch (Exception) + { /* a subscriber that throws must not reach the daemon */ + } } - /// This client's properties, as the daemon last reported them. /// /// Empty until the first info event arrives; waits for it. A @@ -152,17 +177,26 @@ private static unsafe void OnInfoCallback(void* data, pw_client_info* info) // An exception escaping a reverse P/Invoke aborts the process, so nothing here may throw. try { - if (info is null) return; - if (GCHandle.FromIntPtr((nint)data).Target is not PipeWireClientProxy self) return; - if (self._disposed) return; + if (info is null) + return; + if (GCHandle.FromIntPtr((nint)data).Target is not PipeWireClientProxy self) + return; + if (self._disposed) + return; if (info->props is not null) { PipeWireProperties properties = PipeWireProperties.From(info->props); self._properties = properties; - try { self.PropertiesObserved?.Invoke(self.Id, properties); } - catch (Exception ex) { self.LogHandlerFaulted(self.Id, ex); } + try + { + self.PropertiesObserved?.Invoke(self.Id, properties); + } + catch (Exception ex) + { + self.LogHandlerFaulted(self.Id, ex); + } } self._ready.TrySetResult(); @@ -193,12 +227,16 @@ private static unsafe void OnInfoCallback(void* data, pw_client_info* info) /// The proxy has been disposed. /// Another read is already in flight. public async Task> GetPermissionsAsync( - uint index = 0, uint count = 64, CancellationToken cancellationToken = default) + uint index = 0, + uint count = 64, + CancellationToken cancellationToken = default + ) { ObjectDisposedException.ThrowIf(_disposed, this); var waiter = new TaskCompletionSource>( - TaskCreationOptions.RunContinuationsAsynchronously); + TaskCreationOptions.RunContinuationsAsynchronously + ); lock (_permissionsGate) { @@ -211,7 +249,8 @@ public async Task> GetPermissionsAsync( try { int res = Read(index, count); - if (res < 0) throw new PipeWireException("pw_client_get_permissions", res, Id); + if (res < 0) + throw new PipeWireException("pw_client_get_permissions", res, Id); return await waiter.Task.WaitAsync(cancellationToken).ConfigureAwait(false); } @@ -219,13 +258,15 @@ public async Task> GetPermissionsAsync( { lock (_permissionsGate) { - if (ReferenceEquals(_permissionsWaiter, waiter)) _permissionsWaiter = null; + if (ReferenceEquals(_permissionsWaiter, waiter)) + _permissionsWaiter = null; } } unsafe int Read(uint from, uint howMany) { - BoundProxy proxy = _bound ?? throw new ObjectDisposedException(nameof(PipeWireClientProxy)); + BoundProxy proxy = + _bound ?? throw new ObjectDisposedException(nameof(PipeWireClientProxy)); using (_ctx.Lock()) return Native.pw_client_get_permissions((pw_client*)proxy.Object, from, howMany); @@ -234,22 +275,32 @@ unsafe int Read(uint from, uint howMany) [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] private static unsafe void OnPermissionsCallback( - void* data, uint index, uint count, pw_permission* permissions) + void* data, + uint index, + uint count, + pw_permission* permissions + ) { // An exception escaping a reverse P/Invoke aborts the process, so nothing here may throw. try { - if (GCHandle.FromIntPtr((nint)data).Target is not PipeWireClientProxy self) return; + if (GCHandle.FromIntPtr((nint)data).Target is not PipeWireClientProxy self) + return; var entries = ImmutableArray.CreateBuilder((int)count); for (uint i = 0; i < count && permissions is not null; i++) { - entries.Add(new PipeWireObjectPermission( - permissions[i].id, (PipeWirePermissions)permissions[i].permissions)); + entries.Add( + new PipeWireObjectPermission( + permissions[i].id, + (PipeWirePermissions)permissions[i].permissions + ) + ); } TaskCompletionSource>? waiter; - lock (self._permissionsGate) waiter = self._permissionsWaiter; + lock (self._permissionsGate) + waiter = self._permissionsWaiter; waiter?.TrySetResult(entries.ToImmutable()); } @@ -289,11 +340,15 @@ private static unsafe void OnPermissionsCallback( /// The daemon refused. public async Task UpdatePermissionsAsync( ReadOnlyMemory permissions, - CancellationToken cancellationToken = default) + CancellationToken cancellationToken = default + ) { ObjectDisposedException.ThrowIf(_disposed, this); if (permissions.IsEmpty) - throw new ArgumentException("at least one permission is required.", nameof(permissions)); + throw new ArgumentException( + "at least one permission is required.", + nameof(permissions) + ); // Bits the daemon does not define are a caller mistake, not a forward-compatible extension: // permission.h has held the same five since 0.3.77, and a stray bit is either a cast from @@ -306,20 +361,23 @@ public async Task UpdatePermissionsAsync( foreach (PipeWireObjectPermission entry in permissions.Span) { - if ((entry.Permissions & ~defined) == 0) continue; + if ((entry.Permissions & ~defined) == 0) + continue; throw new ArgumentException( $"object {entry.ObjectId} carries permission bits this library does not define: " - + $"0x{(uint)(entry.Permissions & ~defined):x}.", - nameof(permissions)); + + $"0x{(uint)(entry.Permissions & ~defined):x}.", + nameof(permissions) + ); } cancellationToken.ThrowIfCancellationRequested(); // Inside the round-trip: a permission change the daemon refuses is answered out of band, // and a listener attached afterwards can miss it entirely. - await CoreSync.RoundTripAsync( - _ctx, () => Write(permissions.Span), cancellationToken).ConfigureAwait(false); + await CoreSync + .RoundTripAsync(_ctx, () => Write(permissions.Span), cancellationToken) + .ConfigureAwait(false); } /// @@ -342,7 +400,8 @@ await CoreSync.RoundTripAsync( /// public Task ConfineToAsync( ReadOnlySpan allowed, - CancellationToken cancellationToken = default) + CancellationToken cancellationToken = default + ) { var all = new PipeWireObjectPermission[allowed.Length + 1]; all[0] = new PipeWireObjectPermission(AnyObject, PipeWirePermissions.None); @@ -356,8 +415,9 @@ public Task ConfineToAsync( { throw new ArgumentException( "AnyObject is the default this method writes for you; it cannot also be a grant. " - + "Use UpdatePermissionsAsync to set a default of your own.", - nameof(allowed)); + + "Use UpdatePermissionsAsync to set a default of your own.", + nameof(allowed) + ); } } @@ -373,20 +433,23 @@ public Task ConfineToAsync( /// is . public async Task UpdatePropertiesAsync( IReadOnlyDictionary properties, - CancellationToken cancellationToken = default) + CancellationToken cancellationToken = default + ) { ArgumentNullException.ThrowIfNull(properties); ObjectDisposedException.ThrowIf(_disposed, this); - await CoreSync.RoundTripAsync( - _ctx, () => WriteProperties(properties), cancellationToken).ConfigureAwait(false); + await CoreSync + .RoundTripAsync(_ctx, () => WriteProperties(properties), cancellationToken) + .ConfigureAwait(false); } private unsafe int Write(ReadOnlySpan permissions) { - Span native = permissions.Length <= 16 - ? stackalloc pw_permission[permissions.Length] - : new pw_permission[permissions.Length]; + Span native = + permissions.Length <= 16 + ? stackalloc pw_permission[permissions.Length] + : new pw_permission[permissions.Length]; for (int i = 0; i < permissions.Length; i++) { @@ -403,7 +466,11 @@ private unsafe int Write(ReadOnlySpan permissions) using (_ctx.Lock()) { fixed (pw_permission* p = native) - return Native.pw_client_update_permissions((pw_client*)proxy.Object, (uint)native.Length, p); + return Native.pw_client_update_permissions( + (pw_client*)proxy.Object, + (uint)native.Length, + p + ); } } @@ -416,12 +483,14 @@ private unsafe int WriteProperties(IReadOnlyDictionary propertie // The dictionary holds raw pointers into these buffers, so they must not move between being // filled and the native call reading them. A stackalloc cannot move; a plain array can, so // the heap fallback allocates out of the pinned object heap rather than the normal one. - Span scratch = bytes <= 1024 - ? stackalloc byte[bytes] - : GC.AllocateUninitializedArray(bytes, pinned: true); - Span items = properties.Count <= 32 - ? stackalloc spa_dict_item[properties.Count] - : GC.AllocateArray(properties.Count, pinned: true); + Span scratch = + bytes <= 1024 + ? stackalloc byte[bytes] + : GC.AllocateUninitializedArray(bytes, pinned: true); + Span items = + properties.Count <= 32 + ? stackalloc spa_dict_item[properties.Count] + : GC.AllocateArray(properties.Count, pinned: true); var builder = new SpaDictBuilder(scratch, items); foreach ((string key, string value) in properties) @@ -456,7 +525,8 @@ public ValueTask DisposeAsync() private void DisposeCore() { - if (_disposed) return; + if (_disposed) + return; _disposed = true; _bound?.Dispose(); @@ -465,7 +535,10 @@ private void DisposeCore() GC.SuppressFinalize(this); } - [LoggerMessage(EventId = 34700, Level = LogLevel.Warning, - Message = "a properties handler for client {ClientId} threw")] + [LoggerMessage( + EventId = 34700, + Level = LogLevel.Warning, + Message = "a properties handler for client {ClientId} threw" + )] private partial void LogHandlerFaulted(uint clientId, Exception exception); } diff --git a/src/PipeWire.NET/Graph/PipeWireCore.cs b/src/PipeWire.NET/Graph/PipeWireCore.cs index 533771d..381262e 100644 --- a/src/PipeWire.NET/Graph/PipeWireCore.cs +++ b/src/PipeWire.NET/Graph/PipeWireCore.cs @@ -20,7 +20,8 @@ internal PipeWireCore( string? CoreVersion, string? HostName, string? UserName, - PipeWireProperties? Properties = null) + PipeWireProperties? Properties = null + ) { this.Properties = Properties ?? PipeWireProperties.Empty; this.ObjectSerial = this.Properties.Serial; diff --git a/src/PipeWire.NET/Graph/PipeWireDevice.cs b/src/PipeWire.NET/Graph/PipeWireDevice.cs index ab19439..0101d9f 100644 --- a/src/PipeWire.NET/Graph/PipeWireDevice.cs +++ b/src/PipeWire.NET/Graph/PipeWireDevice.cs @@ -29,7 +29,8 @@ internal PipeWireDevice( uint? FactoryId, uint? ClientId, uint? ModuleId = null, - PipeWireProperties? Properties = null) + PipeWireProperties? Properties = null + ) { this.Properties = Properties ?? PipeWireProperties.Empty; this.ObjectSerial = this.Properties.Serial; diff --git a/src/PipeWire.NET/Graph/PipeWireDeviceProvider.cs b/src/PipeWire.NET/Graph/PipeWireDeviceProvider.cs index e64d80b..36f8874 100644 --- a/src/PipeWire.NET/Graph/PipeWireDeviceProvider.cs +++ b/src/PipeWire.NET/Graph/PipeWireDeviceProvider.cs @@ -98,14 +98,18 @@ public static PipeWireDeviceProvider Create( string name, string description, IReadOnlyDictionary>? parameters = null, - IReadOnlyDictionary? properties = null) + IReadOnlyDictionary? properties = null + ) { ArgumentNullException.ThrowIfNull(context); ArgumentException.ThrowIfNullOrEmpty(name); ArgumentException.ThrowIfNullOrEmpty(description); var provider = new PipeWireDeviceProvider( - context, name, context.LoggerFactory.CreateLogger($"PipeWire.NET.Device.{name}")); + context, + name, + context.LoggerFactory.CreateLogger($"PipeWire.NET.Device.{name}") + ); var props = ImmutableArray.CreateBuilder>(); props.Add(new(PipeWireKeys.PW_KEY_DEVICE_NAME, name)); @@ -116,7 +120,12 @@ public static PipeWireDeviceProvider Create( { foreach (KeyValuePair pair in properties) { - if (pair.Key is PipeWireKeys.PW_KEY_DEVICE_NAME or PipeWireKeys.PW_KEY_DEVICE_DESCRIPTION) continue; + if ( + pair.Key + is PipeWireKeys.PW_KEY_DEVICE_NAME + or PipeWireKeys.PW_KEY_DEVICE_DESCRIPTION + ) + continue; props.Add(pair); } } @@ -126,7 +135,9 @@ public static PipeWireDeviceProvider Create( if (parameters is not null) { provider._params = parameters.ToImmutableDictionary( - static p => p.Key, static p => p.Value); + static p => p.Key, + static p => p.Value + ); } provider.Publish(); @@ -168,13 +179,17 @@ public static PipeWireDeviceProvider FromSpaFactory( PipeWireContext context, string factoryName, IReadOnlyDictionary? properties = null, - string? libraryName = null) + string? libraryName = null + ) { ArgumentNullException.ThrowIfNull(context); ArgumentException.ThrowIfNullOrEmpty(factoryName); var provider = new PipeWireDeviceProvider( - context, factoryName, context.LoggerFactory.CreateLogger($"PipeWire.NET.Device.{factoryName}")); + context, + factoryName, + context.LoggerFactory.CreateLogger($"PipeWire.NET.Device.{factoryName}") + ); pw_proxy* exported = SpaFactoryExport.Load( context, @@ -183,9 +198,14 @@ public static PipeWireDeviceProvider FromSpaFactory( "device", properties, libraryName, - out provider._spaHandle); - - provider._exported = new PipeWireProxyHandle(exported, context.LoopOwner, context.CoreOwner!); + out provider._spaHandle + ); + + provider._exported = new PipeWireProxyHandle( + exported, + context.LoopOwner, + context.CoreOwner! + ); provider.LogExported(factoryName); return provider; } @@ -213,17 +233,19 @@ private void Publish() _paramInfoCount = (uint)_params.Count; if (_paramInfoCount > 0) { - _paramInfo = (spa_param_info*)NativeMemory.AllocZeroed( - (nuint)(sizeof(spa_param_info) * (int)_paramInfoCount)); + _paramInfo = (spa_param_info*) + NativeMemory.AllocZeroed((nuint)(sizeof(spa_param_info) * (int)_paramInfoCount)); int i = 0; foreach (SpaParamType id in _params.Keys) { _paramInfo[i].id = (uint)id; // Readable, and writable for the two a caller is expected to change. - _paramInfo[i].flags = (uint)(id is SpaParamType.Profile or SpaParamType.Route - ? SpaParamInfoFlags.Serial | SpaParamInfoFlags.ReadWrite - : SpaParamInfoFlags.Serial | SpaParamInfoFlags.Read); + _paramInfo[i].flags = (uint)( + id is SpaParamType.Profile or SpaParamType.Route + ? SpaParamInfoFlags.Serial | SpaParamInfoFlags.ReadWrite + : SpaParamInfoFlags.Serial | SpaParamInfoFlags.Read + ); i++; } } @@ -249,14 +271,14 @@ private void Publish() var dict = new SpaDictBuilder(scratch, items); foreach (KeyValuePair pair in _properties) { - if (dict.Count == items.Length) break; + if (dict.Count == items.Length) + break; dict.Add(pair.Key, pair.Value); } spa_dict native = dict.Build(); - exported = Native.pw_core_export( - _ctx.CoreHandle, _iface->type, &native, _iface, 0); + exported = Native.pw_core_export(_ctx.CoreHandle, _iface->type, &native, _iface, 0); } if (exported is null) @@ -265,9 +287,9 @@ private void Publish() throw new PipeWireInteropException( "pw_core_export", -38, - daemonMessage: - "the context has no export type for Device. libpipewire-module-client-device " - + "registers it, and client.conf loads it unless module.client-device is off."); + daemonMessage: "the context has no export type for Device. libpipewire-module-client-device " + + "registers it, and client.conf loads it unless module.client-device is off." + ); } _exported = new PipeWireProxyHandle(exported, _ctx.LoopOwner, _ctx.CoreOwner!); @@ -325,7 +347,8 @@ public void SetParameter(SpaParamType parameter, ImmutableArray value { throw new InvalidOperationException( $"'{_name}' is served by a SPA plugin, which answers its own parameters; " - + "only a device built with Create has parameters this process can set."); + + "only a device built with Create has parameters this process can set." + ); } _params = _params.SetItem(parameter, values); @@ -334,7 +357,8 @@ public void SetParameter(SpaParamType parameter, ImmutableArray value // loop thread reads while emitting, so toggling outside the lock races the read. using (_ctx.Lock()) { - if (_disposed || _listeners is null) return; + if (_disposed || _listeners is null) + return; NoteParamsChanged(parameter); EmitInfoLocked(); } @@ -377,7 +401,8 @@ private void EmitInfoLocked() var dict = new SpaDictBuilder(scratch, items); foreach (KeyValuePair pair in _properties) { - if (dict.Count == items.Length) break; + if (dict.Count == items.Length) + break; dict.Add(pair.Key, pair.Value); } @@ -390,13 +415,12 @@ private void EmitInfoLocked() info.@params = _paramInfo; info.n_params = _paramInfoCount; - for (spa_list* node = _listeners->list.next; - node != &_listeners->list; - node = node->next) + for (spa_list* node = _listeners->list.next; node != &_listeners->list; node = node->next) { var hook = (spa_hook*)node; var events = (spa_device_events*)hook->cb.funcs; - if (events is null || events->info is null) continue; + if (events is null || events->info is null) + continue; events->info(hook->cb.data, &info); } @@ -404,10 +428,15 @@ private void EmitInfoLocked() [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] private static int OnAddListener( - void* obj, spa_hook* listener, spa_device_events* events, void* data) + void* obj, + spa_hook* listener, + spa_device_events* events, + void* data + ) { PipeWireDeviceProvider? self = FromData(obj); - if (self is null || listener is null) return -NativeLibc.EINVAL; + if (self is null || listener is null) + return -NativeLibc.EINVAL; try { @@ -440,7 +469,8 @@ private static int OnAddListener( private static int OnSync(void* obj, int seq) { PipeWireDeviceProvider? self = FromData(obj); - if (self is null) return -NativeLibc.EINVAL; + if (self is null) + return -NativeLibc.EINVAL; try { @@ -460,10 +490,17 @@ private static int OnSync(void* obj, int seq) [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] private static int OnEnumParams( - void* obj, int seq, uint id, uint start, uint num, spa_pod* filter) + void* obj, + int seq, + uint id, + uint start, + uint num, + spa_pod* filter + ) { PipeWireDeviceProvider? self = FromData(obj); - if (self is null) return -NativeLibc.EINVAL; + if (self is null) + return -NativeLibc.EINVAL; try { @@ -493,7 +530,12 @@ private static int OnEnumParams( result.next = index + 1; result.param = (spa_pod*)p; - self.EmitResult(seq, 0, (uint)NativeConstants.SPA_RESULT_TYPE_DEVICE_PARAMS, &result); + self.EmitResult( + seq, + 0, + (uint)NativeConstants.SPA_RESULT_TYPE_DEVICE_PARAMS, + &result + ); } sent++; @@ -512,13 +554,15 @@ private static int OnEnumParams( /// Reads an enumeration filter pod, or null when there is none to apply. private static unsafe SpaObject? ParseFilter(spa_pod* filter) { - if (filter is null) return null; + if (filter is null) + return null; // The declared size is the caller's word, capped before a span is built over it the same // way every other length off the wire is. An unparseable filter is ignored rather than // failing the enumeration: the caller still gets the unfiltered set it would have had. uint size = filter->size; - if (size > MaxParamBytes) return null; + if (size > MaxParamBytes) + return null; var bytes = new ReadOnlySpan(filter, (int)size + 8); return SpaPod.TryParse(bytes, out SpaValue? parsed) && parsed is SpaObject o ? o : null; @@ -538,7 +582,8 @@ private static bool MatchesFilter(SpaObject candidate, SpaObject filter) => private static int OnSetParam(void* obj, uint id, uint flags, spa_pod* param) { PipeWireDeviceProvider? self = FromData(obj); - if (self is null) return -NativeLibc.EINVAL; + if (self is null) + return -NativeLibc.EINVAL; try { @@ -597,7 +642,8 @@ private void RaiseParameterWritten(SpaParamType id, SpaObject? value) => ParameterWritten, (Provider: this, Id: id, Value: value), static (h, s) => h(s.Provider, s.Id, s.Value), - static (s, ex) => s.Provider.LogCallbackThrew(nameof(ParameterWritten), ex)); + static (s, ex) => s.Provider.LogCallbackThrew(nameof(ParameterWritten), ex) + ); /// How many listeners are attached. Diagnostics only. private int ListenerCount @@ -605,20 +651,24 @@ private int ListenerCount get { int n = 0; - for (spa_list* node = _listeners->list.next; node != &_listeners->list; node = node->next) n++; + for ( + spa_list* node = _listeners->list.next; + node != &_listeners->list; + node = node->next + ) + n++; return n; } } private void EmitResult(int seq, int res, uint type, void* result) { - for (spa_list* node = _listeners->list.next; - node != &_listeners->list; - node = node->next) + for (spa_list* node = _listeners->list.next; node != &_listeners->list; node = node->next) { var hook = (spa_hook*)node; var events = (spa_device_events*)hook->cb.funcs; - if (events is null || events->result is null) continue; + if (events is null || events->result is null) + continue; events->result(hook->cb.data, seq, res, type, result); } @@ -626,7 +676,8 @@ private void EmitResult(int seq, int res, uint type, void* result) private static PipeWireDeviceProvider? FromData(void* data) { - if (data is null) return null; + if (data is null) + return null; try { @@ -653,7 +704,8 @@ public ValueTask DisposeAsync() /// public void Dispose() { - if (_disposed) return; + if (_disposed) + return; _disposed = true; // Under the loop lock when it can be taken: the proxy destroy and the native frees below @@ -687,7 +739,8 @@ private void ReleaseNative() { if (_iface is not null) { - if (_iface->type is not null) NativeMemory.Free(_iface->type); + if (_iface->type is not null) + NativeMemory.Free(_iface->type); NativeMemory.Free(_iface); _iface = null; } @@ -696,33 +749,59 @@ private void ReleaseNative() // clearing it underneath a live proxy leaves the daemon dispatching through freed memory. if (_spaHandle is not null) { - if (_spaHandle->clear is not null) _ = _spaHandle->clear(_spaHandle); + if (_spaHandle->clear is not null) + _ = _spaHandle->clear(_spaHandle); _spaHandle = null; } - if (_methods is not null) { NativeMemory.Free(_methods); _methods = null; } - if (_listeners is not null) { NativeMemory.Free(_listeners); _listeners = null; } - if (_paramInfo is not null) { NativeMemory.Free(_paramInfo); _paramInfo = null; } + if (_methods is not null) + { + NativeMemory.Free(_methods); + _methods = null; + } + if (_listeners is not null) + { + NativeMemory.Free(_listeners); + _listeners = null; + } + if (_paramInfo is not null) + { + NativeMemory.Free(_paramInfo); + _paramInfo = null; + } - if (_self.IsAllocated) _self.Free(); + if (_self.IsAllocated) + _self.Free(); } - [LoggerMessage(EventId = 34300, Level = LogLevel.Information, - Message = "exported device {Name}; other clients can select its profiles")] + [LoggerMessage( + EventId = 34300, + Level = LogLevel.Information, + Message = "exported device {Name}; other clients can select its profiles" + )] private partial void LogExported(string name); - [LoggerMessage(EventId = 34302, Level = LogLevel.Debug, - Message = "enum_params id={Id} start={Start} num={Num}")] + [LoggerMessage( + EventId = 34302, + Level = LogLevel.Debug, + Message = "enum_params id={Id} start={Start} num={Num}" + )] private partial void LogEnumParams(uint id, uint start, uint num); - [LoggerMessage(EventId = 34304, Level = LogLevel.Debug, - Message = "enum_params sent {Sent} results to {Listeners} listeners")] + [LoggerMessage( + EventId = 34304, + Level = LogLevel.Debug, + Message = "enum_params sent {Sent} results to {Listeners} listeners" + )] private partial void LogEnumSent(uint sent, int listeners); [LoggerMessage(EventId = 34303, Level = LogLevel.Debug, Message = "a listener attached")] private partial void LogListenerAttached(); - [LoggerMessage(EventId = 34301, Level = LogLevel.Error, - Message = "a device provider callback ({Callback}) threw")] + [LoggerMessage( + EventId = 34301, + Level = LogLevel.Error, + Message = "a device provider callback ({Callback}) threw" + )] private partial void LogCallbackThrew(string callback, Exception exception); } diff --git a/src/PipeWire.NET/Graph/PipeWireDeviceProxy.cs b/src/PipeWire.NET/Graph/PipeWireDeviceProxy.cs index d059634..2bf5c27 100644 --- a/src/PipeWire.NET/Graph/PipeWireDeviceProxy.cs +++ b/src/PipeWire.NET/Graph/PipeWireDeviceProxy.cs @@ -31,28 +31,49 @@ private PipeWireDeviceProxy(PipeWireContext ctx, uint id, ILogger logger) : base(ctx, id) => _logger = logger; internal static unsafe PipeWireDeviceProxy Bind( - PipeWireContext ctx, pw_registry* registry, uint id, uint version, ILogger logger, - Action? propertiesObserved = null) + PipeWireContext ctx, + pw_registry* registry, + uint id, + uint version, + ILogger logger, + Action? propertiesObserved = null + ) { // The observer is in place before the proxy is bound: the first info after a bind is the // only one that carries the object's properties (later ones set no PROPS in their change // mask, so their dictionary arrives empty), and it can arrive the moment the bind is sent. // Assigned after Bind returned, a fast daemon's first info found no observer and the // properties never reached the registry. - var control = new PipeWireDeviceProxy(ctx, id, logger) { PropertiesObserved = propertiesObserved }; - control.Attach(BoundProxy.Bind( - ctx, registry, id, PipeWireKeys.PW_TYPE_INTERFACE_Device, version, NativeConstants.PW_VERSION_DEVICE, - sizeof(pw_device_events), - events => - { - var table = (pw_device_events*)events; - table->version = NativeConstants.PW_VERSION_DEVICE_EVENTS; - table->info = &OnInfoCallback; - table->param = &OnParamCallback; - }, - static (proxy, hook, events, data) => Native.pw_device_add_listener( - (pw_device*)proxy, (spa_hook*)hook, (pw_device_events*)events, (void*)data), - control)); + var control = new PipeWireDeviceProxy(ctx, id, logger) + { + PropertiesObserved = propertiesObserved, + }; + control.Attach( + BoundProxy.Bind( + ctx, + registry, + id, + PipeWireKeys.PW_TYPE_INTERFACE_Device, + version, + NativeConstants.PW_VERSION_DEVICE, + sizeof(pw_device_events), + events => + { + var table = (pw_device_events*)events; + table->version = NativeConstants.PW_VERSION_DEVICE_EVENTS; + table->info = &OnInfoCallback; + table->param = &OnParamCallback; + }, + static (proxy, hook, events, data) => + Native.pw_device_add_listener( + (pw_device*)proxy, + (spa_hook*)hook, + (pw_device_events*)events, + (void*)data + ), + control + ) + ); return control; } @@ -60,8 +81,8 @@ internal static unsafe PipeWireDeviceProxy Bind( /// Every profile this device offers, such as the configurations of a sound card. /// Abandons the wait. public Task> EnumerateProfilesAsync( - CancellationToken cancellationToken = default) => - EnumerateParametersAsync(SpaParamType.EnumProfile, cancellationToken); + CancellationToken cancellationToken = default + ) => EnumerateParametersAsync(SpaParamType.EnumProfile, cancellationToken); /// The profile the device is currently using, or if it has none. /// Abandons the wait. @@ -95,27 +116,34 @@ public Task> EnumerateProfilesAsync( /// /// public Task SetProfileAsync( - int index, bool save = false, CancellationToken cancellationToken = default) => + int index, + bool save = false, + CancellationToken cancellationToken = default + ) => SetParameterAsync( SpaParamType.Profile, - new SpaObject(SpaType.ObjectParamProfile, SpaParamType.Profile, - [ - new SpaPodProperty(SpaParamProfile.Index, 0, new SpaInt(index)), - new SpaPodProperty(SpaParamProfile.Save, 0, new SpaBool(save)), - ]), - cancellationToken); + new SpaObject( + SpaType.ObjectParamProfile, + SpaParamType.Profile, + [ + new SpaPodProperty(SpaParamProfile.Index, 0, new SpaInt(index)), + new SpaPodProperty(SpaParamProfile.Save, 0, new SpaBool(save)), + ] + ), + cancellationToken + ); /// Every route the device offers: its jacks, speakers and microphones. /// Abandons the wait. public Task> EnumerateRoutesAsync( - CancellationToken cancellationToken = default) => - EnumerateParametersAsync(SpaParamType.EnumRoute, cancellationToken); + CancellationToken cancellationToken = default + ) => EnumerateParametersAsync(SpaParamType.EnumRoute, cancellationToken); /// The routes currently in use, one per direction the profile provides. /// Abandons the wait. public Task> GetActiveRoutesAsync( - CancellationToken cancellationToken = default) => - EnumerateParametersAsync(SpaParamType.Route, cancellationToken); + CancellationToken cancellationToken = default + ) => EnumerateParametersAsync(SpaParamType.Route, cancellationToken); /// /// Selects a route for one of the device's ports. @@ -133,17 +161,24 @@ public Task> GetActiveRoutesAsync( /// it: the daemon can still apply the change after this throws. /// public Task SetRouteAsync( - int routeIndex, int devicePort, bool save = false, - CancellationToken cancellationToken = default) => + int routeIndex, + int devicePort, + bool save = false, + CancellationToken cancellationToken = default + ) => SetParameterAsync( SpaParamType.Route, - new SpaObject(SpaType.ObjectParamRoute, SpaParamType.Route, - [ - new SpaPodProperty(SpaParamRoute.Index, 0, new SpaInt(routeIndex)), - new SpaPodProperty(SpaParamRoute.Device, 0, new SpaInt(devicePort)), - new SpaPodProperty(SpaParamRoute.Save, 0, new SpaBool(save)), - ]), - cancellationToken); + new SpaObject( + SpaType.ObjectParamRoute, + SpaParamType.Route, + [ + new SpaPodProperty(SpaParamRoute.Index, 0, new SpaInt(routeIndex)), + new SpaPodProperty(SpaParamRoute.Device, 0, new SpaInt(devicePort)), + new SpaPodProperty(SpaParamRoute.Save, 0, new SpaBool(save)), + ] + ), + cancellationToken + ); /// /// Sets the hardware volume and mute of a route. @@ -176,49 +211,78 @@ public Task SetRouteVolumeAsync( ReadOnlySpan channelVolumes, bool muted, bool save = false, - CancellationToken cancellationToken = default) + CancellationToken cancellationToken = default + ) { if (channelVolumes.IsEmpty) - throw new ArgumentException("at least one channel volume is required.", nameof(channelVolumes)); + throw new ArgumentException( + "at least one channel volume is required.", + nameof(channelVolumes) + ); var values = ImmutableArray.CreateBuilder(channelVolumes.Length); foreach (float volume in channelVolumes) { if (float.IsNegative(volume) || float.IsNaN(volume)) - throw new ArgumentException("a channel volume must be a non-negative number.", nameof(channelVolumes)); + throw new ArgumentException( + "a channel volume must be a non-negative number.", + nameof(channelVolumes) + ); values.Add(new SpaFloat(volume)); } // The volume lives in a Props object nested inside the Route, because a route describes both // which jack is selected and what its mixer is set to. - var props = new SpaObject(SpaType.ObjectProps, SpaParamType.Props, - [ - new SpaPodProperty(SpaProp.Mute, 0, new SpaBool(muted)), - new SpaPodProperty(SpaProp.ChannelVolumes, 0, - new SpaArray(SpaType.Float, values.MoveToImmutable())), - ]); + var props = new SpaObject( + SpaType.ObjectProps, + SpaParamType.Props, + [ + new SpaPodProperty(SpaProp.Mute, 0, new SpaBool(muted)), + new SpaPodProperty( + SpaProp.ChannelVolumes, + 0, + new SpaArray(SpaType.Float, values.MoveToImmutable()) + ), + ] + ); return SetParameterAsync( SpaParamType.Route, - new SpaObject(SpaType.ObjectParamRoute, SpaParamType.Route, - [ - new SpaPodProperty(SpaParamRoute.Index, 0, new SpaInt(routeIndex)), - new SpaPodProperty(SpaParamRoute.Device, 0, new SpaInt(devicePort)), - new SpaPodProperty(SpaParamRoute.Props, 0, props), - new SpaPodProperty(SpaParamRoute.Save, 0, new SpaBool(save)), - ]), - cancellationToken); + new SpaObject( + SpaType.ObjectParamRoute, + SpaParamType.Route, + [ + new SpaPodProperty(SpaParamRoute.Index, 0, new SpaInt(routeIndex)), + new SpaPodProperty(SpaParamRoute.Device, 0, new SpaInt(devicePort)), + new SpaPodProperty(SpaParamRoute.Props, 0, props), + new SpaPodProperty(SpaParamRoute.Save, 0, new SpaBool(save)), + ] + ), + cancellationToken + ); } private protected override unsafe int EnumParamsNative( - void* proxy, int seq, uint id, uint start, uint num, spa_pod* filter) => - Native.pw_device_enum_params((pw_device*)proxy, seq, id, start, num, filter); + void* proxy, + int seq, + uint id, + uint start, + uint num, + spa_pod* filter + ) => Native.pw_device_enum_params((pw_device*)proxy, seq, id, start, num, filter); - private protected override unsafe int SetParamNative(void* proxy, uint id, uint flags, spa_pod* param) => - Native.pw_device_set_param((pw_device*)proxy, id, flags, param); + private protected override unsafe int SetParamNative( + void* proxy, + uint id, + uint flags, + spa_pod* param + ) => Native.pw_device_set_param((pw_device*)proxy, id, flags, param); - private protected override unsafe int SubscribeParamsNative(void* proxy, uint* ids, uint count) => - Native.pw_device_subscribe_params((pw_device*)proxy, ids, count); + private protected override unsafe int SubscribeParamsNative( + void* proxy, + uint* ids, + uint count + ) => Native.pw_device_subscribe_params((pw_device*)proxy, ids, count); private protected override void OnHandlerFaulted(Exception exception) => LogHandlerFaulted(Id, exception); @@ -244,7 +308,13 @@ private static unsafe void OnInfoCallback(void* data, pw_device_info* info) [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] private static unsafe void OnParamCallback( - void* data, int seq, uint id, uint index, uint next, spa_pod* param) + void* data, + int seq, + uint id, + uint index, + uint next, + spa_pod* param + ) { try { @@ -256,7 +326,10 @@ private static unsafe void OnParamCallback( } } - [LoggerMessage(EventId = 33100, Level = LogLevel.Error, - Message = "a ParameterChanged handler for device {DeviceId} threw")] + [LoggerMessage( + EventId = 33100, + Level = LogLevel.Error, + Message = "a ParameterChanged handler for device {DeviceId} threw" + )] private partial void LogHandlerFaulted(uint deviceId, Exception exception); } diff --git a/src/PipeWire.NET/Graph/PipeWireExportedFormat.cs b/src/PipeWire.NET/Graph/PipeWireExportedFormat.cs index 6ced05c..0423f0d 100644 --- a/src/PipeWire.NET/Graph/PipeWireExportedFormat.cs +++ b/src/PipeWire.NET/Graph/PipeWireExportedFormat.cs @@ -16,7 +16,12 @@ namespace PipeWire.NET.Graph; [SupportedOSPlatform("linux")] public sealed class PipeWireExportedFormat { - private PipeWireExportedFormat(int rate, int channels, SpaAudioFormat sampleFormat, int bytesPerSample) + private PipeWireExportedFormat( + int rate, + int channels, + SpaAudioFormat sampleFormat, + int bytesPerSample + ) { Rate = rate; Channels = channels; @@ -95,7 +100,12 @@ internal int WriteFormat(Span destination, bool asEnum) // // The offered values lead each choice, so they stay the preferred answer, and whatever // the peer settles on comes back through port_set_param and is recorded there. - b.AddChoiceEnum(SpaFormat.AudioFormat, SampleFormat, SpaAudioFormat.F32Le, SpaAudioFormat.S16Le); + b.AddChoiceEnum( + SpaFormat.AudioFormat, + SampleFormat, + SpaAudioFormat.F32Le, + SpaAudioFormat.S16Le + ); b.AddChoiceRangeInt(SpaFormat.AudioRate, Rate, 1, MaxRate); b.AddChoiceRangeInt(SpaFormat.AudioChannels, Channels, 1, MaxChannels); } @@ -136,16 +146,26 @@ internal int WriteFormat(Span destination, bool asEnum) /// internal static PipeWireExportedFormat? FromPod(ReadOnlySpan pod) { - if (!SpaPod.TryParse(pod, out SpaValue? value) || value is not SpaObject format) return null; - - if (Fixed(format, SpaFormat.MediaType) is not SpaId mediaType - || mediaType.Value != (uint)SpaMediaType.Audio) return null; - if (Fixed(format, SpaFormat.MediaSubtype) is not SpaId subtype - || subtype.Value != (uint)SpaMediaSubtype.Raw) return null; - - if (Fixed(format, SpaFormat.AudioFormat) is not SpaId sampleFormat) return null; - if (Fixed(format, SpaFormat.AudioRate) is not SpaInt rate) return null; - if (Fixed(format, SpaFormat.AudioChannels) is not SpaInt channels) return null; + if (!SpaPod.TryParse(pod, out SpaValue? value) || value is not SpaObject format) + return null; + + if ( + Fixed(format, SpaFormat.MediaType) is not SpaId mediaType + || mediaType.Value != (uint)SpaMediaType.Audio + ) + return null; + if ( + Fixed(format, SpaFormat.MediaSubtype) is not SpaId subtype + || subtype.Value != (uint)SpaMediaSubtype.Raw + ) + return null; + + if (Fixed(format, SpaFormat.AudioFormat) is not SpaId sampleFormat) + return null; + if (Fixed(format, SpaFormat.AudioRate) is not SpaInt rate) + return null; + if (Fixed(format, SpaFormat.AudioChannels) is not SpaInt channels) + return null; // Only what WriteFormat offers. Anything else is a format this node never said it could make. var settled = (SpaAudioFormat)sampleFormat.Value; @@ -156,17 +176,20 @@ internal int WriteFormat(Span destination, bool asEnum) _ => 0, }; - if (bytesPerSample == 0) return null; - if (rate.Value is < 1 or > MaxRate || channels.Value is < 1 or > MaxChannels) return null; + if (bytesPerSample == 0) + return null; + if (rate.Value is < 1 or > MaxRate || channels.Value is < 1 or > MaxChannels) + return null; return new PipeWireExportedFormat(rate.Value, channels.Value, settled, bytesPerSample); - static SpaValue? Fixed(SpaObject o, SpaKey key) => o.Find(key)?.Value switch - { - SpaChoice { Kind: SpaChoiceType.None } choice when !choice.Alternatives.IsDefaultOrEmpty - => choice.Alternatives[0], - var v => v, - }; + static SpaValue? Fixed(SpaObject o, SpaKey key) => + o.Find(key)?.Value switch + { + SpaChoice { Kind: SpaChoiceType.None } choice + when !choice.Alternatives.IsDefaultOrEmpty => choice.Alternatives[0], + var v => v, + }; } /// @@ -218,7 +241,12 @@ internal int WriteBuffers(Span destination) b.PushObject(SpaType.ObjectParamBuffers, SpaParamType.Buffers); b.AddChoiceRangeInt(SpaParamBuffers.Buffers, 2, 1, 32); b.AddInt(SpaParamBuffers.Blocks, 1); - b.AddChoiceRangeInt(SpaParamBuffers.Size, quantum * BytesPerFrame, BytesPerFrame, int.MaxValue); + b.AddChoiceRangeInt( + SpaParamBuffers.Size, + quantum * BytesPerFrame, + BytesPerFrame, + int.MaxValue + ); b.AddInt(SpaParamBuffers.Stride, BytesPerFrame); b.Pop(); diff --git a/src/PipeWire.NET/Graph/PipeWireFactory.cs b/src/PipeWire.NET/Graph/PipeWireFactory.cs index c37c2b6..9868291 100644 --- a/src/PipeWire.NET/Graph/PipeWireFactory.cs +++ b/src/PipeWire.NET/Graph/PipeWireFactory.cs @@ -20,7 +20,8 @@ internal PipeWireFactory( string? TypeName, uint? TypeVersion, uint? ModuleId, - PipeWireProperties? Properties = null) + PipeWireProperties? Properties = null + ) { this.Properties = Properties ?? PipeWireProperties.Empty; this.ObjectSerial = this.Properties.Serial; diff --git a/src/PipeWire.NET/Graph/PipeWireFilter.cs b/src/PipeWire.NET/Graph/PipeWireFilter.cs index 189641e..c8ae53f 100644 --- a/src/PipeWire.NET/Graph/PipeWireFilter.cs +++ b/src/PipeWire.NET/Graph/PipeWireFilter.cs @@ -104,7 +104,11 @@ private PipeWireFilter(PipeWireContext ctx, string name, ILogger logger) /// A struct rather than an object, and a single delegate rather than an event, for the same /// reason: neither may allocate on this thread. /// - public delegate void ProcessHandler(PipeWireFilter filter, uint sampleCount, in PipeWireGraphClock clock); + public delegate void ProcessHandler( + PipeWireFilter filter, + uint sampleCount, + in PipeWireGraphClock clock + ); private PipeWireVideoCycle _videoCycle; @@ -120,9 +124,13 @@ private PipeWireFilter(PipeWireContext ctx, string name, ILogger logger) /// public PipeWireVideoCycle VideoCycle => _videoCycle; - /// Raised when the filter changes state, on the loop thread. - public event Action? StateChanged; + public event Action< + PipeWireFilter, + PipeWireFilterState, + PipeWireFilterState, + string? + >? StateChanged; /// The node id the filter was given, or before it has one. /// @@ -141,7 +149,8 @@ public unsafe uint? NodeId { get { - if (_disposed || _handle is null || !_connected) return null; + if (_disposed || _handle is null || !_connected) + return null; // Under the loop lock, like the stream's equivalent: pw_filter_* is called with the // loop held, and without it this reads a field the loop thread is free to be writing @@ -149,7 +158,8 @@ public unsafe uint? NodeId uint id; using (_ctx.Lock()) { - if (_disposed || _handle is null) return null; + if (_disposed || _handle is null) + return null; id = Native.pw_filter_get_node_id(_handle.Filter); } @@ -177,23 +187,29 @@ public Task WaitForNodeIdAsync(CancellationToken cancellationToken = defau throw new InvalidOperationException("the filter is not connected."); // Fast path: the id is often already there. - if (NodeId is { } ready) return Task.FromResult(ready); + if (NodeId is { } ready) + return Task.FromResult(ready); cancellationToken.ThrowIfCancellationRequested(); // Event-driven, not polled. The id arrives with the node's binding while state changes // mark the progress around it, so every transition re-reads the live id rather than // trusting any one event to carry it. Continuations run off the loop thread: completing // inline would run a stranger's continuation with the native lock held. - var waiter = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var waiter = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously + ); Action? handler = null; handler = (_, _, _, _) => { - if (NodeId is { } id) waiter.TrySetResult(id); + if (NodeId is { } id) + waiter.TrySetResult(id); }; CancellationTokenRegistration registration = cancellationToken.Register( - static s => ((TaskCompletionSource)s!).TrySetCanceled(), waiter); + static s => ((TaskCompletionSource)s!).TrySetCanceled(), + waiter + ); StateChanged += handler; _ = waiter.Task.ContinueWith( @@ -204,7 +220,8 @@ public Task WaitForNodeIdAsync(CancellationToken cancellationToken = defau }, CancellationToken.None, TaskContinuationOptions.ExecuteSynchronously, - TaskScheduler.Default); + TaskScheduler.Default + ); // Re-check under subscription: the id may have arrived between the fast path and here. if (NodeId is { } arrived) @@ -217,18 +234,25 @@ public Task WaitForNodeIdAsync(CancellationToken cancellationToken = defau // including assigning the id with no further state change to announce it - has been // dispatched by the time this answers. The recheck then either finishes or waits for the // next real transition; the caller's token is what ends a wait for an id that never comes. - _ = CoreSync.RoundTripAsync(_ctx, cancellationToken).ContinueWith( - t => - { - if (t.IsFaulted) - waiter.TrySetException(t.Exception?.InnerException - ?? new InvalidOperationException("the node-id wait ended with its barrier.")); - else if (NodeId is { } id) - waiter.TrySetResult(id); - }, - CancellationToken.None, - TaskContinuationOptions.ExecuteSynchronously, - TaskScheduler.Default); + _ = CoreSync + .RoundTripAsync(_ctx, cancellationToken) + .ContinueWith( + t => + { + if (t.IsFaulted) + waiter.TrySetException( + t.Exception?.InnerException + ?? new InvalidOperationException( + "the node-id wait ended with its barrier." + ) + ); + else if (NodeId is { } id) + waiter.TrySetResult(id); + }, + CancellationToken.None, + TaskContinuationOptions.ExecuteSynchronously, + TaskScheduler.Default + ); return waiter.Task; } @@ -240,9 +264,11 @@ public unsafe PipeWireFilterState State { // Under the loop lock, like every other call into the filter: the state is read from // structures the loop thread mutates as the filter transitions. - if (_disposed || _handle is null || _handle.IsInvalid) return PipeWireFilterState.Unconnected; + if (_disposed || _handle is null || _handle.IsInvalid) + return PipeWireFilterState.Unconnected; - if (!_ctx.TryLock(out PipeWireContext.LoopLock scope)) return PipeWireFilterState.Unconnected; + if (!_ctx.TryLock(out PipeWireContext.LoopLock scope)) + return PipeWireFilterState.Unconnected; using (scope) { @@ -264,12 +290,17 @@ public unsafe PipeWireFilterState State public static unsafe PipeWireFilter Create( PipeWireContext context, string name, - IReadOnlyDictionary? properties = null) + IReadOnlyDictionary? properties = null + ) { ArgumentNullException.ThrowIfNull(context); ArgumentException.ThrowIfNullOrEmpty(name); - var filter = new PipeWireFilter(context, name, context.LoggerFactory.CreateLogger()); + var filter = new PipeWireFilter( + context, + name, + context.LoggerFactory.CreateLogger() + ); try { using (context.Lock()) @@ -278,7 +309,8 @@ public static unsafe PipeWireFilter Create( if (core is null) { throw new InvalidOperationException( - "the context is not connected; call StartAsync before creating a filter."); + "the context is not connected; call StartAsync before creating a filter." + ); } pw_filter* native; @@ -292,7 +324,11 @@ public static unsafe PipeWireFilter Create( try { - filter._handle = new PipeWireFilterHandle(native, context.LoopOwner, context.CoreOwner); + filter._handle = new PipeWireFilterHandle( + native, + context.LoopOwner, + context.CoreOwner + ); } catch { @@ -303,7 +339,8 @@ public static unsafe PipeWireFilter Create( throw; } - filter._events = (pw_filter_events*)NativeMemory.AllocZeroed((nuint)sizeof(pw_filter_events)); + filter._events = (pw_filter_events*) + NativeMemory.AllocZeroed((nuint)sizeof(pw_filter_events)); filter._events->version = NativeConstants.PW_VERSION_FILTER_EVENTS; filter._events->process = &OnProcessCallback; filter._events->state_changed = &OnStateChangedCallback; @@ -313,15 +350,19 @@ public static unsafe PipeWireFilter Create( filter._hook = (spa_hook*)NativeMemory.AllocZeroed((nuint)sizeof(spa_hook)); // Weak: a strong self-handle roots the filter for the life of the process, so one dropped - // without disposal leaks the native filter too. - filter._self = GCHandle.Alloc(filter, GCHandleType.Weak); + // without disposal leaks the native filter too. + filter._self = GCHandle.Alloc(filter, GCHandleType.Weak); // Handed over before the listener is attached, so a failure below still frees it and // the free happens after pw_filter_destroy rather than racing it. filter._handle!.OwnListener(filter._events, filter._hook, filter._self); Native.pw_filter_add_listener( - native, filter._hook, filter._events, (void*)GCHandle.ToIntPtr(filter._self)); + native, + filter._hook, + filter._events, + (void*)GCHandle.ToIntPtr(filter._self) + ); } return filter; @@ -364,18 +405,26 @@ private static unsafe void OnCommandCallback(void* data, spa_command* command) // An exception escaping a reverse P/Invoke aborts the process, so nothing here may throw. try { - if (command is null) return; - if (GCHandle.FromIntPtr((nint)data).Target is not PipeWireFilter self) return; - if (self._disposed) return; + if (command is null) + return; + if (GCHandle.FromIntPtr((nint)data).Target is not PipeWireFilter self) + return; + if (self._disposed) + return; // SPA_COMMAND_ID: the id only names a node command when the body says it is one, and a // command of another type reuses the same numbers for different things. - if (command->body.body.type != (uint)SpaType.CommandNode) return; + if (command->body.body.type != (uint)SpaType.CommandNode) + return; Action? handler = self.CommandReceived; - if (handler is null) return; + if (handler is null) + return; - try { handler(self, (SpaNodeCommand)command->body.body.id); } + try + { + handler(self, (SpaNodeCommand)command->body.body.id); + } catch (Exception ex) { Interlocked.Increment(ref self._processFaults); @@ -399,7 +448,8 @@ public unsafe bool IsDriving get { ObjectDisposedException.ThrowIf(_disposed, this); - if (_handle is null || !_connected) return false; + if (_handle is null || !_connected) + return false; using (_ctx.Lock()) return Native.pw_filter_is_driving(_handle.Filter); @@ -427,7 +477,8 @@ public unsafe void RemovePort(PipeWireFilterPort port) using (_ctx.Lock()) { int res = Native.pw_filter_remove_port(port.PortData); - if (res < 0) throw new PipeWireException("pw_filter_remove_port", res); + if (res < 0) + throw new PipeWireException("pw_filter_remove_port", res); } _ports.Remove(port); @@ -447,11 +498,14 @@ public unsafe void RemovePort(PipeWireFilterPort port) /// is null. /// The daemon refused the update. public unsafe int UpdateProperties( - IReadOnlyDictionary properties, PipeWireFilterPort? port = null) + IReadOnlyDictionary properties, + PipeWireFilterPort? port = null + ) { ObjectDisposedException.ThrowIf(_disposed, this); ArgumentNullException.ThrowIfNull(properties); - if (properties.Count == 0 || _handle is null) return 0; + if (properties.Count == 0 || _handle is null) + return 0; // Sized from the input rather than a fixed scratch: a long value would otherwise lose its // tail. UTF-8 is at most 4 bytes per char, plus a terminator for each key and value. @@ -463,14 +517,16 @@ public unsafe int UpdateProperties( spa_dict_item[] items = new spa_dict_item[properties.Count]; var builder = new SpaDictBuilder(scratch, items); - foreach (KeyValuePair kv in properties) builder.Add(kv.Key, kv.Value); + foreach (KeyValuePair kv in properties) + builder.Add(kv.Key, kv.Value); spa_dict native = builder.Build(); using (_ctx.Lock()) { int res = Native.pw_filter_update_properties(_handle.Filter, port?.PortData, &native); - if (res < 0) throw new PipeWireException("pw_filter_update_properties", res); + if (res < 0) + throw new PipeWireException("pw_filter_update_properties", res); return res; } } @@ -491,7 +547,9 @@ public unsafe void SetError(int result, string message) ObjectDisposedException.ThrowIf(_disposed, this); ArgumentNullException.ThrowIfNull(message); if (_handle is null || !_connected) - throw new InvalidOperationException("connect the filter before reporting an error on it."); + throw new InvalidOperationException( + "connect the filter before reporting an error on it." + ); using (_ctx.Lock()) { @@ -519,10 +577,12 @@ public unsafe void TriggerProcess() // whichever node actually drives, and a node that does not implement RequestProcess // answers ENOTSUP and the daemon logs it - once per call, so a caller triggering at // frame rate fills the log and changes nothing. - if (!Native.pw_filter_is_driving(_handle.Filter)) return; + if (!Native.pw_filter_is_driving(_handle.Filter)) + return; int rc = Native.pw_filter_trigger_process(_handle.Filter); - if (rc < 0) throw new PipeWireInteropException("pw_filter_trigger_process", rc); + if (rc < 0) + throw new PipeWireInteropException("pw_filter_trigger_process", rc); } } @@ -544,7 +604,8 @@ public unsafe void SetActive(bool active) using (_ctx.Lock()) { int rc = Native.pw_filter_set_active(_handle.Filter, active); - if (rc < 0) throw new PipeWireInteropException("pw_filter_set_active", rc); + if (rc < 0) + throw new PipeWireInteropException("pw_filter_set_active", rc); } } @@ -636,20 +697,25 @@ public unsafe PipeWireFilterPort AddPort( PipeWirePortDirection direction, string name, PipeWireDspFormat format, - IReadOnlyDictionary? properties = null) + IReadOnlyDictionary? properties = null + ) { ObjectDisposedException.ThrowIf(_disposed, this); ArgumentException.ThrowIfNullOrEmpty(name); if (_connected) - throw new InvalidOperationException("ports must be added before the filter is connected."); + throw new InvalidOperationException( + "ports must be added before the filter is connected." + ); SpaDirection spaDirection = direction switch { PipeWirePortDirection.In => SpaDirection.Input, PipeWirePortDirection.Out => SpaDirection.Output, _ => throw new ArgumentException( - "a filter port is an input or an output.", nameof(direction)), + "a filter port is an input or an output.", + nameof(direction) + ), }; string dsp = format switch @@ -678,8 +744,14 @@ public unsafe PipeWireFilterPort AddPort( using (_ctx.Lock()) { portData = Native.pw_filter_add_port( - _handle!.Filter, spaDirection, PipeWireFilterPortFlags.MapBuffers, - 0, BuildProperties(properties2), null, 0); + _handle!.Filter, + spaDirection, + PipeWireFilterPortFlags.MapBuffers, + 0, + BuildProperties(properties2), + null, + 0 + ); } if (portData is null) @@ -698,7 +770,8 @@ public unsafe PipeWireFilterPort AddPort( /// PipeWire refused the connection. public async Task ConnectAsync( PipeWireFilterFlags flags = PipeWireFilterFlags.RtProcess, - CancellationToken cancellationToken = default) + CancellationToken cancellationToken = default + ) { ObjectDisposedException.ThrowIf(_disposed, this); cancellationToken.ThrowIfCancellationRequested(); @@ -708,7 +781,8 @@ public async Task ConnectAsync( // also keeps a refusal from being answered before anything is listening for it. // Marked connected only once the round-trip has actually succeeded: setting it first leaves // a filter that refused to connect claiming it did. - await CoreSync.RoundTripAsync(_ctx, () => ConnectNative(flags), cancellationToken) + await CoreSync + .RoundTripAsync(_ctx, () => ConnectNative(flags), cancellationToken) .ConfigureAwait(false); _connected = true; @@ -721,7 +795,9 @@ private unsafe int ConnectNative(PipeWireFilterFlags flags) return Native.pw_filter_connect(_handle!.Filter, flags, null, 0); } - private static unsafe pw_properties* BuildProperties(IReadOnlyDictionary? properties) + private static unsafe pw_properties* BuildProperties( + IReadOnlyDictionary? properties + ) { if (properties is null || properties.Count == 0) return null; @@ -732,12 +808,14 @@ private unsafe int ConnectNative(PipeWireFilterFlags flags) // pw_properties_new_dict copies what it is given, so these buffers only have to survive the // call - but they must not move during it, which is why the heap fallback is pinned. - Span scratch = bytes <= 512 - ? stackalloc byte[bytes] - : GC.AllocateUninitializedArray(bytes, pinned: true); - Span items = properties.Count <= 16 - ? stackalloc spa_dict_item[properties.Count] - : GC.AllocateArray(properties.Count, pinned: true); + Span scratch = + bytes <= 512 + ? stackalloc byte[bytes] + : GC.AllocateUninitializedArray(bytes, pinned: true); + Span items = + properties.Count <= 16 + ? stackalloc spa_dict_item[properties.Count] + : GC.AllocateArray(properties.Count, pinned: true); var builder = new SpaDictBuilder(scratch, items); foreach ((string key, string value) in properties) @@ -755,10 +833,12 @@ private static unsafe void OnProcessCallback(void* data, spa_io_position* positi try { var self = (PipeWireFilter?)GCHandle.FromIntPtr((nint)data).Target; - if (self is null || self._disposed) return; + if (self is null || self._disposed) + return; ProcessHandler? callback = self.ProcessCallback; - if (callback is null || position is null) return; + if (callback is null || position is null) + return; // The whole clock, not just the duration. The position area is the only timing a filter // is supposed to read - pw_filter_get_time is deprecated in favour of exactly this - so @@ -766,8 +846,15 @@ private static unsafe void OnProcessCallback(void* data, spa_io_position* positi // work or line it up against anything else on the graph. spa_io_clock* c = &position->clock; PipeWireGraphClock clock = new( - c->nsec, c->position, c->duration, - c->rate.num, c->rate.denom, c->delay, c->rate_diff, c->next_nsec); + c->nsec, + c->position, + c->duration, + c->rate.num, + c->rate.denom, + c->delay, + c->rate_diff, + c->next_nsec + ); // The graph's frame geometry for this cycle. A video port has no size of its own, so // this is the only place a filter can learn how many pixels its buffer holds - and it @@ -775,8 +862,12 @@ private static unsafe void OnProcessCallback(void* data, spa_io_position* positi spa_io_video_size* v = &position->video; self._videoCycle = new PipeWireVideoCycle( (v->flags & (uint)SpaIoVideoSizeFlags.Valid) != 0, - v->size.width, v->size.height, v->stride, - v->framerate.num, v->framerate.denom); + v->size.width, + v->size.height, + v->stride, + v->framerate.num, + v->framerate.denom + ); // Invoked directly rather than through GetInvocationList: walking one allocates, which // is why this is a single delegate and not an event. @@ -807,16 +898,26 @@ private static unsafe void OnProcessCallback(void* data, spa_io_position* positi } [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] - private static unsafe void OnIoChangedCallback(void* data, void* portData, uint id, void* area, uint size) + private static unsafe void OnIoChangedCallback( + void* data, + void* portData, + uint id, + void* area, + uint size + ) { try { var self = (PipeWireFilter?)GCHandle.FromIntPtr((nint)data).Target; - if (self is null || self._disposed) return; + if (self is null || self._disposed) + return; // The node's position area; port io areas are pw_filter's own business. if (portData is null && id == (uint)SpaIoType.Position) - self._position = area is not null && size >= (uint)sizeof(spa_io_position) ? (spa_io_position*)area : null; + self._position = + area is not null && size >= (uint)sizeof(spa_io_position) + ? (spa_io_position*)area + : null; } catch { @@ -825,13 +926,20 @@ private static unsafe void OnIoChangedCallback(void* data, void* portData, uint } [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] - private static unsafe void OnParamChangedCallback(void* data, void* portData, uint id, spa_pod* param) + private static unsafe void OnParamChangedCallback( + void* data, + void* portData, + uint id, + spa_pod* param + ) { try { var self = (PipeWireFilter?)GCHandle.FromIntPtr((nint)data).Target; - if (self is null || self._disposed || portData is null || param is null) return; - if (id != (uint)SpaParamType.Format) return; + if (self is null || self._disposed || portData is null || param is null) + return; + if (id != (uint)SpaParamType.Format) + return; PipeWireFilterPort? port = self._ports.Find(p => p.PortData == portData); if (port is { Format: PipeWireDspFormat.Rgba32FloatVideo }) @@ -841,7 +949,8 @@ private static unsafe void OnParamChangedCallback(void* data, void* portData, ui { // A native frame on the loop thread: contained, and the port keeps whatever pool the // graph gives it, which the pixel accessor refuses rather than overruns. - if (GCHandle.FromIntPtr((nint)data).Target is PipeWireFilter self) self.LogVideoBuffersFailed(ex); + if (GCHandle.FromIntPtr((nint)data).Target is PipeWireFilter self) + self.LogVideoBuffersFailed(ex); } } @@ -865,11 +974,15 @@ private static unsafe void OnParamChangedCallback(void* data, void* portData, ui /// private unsafe void DeclareVideoBuffers(PipeWireFilterPort port) { - uint width = DefaultVideoWidth, height = DefaultVideoHeight; + uint width = DefaultVideoWidth, + height = DefaultVideoHeight; spa_io_position* position = _position; - if (position is not null + if ( + position is not null && (position->video.flags & (uint)SpaIoVideoSizeFlags.Valid) != 0 - && position->video.size.width > 0 && position->video.size.height > 0) + && position->video.size.width > 0 + && position->video.size.height > 0 + ) { width = position->video.size.width; height = position->video.size.height; @@ -896,8 +1009,10 @@ private unsafe void DeclareVideoBuffers(PipeWireFilterPort port) rc = Native.pw_filter_update_params(_handle!.Filter, port.PortData, ¶m, 1); } - if (rc < 0) LogVideoBuffersRefused(port.Name, rc); - else LogVideoBuffersDeclared(port.Name, width, height, size); + if (rc < 0) + LogVideoBuffersRefused(port.Name, rc); + else + LogVideoBuffersDeclared(port.Name, width, height, size); } // Upstream's default.video.width and height (settings.c), what the graph publishes when nothing @@ -907,17 +1022,21 @@ private unsafe void DeclareVideoBuffers(PipeWireFilterPort port) [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] private static unsafe void OnStateChangedCallback( - void* data, PipeWireFilterState old, PipeWireFilterState state, sbyte* error) + void* data, + PipeWireFilterState old, + PipeWireFilterState state, + sbyte* error + ) { try { var self = (PipeWireFilter?)GCHandle.FromIntPtr((nint)data).Target; - if (self is null || self._disposed) return; + if (self is null || self._disposed) + return; string? message = error is null ? null - : Encoding.UTF8.GetString( - DaemonText.Bytes((sbyte*)error)); + : Encoding.UTF8.GetString(DaemonText.Bytes((sbyte*)error)); self.RaiseStateChanged(old, state, message); } @@ -927,12 +1046,20 @@ private static unsafe void OnStateChangedCallback( } } - private void RaiseStateChanged(PipeWireFilterState old, PipeWireFilterState state, string? error) + private void RaiseStateChanged( + PipeWireFilterState old, + PipeWireFilterState state, + string? error + ) { LogStateChanged(Name, old, state, error); // Not the realtime path, so one throwing subscriber must not starve the rest. - SafeCallback.Raise(StateChanged, h => h(this, old, state, error), ex => LogHandlerFaulted(Name, ex)); + SafeCallback.Raise( + StateChanged, + h => h(this, old, state, error), + ex => LogHandlerFaulted(Name, ex) + ); } /// @@ -945,7 +1072,8 @@ private void RaiseStateChanged(PipeWireFilterState old, PipeWireFilterState stat /// public ValueTask DisposeAsync() { - if (_disposed) return ValueTask.CompletedTask; + if (_disposed) + return ValueTask.CompletedTask; _disposed = true; Release(); GC.SuppressFinalize(this); @@ -965,9 +1093,12 @@ private unsafe void Release() // over, so a throw in between leaves blocks and a GCHandle that nothing else will free. if (!ownedByHandle) { - if (_hook is not null) NativeMemory.Free(_hook); - if (_events is not null) NativeMemory.Free(_events); - if (_self.IsAllocated) _self.Free(); + if (_hook is not null) + NativeMemory.Free(_hook); + if (_events is not null) + NativeMemory.Free(_events); + if (_self.IsAllocated) + _self.Free(); } _hook = null; @@ -976,28 +1107,50 @@ private unsafe void Release() _ports.Clear(); } - [LoggerMessage(EventId = 33310, Level = LogLevel.Debug, - Message = "video port '{Port}' asks for {Width}x{Height} frames: buffers of {Size} bytes")] + [LoggerMessage( + EventId = 33310, + Level = LogLevel.Debug, + Message = "video port '{Port}' asks for {Width}x{Height} frames: buffers of {Size} bytes" + )] private partial void LogVideoBuffersDeclared(string port, uint width, uint height, int size); - [LoggerMessage(EventId = 33311, Level = LogLevel.Warning, - Message = "the graph refused video port '{Port}''s buffer size ({Result}); frames larger than its default pool will not be handed out")] + [LoggerMessage( + EventId = 33311, + Level = LogLevel.Warning, + Message = "the graph refused video port '{Port}''s buffer size ({Result}); frames larger than its default pool will not be handed out" + )] private partial void LogVideoBuffersRefused(string port, int result); - [LoggerMessage(EventId = 33312, Level = LogLevel.Error, - Message = "declaring a video port's buffers threw")] + [LoggerMessage( + EventId = 33312, + Level = LogLevel.Error, + Message = "declaring a video port's buffers threw" + )] private partial void LogVideoBuffersFailed(Exception exception); - [LoggerMessage(EventId = 33300, Level = LogLevel.Debug, - Message = "filter '{Name}' connected with {PortCount} port(s)")] + [LoggerMessage( + EventId = 33300, + Level = LogLevel.Debug, + Message = "filter '{Name}' connected with {PortCount} port(s)" + )] private partial void LogConnected(string name, int portCount); - [LoggerMessage(EventId = 33301, Level = LogLevel.Debug, - Message = "filter '{Name}' state {Old} -> {State} {Error}")] + [LoggerMessage( + EventId = 33301, + Level = LogLevel.Debug, + Message = "filter '{Name}' state {Old} -> {State} {Error}" + )] private partial void LogStateChanged( - string name, PipeWireFilterState old, PipeWireFilterState state, string? error); - - [LoggerMessage(EventId = 33302, Level = LogLevel.Error, - Message = "a StateChanged handler for filter '{Name}' threw")] + string name, + PipeWireFilterState old, + PipeWireFilterState state, + string? error + ); + + [LoggerMessage( + EventId = 33302, + Level = LogLevel.Error, + Message = "a StateChanged handler for filter '{Name}' threw" + )] private partial void LogHandlerFaulted(string name, Exception exception); } diff --git a/src/PipeWire.NET/Graph/PipeWireFilterPort.cs b/src/PipeWire.NET/Graph/PipeWireFilterPort.cs index ac7adf5..b01377f 100644 --- a/src/PipeWire.NET/Graph/PipeWireFilterPort.cs +++ b/src/PipeWire.NET/Graph/PipeWireFilterPort.cs @@ -28,8 +28,12 @@ public sealed unsafe class PipeWireFilterPort private readonly PipeWireDspFormat _format; internal PipeWireFilterPort( - PipeWireFilter owner, void* portData, PipeWirePortDirection direction, string name, - PipeWireDspFormat format) + PipeWireFilter owner, + void* portData, + PipeWirePortDirection direction, + string name, + PipeWireDspFormat format + ) { _owner = owner; _portData = portData; @@ -85,7 +89,8 @@ public Span GetSamples(uint sampleCount) // sequences get a typed accessor of their own when the sequence transport lands. if (_format is not PipeWireDspFormat.MonoAudio) throw new InvalidOperationException( - $"port '{Name}' carries {_format}, not audio; GetSamples is audio-only."); + $"port '{Name}' carries {_format}, not audio; GetSamples is audio-only." + ); return DspBuffer(sampleCount); } @@ -118,7 +123,8 @@ public Span GetPixels(uint width, uint height) if (_format is not PipeWireDspFormat.Rgba32FloatVideo) throw new InvalidOperationException( - $"port '{Name}' carries {_format}, not video; GetPixels is video-only."); + $"port '{Name}' carries {_format}, not video; GetPixels is video-only." + ); return DspBuffer(checked(width * height * 4)); } @@ -146,7 +152,8 @@ public Span GetPixels(uint width, uint height) private Span DspBuffer(uint floats) { pw_buffer* buf = Interop.Native.pw_filter_dequeue_buffer(_portData); - if (buf is null) return default; + if (buf is null) + return default; spa_buffer* sb = buf->buffer; if (sb is null || sb->n_datas == 0 || sb->datas is null || sb->datas[0].data is null) @@ -194,22 +201,27 @@ private Span DspBuffer(uint floats) RequireSequencePort(nameof(ReadEvents)); pw_buffer* buf = Interop.Native.pw_filter_dequeue_buffer(_portData); - if (buf is null) return null; + if (buf is null) + return null; try { spa_buffer* sb = buf->buffer; - if (sb is null || sb->n_datas == 0 || sb->datas is null) return null; + if (sb is null || sb->n_datas == 0 || sb->datas is null) + return null; spa_data* d = &sb->datas[0]; - if (d->data is null || d->chunk is null) return null; + if (d->data is null || d->chunk is null) + return null; uint size = d->chunk->size; - if (size == 0 || size > d->maxsize) return null; + if (size == 0 || size > d->maxsize) + return null; var pod = new ReadOnlySpan((byte*)d->data + d->chunk->offset, checked((int)size)); - return Spa.SpaPod.TryParse(pod, out Spa.SpaValue? parsed) && parsed is Spa.SpaSequence seq + return + Spa.SpaPod.TryParse(pod, out Spa.SpaValue? parsed) && parsed is Spa.SpaSequence seq ? seq : null; } @@ -245,15 +257,18 @@ public bool WriteEvents(scoped ReadOnlySpan events) RequireSequencePort(nameof(WriteEvents)); pw_buffer* buf = Interop.Native.pw_filter_dequeue_buffer(_portData); - if (buf is null) return false; + if (buf is null) + return false; try { spa_buffer* sb = buf->buffer; - if (sb is null || sb->n_datas == 0 || sb->datas is null) return false; + if (sb is null || sb->n_datas == 0 || sb->datas is null) + return false; spa_data* d = &sb->datas[0]; - if (d->data is null || d->chunk is null || d->maxsize == 0) return false; + if (d->data is null || d->chunk is null || d->maxsize == 0) + return false; var target = new Span(d->data, checked((int)d->maxsize)); var sequence = new Spa.SpaSequence(0, [.. events]); @@ -262,8 +277,9 @@ public bool WriteEvents(scoped ReadOnlySpan events) { throw new ArgumentException( $"the events need {Spa.SpaPod.GetByteCount(sequence)} bytes, and the port's " - + $"buffer holds {d->maxsize}.", - nameof(events)); + + $"buffer holds {d->maxsize}.", + nameof(events) + ); } d->chunk->offset = 0; @@ -284,6 +300,7 @@ private void RequireSequencePort(string member) return; throw new InvalidOperationException( - $"port '{Name}' carries {_format}, not a sequence; {member} is for MIDI and control ports."); + $"port '{Name}' carries {_format}, not a sequence; {member} is for MIDI and control ports." + ); } } diff --git a/src/PipeWire.NET/Graph/PipeWireGlobalParser.cs b/src/PipeWire.NET/Graph/PipeWireGlobalParser.cs index c9de185..d0f5827 100644 --- a/src/PipeWire.NET/Graph/PipeWireGlobalParser.cs +++ b/src/PipeWire.NET/Graph/PipeWireGlobalParser.cs @@ -28,8 +28,13 @@ internal static unsafe class PipeWireGlobalParser /// having no name would hide it from the graph entirely. /// internal static PipeWireNode ParseNode( - uint id, PipeWirePermissions permissions, uint version, PipeWireProperties props) => - new(id, + uint id, + PipeWirePermissions permissions, + uint version, + PipeWireProperties props + ) => + new( + id, props.Text(PipeWireKeys.PW_KEY_NODE_NAME), props.Text(PipeWireKeys.PW_KEY_NODE_DESCRIPTION), props.Text(PipeWireKeys.SPA_KEY_MEDIA_CLASS), @@ -42,15 +47,22 @@ internal static PipeWireNode ParseNode( props.Text(PipeWireKeys.SPA_KEY_OBJECT_PATH), props.Int(PipeWireKeys.PW_KEY_PRIORITY_DRIVER), props.Int(PipeWireKeys.PW_KEY_PRIORITY_SESSION), - props); + props + ); /// /// Builds a port, or explains why it cannot. A port needs an owning node and a direction; with /// either missing there is nowhere to file it and no way to say which way it faces. /// internal static bool TryParsePort( - uint id, PipeWirePermissions permissions, uint version, PipeWireProperties props, - out PipeWirePort? port, out string reason, out string? offendingValue) + uint id, + PipeWirePermissions permissions, + uint version, + PipeWireProperties props, + out PipeWirePort? port, + out string reason, + out string? offendingValue + ) { port = null; @@ -71,12 +83,14 @@ internal static bool TryParsePort( } port = new PipeWirePort( - id, parsedNodeId, + id, + parsedNodeId, props.Text(PipeWireKeys.PW_KEY_PORT_NAME), parsedDirection, props.Flag(PipeWireKeys.PW_KEY_PORT_MONITOR), props.Id(PipeWireKeys.PW_KEY_PORT_ID), - props.Flag(PipeWireKeys.PW_KEY_PORT_CONTROL) || parsedDirection is PipeWirePortDirection.Control, + props.Flag(PipeWireKeys.PW_KEY_PORT_CONTROL) + || parsedDirection is PipeWirePortDirection.Control, props.Flag(PipeWireKeys.PW_KEY_PORT_PHYSICAL), props.Flag(PipeWireKeys.PW_KEY_PORT_TERMINAL), props.Text(PipeWireKeys.PW_KEY_FORMAT_DSP), @@ -86,7 +100,8 @@ internal static bool TryParsePort( props.Text(PipeWireKeys.SPA_KEY_OBJECT_PATH), permissions, version, - props); + props + ); reason = string.Empty; offendingValue = null; @@ -98,23 +113,62 @@ internal static bool TryParsePort( /// any of them describes no route. /// internal static bool TryParseLink( - uint id, PipeWirePermissions permissions, uint version, PipeWireProperties props, - out PipeWireLink? link, out string reason, out string? offendingValue) + uint id, + PipeWirePermissions permissions, + uint version, + PipeWireProperties props, + out PipeWireLink? link, + out string reason, + out string? offendingValue + ) { link = null; - if (!TryReadId(props, PipeWireKeys.PW_KEY_LINK_OUTPUT_NODE, out uint outputNode, out reason, out offendingValue) || - !TryReadId(props, PipeWireKeys.PW_KEY_LINK_OUTPUT_PORT, out uint outputPort, out reason, out offendingValue) || - !TryReadId(props, PipeWireKeys.PW_KEY_LINK_INPUT_NODE, out uint inputNode, out reason, out offendingValue) || - !TryReadId(props, PipeWireKeys.PW_KEY_LINK_INPUT_PORT, out uint inputPort, out reason, out offendingValue)) + if ( + !TryReadId( + props, + PipeWireKeys.PW_KEY_LINK_OUTPUT_NODE, + out uint outputNode, + out reason, + out offendingValue + ) + || !TryReadId( + props, + PipeWireKeys.PW_KEY_LINK_OUTPUT_PORT, + out uint outputPort, + out reason, + out offendingValue + ) + || !TryReadId( + props, + PipeWireKeys.PW_KEY_LINK_INPUT_NODE, + out uint inputNode, + out reason, + out offendingValue + ) + || !TryReadId( + props, + PipeWireKeys.PW_KEY_LINK_INPUT_PORT, + out uint inputPort, + out reason, + out offendingValue + ) + ) return false; link = new PipeWireLink( - id, inputNode, inputPort, outputNode, outputPort, permissions, version, + id, + inputNode, + inputPort, + outputNode, + outputPort, + permissions, + version, props.Flag(PipeWireKeys.PW_KEY_LINK_PASSIVE), props.Id(PipeWireKeys.PW_KEY_FACTORY_ID), props.Id(PipeWireKeys.PW_KEY_CLIENT_ID), - props); + props + ); reason = string.Empty; offendingValue = null; return true; @@ -125,8 +179,15 @@ internal static bool TryParseLink( /// for having no name would hide a whole sound card from the graph. /// internal static PipeWireDevice ParseDevice( - uint id, PipeWirePermissions permissions, uint version, PipeWireProperties props) => - new(id, permissions, version, + uint id, + PipeWirePermissions permissions, + uint version, + PipeWireProperties props + ) => + new( + id, + permissions, + version, props.Text(PipeWireKeys.SPA_KEY_DEVICE_NAME), props.Text(PipeWireKeys.SPA_KEY_DEVICE_DESCRIPTION), props.Text(PipeWireKeys.SPA_KEY_DEVICE_NICK), @@ -136,12 +197,20 @@ internal static PipeWireDevice ParseDevice( props.Id(PipeWireKeys.PW_KEY_FACTORY_ID), props.Id(PipeWireKeys.PW_KEY_CLIENT_ID), props.Id(PipeWireKeys.PW_KEY_MODULE_ID), - props); + props + ); /// Builds a client. Always succeeds; every field is optional. internal static PipeWireClient ParseClient( - uint id, PipeWirePermissions permissions, uint version, PipeWireProperties props) => - new(id, permissions, version, + uint id, + PipeWirePermissions permissions, + uint version, + PipeWireProperties props + ) => + new( + id, + permissions, + version, props.Text(PipeWireKeys.PW_KEY_APP_NAME), props.Int(PipeWireKeys.PW_KEY_SEC_PID), props.Id(PipeWireKeys.PW_KEY_SEC_UID), @@ -153,46 +222,80 @@ internal static PipeWireClient ParseClient( props.Text(PipeWireKeys.PW_KEY_SEC_LABEL), props.Text(PipeWireKeys.PW_KEY_SEC_APP_ID), props.Text(PipeWireKeys.PW_KEY_SEC_INSTANCE_ID), - props); + props + ); /// Builds a factory. Always succeeds; every field is optional. internal static PipeWireFactory ParseFactory( - uint id, PipeWirePermissions permissions, uint version, PipeWireProperties props) => - new(id, permissions, version, + uint id, + PipeWirePermissions permissions, + uint version, + PipeWireProperties props + ) => + new( + id, + permissions, + version, props.Text(PipeWireKeys.PW_KEY_FACTORY_NAME), props.Text(PipeWireKeys.PW_KEY_FACTORY_TYPE_NAME), props.Id(PipeWireKeys.PW_KEY_FACTORY_TYPE_VERSION), props.Id(PipeWireKeys.PW_KEY_MODULE_ID), - props); + props + ); /// Builds a module. Always succeeds; every field is optional. internal static PipeWireModule ParseModule( - uint id, PipeWirePermissions permissions, uint version, PipeWireProperties props) => - new(id, permissions, version, + uint id, + PipeWirePermissions permissions, + uint version, + PipeWireProperties props + ) => + new( + id, + permissions, + version, props.Text(PipeWireKeys.PW_KEY_MODULE_NAME), props.Text(PipeWireKeys.PW_KEY_MODULE_DESCRIPTION), props.Text(PipeWireKeys.PW_KEY_MODULE_AUTHOR), props.Text(PipeWireKeys.PW_KEY_MODULE_VERSION), - props); + props + ); /// Builds a metadata store. Always succeeds; the name is optional. internal static PipeWireMetadata ParseMetadata( - uint id, PipeWirePermissions permissions, uint version, PipeWireProperties props) => - new(id, permissions, version, props.Text(PipeWireKeys.PW_KEY_METADATA_NAME), + uint id, + PipeWirePermissions permissions, + uint version, + PipeWireProperties props + ) => + new( + id, + permissions, + version, + props.Text(PipeWireKeys.PW_KEY_METADATA_NAME), props.Id(PipeWireKeys.PW_KEY_CLIENT_ID), props.Id(PipeWireKeys.PW_KEY_FACTORY_ID), props.Id(PipeWireKeys.PW_KEY_MODULE_ID), - props); + props + ); /// Builds the core object. Always succeeds; every field is optional. internal static PipeWireCore ParseCore( - uint id, PipeWirePermissions permissions, uint version, PipeWireProperties props) => - new(id, permissions, version, + uint id, + PipeWirePermissions permissions, + uint version, + PipeWireProperties props + ) => + new( + id, + permissions, + version, props.Text(PipeWireKeys.PW_KEY_CORE_NAME), props.Text(PipeWireKeys.PW_KEY_CORE_VERSION), props.Text(PipeWireKeys.PW_KEY_APP_PROCESS_HOST), props.Text(PipeWireKeys.PW_KEY_APP_PROCESS_USER), - props); + props + ); /// /// Reads a property that names another object and must be there. @@ -202,7 +305,12 @@ internal static PipeWireCore ParseCore( /// value travels back so the refusal can name what the daemon actually sent. /// private static bool TryReadId( - PipeWireProperties props, string key, out uint value, out string reason, out string? offendingValue) + PipeWireProperties props, + string key, + out uint value, + out string reason, + out string? offendingValue + ) { string? raw = props.GetValueOrDefault(key); if (uint.TryParse(raw, out value)) @@ -225,11 +333,21 @@ internal static bool TryParseDirection(string? value, out PipeWirePortDirection { switch (value) { - case "in": direction = PipeWirePortDirection.In; return true; - case "out": direction = PipeWirePortDirection.Out; return true; - case "control": direction = PipeWirePortDirection.Control; return true; - case "notify": direction = PipeWirePortDirection.Notify; return true; - default: direction = default; return false; + case "in": + direction = PipeWirePortDirection.In; + return true; + case "out": + direction = PipeWirePortDirection.Out; + return true; + case "control": + direction = PipeWirePortDirection.Control; + return true; + case "notify": + direction = PipeWirePortDirection.Notify; + return true; + default: + direction = default; + return false; } } @@ -245,18 +363,25 @@ internal static bool ParseBool(ReadOnlySpan value) => /// allocates. The span is valid only for the duration of the callback that supplied /// - materialise it with to keep it. /// - internal static bool TryReadValue(spa_dict* dict, ReadOnlySpan keyUtf8, out ReadOnlySpan value) + internal static bool TryReadValue( + spa_dict* dict, + ReadOnlySpan keyUtf8, + out ReadOnlySpan value + ) { value = default; - if (dict is null) return false; + if (dict is null) + return false; int nItems = (int)dict->n_items; - if (nItems == 0 || dict->items is null) return false; + if (nItems == 0 || dict->items is null) + return false; for (int i = 0; i < nItems; i++) { spa_dict_item* item = dict->items + i; - if (item->key is null) continue; + if (item->key is null) + continue; if (!DaemonText.Bytes(item->key).SequenceEqual(keyUtf8)) continue; @@ -264,7 +389,8 @@ internal static bool TryReadValue(spa_dict* dict, ReadOnlySpan keyUtf8, ou // caller would turn the empty span it used to produce back into null anyway - so the // three states the daemon can express (absent, present and null, present and empty) // collapse to two here, and only the genuinely empty one reads as an empty string. - if (item->value is null) return false; + if (item->value is null) + return false; value = DaemonText.Bytes(item->value); return true; diff --git a/src/PipeWire.NET/Graph/PipeWireGraphClock.cs b/src/PipeWire.NET/Graph/PipeWireGraphClock.cs index edb0283..03fcd50 100644 --- a/src/PipeWire.NET/Graph/PipeWireGraphClock.cs +++ b/src/PipeWire.NET/Graph/PipeWireGraphClock.cs @@ -31,4 +31,5 @@ public readonly record struct PipeWireGraphClock( uint RateDen, long Delay, double RateDiff, - ulong NextTimeNs); + ulong NextTimeNs +); diff --git a/src/PipeWire.NET/Graph/PipeWireGraphExtensions.cs b/src/PipeWire.NET/Graph/PipeWireGraphExtensions.cs index 8bc5815..f09a45c 100644 --- a/src/PipeWire.NET/Graph/PipeWireGraphExtensions.cs +++ b/src/PipeWire.NET/Graph/PipeWireGraphExtensions.cs @@ -35,7 +35,8 @@ public bool IsStillIn(PipeWireGraphSnapshot graph) { ArgumentNullException.ThrowIfNull(graph); - if (!graph.TryGetObject(held.Id, out IPipeWireObject? current)) return false; + if (!graph.TryGetObject(held.Id, out IPipeWireObject? current)) + return false; if (held.ObjectSerial is { } mine && current.ObjectSerial is { } theirs) return mine == theirs; @@ -55,11 +56,13 @@ public bool IsStillIn(PipeWireGraphSnapshot graph) /// than being refused. Picking "the first input port" is the normal way to find a link /// endpoint, and it has to not find that one. /// - public bool IsDataInput => port.PortDirection is PipeWirePortDirection.In && !port.IsControl; + public bool IsDataInput => + port.PortDirection is PipeWirePortDirection.In && !port.IsControl; /// True for a port that carries media out of its node. /// Excludes control ports, for the reason on the data input above. - public bool IsDataOutput => port.PortDirection is PipeWirePortDirection.Out && !port.IsControl; + public bool IsDataOutput => + port.PortDirection is PipeWirePortDirection.Out && !port.IsControl; /// True for a notification port (port.direction=notify). public bool IsNotify => port.PortDirection is PipeWirePortDirection.Notify; @@ -84,7 +87,10 @@ public bool IsStillIn(PipeWireGraphSnapshot graph) extension(PipeWireGraphSnapshot graph) { /// The ports of a node, in the given direction. - public IEnumerable GetPortsForNode(uint nodeId, PipeWirePortDirection direction) + public IEnumerable GetPortsForNode( + uint nodeId, + PipeWirePortDirection direction + ) { foreach (PipeWirePort port in graph.GetPortsForNode(nodeId)) if (port.PortDirection == direction) @@ -110,7 +116,8 @@ public bool CanCaptureFrom(PipeWireNode node) { ArgumentNullException.ThrowIfNull(node); foreach (PipeWirePort port in graph.GetPortsForNode(node.NodeId)) - if (port.IsDataOutput) return true; + if (port.IsDataOutput) + return true; return false; } @@ -119,7 +126,8 @@ public bool CanSendTo(PipeWireNode node) { ArgumentNullException.ThrowIfNull(node); foreach (PipeWirePort port in graph.GetPortsForNode(node.NodeId)) - if (port.IsDataInput) return true; + if (port.IsDataInput) + return true; return false; } @@ -130,11 +138,19 @@ public bool CanSendTo(PipeWireNode node) /// it in a loop. /// public ImmutableArray GetVideoSources() => - [.. graph.Nodes.Where(n => n.Media is PipeWireMediaKind.Video && graph.CanCaptureFrom(n))]; + [ + .. graph.Nodes.Where(n => + n.Media is PipeWireMediaKind.Video && graph.CanCaptureFrom(n) + ), + ]; /// Nodes carrying audio that media can actually be read from. /// Includes sinks, which are readable through their monitor ports. public ImmutableArray GetAudioSources() => - [.. graph.Nodes.Where(n => n.Media is PipeWireMediaKind.Audio && graph.CanCaptureFrom(n))]; + [ + .. graph.Nodes.Where(n => + n.Media is PipeWireMediaKind.Audio && graph.CanCaptureFrom(n) + ), + ]; } } diff --git a/src/PipeWire.NET/Graph/PipeWireGraphSnapshot.cs b/src/PipeWire.NET/Graph/PipeWireGraphSnapshot.cs index 1099e74..4628bd1 100644 --- a/src/PipeWire.NET/Graph/PipeWireGraphSnapshot.cs +++ b/src/PipeWire.NET/Graph/PipeWireGraphSnapshot.cs @@ -30,7 +30,8 @@ internal PipeWireGraphSnapshot( IEnumerable nodes, IEnumerable ports, IEnumerable links, - IEnumerable? others = null) + IEnumerable? others = null + ) { Version = version; Nodes = [.. nodes]; @@ -67,42 +68,78 @@ internal PipeWireGraphSnapshot( private object? _objectsByIdLock; private FrozenDictionary NodesById => - LazyInitializer.EnsureInitialized(ref _nodesById, ref _nodesByIdLock, - () => Nodes.ToFrozenDictionary(static n => n.NodeId)); + LazyInitializer.EnsureInitialized( + ref _nodesById, + ref _nodesByIdLock, + () => Nodes.ToFrozenDictionary(static n => n.NodeId) + ); private FrozenDictionary PortsById => - LazyInitializer.EnsureInitialized(ref _portsById, ref _portsByIdLock, - () => Ports.ToFrozenDictionary(static p => p.PortId)); + LazyInitializer.EnsureInitialized( + ref _portsById, + ref _portsByIdLock, + () => Ports.ToFrozenDictionary(static p => p.PortId) + ); private FrozenDictionary LinksById => - LazyInitializer.EnsureInitialized(ref _linksById, ref _linksByIdLock, - () => Links.ToFrozenDictionary(static l => l.LinkId)); + LazyInitializer.EnsureInitialized( + ref _linksById, + ref _linksByIdLock, + () => Links.ToFrozenDictionary(static l => l.LinkId) + ); private FrozenDictionary> LinksByNode => - LazyInitializer.EnsureInitialized(ref _linksByNode, ref _linksByNodeLock, - () => Links.SelectMany(l => new[] { (Node: l.InputNodeId, Link: l), (Node: l.OutputNodeId, Link: l) }) - .GroupBy(static x => x.Node) - .ToFrozenDictionary(static g => g.Key, - static g => g.Select(static x => x.Link).Distinct().ToImmutableArray())); + LazyInitializer.EnsureInitialized( + ref _linksByNode, + ref _linksByNodeLock, + () => + Links + .SelectMany(l => + new[] { (Node: l.InputNodeId, Link: l), (Node: l.OutputNodeId, Link: l) } + ) + .GroupBy(static x => x.Node) + .ToFrozenDictionary( + static g => g.Key, + static g => g.Select(static x => x.Link).Distinct().ToImmutableArray() + ) + ); private FrozenDictionary> PortsByNode => - LazyInitializer.EnsureInitialized(ref _portsByNode, ref _portsByNodeLock, - () => Ports.GroupBy(static p => p.NodeId) - .ToFrozenDictionary(static g => g.Key, static g => g.ToImmutableArray())); + LazyInitializer.EnsureInitialized( + ref _portsByNode, + ref _portsByNodeLock, + () => + Ports + .GroupBy(static p => p.NodeId) + .ToFrozenDictionary(static g => g.Key, static g => g.ToImmutableArray()) + ); private FrozenDictionary> InputLinksByPort => - LazyInitializer.EnsureInitialized(ref _inputLinksByPort, ref _inputLinksByPortLock, - () => Links.GroupBy(static l => l.InputPortId) - .ToFrozenDictionary(static g => g.Key, static g => g.ToImmutableArray())); + LazyInitializer.EnsureInitialized( + ref _inputLinksByPort, + ref _inputLinksByPortLock, + () => + Links + .GroupBy(static l => l.InputPortId) + .ToFrozenDictionary(static g => g.Key, static g => g.ToImmutableArray()) + ); private FrozenDictionary> OutputLinksByPort => - LazyInitializer.EnsureInitialized(ref _outputLinksByPort, ref _outputLinksByPortLock, - () => Links.GroupBy(static l => l.OutputPortId) - .ToFrozenDictionary(static g => g.Key, static g => g.ToImmutableArray())); + LazyInitializer.EnsureInitialized( + ref _outputLinksByPort, + ref _outputLinksByPortLock, + () => + Links + .GroupBy(static l => l.OutputPortId) + .ToFrozenDictionary(static g => g.Key, static g => g.ToImmutableArray()) + ); private FrozenDictionary ObjectsById => - LazyInitializer.EnsureInitialized(ref _objectsById, ref _objectsByIdLock, - () => Objects.ToFrozenDictionary(static o => o.Id)); + LazyInitializer.EnsureInitialized( + ref _objectsById, + ref _objectsByIdLock, + () => Objects.ToFrozenDictionary(static o => o.Id) + ); /// /// Increases by one per published snapshot. A local counter for spotting missed publications; @@ -142,8 +179,7 @@ internal PipeWireGraphSnapshot( public ImmutableArray Modules => OfKind(ref _modules); /// Every metadata store, such as default and settings. - public ImmutableArray Metadata => - OfKind(ref _metadata); + public ImmutableArray Metadata => OfKind(ref _metadata); /// The daemon core, or if it has not been seen yet. public PipeWireCore? Core => Single(); @@ -176,17 +212,20 @@ private ImmutableArray OfKind(ref ImmutableArray cache) where T : class, IPipeWireObject { ImmutableArray current = cache; - if (!current.IsDefault) return current; + if (!current.IsDefault) + return current; ImmutableInterlocked.InterlockedInitialize(ref cache, [.. Objects.OfType()]); return cache; } - private T? Single() where T : class, IPipeWireObject + private T? Single() + where T : class, IPipeWireObject { foreach (IPipeWireObject candidate in Objects) { - if (candidate is T match) return match; + if (candidate is T match) + return match; } return null; @@ -214,16 +253,20 @@ public bool TryGetLink(uint id, [NotNullWhen(true)] out PipeWireLink? value) => LinksById.TryGetValue(id, out value); /// The device with this id, or if the graph has none. - public PipeWireDevice? GetDevice(uint id) => ObjectsById.GetValueOrDefault(id) as PipeWireDevice; + public PipeWireDevice? GetDevice(uint id) => + ObjectsById.GetValueOrDefault(id) as PipeWireDevice; /// The client with this id, or if the graph has none. - public PipeWireClient? GetClient(uint id) => ObjectsById.GetValueOrDefault(id) as PipeWireClient; + public PipeWireClient? GetClient(uint id) => + ObjectsById.GetValueOrDefault(id) as PipeWireClient; /// The factory with this id, or if the graph has none. - public PipeWireFactory? GetFactory(uint id) => ObjectsById.GetValueOrDefault(id) as PipeWireFactory; + public PipeWireFactory? GetFactory(uint id) => + ObjectsById.GetValueOrDefault(id) as PipeWireFactory; /// The module with this id, or if the graph has none. - public PipeWireModule? GetModule(uint id) => ObjectsById.GetValueOrDefault(id) as PipeWireModule; + public PipeWireModule? GetModule(uint id) => + ObjectsById.GetValueOrDefault(id) as PipeWireModule; /// The metadata store with this name, or if there is none. /// The store name, such as default. @@ -241,10 +284,11 @@ public bool TryGetLink(uint id, [NotNullWhen(true)] out PipeWireLink? value) => /// Looks up any object by id, whatever kind it is. public bool TryGetObject(uint id, [NotNullWhen(true)] out IPipeWireObject? value) { - value = GetNode(id) - ?? (IPipeWireObject?)GetPort(id) - ?? GetLink(id) - ?? ObjectsById.GetValueOrDefault(id); + value = + GetNode(id) + ?? (IPipeWireObject?)GetPort(id) + ?? GetLink(id) + ?? ObjectsById.GetValueOrDefault(id); return value is not null; } diff --git a/src/PipeWire.NET/Graph/PipeWireLatency.cs b/src/PipeWire.NET/Graph/PipeWireLatency.cs index 68af92b..0edf850 100644 --- a/src/PipeWire.NET/Graph/PipeWireLatency.cs +++ b/src/PipeWire.NET/Graph/PipeWireLatency.cs @@ -27,7 +27,8 @@ public sealed record PipeWireLatency( int MinRate, int MaxRate, long MinNs, - long MaxNs) + long MaxNs +) { /// Reads one out of a SPA_PARAM_Latency object, or null if it is not one. /// @@ -37,7 +38,8 @@ public sealed record PipeWireLatency( /// public static PipeWireLatency? From(SpaObject? param) { - if (param is null || param.ObjectType != SpaType.ObjectParamLatency) return null; + if (param is null || param.ObjectType != SpaType.ObjectParamLatency) + return null; return new PipeWireLatency( (SpaDirection)Id(param, SpaParamLatency.Direction), @@ -46,21 +48,25 @@ public sealed record PipeWireLatency( Int(param, SpaParamLatency.MinRate), Int(param, SpaParamLatency.MaxRate), Long(param, SpaParamLatency.MinNs), - Long(param, SpaParamLatency.MaxNs)); + Long(param, SpaParamLatency.MaxNs) + ); } /// This latency as the parameter object the daemon expects. public SpaObject ToParameter() => - new(SpaType.ObjectParamLatency, SpaParamType.Latency, - [ - new SpaPodProperty((uint)SpaParamLatency.Direction, 0, new SpaId((uint)Direction)), - new SpaPodProperty((uint)SpaParamLatency.MinQuantum, 0, new SpaFloat(MinQuantum)), - new SpaPodProperty((uint)SpaParamLatency.MaxQuantum, 0, new SpaFloat(MaxQuantum)), - new SpaPodProperty((uint)SpaParamLatency.MinRate, 0, new SpaInt(MinRate)), - new SpaPodProperty((uint)SpaParamLatency.MaxRate, 0, new SpaInt(MaxRate)), - new SpaPodProperty((uint)SpaParamLatency.MinNs, 0, new SpaLong(MinNs)), - new SpaPodProperty((uint)SpaParamLatency.MaxNs, 0, new SpaLong(MaxNs)), - ]); + new( + SpaType.ObjectParamLatency, + SpaParamType.Latency, + [ + new SpaPodProperty((uint)SpaParamLatency.Direction, 0, new SpaId((uint)Direction)), + new SpaPodProperty((uint)SpaParamLatency.MinQuantum, 0, new SpaFloat(MinQuantum)), + new SpaPodProperty((uint)SpaParamLatency.MaxQuantum, 0, new SpaFloat(MaxQuantum)), + new SpaPodProperty((uint)SpaParamLatency.MinRate, 0, new SpaInt(MinRate)), + new SpaPodProperty((uint)SpaParamLatency.MaxRate, 0, new SpaInt(MaxRate)), + new SpaPodProperty((uint)SpaParamLatency.MinNs, 0, new SpaLong(MinNs)), + new SpaPodProperty((uint)SpaParamLatency.MaxNs, 0, new SpaLong(MaxNs)), + ] + ); private static uint Id(SpaObject o, SpaParamLatency key) => o[(uint)key] is SpaId v ? v.Value : 0; diff --git a/src/PipeWire.NET/Graph/PipeWireLink.cs b/src/PipeWire.NET/Graph/PipeWireLink.cs index d861348..25c6e66 100644 --- a/src/PipeWire.NET/Graph/PipeWireLink.cs +++ b/src/PipeWire.NET/Graph/PipeWireLink.cs @@ -21,7 +21,8 @@ internal PipeWireLink( bool IsPassive = false, uint? FactoryId = null, uint? ClientId = null, - PipeWireProperties? Properties = null) + PipeWireProperties? Properties = null + ) { this.Properties = Properties ?? PipeWireProperties.Empty; this.ObjectSerial = this.Properties.Serial; diff --git a/src/PipeWire.NET/Graph/PipeWireLinkBuilder.cs b/src/PipeWire.NET/Graph/PipeWireLinkBuilder.cs index 2a0ce9c..d511401 100644 --- a/src/PipeWire.NET/Graph/PipeWireLinkBuilder.cs +++ b/src/PipeWire.NET/Graph/PipeWireLinkBuilder.cs @@ -27,7 +27,11 @@ public readonly struct PipeWireLinkBuilder private readonly PipeWireObjectOptions _options; internal PipeWireLinkBuilder( - PipeWireRegistry registry, PipeWirePort output, PipeWirePort input, PipeWireObjectOptions options) + PipeWireRegistry registry, + PipeWirePort output, + PipeWirePort input, + PipeWireObjectOptions options + ) { _registry = registry; _output = output; @@ -54,11 +58,19 @@ public PipeWireLinkBuilder WithProperty(string key, string value) ArgumentNullException.ThrowIfNull(value); PipeWireObjectOptions.ThrowIfReserved(key, forLink: true); - ImmutableArray> existing = - _options.Properties.IsDefault ? [] : _options.Properties; + ImmutableArray> existing = _options.Properties.IsDefault + ? [] + : _options.Properties; - return new(_registry, _output, _input, - _options with { Properties = existing.Add(new(key, value)) }); + return new( + _registry, + _output, + _input, + _options with + { + Properties = existing.Add(new(key, value)), + } + ); } /// diff --git a/src/PipeWire.NET/Graph/PipeWireLinkProxy.cs b/src/PipeWire.NET/Graph/PipeWireLinkProxy.cs index ef3c1d4..2c732c9 100644 --- a/src/PipeWire.NET/Graph/PipeWireLinkProxy.cs +++ b/src/PipeWire.NET/Graph/PipeWireLinkProxy.cs @@ -27,8 +27,9 @@ namespace PipeWire.NET.Graph; public sealed partial class PipeWireLinkProxy : IDisposable, IAsyncDisposable { private readonly ILogger _logger; - private readonly TaskCompletionSource _ready = - new(TaskCreationOptions.RunContinuationsAsynchronously); + private readonly TaskCompletionSource _ready = new( + TaskCreationOptions.RunContinuationsAsynchronously + ); private BoundProxy? _bound; private volatile bool _disposed; @@ -53,7 +54,8 @@ private sealed record LinkSnapshot( uint OutputNodeId, uint OutputPortId, uint InputNodeId, - uint InputPortId); + uint InputPortId + ); /// The global id of the link this is bound to. public uint LinkId { get; } @@ -112,8 +114,13 @@ private sealed record LinkSnapshot( public event Action? StateChanged; internal static unsafe PipeWireLinkProxy Bind( - PipeWireContext ctx, pw_registry* registry, uint id, uint version, ILogger logger, - Action? propertiesObserved = null) + PipeWireContext ctx, + pw_registry* registry, + uint id, + uint version, + ILogger logger, + Action? propertiesObserved = null + ) { // The observer is in place before the proxy is bound: the first info after a bind is the // only one that carries the object's properties (later ones set no PROPS in their change @@ -122,7 +129,12 @@ internal static unsafe PipeWireLinkProxy Bind( // properties never reached the registry. var control = new PipeWireLinkProxy(id, logger) { PropertiesObserved = propertiesObserved }; control._bound = BoundProxy.Bind( - ctx, registry, id, PipeWireKeys.PW_TYPE_INTERFACE_Link, version, NativeConstants.PW_VERSION_LINK, + ctx, + registry, + id, + PipeWireKeys.PW_TYPE_INTERFACE_Link, + version, + NativeConstants.PW_VERSION_LINK, sizeof(pw_link_events), events => { @@ -130,9 +142,15 @@ internal static unsafe PipeWireLinkProxy Bind( table->version = NativeConstants.PW_VERSION_LINK_EVENTS; table->info = &OnInfo; }, - static (proxy, hook, events, data) => Native.pw_link_add_listener( - (pw_link*)proxy, (spa_hook*)hook, (pw_link_events*)events, (void*)data), - control); + static (proxy, hook, events, data) => + Native.pw_link_add_listener( + (pw_link*)proxy, + (spa_hook*)hook, + (pw_link_events*)events, + (void*)data + ), + control + ); control._bound.Removed = control.RaiseRemoved; @@ -153,14 +171,19 @@ internal static unsafe PipeWireLinkProxy Bind( private void RaiseRemoved() { Action? handler = Removed; - if (handler is null) return; + if (handler is null) + return; // A native callback frame, so nothing may escape it. - try { handler(); } - catch (Exception) { /* a subscriber that throws must not reach the daemon */ } + try + { + handler(); + } + catch (Exception) + { /* a subscriber that throws must not reach the daemon */ + } } - /// Waits for the daemon's first report about this link. /// Abandons the wait. /// @@ -174,19 +197,21 @@ public Task ReadyAsync(CancellationToken cancellationToken = default) } [System.Runtime.InteropServices.UnmanagedCallersOnly( - CallConvs = [typeof(System.Runtime.CompilerServices.CallConvCdecl)])] + CallConvs = [typeof(System.Runtime.CompilerServices.CallConvCdecl)] + )] private static unsafe void OnInfo(void* data, pw_link_info* info) { // An exception escaping a reverse P/Invoke aborts the process, so nothing below may throw. try { - if (data is null || info is null) return; - if (GCHandle.FromIntPtr((nint)data).Target is not PipeWireLinkProxy self) return; - if (self._disposed) return; + if (data is null || info is null) + return; + if (GCHandle.FromIntPtr((nint)data).Target is not PipeWireLinkProxy self) + return; + if (self._disposed) + return; - string? error = info->error is null - ? null - : DaemonText.String(info->error); + string? error = info->error is null ? null : DaemonText.String(info->error); // The error only means anything in the error state. Carrying a stale one alongside a // recovered link would have a caller reporting a failure that is over. @@ -196,7 +221,8 @@ private static unsafe void OnInfo(void* data, pw_link_info* info) info->output_node_id, info->output_port_id, info->input_node_id, - info->input_port_id); + info->input_port_id + ); self._snapshot = snapshot; self._ready.TrySetResult(); @@ -211,15 +237,21 @@ private static unsafe void OnInfo(void* data, pw_link_info* info) { // Reached only if the marshalling above fails, which would mean the daemon sent // something the struct does not describe. - try { Console.Error.WriteLine($"PipeWire.NET link info callback faulted: {ex}"); } - catch (IOException) { /* Deliberately not logged: nothing left that could report it. */ } + try + { + Console.Error.WriteLine($"PipeWire.NET link info callback faulted: {ex}"); + } + catch (IOException) + { /* Deliberately not logged: nothing left that could report it. */ + } } } /// public void Dispose() { - if (_disposed) return; + if (_disposed) + return; _disposed = true; _ready.TrySetCanceled(); @@ -234,11 +266,17 @@ public ValueTask DisposeAsync() return ValueTask.CompletedTask; } - [LoggerMessage(EventId = 34500, Level = LogLevel.Debug, - Message = "link {LinkId} is {State}{Error}")] + [LoggerMessage( + EventId = 34500, + Level = LogLevel.Debug, + Message = "link {LinkId} is {State}{Error}" + )] private partial void LogState(uint linkId, PipeWireLinkState state, string? error); - [LoggerMessage(EventId = 34501, Level = LogLevel.Warning, - Message = "a link state handler threw")] + [LoggerMessage( + EventId = 34501, + Level = LogLevel.Warning, + Message = "a link state handler threw" + )] private partial void LogHandlerFaulted(Exception exception); } diff --git a/src/PipeWire.NET/Graph/PipeWireMetadata.cs b/src/PipeWire.NET/Graph/PipeWireMetadata.cs index 44c88fc..7d55f43 100644 --- a/src/PipeWire.NET/Graph/PipeWireMetadata.cs +++ b/src/PipeWire.NET/Graph/PipeWireMetadata.cs @@ -26,7 +26,8 @@ internal PipeWireMetadata( uint? ClientId = null, uint? FactoryId = null, uint? ModuleId = null, - PipeWireProperties? Properties = null) + PipeWireProperties? Properties = null + ) { this.Properties = Properties ?? PipeWireProperties.Empty; this.ObjectSerial = this.Properties.Serial; diff --git a/src/PipeWire.NET/Graph/PipeWireMetadataEntry.cs b/src/PipeWire.NET/Graph/PipeWireMetadataEntry.cs index 7fa420a..53c89ac 100644 --- a/src/PipeWire.NET/Graph/PipeWireMetadataEntry.cs +++ b/src/PipeWire.NET/Graph/PipeWireMetadataEntry.cs @@ -31,14 +31,16 @@ public string? NameValue { get { - if (string.IsNullOrEmpty(Value)) return null; + if (string.IsNullOrEmpty(Value)) + return null; try { using var document = JsonDocument.Parse(Value); - return document.RootElement.ValueKind == JsonValueKind.Object - && document.RootElement.TryGetProperty("name", out JsonElement name) - && name.ValueKind == JsonValueKind.String + return + document.RootElement.ValueKind == JsonValueKind.Object + && document.RootElement.TryGetProperty("name", out JsonElement name) + && name.ValueKind == JsonValueKind.String ? name.GetString() : null; } diff --git a/src/PipeWire.NET/Graph/PipeWireMetadataProvider.cs b/src/PipeWire.NET/Graph/PipeWireMetadataProvider.cs index ef8d3d8..a5c91ad 100644 --- a/src/PipeWire.NET/Graph/PipeWireMetadataProvider.cs +++ b/src/PipeWire.NET/Graph/PipeWireMetadataProvider.cs @@ -50,7 +50,10 @@ public sealed unsafe partial class PipeWireMetadataProvider : IDisposable, IAsyn { private readonly PipeWireContext _ctx; private readonly ILogger _logger; - private readonly ConcurrentDictionary<(uint Subject, string Key), PipeWireMetadataEntry> _entries = new(); + private readonly ConcurrentDictionary< + (uint Subject, string Key), + PipeWireMetadataEntry + > _entries = new(); private PipeWireImplMetadataHandle? _handle; @@ -90,7 +93,9 @@ private PipeWireMetadataProvider(PipeWireContext ctx, string name, ILogger logge public string? Get(string key, uint subject = PipeWireMetadataProxy.SubjectCore) { ArgumentNullException.ThrowIfNull(key); - return _entries.TryGetValue((subject, key), out PipeWireMetadataEntry? entry) ? entry.Value : null; + return _entries.TryGetValue((subject, key), out PipeWireMetadataEntry? entry) + ? entry.Value + : null; } /// @@ -129,12 +134,20 @@ private PipeWireMetadataProvider(PipeWireContext ctx, string name, ILogger logge /// /// is null or empty. /// The daemon refused to register it. - public static PipeWireMetadataProvider Create(PipeWireContext ctx, string name, bool export = true) + public static PipeWireMetadataProvider Create( + PipeWireContext ctx, + string name, + bool export = true + ) { ArgumentNullException.ThrowIfNull(ctx); ArgumentException.ThrowIfNullOrEmpty(name); - var provider = new PipeWireMetadataProvider(ctx, name, ctx.LoggerFactory.CreateLogger("PipeWire.NET.MetadataProvider")) + var provider = new PipeWireMetadataProvider( + ctx, + name, + ctx.LoggerFactory.CreateLogger("PipeWire.NET.MetadataProvider") + ) { _export = export, }; @@ -168,10 +181,17 @@ private void Start() pw_impl_metadata* impl; fixed (byte* n = nameUtf8) impl = Native.pw_context_create_metadata( - _ctx.ContextHandle, (sbyte*)n, Native.pw_properties_new_dict(&dict), 0); + _ctx.ContextHandle, + (sbyte*)n, + Native.pw_properties_new_dict(&dict), + 0 + ); if (impl is null) - throw new PipeWireInteropException("pw_context_create_metadata", -NativeLibc.ENOMEM); + throw new PipeWireInteropException( + "pw_context_create_metadata", + -NativeLibc.ENOMEM + ); _handle = new PipeWireImplMetadataHandle(impl, _ctx.LoopOwner); @@ -185,13 +205,21 @@ private void Start() _handle.OwnListener(_events, _hook, _self); - Native.pw_impl_metadata_add_listener(impl, _hook, table, (void*)GCHandle.ToIntPtr(_self)); + Native.pw_impl_metadata_add_listener( + impl, + _hook, + table, + (void*)GCHandle.ToIntPtr(_self) + ); // Register publishes a global in this client's own context. Exporting is what makes it // reach the daemon, and through it every other client. if (!_export) { - int rc = Native.pw_impl_metadata_register(impl, Native.pw_properties_new_dict(&dict)); + int rc = Native.pw_impl_metadata_register( + impl, + Native.pw_properties_new_dict(&dict) + ); if (rc < 0) throw new PipeWireInteropException("pw_impl_metadata_register", rc); } @@ -208,7 +236,9 @@ private void Start() exportProps.Add(NativeConstants.PW_KEY_METADATA_NAME, Name); spa_dict exportDict = exportProps.Build(); - ReadOnlySpan typeUtf8 = Encoding.UTF8.GetBytes(PipeWireKeys.PW_TYPE_INTERFACE_Metadata + '\0'); + ReadOnlySpan typeUtf8 = Encoding.UTF8.GetBytes( + PipeWireKeys.PW_TYPE_INTERFACE_Metadata + '\0' + ); pw_proxy* exported = null; if (_export) @@ -220,12 +250,20 @@ private void Start() pw_metadata* implementation = Native.pw_impl_metadata_get_implementation(impl); if (implementation is null) - throw new PipeWireInteropException("pw_impl_metadata_get_implementation", -NativeLibc.EINVAL); + throw new PipeWireInteropException( + "pw_impl_metadata_get_implementation", + -NativeLibc.EINVAL + ); fixed (byte* t = typeUtf8) { exported = Native.pw_core_export( - _ctx.CoreHandle, (sbyte*)t, &exportDict, implementation, 0); + _ctx.CoreHandle, + (sbyte*)t, + &exportDict, + implementation, + 0 + ); } } @@ -250,7 +288,8 @@ internal unsafe uint? ServedGlobalId get { PipeWireProxyHandle? exported = _exported; - if (exported is null || exported.IsInvalid) return null; + if (exported is null || exported.IsInvalid) + return null; uint id = Native.pw_proxy_get_bound_id(exported.Proxy); return id == NativeConstants.SPA_ID_INVALID ? null : id; } @@ -296,7 +335,8 @@ public void Set( string key, string? value, string? type = null, - uint subject = PipeWireMetadataProxy.SubjectCore) + uint subject = PipeWireMetadataProxy.SubjectCore + ) { ArgumentNullException.ThrowIfNull(key); ThrowIfContainsNul(key, nameof(key)); @@ -325,7 +365,9 @@ private void SetProperty(uint subject, string? key, string? type, string? value) ReadOnlySpan keyUtf8 = key is null ? default : Encoding.UTF8.GetBytes(key + '\0'); ReadOnlySpan typeUtf8 = type is null ? default : Encoding.UTF8.GetBytes(type + '\0'); - ReadOnlySpan valueUtf8 = value is null ? default : Encoding.UTF8.GetBytes(value + '\0'); + ReadOnlySpan valueUtf8 = value is null + ? default + : Encoding.UTF8.GetBytes(value + '\0'); // No round-trip: the store is ours, so the write lands in this process and the callback // that updates the cache runs before this returns. @@ -339,12 +381,15 @@ private void SetProperty(uint subject, string? key, string? type, string? value) fixed (byte* v = valueUtf8) { int rc = Native.pw_impl_metadata_set_property( - _handle.Metadata, subject, + _handle.Metadata, + subject, key is null ? null : (sbyte*)k, type is null ? null : (sbyte*)t, - value is null ? null : (sbyte*)v); + value is null ? null : (sbyte*)v + ); - if (rc < 0) throw new PipeWireInteropException("pw_impl_metadata_set_property", rc); + if (rc < 0) + throw new PipeWireInteropException("pw_impl_metadata_set_property", rc); } } } @@ -370,15 +415,23 @@ public void Clear() } [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] - private static int OnPropertyCallback(void* data, uint subject, sbyte* key, sbyte* type, sbyte* value) + private static int OnPropertyCallback( + void* data, + uint subject, + sbyte* key, + sbyte* type, + sbyte* value + ) { - if (data is null) return 0; + if (data is null) + return 0; PipeWireMetadataProvider self; try { // A freed handle throws out of the lookup, and this is a native frame. - if (GCHandle.FromIntPtr((nint)data).Target is not PipeWireMetadataProvider found) return 0; + if (GCHandle.FromIntPtr((nint)data).Target is not PipeWireMetadataProvider found) + return 0; self = found; } catch (Exception) @@ -416,9 +469,12 @@ private void Apply(uint subject, string? key, string? type, string? value) foreach ((uint Subject, string Key) existing in _entries.Keys) { - if (!everySubject && existing.Subject != subject) continue; + if (!everySubject && existing.Subject != subject) + continue; if (_entries.TryRemove(existing, out PipeWireMetadataEntry? removed)) - Raise(new PipeWireMetadataEntry(removed.Subject, removed.Key, removed.Type, null)); + Raise( + new PipeWireMetadataEntry(removed.Subject, removed.Key, removed.Type, null) + ); } return; @@ -426,8 +482,10 @@ private void Apply(uint subject, string? key, string? type, string? value) var entry = new PipeWireMetadataEntry(subject, key, type, value); - if (value is null) _entries.TryRemove((subject, key), out _); - else _entries[(subject, key)] = entry; + if (value is null) + _entries.TryRemove((subject, key), out _); + else + _entries[(subject, key)] = entry; Raise(entry); } @@ -449,7 +507,8 @@ public ValueTask DisposeAsync() private void DisposeCore() { - if (_disposed) return; + if (_disposed) + return; _disposed = true; // The listener's memory goes with the handle, freed after the implementation is destroyed. @@ -466,23 +525,40 @@ private void DisposeCore() _entries.Clear(); } - [LoggerMessage(EventId = 34203, Level = LogLevel.Information, - Message = "exported metadata store {Name}; other clients can bind it")] + [LoggerMessage( + EventId = 34203, + Level = LogLevel.Information, + Message = "exported metadata store {Name}; other clients can bind it" + )] private partial void LogExported(string name); - [LoggerMessage(EventId = 34204, Level = LogLevel.Warning, + [LoggerMessage( + EventId = 34204, + Level = LogLevel.Warning, Message = "metadata store {Name} could not be exported, so only this client can see it. " - + "The context has no export type for Metadata, which libpipewire-module-metadata " - + "registers and client.conf loads unless module.metadata is turned off.")] + + "The context has no export type for Metadata, which libpipewire-module-metadata " + + "registers and client.conf loads unless module.metadata is turned off." + )] private partial void LogNotExported(string name); - [LoggerMessage(EventId = 34200, Level = LogLevel.Information, Message = "serving metadata store {Name}")] + [LoggerMessage( + EventId = 34200, + Level = LogLevel.Information, + Message = "serving metadata store {Name}" + )] private partial void LogRegistered(string name); - [LoggerMessage(EventId = 34201, Level = LogLevel.Error, Message = "dispatching a metadata change failed")] + [LoggerMessage( + EventId = 34201, + Level = LogLevel.Error, + Message = "dispatching a metadata change failed" + )] private partial void LogDispatchFailed(Exception ex); - [LoggerMessage(EventId = 34202, Level = LogLevel.Error, - Message = "an EntryChanged handler for store {Name} threw")] + [LoggerMessage( + EventId = 34202, + Level = LogLevel.Error, + Message = "an EntryChanged handler for store {Name} threw" + )] private partial void LogHandlerFaulted(string name, Exception ex); } diff --git a/src/PipeWire.NET/Graph/PipeWireMetadataProxy.cs b/src/PipeWire.NET/Graph/PipeWireMetadataProxy.cs index d07b50f..70e4faf 100644 --- a/src/PipeWire.NET/Graph/PipeWireMetadataProxy.cs +++ b/src/PipeWire.NET/Graph/PipeWireMetadataProxy.cs @@ -1,6 +1,6 @@ using System.Collections.Concurrent; -using System.Globalization; using System.Diagnostics; +using System.Globalization; using System.Runtime.CompilerServices; using System.Runtime.InteropServices; using System.Runtime.Versioning; @@ -38,7 +38,10 @@ public sealed partial class PipeWireMetadataProxy : IDisposable, IAsyncDisposabl private readonly PipeWireContext _ctx; private readonly ILogger _logger; - private readonly ConcurrentDictionary<(uint Subject, string Key), PipeWireMetadataEntry> _entries = new(); + private readonly ConcurrentDictionary< + (uint Subject, string Key), + PipeWireMetadataEntry + > _entries = new(); private readonly MetadataReconciler _reconciler = new(TimeSpan.FromSeconds(5)); @@ -67,11 +70,21 @@ private PipeWireMetadataProxy(PipeWireContext ctx, uint id, ILogger logger) public event Action? EntryChanged; internal static unsafe PipeWireMetadataProxy Bind( - PipeWireContext ctx, pw_registry* registry, uint id, uint version, ILogger logger) + PipeWireContext ctx, + pw_registry* registry, + uint id, + uint version, + ILogger logger + ) { var store = new PipeWireMetadataProxy(ctx, id, logger); store._bound = BoundProxy.Bind( - ctx, registry, id, PipeWireKeys.PW_TYPE_INTERFACE_Metadata, version, NativeConstants.PW_VERSION_METADATA, + ctx, + registry, + id, + PipeWireKeys.PW_TYPE_INTERFACE_Metadata, + version, + NativeConstants.PW_VERSION_METADATA, sizeof(pw_metadata_events), events => { @@ -79,9 +92,15 @@ internal static unsafe PipeWireMetadataProxy Bind( table->version = NativeConstants.PW_VERSION_METADATA_EVENTS; table->property = &OnPropertyCallback; }, - static (proxy, hook, events, data) => Native.pw_metadata_add_listener( - (pw_metadata*)proxy, (spa_hook*)hook, (pw_metadata_events*)events, (void*)data), - store); + static (proxy, hook, events, data) => + Native.pw_metadata_add_listener( + (pw_metadata*)proxy, + (spa_hook*)hook, + (pw_metadata_events*)events, + (void*)data + ), + store + ); store._bound.Removed = store.RaiseRemoved; @@ -102,14 +121,19 @@ internal static unsafe PipeWireMetadataProxy Bind( private void RaiseRemoved() { Action? handler = Removed; - if (handler is null) return; + if (handler is null) + return; // A native callback frame, so nothing may escape it. - try { handler(); } - catch (Exception) { /* a subscriber that throws must not reach the daemon */ } + try + { + handler(); + } + catch (Exception) + { /* a subscriber that throws must not reach the daemon */ + } } - /// /// Waits for the store to have sent everything it holds. /// @@ -151,7 +175,9 @@ private static void ThrowIfContainsNul(string? text, string paramName) public string? Get(string key, uint subject = SubjectCore) { ArgumentNullException.ThrowIfNull(key); - return _entries.TryGetValue((subject, key), out PipeWireMetadataEntry? entry) ? entry.Value : null; + return _entries.TryGetValue((subject, key), out PipeWireMetadataEntry? entry) + ? entry.Value + : null; } /// @@ -186,7 +212,8 @@ public async Task SetAsync( string? value, string? type = null, uint subject = SubjectCore, - CancellationToken cancellationToken = default) + CancellationToken cancellationToken = default + ) { ArgumentNullException.ThrowIfNull(key); ThrowIfContainsNul(key, nameof(key)); @@ -227,7 +254,8 @@ public async Task SetAsync( issued = true; return Write(subject, key, type, value); }, - cancellationToken); + cancellationToken + ); // Raised here, not when the echo lands. A write superseded before its echo arrives // never gets one, and a caller should still be told about a change it made. Skipped @@ -258,8 +286,10 @@ public async Task SetAsync( // does not roll back - the request is already on its way and the daemon may still apply // it, so the optimistic value remains the better guess there. Rollback(subject, key, applied, previous, didApply); - if (!issued) _reconciler.Forget(subject, key, pending); - else _reconciler.Settle(subject, key); + if (!issued) + _reconciler.Forget(subject, key, pending); + else + _reconciler.Settle(subject, key); throw; } catch @@ -279,8 +309,10 @@ public async Task SetAsync( // Records age from acknowledgement, and a write that went out and then failed will // never get one. Starting the clock here is what keeps it from being kept for ever; // it stays recognisable for the window, which is the whole reason it was not forgotten. - if (!issued) _reconciler.Forget(subject, key, pending); - else _reconciler.Settle(subject, key); + if (!issued) + _reconciler.Forget(subject, key, pending); + else + _reconciler.Settle(subject, key); throw; } } @@ -299,9 +331,11 @@ private void Rollback( string key, PipeWireMetadataEntry applied, PipeWireMetadataEntry? previous, - bool didApply) + bool didApply + ) { - if (!didApply) return; + if (!didApply) + return; lock (_clearGate) { @@ -310,9 +344,10 @@ private void Rollback( bool stillOurs = now is null ? applied.Value is null : string.Equals(now.Value, applied.Value, StringComparison.Ordinal) - && string.Equals(now.Type, applied.Type, StringComparison.Ordinal); + && string.Equals(now.Type, applied.Type, StringComparison.Ordinal); - if (!stillOurs) return; + if (!stillOurs) + return; if (previous is null) _entries.TryRemove((subject, key), out _); @@ -400,8 +435,13 @@ private void Apply(PipeWireMetadataEntry entry) public Task SetForcedQuantumAsync(int samples, CancellationToken cancellationToken = default) { ArgumentOutOfRangeException.ThrowIfNegative(samples); - return SetAsync("clock.force-quantum", samples.ToString(CultureInfo.InvariantCulture), - null, SubjectCore, cancellationToken); + return SetAsync( + "clock.force-quantum", + samples.ToString(CultureInfo.InvariantCulture), + null, + SubjectCore, + cancellationToken + ); } /// Pins the graph to one sample rate, or releases it. @@ -417,13 +457,19 @@ public Task SetForcedQuantumAsync(int samples, CancellationToken cancellationTok public Task SetForcedRateAsync(int hz, CancellationToken cancellationToken = default) { ArgumentOutOfRangeException.ThrowIfNegative(hz); - return SetAsync("clock.force-rate", hz.ToString(CultureInfo.InvariantCulture), - null, SubjectCore, cancellationToken); + return SetAsync( + "clock.force-rate", + hz.ToString(CultureInfo.InvariantCulture), + null, + SubjectCore, + cancellationToken + ); } /// Reads a settings value that is a bare integer, or null if it is absent or not one. private int? Integer(string key) => - Find(key)?.Value is { } raw && int.TryParse(raw, CultureInfo.InvariantCulture, out int value) + Find(key)?.Value is { } raw + && int.TryParse(raw, CultureInfo.InvariantCulture, out int value) ? value : null; @@ -438,11 +484,19 @@ public Task SetForcedRateAsync(int hz, CancellationToken cancellationToken = def /// objects come and go, so a default stored by id would drift onto a different device. /// /// is null or empty. - public Task SetDefaultAudioSinkAsync(string nodeName, CancellationToken cancellationToken = default) + public Task SetDefaultAudioSinkAsync( + string nodeName, + CancellationToken cancellationToken = default + ) { ArgumentException.ThrowIfNullOrEmpty(nodeName); - return SetAsync("default.audio.sink", NameJson(nodeName), "Spa:String:JSON", - SubjectCore, cancellationToken); + return SetAsync( + "default.audio.sink", + NameJson(nodeName), + "Spa:String:JSON", + SubjectCore, + cancellationToken + ); } /// Sets the default audio source by node name. @@ -453,11 +507,19 @@ public Task SetDefaultAudioSinkAsync(string nodeName, CancellationToken cancella /// /// /// is null or empty. - public Task SetDefaultAudioSourceAsync(string nodeName, CancellationToken cancellationToken = default) + public Task SetDefaultAudioSourceAsync( + string nodeName, + CancellationToken cancellationToken = default + ) { ArgumentException.ThrowIfNullOrEmpty(nodeName); - return SetAsync("default.audio.source", NameJson(nodeName), "Spa:String:JSON", - SubjectCore, cancellationToken); + return SetAsync( + "default.audio.source", + NameJson(nodeName), + "Spa:String:JSON", + SubjectCore, + cancellationToken + ); } /// Removes every entry in the store. @@ -512,7 +574,9 @@ private unsafe int Write(uint subject, string key, string? type, string? value) { ReadOnlySpan keyUtf8 = Encoding.UTF8.GetBytes(key + '\0'); ReadOnlySpan typeUtf8 = type is null ? default : Encoding.UTF8.GetBytes(type + '\0'); - ReadOnlySpan valueUtf8 = value is null ? default : Encoding.UTF8.GetBytes(value + '\0'); + ReadOnlySpan valueUtf8 = value is null + ? default + : Encoding.UTF8.GetBytes(value + '\0'); // Referenced for the duration of the call. Destroying a proxy clears its pointer before it // takes the loop lock, so the lock alone does not stop this becoming null mid-call. @@ -527,9 +591,12 @@ private unsafe int Write(uint subject, string key, string? type, string? value) fixed (byte* v = valueUtf8) { return Native.pw_metadata_set_property( - (pw_metadata*)proxy.Object, subject, (sbyte*)k, + (pw_metadata*)proxy.Object, + subject, + (sbyte*)k, type is null ? null : (sbyte*)t, - value is null ? null : (sbyte*)v); + value is null ? null : (sbyte*)v + ); } } } @@ -558,9 +625,12 @@ private void OnProperty(uint subject, string? key, string? type, string? value) // that the entries went. foreach ((uint Subject, string Key) existing in _entries.Keys) { - if (!everySubject && existing.Subject != subject) continue; + if (!everySubject && existing.Subject != subject) + continue; if (_entries.TryRemove(existing, out PipeWireMetadataEntry? removed)) - Raise(new PipeWireMetadataEntry(removed.Subject, removed.Key, removed.Type, null)); + Raise( + new PipeWireMetadataEntry(removed.Subject, removed.Key, removed.Type, null) + ); } return; @@ -569,38 +639,51 @@ private void OnProperty(uint subject, string? key, string? type, string? value) var entry = new PipeWireMetadataEntry(subject, key, type, value); MetadataReconciler.EchoAction action = _reconciler.Classify(subject, key, type, value); - if (action == MetadataReconciler.EchoAction.Drop) return; + if (action == MetadataReconciler.EchoAction.Drop) + return; lock (_clearGate) { // A clear landed between this event being dispatched and being handled, so whatever // this reports is from before the store was emptied. Applying it now would put a // cleared entry back, and the correcting event for that never comes. - if (Volatile.Read(ref _epoch) != epoch) return; + if (Volatile.Read(ref _epoch) != epoch) + return; Apply(entry); } // Outside the gate. Subscribers are user code running on the loop thread, and holding a // lock across them lets one that waits on anything else stall every writer. - if (action != MetadataReconciler.EchoAction.AlreadyKnown) Raise(entry); + if (action != MetadataReconciler.EchoAction.AlreadyKnown) + Raise(entry); } /// Reports one change to subscribers, isolating a handler that throws. private void Raise(PipeWireMetadataEntry entry) { - SafeCallback.Raise(EntryChanged, h => h(this, entry), ex => LogHandlerFaulted(Id, entry.Key, ex)); + SafeCallback.Raise( + EntryChanged, + h => h(this, entry), + ex => LogHandlerFaulted(Id, entry.Key, ex) + ); } [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] private static unsafe int OnPropertyCallback( - void* data, uint subject, sbyte* key, sbyte* type, sbyte* value) + void* data, + uint subject, + sbyte* key, + sbyte* type, + sbyte* value + ) { // An exception escaping a reverse P/Invoke aborts the process, so nothing here may throw. try { var self = (PipeWireMetadataProxy?)GCHandle.FromIntPtr((nint)data).Target; - if (self is null || self._disposed) return 0; + if (self is null || self._disposed) + return 0; self.OnProperty(subject, Utf8(key), Utf8(type), Utf8(value)); } @@ -612,8 +695,7 @@ private static unsafe int OnPropertyCallback( return 0; static string? Utf8(sbyte* p) => - p is null ? null : Encoding.UTF8.GetString( - DaemonText.Bytes((sbyte*)p)); + p is null ? null : Encoding.UTF8.GetString(DaemonText.Bytes((sbyte*)p)); } /// @@ -634,7 +716,8 @@ public ValueTask DisposeAsync() private void DisposeCore() { - if (_disposed) return; + if (_disposed) + return; _disposed = true; _bound?.Dispose(); @@ -645,7 +728,10 @@ private void DisposeCore() GC.SuppressFinalize(this); } - [LoggerMessage(EventId = 33200, Level = LogLevel.Error, - Message = "an EntryChanged handler for store {StoreId} key {Key} threw")] + [LoggerMessage( + EventId = 33200, + Level = LogLevel.Error, + Message = "an EntryChanged handler for store {StoreId} key {Key} threw" + )] private partial void LogHandlerFaulted(uint storeId, string key, Exception exception); } diff --git a/src/PipeWire.NET/Graph/PipeWireModule.cs b/src/PipeWire.NET/Graph/PipeWireModule.cs index f56b08a..468ce7f 100644 --- a/src/PipeWire.NET/Graph/PipeWireModule.cs +++ b/src/PipeWire.NET/Graph/PipeWireModule.cs @@ -20,7 +20,8 @@ internal PipeWireModule( string? Description, string? Author, string? ModuleVersion, - PipeWireProperties? Properties = null) + PipeWireProperties? Properties = null + ) { this.Properties = Properties ?? PipeWireProperties.Empty; this.ObjectSerial = this.Properties.Serial; diff --git a/src/PipeWire.NET/Graph/PipeWireModuleProxy.cs b/src/PipeWire.NET/Graph/PipeWireModuleProxy.cs index 22c96b8..1196dfb 100644 --- a/src/PipeWire.NET/Graph/PipeWireModuleProxy.cs +++ b/src/PipeWire.NET/Graph/PipeWireModuleProxy.cs @@ -17,8 +17,9 @@ namespace PipeWire.NET.Graph; [SupportedOSPlatform("linux")] internal sealed class PipeWireModuleProxy : IDisposable { - private readonly TaskCompletionSource _info = - new(TaskCreationOptions.RunContinuationsAsynchronously); + private readonly TaskCompletionSource _info = new( + TaskCreationOptions.RunContinuationsAsynchronously + ); private BoundProxy? _bound; @@ -26,11 +27,20 @@ internal sealed class PipeWireModuleProxy : IDisposable internal Task Properties => _info.Task; internal static unsafe PipeWireModuleProxy Bind( - PipeWireContext ctx, pw_registry* registry, uint id, uint version) + PipeWireContext ctx, + pw_registry* registry, + uint id, + uint version + ) { var reader = new PipeWireModuleProxy(); reader._bound = BoundProxy.Bind( - ctx, registry, id, PipeWireKeys.PW_TYPE_INTERFACE_Module, version, NativeConstants.PW_VERSION_MODULE, + ctx, + registry, + id, + PipeWireKeys.PW_TYPE_INTERFACE_Module, + version, + NativeConstants.PW_VERSION_MODULE, sizeof(pw_module_events), events => { @@ -38,9 +48,15 @@ internal static unsafe PipeWireModuleProxy Bind( table->version = NativeConstants.PW_VERSION_MODULE_EVENTS; table->info = &OnInfoCallback; }, - static (proxy, hook, events, data) => Native.pw_module_add_listener( - (pw_module*)proxy, (spa_hook*)hook, (pw_module_events*)events, (void*)data), - reader); + static (proxy, hook, events, data) => + Native.pw_module_add_listener( + (pw_module*)proxy, + (spa_hook*)hook, + (pw_module_events*)events, + (void*)data + ), + reader + ); reader._bound.Removed = reader.RaiseRemoved; @@ -61,31 +77,41 @@ internal static unsafe PipeWireModuleProxy Bind( private void RaiseRemoved() { Action? handler = Removed; - if (handler is null) return; + if (handler is null) + return; // A native callback frame, so nothing may escape it. - try { handler(); } - catch (Exception) { /* a subscriber that throws must not reach the daemon */ } + try + { + handler(); + } + catch (Exception) + { /* a subscriber that throws must not reach the daemon */ + } } - [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] private static unsafe void OnInfoCallback(void* data, pw_module_info* info) { // A native callback frame: an escaping exception aborts the process. try { - if (data is null || info is null) return; - if (GCHandle.FromIntPtr((nint)data).Target is not PipeWireModuleProxy self) return; + if (data is null || info is null) + return; + if (GCHandle.FromIntPtr((nint)data).Target is not PipeWireModuleProxy self) + return; // filename and args are not properties, but they are the two facts a reader of a module // most often wants and they arrive nowhere else, so they travel as properties here. var extra = new Dictionary(StringComparer.Ordinal); - if (info->filename is not null) extra[PipeWireKeys.MODULE_FILENAME] = DaemonText.String(info->filename)!; - if (info->args is not null) extra[PipeWireKeys.MODULE_ARGS] = DaemonText.String(info->args)!; + if (info->filename is not null) + extra[PipeWireKeys.MODULE_FILENAME] = DaemonText.String(info->filename)!; + if (info->args is not null) + extra[PipeWireKeys.MODULE_ARGS] = DaemonText.String(info->args)!; self._info.TrySetResult( - PipeWireProperties.From(info->props).MergedWith(PipeWireProperties.FromItems(extra))); + PipeWireProperties.From(info->props).MergedWith(PipeWireProperties.FromItems(extra)) + ); } catch (Exception) { diff --git a/src/PipeWire.NET/Graph/PipeWireNode.cs b/src/PipeWire.NET/Graph/PipeWireNode.cs index 9cf2820..665facd 100644 --- a/src/PipeWire.NET/Graph/PipeWireNode.cs +++ b/src/PipeWire.NET/Graph/PipeWireNode.cs @@ -22,7 +22,8 @@ internal PipeWireNode( string? ObjectPath = null, int? DriverPriority = null, int? SessionPriority = null, - PipeWireProperties? Properties = null) + PipeWireProperties? Properties = null + ) { this.Properties = Properties ?? PipeWireProperties.Empty; this.ObjectSerial = this.Properties.Serial; diff --git a/src/PipeWire.NET/Graph/PipeWireNodeBuilder.cs b/src/PipeWire.NET/Graph/PipeWireNodeBuilder.cs index e960d1f..05dbf1e 100644 --- a/src/PipeWire.NET/Graph/PipeWireNodeBuilder.cs +++ b/src/PipeWire.NET/Graph/PipeWireNodeBuilder.cs @@ -27,7 +27,11 @@ public readonly struct PipeWireNodeBuilder private readonly PipeWireObjectOptions _options; internal PipeWireNodeBuilder( - PipeWireRegistry registry, string description, string? name, PipeWireObjectOptions options) + PipeWireRegistry registry, + string description, + string? name, + PipeWireObjectOptions options + ) { _registry = registry; _description = description; @@ -82,11 +86,19 @@ public PipeWireNodeBuilder WithProperty(string key, string value) ArgumentNullException.ThrowIfNull(value); PipeWireObjectOptions.ThrowIfReserved(key, forLink: false); - ImmutableArray> existing = - _options.Properties.IsDefault ? [] : _options.Properties; - - return new(_registry, _description, _name, - _options with { Properties = existing.Add(new(key, value)) }); + ImmutableArray> existing = _options.Properties.IsDefault + ? [] + : _options.Properties; + + return new( + _registry, + _description, + _name, + _options with + { + Properties = existing.Add(new(key, value)), + } + ); } /// Sets media.class: whether the node is read from or written to. @@ -135,8 +147,9 @@ public PipeWireNodeBuilder WithTarget(PipeWireNode node) { throw new ArgumentException( $"node {node.NodeId} has no node.name, so there is nothing to target it by. " - + "Target it by object.serial instead.", - nameof(node)); + + "Target it by object.serial instead.", + nameof(node) + ); } return WithTarget(node.NodeName!); diff --git a/src/PipeWire.NET/Graph/PipeWireNodeProvider.cs b/src/PipeWire.NET/Graph/PipeWireNodeProvider.cs index 5bfc094..0da4bf6 100644 --- a/src/PipeWire.NET/Graph/PipeWireNodeProvider.cs +++ b/src/PipeWire.NET/Graph/PipeWireNodeProvider.cs @@ -54,12 +54,14 @@ public sealed unsafe partial class PipeWireNodeProvider : IDisposable, IAsyncDis private GCHandle _self; private spa_node* _node; private spa_node_methods* _methods; + // What the graph installed for this node to call back into. `ready` and `reuse_buffer` stay // unused deliberately - a null `ready` is how a node asks for synchronous operation, and the // buffers to reuse go through the input port's io area - but `xrun` is how a node reports that // it missed a cycle, and nothing else can report that on its behalf. private spa_node_callbacks* _callbacks; private void* _callbacksData; + // Every listener on this node, not just the one that exported it: the audio adapter that wraps // it keeps one for good, and synchronous queries add and remove their own (see SpaHookList). private spa_hook_list* _hooks; @@ -119,7 +121,8 @@ private PipeWireNodeProvider(PipeWireContext ctx, string name, SpaDirection dire public unsafe bool ReportXrun(ulong triggerMicroseconds, ulong delayMicroseconds) { spa_node_callbacks* callbacks = _callbacks; - if (_disposed || callbacks is null || callbacks->xrun is null) return false; + if (_disposed || callbacks is null || callbacks->xrun is null) + return false; return callbacks->xrun(_callbacksData, triggerMicroseconds, delayMicroseconds, null) >= 0; } @@ -211,7 +214,8 @@ public static PipeWireNodeProvider Create( string name, PipeWireExportedFormat format, SpaDirection direction = SpaDirection.Output, - IReadOnlyDictionary? properties = null) + IReadOnlyDictionary? properties = null + ) { ArgumentNullException.ThrowIfNull(ctx); ArgumentNullException.ThrowIfNull(format); @@ -254,7 +258,8 @@ public static PipeWireNodeProvider FromSpaFactory( PipeWireContext ctx, string factoryName, IReadOnlyDictionary? properties = null, - string? libraryName = null) + string? libraryName = null + ) { ArgumentNullException.ThrowIfNull(ctx); ArgumentException.ThrowIfNullOrEmpty(factoryName); @@ -267,11 +272,15 @@ public static PipeWireNodeProvider FromSpaFactory( "node", properties, libraryName, - out node._spaHandle); + out node._spaHandle + ); return node; } - private void Initialize(PipeWireExportedFormat format, IReadOnlyDictionary? properties) + private void Initialize( + PipeWireExportedFormat format, + IReadOnlyDictionary? properties + ) { OfferedFormat = format; @@ -323,7 +332,8 @@ private void Initialize(PipeWireExportedFormat format, IReadOnlyDictionary pair in properties) props[pair.Key] = pair.Value; + foreach (KeyValuePair pair in properties) + props[pair.Key] = pair.Value; } // Wrapped in the adapter, the way pw_stream wraps its own node for audio and video @@ -354,13 +364,15 @@ private void Initialize(PipeWireExportedFormat format, IReadOnlyDictionary scratch = stackalloc byte[2048]; Span items = stackalloc spa_dict_item[24]; var dict = new SpaDictBuilder(scratch, items); - foreach (KeyValuePair pair in props) dict.Add(pair.Key, pair.Value); + foreach (KeyValuePair pair in props) + dict.Add(pair.Key, pair.Value); spa_dict built = dict.Build(); // The factory takes ownership of the properties, as stream.c's does. @@ -368,8 +380,15 @@ private void Initialize(PipeWireExportedFormat format, IReadOnlyDictionary public ValueTask DisposeAsync() { - if (_disposed) return ValueTask.CompletedTask; + if (_disposed) + return ValueTask.CompletedTask; _disposed = true; using (_ctx.Lock()) @@ -450,7 +471,8 @@ public ValueTask DisposeAsync() /// private void DestroyAdapter() { - if (_adapter is null) return; + if (_adapter is null) + return; Native.pw_impl_node_destroy(_adapter); _adapter = null; @@ -462,21 +484,36 @@ private void Cleanup() // adapter was never created or has just been; taking the lock again is harmless (recursive). if (_adapter is not null) { - using (_ctx.Lock()) DestroyAdapter(); + using (_ctx.Lock()) + DestroyAdapter(); } // The factory's node lives in the handle, so clearing it is what actually releases the // plugin's resources; the proxy only removed it from the graph. if (_spaHandle is not null) { - if (_spaHandle->clear is not null) _ = _spaHandle->clear(_spaHandle); + if (_spaHandle->clear is not null) + _ = _spaHandle->clear(_spaHandle); _spaHandle = null; } - if (_node is not null) { NativeMemory.Free(_node); _node = null; } - if (_hooks is not null) { NativeMemory.Free(_hooks); _hooks = null; } - if (_methods is not null) { NativeMemory.Free(_methods); _methods = null; } - if (_self.IsAllocated) _self.Free(); + if (_node is not null) + { + NativeMemory.Free(_node); + _node = null; + } + if (_hooks is not null) + { + NativeMemory.Free(_hooks); + _hooks = null; + } + if (_methods is not null) + { + NativeMemory.Free(_methods); + _methods = null; + } + if (_self.IsAllocated) + _self.Free(); } private static PipeWireNodeProvider? From(void* data) => @@ -493,7 +530,8 @@ private static int OnAddListener(void* obj, spa_hook* hook, spa_node_events* eve try { PipeWireNodeProvider? self = From(obj); - if (self is null) return -NativeLibc.EINVAL; + if (self is null) + return -NativeLibc.EINVAL; if (self._hooks is null || hook is null) { @@ -550,14 +588,16 @@ private void EmitInfo() /// private void EmitNodeInfo() { - if (_hooks is null) return; + if (_hooks is null) + return; Span scratch = stackalloc byte[4096]; Span items = stackalloc spa_dict_item[32]; var dict = new SpaDictBuilder(scratch, items); foreach (KeyValuePair pair in _nodeProperties) { - if (dict.Count == items.Length) break; + if (dict.Count == items.Length) + break; dict.Add(pair.Key, pair.Value); } @@ -579,7 +619,8 @@ private void EmitNodeInfo() next = l->next; var h = (spa_hook*)l; var ev = (spa_node_events*)h->cb.funcs; - if (ev is not null && ev->info is not null) ev->info(h->cb.data, &info); + if (ev is not null && ev->info is not null) + ev->info(h->cb.data, &info); } } @@ -593,14 +634,27 @@ private void EmitNodeInfo() /// private void EmitPortInfo() { - if (_hooks is null) return; + if (_hooks is null) + return; bool formatSet = NegotiatedFormat is not null; spa_param_info* paramInfo = stackalloc spa_param_info[5]; - paramInfo[0] = new spa_param_info { id = (uint)SpaParamType.EnumFormat, flags = (uint)SpaParamInfoFlags.Read }; - paramInfo[1] = new spa_param_info { id = (uint)SpaParamType.Meta, flags = (uint)SpaParamInfoFlags.Read }; - paramInfo[2] = new spa_param_info { id = (uint)SpaParamType.Io, flags = (uint)SpaParamInfoFlags.Read }; + paramInfo[0] = new spa_param_info + { + id = (uint)SpaParamType.EnumFormat, + flags = (uint)SpaParamInfoFlags.Read, + }; + paramInfo[1] = new spa_param_info + { + id = (uint)SpaParamType.Meta, + flags = (uint)SpaParamInfoFlags.Read, + }; + paramInfo[2] = new spa_param_info + { + id = (uint)SpaParamType.Io, + flags = (uint)SpaParamInfoFlags.Read, + }; paramInfo[3] = new spa_param_info { id = (uint)SpaParamType.Format, @@ -626,7 +680,8 @@ private void EmitPortInfo() next = l->next; var h = (spa_hook*)l; var ev = (spa_node_events*)h->cb.funcs; - if (ev is not null && ev->port_info is not null) ev->port_info(h->cb.data, _direction, PortId, &port); + if (ev is not null && ev->port_info is not null) + ev->port_info(h->cb.data, _direction, PortId, &port); } } @@ -636,7 +691,8 @@ private static int OnSetCallbacks(void* obj, spa_node_callbacks* callbacks, void try { PipeWireNodeProvider? self = From(obj); - if (self is null) return -NativeLibc.EINVAL; + if (self is null) + return -NativeLibc.EINVAL; self._callbacks = callbacks; self._callbacksData = data; @@ -649,11 +705,23 @@ private static int OnSetCallbacks(void* obj, spa_node_callbacks* callbacks, void } [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] - private static int OnEnumParams(void* obj, int seq, uint id, uint start, uint num, spa_pod* filter) + private static int OnEnumParams( + void* obj, + int seq, + uint id, + uint start, + uint num, + spa_pod* filter + ) { try { - _ = obj; _ = seq; _ = id; _ = start; _ = num; _ = filter; + _ = obj; + _ = seq; + _ = id; + _ = start; + _ = num; + _ = filter; return 0; } catch (Exception ex) @@ -667,7 +735,10 @@ private static int OnSetParam(void* obj, uint id, uint flags, spa_pod* param) { try { - _ = obj; _ = id; _ = flags; _ = param; + _ = obj; + _ = id; + _ = flags; + _ = param; return 0; } catch (Exception ex) @@ -682,7 +753,8 @@ private static int OnSetIo(void* obj, uint id, void* area, nuint size) try { PipeWireNodeProvider? self = From(obj); - if (self is null) return -NativeLibc.EINVAL; + if (self is null) + return -NativeLibc.EINVAL; // Return codes are spa/node/node.h's contract for set_io, and audioconvert's behaviour: // -ENOSPC for an area too small to be the struct, -ENOENT for an id this node does not use. @@ -722,7 +794,8 @@ private static int OnSendCommand(void* obj, spa_command* command) { try { - _ = obj; _ = command; + _ = obj; + _ = command; return 0; } catch (Exception ex) @@ -732,12 +805,20 @@ private static int OnSendCommand(void* obj, spa_command* command) } [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] - private static int OnPortSetIo(void* obj, SpaDirection direction, uint port, uint id, void* area, nuint size) + private static int OnPortSetIo( + void* obj, + SpaDirection direction, + uint port, + uint id, + void* area, + nuint size + ) { try { PipeWireNodeProvider? self = From(obj); - if (self is null) return -NativeLibc.EINVAL; + if (self is null) + return -NativeLibc.EINVAL; // SPA_IO_Buffers: where the graph says which buffer is current and reads back what this // node produced. Without it a source has nowhere to publish and simply never emits. @@ -764,12 +845,21 @@ private static int OnPortSetIo(void* obj, SpaDirection direction, uint port, uin [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] private static int OnPortEnumParams( - void* obj, int seq, SpaDirection direction, uint port, uint id, uint start, uint num, spa_pod* filter) + void* obj, + int seq, + SpaDirection direction, + uint port, + uint id, + uint start, + uint num, + spa_pod* filter + ) { try { PipeWireNodeProvider? self = From(obj); - if (self is null) return -NativeLibc.EINVAL; + if (self is null) + return -NativeLibc.EINVAL; return self.EnumeratePortParams(seq, id, start, filter); } @@ -780,12 +870,20 @@ private static int OnPortEnumParams( } [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] - private static int OnPortSetParam(void* obj, SpaDirection direction, uint port, uint id, uint flags, spa_pod* param) + private static int OnPortSetParam( + void* obj, + SpaDirection direction, + uint port, + uint id, + uint flags, + spa_pod* param + ) { try { PipeWireNodeProvider? self = From(obj); - if (self is null) return -NativeLibc.EINVAL; + if (self is null) + return -NativeLibc.EINVAL; // A null param clears the format, which is how the peer disconnects. The id comes from the // generated enum rather than a literal: SPA_PARAM_ starts at Invalid = 0, so the values are @@ -814,7 +912,9 @@ private static int OnPortSetParam(void* obj, SpaDirection direction, uint port, // derived from them - a cycle sized off the offered format would then write the wrong // number of bytes into a buffer the graph owns. var settled = new ReadOnlySpan( - param, checked((int)(param->size + (uint)sizeof(spa_pod)))); + param, + checked((int)(param->size + (uint)sizeof(spa_pod))) + ); PipeWireExportedFormat? agreed = PipeWireExportedFormat.FromPod(settled); if (agreed is null) @@ -862,13 +962,21 @@ private struct IoUpdate } [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] - private static int DoSetBuffersIo(spa_loop* loop, bool async, uint seq, void* data, nuint size, void* userData) + private static int DoSetBuffersIo( + spa_loop* loop, + bool async, + uint seq, + void* data, + nuint size, + void* userData + ) { // Runs under the data loop's lock, called from C: nothing may escape. try { var update = (IoUpdate*)userData; - if (From(update->Node) is { } self) self._io = update->Area; + if (From(update->Node) is { } self) + self._io = update->Area; return 0; } catch (Exception ex) @@ -879,12 +987,19 @@ private static int DoSetBuffersIo(spa_loop* loop, bool async, uint seq, void* da [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] private static int OnPortUseBuffers( - void* obj, SpaDirection direction, uint port, uint flags, spa_buffer** buffers, uint count) + void* obj, + SpaDirection direction, + uint port, + uint flags, + spa_buffer** buffers, + uint count + ) { try { PipeWireNodeProvider? self = From(obj); - if (self is null) return -NativeLibc.EINVAL; + if (self is null) + return -NativeLibc.EINVAL; // The count is what the data loop gates on, so it is published last when a pool arrives and // first when one goes away. Assigning it before the array it describes leaves a window in @@ -923,7 +1038,8 @@ private static int OnPortReuseBuffer(void* obj, uint port, uint bufferId) try { PipeWireNodeProvider? self = From(obj); - if (self is null) return -NativeLibc.EINVAL; + if (self is null) + return -NativeLibc.EINVAL; // Upstream's export-source: reuse_buffer puts the buffer back on its free list. Same // snapshot as RunCycle, and the same gate: a count that is set describes an array that @@ -951,7 +1067,8 @@ private static int OnPortReuseBuffer(void* obj, uint port, uint bufferId) private static int OnProcess(void* obj) { PipeWireNodeProvider? self = From(obj); - if (self is null) return -NativeLibc.EINVAL; + if (self is null) + return -NativeLibc.EINVAL; // This runs on the realtime data-loop thread, called from C. A managed exception cannot // unwind through the native frames above it, so the runtime aborts the whole process @@ -967,7 +1084,8 @@ private static int OnProcess(void* obj) // Logged once per kind of failure, not per cycle: this is the realtime thread, and a handler // that throws on every cycle would otherwise log at the graph's rate. Every occurrence is // still in LastProcessError. - if (self.LastProcessError?.GetType() != ex.GetType()) self.LogProcessFaulted(self._name, ex); + if (self.LastProcessError?.GetType() != ex.GetType()) + self.LogProcessFaulted(self._name, ex); self.LastProcessError = ex; return -NativeLibc.EIO; } @@ -994,23 +1112,38 @@ private int RunCycle() if (io is null || buffers is null || bufferCount == 0) return consuming ? (int)SpaStatus.NeedData : (int)SpaStatus.Ok; - if (consuming) return ConsumeCycle(io, buffers, bufferCount); - if (ProduceTrace is not { } trace) return ProduceCycle(io, buffers, free, bufferCount); + if (consuming) + return ConsumeCycle(io, buffers, bufferCount); + if (ProduceTrace is not { } trace) + return ProduceCycle(io, buffers, free, bufferCount); int entryStatus = io->status; uint entryBuffer = io->buffer_id; int result = ProduceCycle(io, buffers, free, bufferCount); uint freeCount = 0; - foreach (bool f in free) if (f) freeCount++; + foreach (bool f in free) + if (f) + freeCount++; trace[ProduceTraceCount++ % trace.Length] = new ProduceCycleRecord( - entryStatus, entryBuffer, result == (int)SpaStatus.HaveData ? io->buffer_id : uint.MaxValue, - result, freeCount, bufferCount); + entryStatus, + entryBuffer, + result == (int)SpaStatus.HaveData ? io->buffer_id : uint.MaxValue, + result, + freeCount, + bufferCount + ); return result; } /// One source cycle as the io area saw it, for a test diagnosing lost or reused buffers. internal readonly record struct ProduceCycleRecord( - int EntryStatus, uint EntryBuffer, uint Published, int Result, uint FreeAfter, uint Pool); + int EntryStatus, + uint EntryBuffer, + uint Published, + int Result, + uint FreeAfter, + uint Pool + ); /// /// Set by a test before the node runs to record every source cycle, round-robin. Written only by @@ -1031,20 +1164,24 @@ internal readonly record struct ProduceCycleRecord( /// private int ConsumeCycle(spa_io_buffers* io, spa_buffer** buffers, uint bufferCount) { - if (io->status != (int)SpaStatus.HaveData) return (int)SpaStatus.NeedData; + if (io->status != (int)SpaStatus.HaveData) + return (int)SpaStatus.NeedData; uint index = io->buffer_id; - if (index >= bufferCount) return (int)SpaStatus.NeedData; + if (index >= bufferCount) + return (int)SpaStatus.NeedData; try { HasProcessed = true; spa_buffer* buffer = buffers[index]; - if (buffer is null || buffer->n_datas == 0 || buffer->datas is null) return (int)SpaStatus.NeedData; + if (buffer is null || buffer->n_datas == 0 || buffer->datas is null) + return (int)SpaStatus.NeedData; spa_data* d = &buffer->datas[0]; - if (d->data is null || d->chunk is null || ProcessCallback is not { } handler) return (int)SpaStatus.NeedData; + if (d->data is null || d->chunk is null || ProcessCallback is not { } handler) + return (int)SpaStatus.NeedData; // Only what the producer wrote, bounded by the mapping: the offset is clamped to the // block and the size to what remains after it, as every upstream reader of a chunk @@ -1087,9 +1224,15 @@ private int ConsumeCycle(spa_io_buffers* io, spa_buffer** buffers, uint bufferCo /// -EPIPE for good. /// /// - private int ProduceCycle(spa_io_buffers* io, spa_buffer** buffers, bool[] free, uint bufferCount) + private int ProduceCycle( + spa_io_buffers* io, + spa_buffer** buffers, + bool[] free, + uint bufferCount + ) { - if (io->status == (int)SpaStatus.HaveData) return (int)SpaStatus.HaveData; + if (io->status == (int)SpaStatus.HaveData) + return (int)SpaStatus.HaveData; if (io->buffer_id < bufferCount) { @@ -1101,19 +1244,23 @@ private int ProduceCycle(spa_io_buffers* io, spa_buffer** buffers, bool[] free, for (uint i = 0; i < bufferCount; i++) { uint candidate = (_nextBuffer + i) % bufferCount; - if (!free[candidate]) continue; + if (!free[candidate]) + continue; index = candidate; break; } - if (index == uint.MaxValue) return -NativeLibc.EPIPE; + if (index == uint.MaxValue) + return -NativeLibc.EPIPE; spa_buffer* buffer = buffers[index]; - if (buffer is null || buffer->n_datas == 0 || buffer->datas is null) return (int)SpaStatus.Ok; + if (buffer is null || buffer->n_datas == 0 || buffer->datas is null) + return (int)SpaStatus.Ok; spa_data* d = &buffer->datas[0]; - if (d->data is null || d->chunk is null || ProcessCallback is not { } handler) return (int)SpaStatus.Ok; + if (d->data is null || d->chunk is null || ProcessCallback is not { } handler) + return (int)SpaStatus.Ok; PipeWireExportedFormat? format = NegotiatedFormat ?? OfferedFormat; @@ -1126,12 +1273,14 @@ private int ProduceCycle(spa_io_buffers* io, spa_buffer** buffers, bool[] free, if (quantum > 0) { ulong wanted = quantum * (ulong)format.BytesPerFrame; - if (wanted > 0 && wanted < cycleBytes) cycleBytes = (uint)wanted; + if (wanted > 0 && wanted < cycleBytes) + cycleBytes = (uint)wanted; } } int written = handler(this, new Span(d->data, checked((int)cycleBytes))); - if (written <= 0) return (int)SpaStatus.Ok; + if (written <= 0) + return (int)SpaStatus.Ok; free[index] = false; _nextBuffer = (index + 1) % bufferCount; @@ -1159,31 +1308,48 @@ private int ProduceCycle(spa_io_buffers* io, spa_buffer** buffers, bool[] free, /// means rather than having two notions of it. An unreadable filter is treated as no constraint, /// as upstream does: refusing would drop the parameter on the strength of a pod we failed to parse. /// - private static int ProjectParam(ReadOnlySpan candidate, spa_pod* filter, Span destination) + private static int ProjectParam( + ReadOnlySpan candidate, + spa_pod* filter, + Span destination + ) { - if (!SpaPod.TryParse(candidate, out SpaValue? candidateValue) || candidateValue is not SpaObject offered) + if ( + !SpaPod.TryParse(candidate, out SpaValue? candidateValue) + || candidateValue is not SpaObject offered + ) return 0; var filterBytes = new ReadOnlySpan( - filter, checked((int)(filter->size + (uint)sizeof(spa_pod)))); - - if (!SpaPod.TryParse(filterBytes, out SpaValue? filterValue) || filterValue is not SpaObject wanted) + filter, + checked((int)(filter->size + (uint)sizeof(spa_pod))) + ); + + if ( + !SpaPod.TryParse(filterBytes, out SpaValue? filterValue) + || filterValue is not SpaObject wanted + ) { - if (candidate.Length > destination.Length) return 0; + if (candidate.Length > destination.Length) + return 0; candidate.CopyTo(destination); return candidate.Length; } SpaObject? narrowed = SpaPodProjection.Project(offered, wanted); - if (narrowed is null) return 0; + if (narrowed is null) + return 0; - return SpaPod.TryWrite(narrowed, destination, out int projectedLength) ? projectedLength : 0; + return SpaPod.TryWrite(narrowed, destination, out int projectedLength) + ? projectedLength + : 0; } /// Delivers one enumerated parameter to every listener, as spa_node_emit_result does. private int EmitParam(int seq, uint id, uint start, ReadOnlySpan pod) { - if (_hooks is null) return 0; + if (_hooks is null) + return 0; fixed (byte* p = pod) { @@ -1201,7 +1367,13 @@ private int EmitParam(int seq, uint id, uint start, ReadOnlySpan pod) var h = (spa_hook*)l; var ev = (spa_node_events*)h->cb.funcs; if (ev is not null && ev->result is not null) - ev->result(h->cb.data, seq, 0, (uint)NativeConstants.SPA_RESULT_TYPE_NODE_PARAMS, &result); + ev->result( + h->cb.data, + seq, + 0, + (uint)NativeConstants.SPA_RESULT_TYPE_NODE_PARAMS, + &result + ); } } @@ -1224,70 +1396,114 @@ private int EmitParam(int seq, uint id, uint start, ReadOnlySpan pod) /// private int EnumeratePortParams(int seq, uint id, uint start, spa_pod* filter) { - if (start > 0) return 0; + if (start > 0) + return 0; Span candidate = stackalloc byte[2048]; int length = (SpaParamType)id switch { SpaParamType.EnumFormat => OfferedFormat!.WriteFormat(candidate, asEnum: true), - SpaParamType.Format => NegotiatedFormat is { } settled ? settled.WriteFormat(candidate, asEnum: false) : 0, + SpaParamType.Format => NegotiatedFormat is { } settled + ? settled.WriteFormat(candidate, asEnum: false) + : 0, SpaParamType.Buffers => (NegotiatedFormat ?? OfferedFormat)!.WriteBuffers(candidate), SpaParamType.Meta => PipeWireExportedFormat.WriteMetaHeader(candidate), SpaParamType.Io => PipeWireExportedFormat.WriteIoBuffers(candidate), _ => -1, }; - if (length < 0) return -NativeLibc.ENOENT; - if (length == 0) return 0; + if (length < 0) + return -NativeLibc.ENOENT; + if (length == 0) + return 0; - if (filter is null) return EmitParam(seq, id, start, candidate[..length]); + if (filter is null) + return EmitParam(seq, id, start, candidate[..length]); Span narrowed = stackalloc byte[2048]; int projected = ProjectParam(candidate[..length], filter, narrowed); return projected > 0 ? EmitParam(seq, id, start, narrowed[..projected]) : 0; } - [LoggerMessage(EventId = 34600, Level = LogLevel.Error, - Message = "exported node '{Name}': its {Callback} callback threw; the graph was answered -EIO")] + [LoggerMessage( + EventId = 34600, + Level = LogLevel.Error, + Message = "exported node '{Name}': its {Callback} callback threw; the graph was answered -EIO" + )] private partial void LogCallbackFaulted(string name, string callback, Exception exception); - [LoggerMessage(EventId = 34601, Level = LogLevel.Error, - Message = "exported node '{Name}': the process handler threw; the cycle was dropped (later failures of the same kind are only recorded in LastProcessError)")] + [LoggerMessage( + EventId = 34601, + Level = LogLevel.Error, + Message = "exported node '{Name}': the process handler threw; the cycle was dropped (later failures of the same kind are only recorded in LastProcessError)" + )] private partial void LogProcessFaulted(string name, Exception exception); - [LoggerMessage(EventId = 34602, Level = LogLevel.Warning, - Message = "exported node '{Name}': refused a listener (no hook list, or a null hook)")] + [LoggerMessage( + EventId = 34602, + Level = LogLevel.Warning, + Message = "exported node '{Name}': refused a listener (no hook list, or a null hook)" + )] private partial void LogListenerRefused(string name); - [LoggerMessage(EventId = 34603, Level = LogLevel.Debug, - Message = "exported node '{Name}': io area {Io} is not one it uses")] + [LoggerMessage( + EventId = 34603, + Level = LogLevel.Debug, + Message = "exported node '{Name}': io area {Io} is not one it uses" + )] private partial void LogIoRefused(string name, SpaIoType io); - [LoggerMessage(EventId = 34604, Level = LogLevel.Warning, - Message = "exported node '{Name}': io area {Io} is {Size} bytes, too small for the struct")] + [LoggerMessage( + EventId = 34604, + Level = LogLevel.Warning, + Message = "exported node '{Name}': io area {Io} is {Size} bytes, too small for the struct" + )] private partial void LogIoAreaTooSmall(string name, SpaIoType io, nuint size); - [LoggerMessage(EventId = 34605, Level = LogLevel.Debug, - Message = "exported node '{Name}': parameter {Param} cannot be set on its port")] + [LoggerMessage( + EventId = 34605, + Level = LogLevel.Debug, + Message = "exported node '{Name}': parameter {Param} cannot be set on its port" + )] private partial void LogParamRefused(string name, SpaParamType param); - [LoggerMessage(EventId = 34606, Level = LogLevel.Warning, - Message = "exported node '{Name}': refused a format it cannot carry")] + [LoggerMessage( + EventId = 34606, + Level = LogLevel.Warning, + Message = "exported node '{Name}': refused a format it cannot carry" + )] private partial void LogFormatRefused(string name); - [LoggerMessage(EventId = 34607, Level = LogLevel.Debug, - Message = "exported node '{Name}': format settled on {SampleFormat} {Rate} Hz x{Channels}")] - private partial void LogFormatSettled(string name, SpaAudioFormat sampleFormat, int rate, int channels); - - [LoggerMessage(EventId = 34608, Level = LogLevel.Debug, - Message = "exported node '{Name}': format cleared")] + [LoggerMessage( + EventId = 34607, + Level = LogLevel.Debug, + Message = "exported node '{Name}': format settled on {SampleFormat} {Rate} Hz x{Channels}" + )] + private partial void LogFormatSettled( + string name, + SpaAudioFormat sampleFormat, + int rate, + int channels + ); + + [LoggerMessage( + EventId = 34608, + Level = LogLevel.Debug, + Message = "exported node '{Name}': format cleared" + )] private partial void LogFormatCleared(string name); - [LoggerMessage(EventId = 34609, Level = LogLevel.Debug, - Message = "exported node '{Name}': the peer allocated {Count} buffers")] + [LoggerMessage( + EventId = 34609, + Level = LogLevel.Debug, + Message = "exported node '{Name}': the peer allocated {Count} buffers" + )] private partial void LogBuffersArrived(string name, uint count); - [LoggerMessage(EventId = 34610, Level = LogLevel.Warning, - Message = "exported node '{Name}': the peer returned buffer {BufferId}, outside its pool of {Count}")] + [LoggerMessage( + EventId = 34610, + Level = LogLevel.Warning, + Message = "exported node '{Name}': the peer returned buffer {BufferId}, outside its pool of {Count}" + )] private partial void LogReuseRefused(string name, uint bufferId, uint count); } diff --git a/src/PipeWire.NET/Graph/PipeWireNodeProxy.cs b/src/PipeWire.NET/Graph/PipeWireNodeProxy.cs index 8264ee2..8873717 100644 --- a/src/PipeWire.NET/Graph/PipeWireNodeProxy.cs +++ b/src/PipeWire.NET/Graph/PipeWireNodeProxy.cs @@ -32,28 +32,49 @@ private PipeWireNodeProxy(PipeWireContext ctx, uint id, ILogger logger) : base(ctx, id) => _logger = logger; internal static unsafe PipeWireNodeProxy Bind( - PipeWireContext ctx, pw_registry* registry, uint id, uint version, ILogger logger, - Action? propertiesObserved = null) + PipeWireContext ctx, + pw_registry* registry, + uint id, + uint version, + ILogger logger, + Action? propertiesObserved = null + ) { // The observer is in place before the proxy is bound: the first info after a bind is the // only one that carries the object's properties (later ones set no PROPS in their change // mask, so their dictionary arrives empty), and it can arrive the moment the bind is sent. // Assigned after Bind returned, a fast daemon's first info found no observer and the // properties never reached the registry. - var control = new PipeWireNodeProxy(ctx, id, logger) { PropertiesObserved = propertiesObserved }; - control.Attach(BoundProxy.Bind( - ctx, registry, id, PipeWireKeys.PW_TYPE_INTERFACE_Node, version, NativeConstants.PW_VERSION_NODE, - sizeof(pw_node_events), - events => - { - var table = (pw_node_events*)events; - table->version = NativeConstants.PW_VERSION_NODE_EVENTS; - table->info = &OnInfoCallback; - table->param = &OnParamCallback; - }, - static (proxy, hook, events, data) => Native.pw_node_add_listener( - (pw_node*)proxy, (spa_hook*)hook, (pw_node_events*)events, (void*)data), - control)); + var control = new PipeWireNodeProxy(ctx, id, logger) + { + PropertiesObserved = propertiesObserved, + }; + control.Attach( + BoundProxy.Bind( + ctx, + registry, + id, + PipeWireKeys.PW_TYPE_INTERFACE_Node, + version, + NativeConstants.PW_VERSION_NODE, + sizeof(pw_node_events), + events => + { + var table = (pw_node_events*)events; + table->version = NativeConstants.PW_VERSION_NODE_EVENTS; + table->info = &OnInfoCallback; + table->param = &OnParamCallback; + }, + static (proxy, hook, events, data) => + Native.pw_node_add_listener( + (pw_node*)proxy, + (spa_hook*)hook, + (pw_node_events*)events, + (void*)data + ), + control + ) + ); return control; } @@ -82,7 +103,8 @@ internal static unsafe PipeWireNodeProxy Bind( /// on top, and a device with a channel map reports per-channel values whatever that scalar says. /// public async Task> GetChannelVolumesAsync( - CancellationToken cancellationToken = default) + CancellationToken cancellationToken = default + ) { SpaObject? props = await GetParameterOrNullAsync(SpaParamType.Props, cancellationToken) .ConfigureAwait(false); @@ -119,12 +141,20 @@ public Task SetVolumeAsync(float volume, CancellationToken cancellationToken = d // NaN went into a float pod and reached the daemon as a volume. ArgumentOutOfRangeException.ThrowIfNegative(volume); if (float.IsNaN(volume)) - throw new ArgumentOutOfRangeException(nameof(volume), volume, "a volume must be a number."); + throw new ArgumentOutOfRangeException( + nameof(volume), + volume, + "a volume must be a number." + ); return SetParameterAsync( SpaParamType.Props, - new SpaObject(SpaType.ObjectProps, SpaParamType.Props, - [new SpaPodProperty(SpaProp.Volume, 0, new SpaFloat(volume))]), - cancellationToken); + new SpaObject( + SpaType.ObjectProps, + SpaParamType.Props, + [new SpaPodProperty(SpaProp.Volume, 0, new SpaFloat(volume))] + ), + cancellationToken + ); } /// Sets the node's per-channel volumes. @@ -147,27 +177,42 @@ public Task SetVolumeAsync(float volume, CancellationToken cancellationToken = d /// /// is empty or holds a negative value. public Task SetChannelVolumesAsync( - ReadOnlySpan volumes, CancellationToken cancellationToken = default) + ReadOnlySpan volumes, + CancellationToken cancellationToken = default + ) { if (volumes.IsEmpty) - throw new ArgumentException("at least one channel volume is required.", nameof(volumes)); + throw new ArgumentException( + "at least one channel volume is required.", + nameof(volumes) + ); var values = ImmutableArray.CreateBuilder(volumes.Length); foreach (float volume in volumes) { if (float.IsNegative(volume) || float.IsNaN(volume)) - throw new ArgumentException("a channel volume must be a non-negative number.", nameof(volumes)); + throw new ArgumentException( + "a channel volume must be a non-negative number.", + nameof(volumes) + ); values.Add(new SpaFloat(volume)); } return SetParameterAsync( SpaParamType.Props, - new SpaObject(SpaType.ObjectProps, SpaParamType.Props, - [ - new SpaPodProperty(SpaProp.ChannelVolumes, 0, - new SpaArray(SpaType.Float, values.MoveToImmutable())), - ]), - cancellationToken); + new SpaObject( + SpaType.ObjectProps, + SpaParamType.Props, + [ + new SpaPodProperty( + SpaProp.ChannelVolumes, + 0, + new SpaArray(SpaType.Float, values.MoveToImmutable()) + ), + ] + ), + cancellationToken + ); } /// Sets the per-channel volumes, checking the count against the node first. @@ -190,7 +235,8 @@ public Task SetChannelVolumesAsync( public Task SetChannelVolumesAsync( ReadOnlySpan volumes, bool matchChannelMap, - CancellationToken cancellationToken = default) + CancellationToken cancellationToken = default + ) { float[] copy = volumes.ToArray(); return matchChannelMap @@ -200,23 +246,23 @@ public Task SetChannelVolumesAsync( private async Task SetCheckedAsync(float[] volumes, CancellationToken cancellationToken) { - ImmutableArray map = - await GetChannelMapAsync(cancellationToken).ConfigureAwait(false); - ImmutableArray current = - await GetChannelVolumesAsync(cancellationToken).ConfigureAwait(false); + ImmutableArray map = await GetChannelMapAsync(cancellationToken) + .ConfigureAwait(false); + ImmutableArray current = await GetChannelVolumesAsync(cancellationToken) + .ConfigureAwait(false); if (volumes.Length != map.Length && volumes.Length != current.Length) { throw new ArgumentException( $"{volumes.Length} volumes match neither the node's channel map ({map.Length}) " - + $"nor the {current.Length} it currently holds.", - nameof(volumes)); + + $"nor the {current.Length} it currently holds.", + nameof(volumes) + ); } await SetChannelVolumesAsync(volumes, cancellationToken).ConfigureAwait(false); } - /// /// The channels this node actually has, in order, or empty if it does not say. /// @@ -226,7 +272,8 @@ private async Task SetCheckedAsync(float[] volumes, CancellationToken cancellati /// Unlike the volume array, this reflects the node rather than the last thing written to it. /// public async Task> GetChannelMapAsync( - CancellationToken cancellationToken = default) + CancellationToken cancellationToken = default + ) { SpaObject? props = await GetParameterOrNullAsync(SpaParamType.Props, cancellationToken) .ConfigureAwait(false); @@ -237,7 +284,8 @@ public async Task> GetChannelMapAsync( var channels = ImmutableArray.CreateBuilder(map.Items.Length); foreach (SpaValue item in map.Items) { - if (item is SpaId id) channels.Add((SpaAudioChannel)id.Value); + if (item is SpaId id) + channels.Add((SpaAudioChannel)id.Value); } return channels.ToImmutable(); @@ -252,9 +300,13 @@ public async Task> GetChannelMapAsync( public Task SetMutedAsync(bool muted, CancellationToken cancellationToken = default) => SetParameterAsync( SpaParamType.Props, - new SpaObject(SpaType.ObjectProps, SpaParamType.Props, - [new SpaPodProperty(SpaProp.Mute, 0, new SpaBool(muted))]), - cancellationToken); + new SpaObject( + SpaType.ObjectProps, + SpaParamType.Props, + [new SpaPodProperty(SpaProp.Mute, 0, new SpaBool(muted))] + ), + cancellationToken + ); /// /// The extra latency applied to this node, in nanoseconds, or if unset. @@ -273,18 +325,25 @@ public Task SetMutedAsync(bool muted, CancellationToken cancellationToken = defa /// Abandons the wait. The request is already on its way, so cancelling does not recall /// it: the daemon can still apply the change after this throws. /// - public Task SetLatencyOffsetAsync(long nanoseconds, CancellationToken cancellationToken = default) => + public Task SetLatencyOffsetAsync( + long nanoseconds, + CancellationToken cancellationToken = default + ) => SetParameterAsync( SpaParamType.Props, - new SpaObject(SpaType.ObjectProps, SpaParamType.Props, - [new SpaPodProperty(SpaProp.LatencyOffsetNsec, 0, new SpaLong(nanoseconds))]), - cancellationToken); + new SpaObject( + SpaType.ObjectProps, + SpaParamType.Props, + [new SpaPodProperty(SpaProp.LatencyOffsetNsec, 0, new SpaLong(nanoseconds))] + ), + cancellationToken + ); /// The formats this node will accept, as offered for negotiation. /// Abandons the wait. public Task> EnumerateFormatsAsync( - CancellationToken cancellationToken = default) => - EnumerateParametersAsync(SpaParamType.EnumFormat, cancellationToken); + CancellationToken cancellationToken = default + ) => EnumerateParametersAsync(SpaParamType.EnumFormat, cancellationToken); /// /// Describes every property this node supports: its key, its type and the values it accepts. @@ -295,8 +354,8 @@ public Task> EnumerateFormatsAsync( /// so this says whether a volume slider should be shown. /// public Task> EnumeratePropertyInfoAsync( - CancellationToken cancellationToken = default) => - EnumerateParametersAsync(SpaParamType.PropInfo, cancellationToken); + CancellationToken cancellationToken = default + ) => EnumerateParametersAsync(SpaParamType.PropInfo, cancellationToken); internal static ImmutableArray ReadFloatArray(SpaObject? props, SpaProp key) { @@ -306,21 +365,34 @@ internal static ImmutableArray ReadFloatArray(SpaObject? props, SpaProp k var values = ImmutableArray.CreateBuilder(array.Items.Length); foreach (SpaValue item in array.Items) { - if (item is SpaFloat value) values.Add(value.Value); + if (item is SpaFloat value) + values.Add(value.Value); } return values.ToImmutable(); } private protected override unsafe int EnumParamsNative( - void* proxy, int seq, uint id, uint start, uint num, spa_pod* filter) => - Native.pw_node_enum_params((pw_node*)proxy, seq, id, start, num, filter); - - private protected override unsafe int SetParamNative(void* proxy, uint id, uint flags, spa_pod* param) => - Native.pw_node_set_param((pw_node*)proxy, id, flags, param); - - private protected override unsafe int SubscribeParamsNative(void* proxy, uint* ids, uint count) => - Native.pw_node_subscribe_params((pw_node*)proxy, ids, count); + void* proxy, + int seq, + uint id, + uint start, + uint num, + spa_pod* filter + ) => Native.pw_node_enum_params((pw_node*)proxy, seq, id, start, num, filter); + + private protected override unsafe int SetParamNative( + void* proxy, + uint id, + uint flags, + spa_pod* param + ) => Native.pw_node_set_param((pw_node*)proxy, id, flags, param); + + private protected override unsafe int SubscribeParamsNative( + void* proxy, + uint* ids, + uint count + ) => Native.pw_node_subscribe_params((pw_node*)proxy, ids, count); private protected override void OnHandlerFaulted(Exception exception) => LogHandlerFaulted(Id, exception); @@ -346,7 +418,13 @@ private static unsafe void OnInfoCallback(void* data, pw_node_info* info) [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] private static unsafe void OnParamCallback( - void* data, int seq, uint id, uint index, uint next, spa_pod* param) + void* data, + int seq, + uint id, + uint index, + uint next, + spa_pod* param + ) { // An exception escaping a reverse P/Invoke aborts the process, so nothing here may throw. try @@ -369,15 +447,20 @@ private static unsafe void OnParamCallback( /// plain node reports nothing rather than failing. /// public async Task> EnumeratePortConfigsAsync( - CancellationToken cancellationToken = default) + CancellationToken cancellationToken = default + ) { ImmutableArray raw = await EnumerateParametersAsync( - SpaParamType.EnumPortConfig, cancellationToken).ConfigureAwait(false); + SpaParamType.EnumPortConfig, + cancellationToken + ) + .ConfigureAwait(false); var configs = ImmutableArray.CreateBuilder(raw.Length); foreach (SpaObject param in raw) { - if (PipeWirePortConfig.From(param) is { } config) configs.Add(config); + if (PipeWirePortConfig.From(param) is { } config) + configs.Add(config); } return configs.ToImmutable(); @@ -386,9 +469,12 @@ public async Task> EnumeratePortConfigsAsync( /// The port layout this node is currently using. /// Abandons the wait. public async Task GetPortConfigAsync( - CancellationToken cancellationToken = default) => + CancellationToken cancellationToken = default + ) => PipeWirePortConfig.From( - await GetParameterAsync(SpaParamType.PortConfig, cancellationToken).ConfigureAwait(false)); + await GetParameterAsync(SpaParamType.PortConfig, cancellationToken) + .ConfigureAwait(false) + ); /// Reconfigures the node's ports. /// The layout to apply. @@ -405,7 +491,9 @@ public async Task> EnumeratePortConfigsAsync( /// /// is null. public Task SetPortConfigAsync( - PipeWirePortConfig config, CancellationToken cancellationToken = default) + PipeWirePortConfig config, + CancellationToken cancellationToken = default + ) { ArgumentNullException.ThrowIfNull(config); return SetParameterAsync(SpaParamType.PortConfig, config.ToParameter(), cancellationToken); @@ -417,9 +505,12 @@ public Task SetPortConfigAsync( /// Abandons the wait. /// The declared latency, or null when the node declares none. public async Task GetProcessLatencyAsync( - CancellationToken cancellationToken = default) => + CancellationToken cancellationToken = default + ) => PipeWireProcessLatency.From( - await GetParameterAsync(SpaParamType.ProcessLatency, cancellationToken).ConfigureAwait(false)); + await GetParameterAsync(SpaParamType.ProcessLatency, cancellationToken) + .ConfigureAwait(false) + ); /// Declares what this node costs the graph to process. /// The delay this node adds. See the type for which unit to use. @@ -431,24 +522,35 @@ public Task SetPortConfigAsync( /// /// is null. public Task SetProcessLatencyAsync( - PipeWireProcessLatency latency, CancellationToken cancellationToken = default) + PipeWireProcessLatency latency, + CancellationToken cancellationToken = default + ) { ArgumentNullException.ThrowIfNull(latency); - return SetParameterAsync(SpaParamType.ProcessLatency, latency.ToParameter(), cancellationToken); + return SetParameterAsync( + SpaParamType.ProcessLatency, + latency.ToParameter(), + cancellationToken + ); } /// The metadata travelling with this node's stream, per direction. /// Abandons the wait. public async Task> GetTagsAsync( - CancellationToken cancellationToken = default) + CancellationToken cancellationToken = default + ) { - ImmutableArray raw = - await EnumerateParametersAsync(SpaParamType.Tag, cancellationToken).ConfigureAwait(false); + ImmutableArray raw = await EnumerateParametersAsync( + SpaParamType.Tag, + cancellationToken + ) + .ConfigureAwait(false); var tags = ImmutableArray.CreateBuilder(raw.Length); foreach (SpaObject param in raw) { - if (PipeWireTag.From(param) is { } tag) tags.Add(tag); + if (PipeWireTag.From(param) is { } tag) + tags.Add(tag); } return tags.ToImmutable(); @@ -464,7 +566,10 @@ public Task SetTagAsync(PipeWireTag tag, CancellationToken cancellationToken = d return SetParameterAsync(SpaParamType.Tag, tag.ToParameter(), cancellationToken); } - [LoggerMessage(EventId = 33000, Level = LogLevel.Error, - Message = "a ParameterChanged handler for node {NodeId} threw")] + [LoggerMessage( + EventId = 33000, + Level = LogLevel.Error, + Message = "a ParameterChanged handler for node {NodeId} threw" + )] private partial void LogHandlerFaulted(uint nodeId, Exception exception); } diff --git a/src/PipeWire.NET/Graph/PipeWireObjectOptions.cs b/src/PipeWire.NET/Graph/PipeWireObjectOptions.cs index 3e4df7e..ed8ad99 100644 --- a/src/PipeWire.NET/Graph/PipeWireObjectOptions.cs +++ b/src/PipeWire.NET/Graph/PipeWireObjectOptions.cs @@ -27,7 +27,8 @@ namespace PipeWire.NET.Graph; internal readonly record struct PipeWireObjectOptions( bool Linger, bool Passive, - ImmutableArray> Properties = default) + ImmutableArray> Properties = default +) { /// Keys that say what an object is rather than how it is configured. /// @@ -41,8 +42,10 @@ internal readonly record struct PipeWireObjectOptions( private static readonly string[] ReservedForLinks = [ - PipeWireKeys.PW_KEY_LINK_OUTPUT_NODE, PipeWireKeys.PW_KEY_LINK_OUTPUT_PORT, - PipeWireKeys.PW_KEY_LINK_INPUT_NODE, PipeWireKeys.PW_KEY_LINK_INPUT_PORT, + PipeWireKeys.PW_KEY_LINK_OUTPUT_NODE, + PipeWireKeys.PW_KEY_LINK_OUTPUT_PORT, + PipeWireKeys.PW_KEY_LINK_INPUT_NODE, + PipeWireKeys.PW_KEY_LINK_INPUT_PORT, ]; /// Refuses a key the caller does not get to set. @@ -55,7 +58,8 @@ internal static void ThrowIfReserved(string key, bool forLink) { throw new ArgumentException( $"'{key}' is decided by the creation call itself and cannot be set as a property.", - nameof(key)); + nameof(key) + ); } } @@ -63,14 +67,17 @@ private static bool Reserved(string key, bool forLink) { foreach (string reserved in ReservedForEverything) { - if (string.Equals(key, reserved, StringComparison.Ordinal)) return true; + if (string.Equals(key, reserved, StringComparison.Ordinal)) + return true; } - if (!forLink) return false; + if (!forLink) + return false; foreach (string reserved in ReservedForLinks) { - if (string.Equals(key, reserved, StringComparison.Ordinal)) return true; + if (string.Equals(key, reserved, StringComparison.Ordinal)) + return true; } return false; diff --git a/src/PipeWire.NET/Graph/PipeWireObjectPermission.cs b/src/PipeWire.NET/Graph/PipeWireObjectPermission.cs index d57770d..fa8a4fe 100644 --- a/src/PipeWire.NET/Graph/PipeWireObjectPermission.cs +++ b/src/PipeWire.NET/Graph/PipeWireObjectPermission.cs @@ -13,4 +13,7 @@ namespace PipeWire.NET.Graph; /// registry at all - it is hidden rather than refused, so a confined client observes a smaller graph /// instead of an erroring one. /// -public readonly record struct PipeWireObjectPermission(uint ObjectId, PipeWirePermissions Permissions); +public readonly record struct PipeWireObjectPermission( + uint ObjectId, + PipeWirePermissions Permissions +); diff --git a/src/PipeWire.NET/Graph/PipeWireParameterInfo.cs b/src/PipeWire.NET/Graph/PipeWireParameterInfo.cs index b242284..3cd703f 100644 --- a/src/PipeWire.NET/Graph/PipeWireParameterInfo.cs +++ b/src/PipeWire.NET/Graph/PipeWireParameterInfo.cs @@ -13,4 +13,8 @@ namespace PipeWire.NET.Graph; /// enumerating the second is an error rather than an empty answer, which is what makes this worth /// checking rather than guessing. /// -public readonly record struct PipeWireParameterInfo(SpaParamType Parameter, bool CanRead, bool CanWrite); +public readonly record struct PipeWireParameterInfo( + SpaParamType Parameter, + bool CanRead, + bool CanWrite +); diff --git a/src/PipeWire.NET/Graph/PipeWireParameterObject.cs b/src/PipeWire.NET/Graph/PipeWireParameterObject.cs index ebb473b..e7817f5 100644 --- a/src/PipeWire.NET/Graph/PipeWireParameterObject.cs +++ b/src/PipeWire.NET/Graph/PipeWireParameterObject.cs @@ -32,11 +32,13 @@ namespace PipeWire.NET.Graph; public abstract class PipeWireParameterObject : IDisposable, IAsyncDisposable { private readonly PipeWireContext _ctx; + // Concurrent rather than a dictionary behind a lock, and deliberately so. A param event is // dispatched by the loop thread with the loop lock already held, so anything it waits on must // never be held by a thread that is itself waiting for the loop lock. private readonly ConcurrentDictionary> _answers = new(); private BoundProxy? _bound; + // The array reference, not the ImmutableArray wrapping it. A struct assignment has no atomicity // guarantee and cannot be published with Volatile; the single reference inside it can. private PipeWireParameterInfo[] _parameters = []; @@ -75,7 +77,8 @@ public bool CanRead(SpaParamType parameter) { foreach (PipeWireParameterInfo info in Parameters) { - if (info.Parameter == parameter) return info.CanRead; + if (info.Parameter == parameter) + return info.CanRead; } return false; @@ -87,7 +90,8 @@ public bool CanWrite(SpaParamType parameter) { foreach (PipeWireParameterInfo info in Parameters) { - if (info.Parameter == parameter) return info.CanWrite; + if (info.Parameter == parameter) + return info.CanWrite; } return false; @@ -147,17 +151,25 @@ private protected void Attach(BoundProxy bound) private void RaiseRemoved() { Action? handler = Removed; - if (handler is null) return; + if (handler is null) + return; // A native callback frame, so nothing may escape it. - try { handler(); } - catch (Exception ex) { OnHandlerFaulted(ex); } + try + { + handler(); + } + catch (Exception ex) + { + OnHandlerFaulted(ex); + } } /// /// A number to hand to enum_params. Only for tracing: the protocol does not echo it. /// - private int NextRequestTag() => Interlocked.Increment(ref _nextTag) & NativeConstants.SPA_ASYNC_SEQ_MASK; + private int NextRequestTag() => + Interlocked.Increment(ref _nextTag) & NativeConstants.SPA_ASYNC_SEQ_MASK; // Each takes the proxy pointer rather than reading it from the binding. Destroying a proxy // clears that pointer before it takes the loop lock, so a pointer re-read inside the call could @@ -165,10 +177,21 @@ private void RaiseRemoved() /// Dispatches this interface's enum_params. private protected abstract unsafe int EnumParamsNative( - void* proxy, int seq, uint id, uint start, uint num, spa_pod* filter); + void* proxy, + int seq, + uint id, + uint start, + uint num, + spa_pod* filter + ); /// Dispatches this interface's set_param. - private protected abstract unsafe int SetParamNative(void* proxy, uint id, uint flags, spa_pod* param); + private protected abstract unsafe int SetParamNative( + void* proxy, + uint id, + uint flags, + spa_pod* param + ); /// Dispatches this interface's subscribe_params. private protected abstract unsafe int SubscribeParamsNative(void* proxy, uint* ids, uint count); @@ -189,7 +212,9 @@ private protected abstract unsafe int EnumParamsNative( /// "no values", so a caller asking for the wrong parameter finds out. /// public async Task> EnumerateParametersAsync( - SpaParamType parameter, CancellationToken cancellationToken = default) + SpaParamType parameter, + CancellationToken cancellationToken = default + ) { ObjectDisposedException.ThrowIf(_disposed, this); cancellationToken.ThrowIfCancellationRequested(); @@ -220,7 +245,10 @@ public async Task> EnumerateParametersAsync( /// /// The binding has been disposed. public Task> EnumerateParametersAsync( - SpaParamType parameter, SpaObject filter, CancellationToken cancellationToken = default) + SpaParamType parameter, + SpaObject filter, + CancellationToken cancellationToken = default + ) { ObjectDisposedException.ThrowIf(_disposed, this); ArgumentNullException.ThrowIfNull(filter); @@ -240,7 +268,10 @@ public Task> EnumerateParametersAsync( } private async Task> EnumerateFilteredAsync( - SpaParamType parameter, nint filter, CancellationToken cancellationToken) + SpaParamType parameter, + nint filter, + CancellationToken cancellationToken + ) { // The sequence number the answers carry is not the one handed to enum_params. The protocol // replaces it with the connection's own message sequence and returns that, async-tagged, so @@ -250,8 +281,7 @@ private async Task> EnumerateFilteredAsync( // param the request produced. Without the barrier there is nothing to wait on: the protocol // has no "that was the last one". int key = 0; - Task roundTrip = BeginEnumeration( - parameter, filter, k => key = k, cancellationToken); + Task roundTrip = BeginEnumeration(parameter, filter, k => key = k, cancellationToken); try { @@ -281,7 +311,9 @@ private async Task> EnumerateFilteredAsync( /// . /// private static bool IsNoSuchParameter(PipeWireRequestRefusedException ex) => - ex.Result == -NativeLibc.ENOENT && ex.ObjectId is { } id && id != NativeConstants.PW_ID_CORE; + ex.Result == -NativeLibc.ENOENT + && ex.ObjectId is { } id + && id != NativeConstants.PW_ID_CORE; /// /// Like , but an object that does not have the parameter at all @@ -293,7 +325,9 @@ private static bool IsNoSuchParameter(PipeWireRequestRefusedException ex) => /// an error. Every other failure, including an object that is gone, still throws. /// private protected async Task GetParameterOrNullAsync( - SpaParamType parameter, CancellationToken cancellationToken) + SpaParamType parameter, + CancellationToken cancellationToken + ) { try { @@ -313,10 +347,12 @@ private static bool IsNoSuchParameter(PipeWireRequestRefusedException ex) => /// Which parameter to read. /// Abandons the wait. public async Task GetParameterAsync( - SpaParamType parameter, CancellationToken cancellationToken = default) + SpaParamType parameter, + CancellationToken cancellationToken = default + ) { - ImmutableArray all = - await EnumerateParametersAsync(parameter, cancellationToken).ConfigureAwait(false); + ImmutableArray all = await EnumerateParametersAsync(parameter, cancellationToken) + .ConfigureAwait(false); return all.IsDefaultOrEmpty ? null : all[0]; } @@ -337,7 +373,10 @@ private static bool IsNoSuchParameter(PipeWireRequestRefusedException ex) => /// is . /// The binding has been disposed. public async Task SetParameterAsync( - SpaParamType parameter, SpaObject value, CancellationToken cancellationToken = default) + SpaParamType parameter, + SpaObject value, + CancellationToken cancellationToken = default + ) { ArgumentNullException.ThrowIfNull(value); ObjectDisposedException.ThrowIf(_disposed, this); @@ -351,7 +390,10 @@ public async Task SetParameterAsync( /// Issues a parameter write inside its round-trip, under a held reference. private unsafe Task BeginWrite( - SpaParamType parameter, byte[] pod, CancellationToken cancellationToken) + SpaParamType parameter, + byte[] pod, + CancellationToken cancellationToken + ) { if (!Bound.TryUse(out BoundProxy.Use proxy)) throw new ObjectDisposedException(nameof(PipeWireParameterObject)); @@ -359,16 +401,24 @@ private unsafe Task BeginWrite( using (proxy) { nint obj = (nint)proxy.Object; - return CoreSync.RoundTripAsync(_ctx, () => - { - fixed (byte* p = pod) - return SetParamNative((void*)obj, (uint)parameter, 0, (spa_pod*)p); - }, cancellationToken); + return CoreSync.RoundTripAsync( + _ctx, + () => + { + fixed (byte* p = pod) + return SetParamNative((void*)obj, (uint)parameter, 0, (spa_pod*)p); + }, + cancellationToken + ); } } private unsafe Task BeginEnumeration( - SpaParamType parameter, nint filter, Action onKey, CancellationToken cancellationToken) + SpaParamType parameter, + nint filter, + Action onKey, + CancellationToken cancellationToken + ) { if (!Bound.TryUse(out BoundProxy.Use proxy)) throw new ObjectDisposedException(nameof(PipeWireParameterObject)); @@ -385,31 +435,40 @@ private unsafe Task BeginEnumeration( using (proxy) { nint obj = (nint)proxy.Object; - return CoreSync.RoundTripAsync(_ctx, () => - { - // 0 to uint.MaxValue: every value the object has of this parameter. - // The filter travels as an IntPtr because a pointer cannot be captured; it is only - // ever dereferenced here, synchronously, while the caller's pin is still held. - int rc = EnumParamsNative( - (void*)obj, NextRequestTag(), (uint)parameter, 0, uint.MaxValue, - (spa_pod*)filter); - - // Filed while the loop lock is still held, so no param event for this request can - // have been dispatched yet - the loop thread dispatches them, and it is blocked on - // that lock until the scope closes. - // Only when the daemon actually queued the request. A synchronous result has no - // sequence, and filing a collector under key 0 makes OnParam route any seq-0 param - // into this enumeration: the caller collects a parameter it did not ask for, and - // the subscriber that should have been told about it is not. - if (Native.SPA_RESULT_IS_ASYNC(rc)) + return CoreSync.RoundTripAsync( + _ctx, + () => { - int key = Native.SPA_RESULT_ASYNC_SEQ(rc); - _answers[key] = []; - onKey(key); - } - - return rc; - }, cancellationToken); + // 0 to uint.MaxValue: every value the object has of this parameter. + // The filter travels as an IntPtr because a pointer cannot be captured; it is only + // ever dereferenced here, synchronously, while the caller's pin is still held. + int rc = EnumParamsNative( + (void*)obj, + NextRequestTag(), + (uint)parameter, + 0, + uint.MaxValue, + (spa_pod*)filter + ); + + // Filed while the loop lock is still held, so no param event for this request can + // have been dispatched yet - the loop thread dispatches them, and it is blocked on + // that lock until the scope closes. + // Only when the daemon actually queued the request. A synchronous result has no + // sequence, and filing a collector under key 0 makes OnParam route any seq-0 param + // into this enumeration: the caller collects a parameter it did not ask for, and + // the subscriber that should have been told about it is not. + if (Native.SPA_RESULT_IS_ASYNC(rc)) + { + int key = Native.SPA_RESULT_ASYNC_SEQ(rc); + _answers[key] = []; + onKey(key); + } + + return rc; + }, + cancellationToken + ); } } @@ -439,9 +498,10 @@ public unsafe void SubscribeParameters(params ReadOnlySpan paramet { ObjectDisposedException.ThrowIf(_disposed, this); - Span ids = parameters.Length <= 16 - ? stackalloc uint[parameters.Length] - : new uint[parameters.Length]; + Span ids = + parameters.Length <= 16 + ? stackalloc uint[parameters.Length] + : new uint[parameters.Length]; for (int i = 0; i < parameters.Length; i++) ids[i] = (uint)parameters[i]; @@ -477,19 +537,24 @@ public unsafe void SubscribeParameters(params ReadOnlySpan paramet /// private protected unsafe void OnParam(int seq, spa_pod* param) { - if (param is null) return; + if (param is null) + return; // The size is the producer's, and it is used to build the very span the parser then bounds // itself against, so a lie here is one the parser cannot see through. Guarded the way // PipeWireProfilerProxy guards the same shape. uint size = *(uint*)param; - if (size > int.MaxValue - 8) return; + if (size > int.MaxValue - 8) + return; var bytes = new ReadOnlySpan(param, 8 + (int)size); if (!SpaPod.TryParse(bytes, out SpaValue? value) || value is not SpaObject parsed) return; - bool filed = _answers.TryGetValue(Native.SPA_RESULT_ASYNC_SEQ(seq), out List? into); + bool filed = _answers.TryGetValue( + Native.SPA_RESULT_ASYNC_SEQ(seq), + out List? into + ); if (filed) { lock (into!) @@ -519,7 +584,8 @@ private protected unsafe void OnInfo(spa_param_info* parameters, uint count) described[i] = new PipeWireParameterInfo( (SpaParamType)parameters![i].id, ((SpaParamInfoFlags)parameters[i].flags & SpaParamInfoFlags.Read) != 0, - ((SpaParamInfoFlags)parameters[i].flags & SpaParamInfoFlags.Write) != 0); + ((SpaParamInfoFlags)parameters[i].flags & SpaParamInfoFlags.Write) != 0 + ); } Volatile.Write(ref _parameters, described); @@ -542,18 +608,26 @@ private protected unsafe void OnInfo(spa_param_info* parameters, uint count) private protected unsafe void OnInfoProperties(spa_dict* properties) { Action? observer = PropertiesObserved; - if (observer is null || properties is null) return; + if (observer is null || properties is null) + return; // A native callback frame, so nothing may escape it. - try { observer(Id, PipeWireProperties.From(properties)); } - catch (Exception ex) { OnHandlerFaulted(ex); } + try + { + observer(Id, PipeWireProperties.From(properties)); + } + catch (Exception ex) + { + OnHandlerFaulted(ex); + } } /// Reports a subscriber that threw, where the logger is in scope. private protected abstract void OnHandlerFaulted(Exception exception); /// Resolves the instance a native callback belongs to, or null once it is gone. - private protected static unsafe T? FromUserData(void* data) where T : PipeWireParameterObject + private protected static unsafe T? FromUserData(void* data) + where T : PipeWireParameterObject { var self = (T?)GCHandle.FromIntPtr((nint)data).Target; return self is null || self._disposed ? null : self; @@ -575,7 +649,8 @@ public ValueTask DisposeAsync() private void DisposeCore() { - if (_disposed) return; + if (_disposed) + return; _disposed = true; // Anything still waiting for answers gets none; the round-trip it is awaiting completes or @@ -585,5 +660,4 @@ private void DisposeCore() GC.SuppressFinalize(this); } - } diff --git a/src/PipeWire.NET/Graph/PipeWirePort.cs b/src/PipeWire.NET/Graph/PipeWirePort.cs index 7e42a22..2849fd9 100644 --- a/src/PipeWire.NET/Graph/PipeWirePort.cs +++ b/src/PipeWire.NET/Graph/PipeWirePort.cs @@ -27,7 +27,8 @@ internal PipeWirePort( string? ObjectPath = null, PipeWirePermissions Permissions = PipeWirePermissions.None, uint InterfaceVersion = 0, - PipeWireProperties? Properties = null) + PipeWireProperties? Properties = null + ) { this.Properties = Properties ?? PipeWireProperties.Empty; this.ObjectSerial = this.Properties.Serial; diff --git a/src/PipeWire.NET/Graph/PipeWirePortConfig.cs b/src/PipeWire.NET/Graph/PipeWirePortConfig.cs index f3569df..5727475 100644 --- a/src/PipeWire.NET/Graph/PipeWirePortConfig.cs +++ b/src/PipeWire.NET/Graph/PipeWirePortConfig.cs @@ -27,7 +27,8 @@ public sealed record PipeWirePortConfig( SpaParamPortConfigMode Mode, bool Monitor = false, bool Control = false, - SpaObject? Format = null) + SpaObject? Format = null +) { /// Reads one out of a SPA_PARAM_PortConfig object, or null if it is not one. /// @@ -36,14 +37,16 @@ public sealed record PipeWirePortConfig( /// public static PipeWirePortConfig? From(SpaObject? param) { - if (param is null || param.ObjectType != SpaType.ObjectParamPortConfig) return null; + if (param is null || param.ObjectType != SpaType.ObjectParamPortConfig) + return null; return new PipeWirePortConfig( (SpaDirection)(param[(uint)SpaParamPortConfig.Direction] is SpaId d ? d.Value : 0), (SpaParamPortConfigMode)(param[(uint)SpaParamPortConfig.Mode] is SpaId m ? m.Value : 0), param[(uint)SpaParamPortConfig.Monitor] is SpaBool mon && mon.Value, param[(uint)SpaParamPortConfig.Control] is SpaBool ctl && ctl.Value, - param[(uint)SpaParamPortConfig.Format] as SpaObject); + param[(uint)SpaParamPortConfig.Format] as SpaObject + ); } /// This configuration as the parameter object the daemon expects. @@ -64,6 +67,10 @@ public SpaObject ToParameter() if (Format is not null) properties.Add(new SpaPodProperty((uint)SpaParamPortConfig.Format, 0, Format)); - return new SpaObject(SpaType.ObjectParamPortConfig, SpaParamType.PortConfig, [.. properties]); + return new SpaObject( + SpaType.ObjectParamPortConfig, + SpaParamType.PortConfig, + [.. properties] + ); } } diff --git a/src/PipeWire.NET/Graph/PipeWirePortProxy.cs b/src/PipeWire.NET/Graph/PipeWirePortProxy.cs index fa59ddb..f536079 100644 --- a/src/PipeWire.NET/Graph/PipeWirePortProxy.cs +++ b/src/PipeWire.NET/Graph/PipeWirePortProxy.cs @@ -34,28 +34,49 @@ private PipeWirePortProxy(PipeWireContext ctx, uint id, ILogger logger) : base(ctx, id) => _logger = logger; internal static unsafe PipeWirePortProxy Bind( - PipeWireContext ctx, pw_registry* registry, uint id, uint version, ILogger logger, - Action? propertiesObserved = null) + PipeWireContext ctx, + pw_registry* registry, + uint id, + uint version, + ILogger logger, + Action? propertiesObserved = null + ) { // The observer is in place before the proxy is bound: the first info after a bind is the // only one that carries the object's properties (later ones set no PROPS in their change // mask, so their dictionary arrives empty), and it can arrive the moment the bind is sent. // Assigned after Bind returned, a fast daemon's first info found no observer and the // properties never reached the registry. - var control = new PipeWirePortProxy(ctx, id, logger) { PropertiesObserved = propertiesObserved }; - control.Attach(BoundProxy.Bind( - ctx, registry, id, PipeWireKeys.PW_TYPE_INTERFACE_Port, version, NativeConstants.PW_VERSION_PORT, - sizeof(pw_port_events), - events => - { - var table = (pw_port_events*)events; - table->version = NativeConstants.PW_VERSION_PORT_EVENTS; - table->info = &OnInfoCallback; - table->param = &OnParamCallback; - }, - static (proxy, hook, events, data) => Native.pw_port_add_listener( - (pw_port*)proxy, (spa_hook*)hook, (pw_port_events*)events, (void*)data), - control)); + var control = new PipeWirePortProxy(ctx, id, logger) + { + PropertiesObserved = propertiesObserved, + }; + control.Attach( + BoundProxy.Bind( + ctx, + registry, + id, + PipeWireKeys.PW_TYPE_INTERFACE_Port, + version, + NativeConstants.PW_VERSION_PORT, + sizeof(pw_port_events), + events => + { + var table = (pw_port_events*)events; + table->version = NativeConstants.PW_VERSION_PORT_EVENTS; + table->info = &OnInfoCallback; + table->param = &OnParamCallback; + }, + static (proxy, hook, events, data) => + Native.pw_port_add_listener( + (pw_port*)proxy, + (spa_hook*)hook, + (pw_port_events*)events, + (void*)data + ), + control + ) + ); return control; } @@ -67,8 +88,8 @@ internal static unsafe PipeWirePortProxy Bind( /// negotiated format, and neither does one whose link is still negotiating. /// public Task> EnumerateFormatsAsync( - CancellationToken cancellationToken = default) => - EnumerateParametersAsync(SpaParamType.Format, cancellationToken); + CancellationToken cancellationToken = default + ) => EnumerateParametersAsync(SpaParamType.Format, cancellationToken); /// Every format this port is willing to accept. /// Abandons the wait. @@ -78,8 +99,8 @@ public Task> EnumerateFormatsAsync( /// . /// public Task> EnumerateSupportedFormatsAsync( - CancellationToken cancellationToken = default) => - EnumerateParametersAsync(SpaParamType.EnumFormat, cancellationToken); + CancellationToken cancellationToken = default + ) => EnumerateParametersAsync(SpaParamType.EnumFormat, cancellationToken); /// The latency the graph has settled on for this port, in both directions. /// Abandons the wait. @@ -88,8 +109,8 @@ public Task> EnumerateSupportedFormatsAsync( /// port's figure. /// public Task> EnumerateLatencyAsync( - CancellationToken cancellationToken = default) => - EnumerateParametersAsync(SpaParamType.Latency, cancellationToken); + CancellationToken cancellationToken = default + ) => EnumerateParametersAsync(SpaParamType.Latency, cancellationToken); /// The settled latency of this port, read as a typed value per direction. /// Abandons the wait. @@ -100,15 +121,17 @@ public Task> EnumerateLatencyAsync( /// something this version does not model. /// public async Task> GetLatenciesAsync( - CancellationToken cancellationToken = default) + CancellationToken cancellationToken = default + ) { - ImmutableArray raw = - await EnumerateLatencyAsync(cancellationToken).ConfigureAwait(false); + ImmutableArray raw = await EnumerateLatencyAsync(cancellationToken) + .ConfigureAwait(false); var latencies = ImmutableArray.CreateBuilder(raw.Length); foreach (SpaObject param in raw) { - if (PipeWireLatency.From(param) is { } latency) latencies.Add(latency); + if (PipeWireLatency.From(param) is { } latency) + latencies.Add(latency); } return latencies.ToImmutable(); @@ -117,23 +140,33 @@ public async Task> GetLatenciesAsync( /// The metadata travelling through this port, per direction. /// Abandons the wait. public async Task> GetTagsAsync( - CancellationToken cancellationToken = default) + CancellationToken cancellationToken = default + ) { - ImmutableArray raw = - await EnumerateParametersAsync(SpaParamType.Tag, cancellationToken).ConfigureAwait(false); + ImmutableArray raw = await EnumerateParametersAsync( + SpaParamType.Tag, + cancellationToken + ) + .ConfigureAwait(false); var tags = ImmutableArray.CreateBuilder(raw.Length); foreach (SpaObject param in raw) { - if (PipeWireTag.From(param) is { } tag) tags.Add(tag); + if (PipeWireTag.From(param) is { } tag) + tags.Add(tag); } return tags.ToImmutable(); } private protected override unsafe int EnumParamsNative( - void* proxy, int seq, uint id, uint start, uint num, spa_pod* filter) => - Native.pw_port_enum_params((pw_port*)proxy, seq, id, start, num, filter); + void* proxy, + int seq, + uint id, + uint start, + uint num, + spa_pod* filter + ) => Native.pw_port_enum_params((pw_port*)proxy, seq, id, start, num, filter); /// /// ENOTSUP, not a call. pw_port_methods has no set_param: a port's format is @@ -142,13 +175,21 @@ private protected override unsafe int EnumParamsNative( /// SetParameterAsync and hiding it would be worse, because a caller holding the base /// type would walk straight past the override. /// - private protected override unsafe int SetParamNative(void* proxy, uint id, uint flags, spa_pod* param) => - -95; - - private protected override unsafe int SubscribeParamsNative(void* proxy, uint* ids, uint count) => - Native.pw_port_subscribe_params((pw_port*)proxy, ids, count); - - private protected override void OnHandlerFaulted(Exception exception) => LogHandlerFaulted(exception); + private protected override unsafe int SetParamNative( + void* proxy, + uint id, + uint flags, + spa_pod* param + ) => -95; + + private protected override unsafe int SubscribeParamsNative( + void* proxy, + uint* ids, + uint count + ) => Native.pw_port_subscribe_params((pw_port*)proxy, ids, count); + + private protected override void OnHandlerFaulted(Exception exception) => + LogHandlerFaulted(exception); [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] private static unsafe void OnInfoCallback(void* data, pw_port_info* info) @@ -171,7 +212,13 @@ private static unsafe void OnInfoCallback(void* data, pw_port_info* info) [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] private static unsafe void OnParamCallback( - void* data, int seq, uint id, uint index, uint next, spa_pod* param) + void* data, + int seq, + uint id, + uint index, + uint next, + spa_pod* param + ) { try { @@ -183,6 +230,10 @@ private static unsafe void OnParamCallback( } } - [LoggerMessage(EventId = 34400, Level = LogLevel.Warning, Message = "a port parameter handler threw")] + [LoggerMessage( + EventId = 34400, + Level = LogLevel.Warning, + Message = "a port parameter handler threw" + )] private partial void LogHandlerFaulted(Exception exception); } diff --git a/src/PipeWire.NET/Graph/PipeWireProcessLatency.cs b/src/PipeWire.NET/Graph/PipeWireProcessLatency.cs index bdf091f..a1fb3bc 100644 --- a/src/PipeWire.NET/Graph/PipeWireProcessLatency.cs +++ b/src/PipeWire.NET/Graph/PipeWireProcessLatency.cs @@ -23,20 +23,25 @@ public sealed record PipeWireProcessLatency(float Quantum = 0f, int Rate = 0, lo /// Reads one out of a SPA_PARAM_ProcessLatency object, or null if it is not one. public static PipeWireProcessLatency? From(SpaObject? param) { - if (param is null || param.ObjectType != SpaType.ObjectParamProcessLatency) return null; + if (param is null || param.ObjectType != SpaType.ObjectParamProcessLatency) + return null; return new PipeWireProcessLatency( param[(uint)SpaParamProcessLatency.Quantum] is SpaFloat q ? q.Value : 0f, param[(uint)SpaParamProcessLatency.Rate] is SpaInt r ? r.Value : 0, - param[(uint)SpaParamProcessLatency.Ns] is SpaLong n ? n.Value : 0L); + param[(uint)SpaParamProcessLatency.Ns] is SpaLong n ? n.Value : 0L + ); } /// This latency as the parameter object the daemon expects. public SpaObject ToParameter() => - new(SpaType.ObjectParamProcessLatency, SpaParamType.ProcessLatency, - [ - new SpaPodProperty((uint)SpaParamProcessLatency.Quantum, 0, new SpaFloat(Quantum)), - new SpaPodProperty((uint)SpaParamProcessLatency.Rate, 0, new SpaInt(Rate)), - new SpaPodProperty((uint)SpaParamProcessLatency.Ns, 0, new SpaLong(Ns)), - ]); + new( + SpaType.ObjectParamProcessLatency, + SpaParamType.ProcessLatency, + [ + new SpaPodProperty((uint)SpaParamProcessLatency.Quantum, 0, new SpaFloat(Quantum)), + new SpaPodProperty((uint)SpaParamProcessLatency.Rate, 0, new SpaInt(Rate)), + new SpaPodProperty((uint)SpaParamProcessLatency.Ns, 0, new SpaLong(Ns)), + ] + ); } diff --git a/src/PipeWire.NET/Graph/PipeWireProfiler.cs b/src/PipeWire.NET/Graph/PipeWireProfiler.cs index 89d5390..32aea31 100644 --- a/src/PipeWire.NET/Graph/PipeWireProfiler.cs +++ b/src/PipeWire.NET/Graph/PipeWireProfiler.cs @@ -16,7 +16,8 @@ internal PipeWireProfiler( uint Id, PipeWirePermissions Permissions, uint InterfaceVersion, - PipeWireProperties? Properties = null) + PipeWireProperties? Properties = null + ) { this.Properties = Properties ?? PipeWireProperties.Empty; this.ObjectSerial = this.Properties.Serial; @@ -42,5 +43,4 @@ internal PipeWireProfiler( /// public ulong? ObjectSerial { get; } - } diff --git a/src/PipeWire.NET/Graph/PipeWireProfilerProxy.cs b/src/PipeWire.NET/Graph/PipeWireProfilerProxy.cs index 8977d9f..1e8460c 100644 --- a/src/PipeWire.NET/Graph/PipeWireProfilerProxy.cs +++ b/src/PipeWire.NET/Graph/PipeWireProfilerProxy.cs @@ -42,7 +42,12 @@ private PipeWireProfilerProxy(uint id, ILogger logger) public event Action? ProfileReceived; internal static unsafe PipeWireProfilerProxy Bind( - PipeWireContext ctx, pw_registry* registry, uint id, uint version, ILogger logger) + PipeWireContext ctx, + pw_registry* registry, + uint id, + uint version, + ILogger logger + ) { // The profiler's protocol marshal lives in a module the client loads for itself; without // it pw_proxy_new has nothing to build the proxy with and the bind returns null. Neither @@ -51,7 +56,12 @@ internal static unsafe PipeWireProfilerProxy Bind( var reader = new PipeWireProfilerProxy(id, logger); reader._bound = BoundProxy.Bind( - ctx, registry, id, PipeWireKeys.PW_TYPE_INTERFACE_Profiler, version, NativeConstants.PW_VERSION_PROFILER, + ctx, + registry, + id, + PipeWireKeys.PW_TYPE_INTERFACE_Profiler, + version, + NativeConstants.PW_VERSION_PROFILER, sizeof(pw_profiler_events), events => { @@ -59,9 +69,15 @@ internal static unsafe PipeWireProfilerProxy Bind( table->version = NativeConstants.PW_VERSION_PROFILER_EVENTS; table->profile = &OnProfileCallback; }, - (proxy, hook, events, data) => Native.pw_profiler_add_listener( - (void*)proxy, (spa_hook*)hook, (pw_profiler_events*)events, (void*)data), - reader); + (proxy, hook, events, data) => + Native.pw_profiler_add_listener( + (void*)proxy, + (spa_hook*)hook, + (pw_profiler_events*)events, + (void*)data + ), + reader + ); reader._bound.Removed = reader.RaiseRemoved; @@ -82,14 +98,19 @@ internal static unsafe PipeWireProfilerProxy Bind( private void RaiseRemoved() { Action? handler = Removed; - if (handler is null) return; + if (handler is null) + return; // A native callback frame, so nothing may escape it. - try { handler(); } - catch (Exception) { /* a subscriber that throws must not reach the daemon */ } + try + { + handler(); + } + catch (Exception) + { /* a subscriber that throws must not reach the daemon */ + } } - /// Loads the profiler extension module into this context, once. /// Loading it twice is harmless; the module refcounts. private static unsafe void EnsureProfilerModule(PipeWireContext ctx) @@ -105,11 +126,13 @@ private static unsafe void EnsureProfilerModule(PipeWireContext ctx) [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] private static unsafe void OnProfileCallback(void* data, spa_pod* pod) { - if (data is null || pod is null) return; + if (data is null || pod is null) + return; PipeWireProfilerProxy? self; try { - if (GCHandle.FromIntPtr((nint)data).Target is not PipeWireProfilerProxy found) return; + if (GCHandle.FromIntPtr((nint)data).Target is not PipeWireProfilerProxy found) + return; self = found; } catch (Exception) @@ -118,7 +141,8 @@ private static unsafe void OnProfileCallback(void* data, spa_pod* pod) return; } - if (self._disposed) return; + if (self._disposed) + return; try { @@ -153,12 +177,14 @@ private static unsafe void OnProfileCallback(void* data, spa_pod* pod) internal static unsafe bool TryParseReport( spa_pod* pod, out ImmutableArray reports, - out int size) + out int size + ) { reports = []; size = 0; - if (pod is null) return false; + if (pod is null) + return false; // Checked before the cast. A size near uint.MaxValue casts to a negative length, and // the span constructor is the one place that would not tell us so. @@ -171,7 +197,8 @@ internal static unsafe bool TryParseReport( size = 8 + (int)pod->size; var bytes = new ReadOnlySpan(pod, size); - if (!SpaPod.TryParse(bytes, out SpaValue? value)) return false; + if (!SpaPod.TryParse(bytes, out SpaValue? value)) + return false; switch (value) { @@ -180,8 +207,7 @@ internal static unsafe bool TryParseReport( return true; case SpaStruct outer: - ImmutableArray found = - [.. outer.Fields.OfType()]; + ImmutableArray found = [.. outer.Fields.OfType()]; reports = found; return found.Length > 0; @@ -207,21 +233,32 @@ public ValueTask DisposeAsync() private void DisposeCore() { - if (_disposed) return; + if (_disposed) + return; _disposed = true; _bound?.Dispose(); _bound = null; } - [LoggerMessage(EventId = 34000, Level = LogLevel.Warning, - Message = "a profiler report of {Size} bytes did not parse as an object")] + [LoggerMessage( + EventId = 34000, + Level = LogLevel.Warning, + Message = "a profiler report of {Size} bytes did not parse as an object" + )] private partial void LogUnparsedReport(int size); - [LoggerMessage(EventId = 34001, Level = LogLevel.Error, Message = "dispatching a profiler report failed")] + [LoggerMessage( + EventId = 34001, + Level = LogLevel.Error, + Message = "dispatching a profiler report failed" + )] private partial void LogProfileDispatchFailed(Exception ex); - [LoggerMessage(EventId = 34002, Level = LogLevel.Error, - Message = "a ProfileReceived handler for profiler {ProfilerId} threw")] + [LoggerMessage( + EventId = 34002, + Level = LogLevel.Error, + Message = "a ProfileReceived handler for profiler {ProfilerId} threw" + )] private partial void LogHandlerFaulted(uint profilerId, Exception ex); } diff --git a/src/PipeWire.NET/Graph/PipeWireProperties.cs b/src/PipeWire.NET/Graph/PipeWireProperties.cs index 57f170b..dcf61d7 100644 --- a/src/PipeWire.NET/Graph/PipeWireProperties.cs +++ b/src/PipeWire.NET/Graph/PipeWireProperties.cs @@ -35,8 +35,7 @@ public sealed class PipeWireProperties : IReadOnlyDictionary private PipeWireProperties(FrozenDictionary items) => _items = items; /// An object the daemon sent no properties for. - public static PipeWireProperties Empty { get; } = - new(FrozenDictionary.Empty); + public static PipeWireProperties Empty { get; } = new(FrozenDictionary.Empty); /// public string this[string key] => _items[key]; @@ -65,7 +64,9 @@ public bool TryGetValue(string key, [MaybeNullWhen(false)] out string value) => /// Reads a property that names another object, or null when it is absent or unusable. internal uint? Id(string key) => - _items.TryGetValue(key, out string? raw) && uint.TryParse(raw, out uint value) ? value : null; + _items.TryGetValue(key, out string? raw) && uint.TryParse(raw, out uint value) + ? value + : null; /// internal int? Int(string key) => @@ -77,7 +78,8 @@ internal bool Flag(string key) => /// Reads object.serial, which every object carries. internal ulong? Serial => - _items.TryGetValue(PipeWireKeys.PW_KEY_OBJECT_SERIAL, out string? raw) && ulong.TryParse(raw, out ulong serial) + _items.TryGetValue(PipeWireKeys.PW_KEY_OBJECT_SERIAL, out string? raw) + && ulong.TryParse(raw, out ulong serial) ? serial : null; @@ -95,23 +97,29 @@ internal bool Flag(string key) => /// internal static unsafe PipeWireProperties From(spa_dict* dict) { - if (dict is null) return Empty; + if (dict is null) + return Empty; int count = (int)dict->n_items; - if (count <= 0 || dict->items is null) return Empty; + if (count <= 0 || dict->items is null) + return Empty; var items = new Dictionary(count, StringComparer.Ordinal); for (int i = 0; i < count; i++) { spa_dict_item* item = dict->items + i; - if (item->key is null || item->value is null) continue; + if (item->key is null || item->value is null) + continue; string key = Encoding.UTF8.GetString(DaemonText.Bytes(item->key)); - if (items.ContainsKey(key)) continue; + if (items.ContainsKey(key)) + continue; items[key] = Encoding.UTF8.GetString(DaemonText.Bytes(item->value)); } - return items.Count == 0 ? Empty : new PipeWireProperties(items.ToFrozenDictionary(StringComparer.Ordinal)); + return items.Count == 0 + ? Empty + : new PipeWireProperties(items.ToFrozenDictionary(StringComparer.Ordinal)); } /// Wraps properties this library assembled rather than read off the wire. @@ -129,11 +137,14 @@ internal static PipeWireProperties FromItems(IReadOnlyDictionary /// internal PipeWireProperties MergedWith(PipeWireProperties other) { - if (other.Count == 0) return this; - if (Count == 0) return other; + if (other.Count == 0) + return this; + if (Count == 0) + return other; var items = new Dictionary(_items, StringComparer.Ordinal); - foreach (KeyValuePair pair in other._items) items[pair.Key] = pair.Value; + foreach (KeyValuePair pair in other._items) + items[pair.Key] = pair.Value; return new PipeWireProperties(items.ToFrozenDictionary(StringComparer.Ordinal)); } } diff --git a/src/PipeWire.NET/Graph/PipeWireRegistry.cs b/src/PipeWire.NET/Graph/PipeWireRegistry.cs index 4a8681d..d381220 100644 --- a/src/PipeWire.NET/Graph/PipeWireRegistry.cs +++ b/src/PipeWire.NET/Graph/PipeWireRegistry.cs @@ -91,9 +91,11 @@ private sealed class PublishWaiters internal TaskCompletionSource Add() { var waiter = new TaskCompletionSource( - TaskCreationOptions.RunContinuationsAsynchronously); + TaskCreationOptions.RunContinuationsAsynchronously + ); - lock (_waiters) _waiters.Add(waiter); + lock (_waiters) + _waiters.Add(waiter); return waiter; } @@ -121,26 +123,31 @@ internal void FailAll(Exception error) private TaskCompletionSource[] Snapshot() { - lock (_waiters) return [.. _waiters]; + lock (_waiters) + return [.. _waiters]; } } private PipeWireGraphSnapshot _current = PipeWireGraphSnapshot.Empty; private long _version; - private PipeWireProxyHandle? _registryOwner; + private PipeWireProxyHandle? _registryOwner; private unsafe pw_registry_events* _events; + // Unmanaged, not a field: pw_registry_add_listener retains this pointer and a `fixed` block only // pins for its own duration. Same reasoning as PipeWireStreamCore. - private unsafe spa_hook* _hook; - private GCHandle _selfHandle; + private unsafe spa_hook* _hook; + private GCHandle _selfHandle; private bool _listenerOwned; - private volatile bool _disposed; + private volatile bool _disposed; /// Signature for . /// The registry that published the snapshot. /// The graph as it stands after the change. - public delegate void GraphChangedHandler(PipeWireRegistry sender, PipeWireGraphSnapshot snapshot); + public delegate void GraphChangedHandler( + PipeWireRegistry sender, + PipeWireGraphSnapshot snapshot + ); /// /// Raised after every graph change, carrying the snapshot that reflects it. @@ -196,8 +203,11 @@ private unsafe void InitializeNative() // get_registry / add_listener touch loop-owned objects -> hold the loop lock. using (_ctx.Lock()) { - pw_registry* registry = - Native.pw_core_get_registry(_ctx.CoreHandle, NativeConstants.PW_VERSION_REGISTRY, 0); + pw_registry* registry = Native.pw_core_get_registry( + _ctx.CoreHandle, + NativeConstants.PW_VERSION_REGISTRY, + 0 + ); // The registry is a proxy like any other, so it gets the same owner: that keeps the // core and context alive long enough to destroy it properly rather than having it @@ -205,13 +215,17 @@ private unsafe void InitializeNative() // pointer lives, so the two cannot disagree about whether it is still valid. if (registry is not null) _registryOwner = new PipeWireProxyHandle( - (pw_proxy*)registry, _ctx.LoopOwner, _ctx.CoreOwner); + (pw_proxy*)registry, + _ctx.LoopOwner, + _ctx.CoreOwner + ); if (registry is null) throw new PipeWireInteropException("pw_core_get_registry", -NativeLibc.ENOMEM); - _events = (pw_registry_events*)NativeMemory.AllocZeroed((nuint)sizeof(pw_registry_events)); - _events->version = NativeConstants.PW_VERSION_REGISTRY_EVENTS; - _events->global = &OnGlobal; + _events = (pw_registry_events*) + NativeMemory.AllocZeroed((nuint)sizeof(pw_registry_events)); + _events->version = NativeConstants.PW_VERSION_REGISTRY_EVENTS; + _events->global = &OnGlobal; _events->global_remove = &OnGlobalRemove; _hook = (spa_hook*)NativeMemory.AllocZeroed((nuint)sizeof(spa_hook)); @@ -223,8 +237,12 @@ private unsafe void InitializeNative() _registryOwner!.OwnListener(_events, _hook, _selfHandle); _listenerOwned = true; - int rc = Native.pw_registry_add_listener(registry, _hook, _events, - (void*)GCHandle.ToIntPtr(_selfHandle)); + int rc = Native.pw_registry_add_listener( + registry, + _hook, + _events, + (void*)GCHandle.ToIntPtr(_selfHandle) + ); if (rc < 0) throw new PipeWireInteropException("pw_registry_add_listener", rc); } @@ -248,8 +266,12 @@ private unsafe void UnwindNative() foreach (uint id in _awaitingPublish.Keys) { if (_awaitingPublish.TryRemove(id, out PublishWaiters? waiters)) - waiters.FailAll(new ObjectDisposedException(nameof(PipeWireRegistry), - $"the registry was disposed while object {id} was being created.")); + waiters.FailAll( + new ObjectDisposedException( + nameof(PipeWireRegistry), + $"the registry was disposed while object {id} was being created." + ) + ); } // Safe in either disposal order. The handle chain - proxy holds core holds context holds @@ -269,9 +291,12 @@ private unsafe void UnwindNative() // and handing them over, and nothing else would ever free them. if (!_listenerOwned) { - if (_hook is not null) NativeMemory.Free(_hook); - if (_events is not null) NativeMemory.Free(_events); - if (_selfHandle.IsAllocated) _selfHandle.Free(); + if (_hook is not null) + NativeMemory.Free(_hook); + if (_events is not null) + NativeMemory.Free(_events); + if (_selfHandle.IsAllocated) + _selfHandle.Free(); } _hook = null; @@ -376,25 +401,33 @@ private void AbandonOwnership(uint id, PipeWireProxyHandle proxy) // Collects rather than raises: the granular removals are announced only after Publish, so a // handler inspecting Current already sees the snapshot the event describes. Raising first // inverts that contract - the port or link is still indexed while its removal is announced. - private void CascadeRemove(uint nodeId, out List removedLinks, out List removedPorts) + private void CascadeRemove( + uint nodeId, + out List removedLinks, + out List removedPorts + ) { removedLinks = []; removedPorts = []; foreach ((uint portId, PipeWirePort port) in _ports) { - if (port.NodeId != nodeId) continue; + if (port.NodeId != nodeId) + continue; foreach ((uint linkId, PipeWireLink link) in _links) { - if (link.OutputPortId != portId && link.InputPortId != portId) continue; - if (!_links.TryRemove(linkId, out _)) continue; + if (link.OutputPortId != portId && link.InputPortId != portId) + continue; + if (!_links.TryRemove(linkId, out _)) + continue; LogRemoved("link", linkId); removedLinks.Add(linkId); } - if (!_ports.TryRemove(portId, out _)) continue; + if (!_ports.TryRemove(portId, out _)) + continue; LogRemoved("port", portId); removedPorts.Add(portId); @@ -437,8 +470,7 @@ private void FailPublishWaiter(uint id, string reason) { if (_awaitingPublish.TryRemove(id, out PublishWaiters? waiters)) { - waiters.FailAll(new PipeWireInteropException( - "bind", -NativeLibc.EINVAL, id, reason)); + waiters.FailAll(new PipeWireInteropException("bind", -NativeLibc.EINVAL, id, reason)); } } @@ -447,11 +479,16 @@ private void FailPublishWaiter(uint id, string reason) /// published. Falls back to the live collections when the object arrived first. /// private async Task AwaitPublishedAsync( - uint id, Func lookup, CancellationToken cancellationToken) where T : class, IPipeWireObject + uint id, + Func lookup, + CancellationToken cancellationToken + ) + where T : class, IPipeWireObject { // A connection already known to be gone cannot announce anything, so waiting on it is a // hang with extra steps. The refusal names the fault that ended it. - if (_ctx.ConnectionFault is { } dead) throw dead; + if (_ctx.ConnectionFault is { } dead) + throw dead; // A registry that is already gone will never publish anything, and `bound` can arrive after // disposal, so this has to be checked here rather than only when disposal runs. @@ -477,26 +514,42 @@ private async Task AwaitPublishedAsync( // Cancels this caller's wait and nobody else's. The context shutdown also ends the wait // by surfacing an error: a publish that depends on a stopped loop never arrives. - using (cancellationToken.UnsafeRegister( - static s => ((TaskCompletionSource)s!).TrySetCanceled(), waiter)) - using (_ctx.Shutdown.UnsafeRegister( - static s => ((TaskCompletionSource)s!).TrySetException( - new ObjectDisposedException(nameof(PipeWireContext), - "the context was disposed while an object publish was in flight.")), waiter)) + using ( + cancellationToken.UnsafeRegister( + static s => ((TaskCompletionSource)s!).TrySetCanceled(), + waiter + ) + ) + using ( + _ctx.Shutdown.UnsafeRegister( + static s => + ((TaskCompletionSource)s!).TrySetException( + new ObjectDisposedException( + nameof(PipeWireContext), + "the context was disposed while an object publish was in flight." + ) + ), + waiter + ) + ) { try { IPipeWireObject published = await waiter.Task.ConfigureAwait(false); - if (published is T typed) return typed; + if (published is T typed) + return typed; // Ids recycle. Between the daemon answering with this id and the global arriving, // the object can be destroyed and the number handed to something else, so the // publish that satisfies this waiter is not always what was created. That is a // lost race rather than a broken invariant, and it is reported as one. throw new PipeWireException( - "create", -NativeLibc.ENOENT, id, + "create", + -NativeLibc.ENOENT, + id, $"global {id} arrived as {published.Kind} rather than {typeof(T).Name}; " - + "the id was reused before the new object was observed"); + + "the id was reused before the new object was observed" + ); } finally { @@ -507,9 +560,13 @@ private async Task AwaitPublishedAsync( /// Drops one waiter, and the id's entry once it was the last. private void Unregister( - uint id, PublishWaiters waiters, TaskCompletionSource waiter) + uint id, + PublishWaiters waiters, + TaskCompletionSource waiter + ) { - if (!waiters.Remove(waiter)) return; + if (!waiters.Remove(waiter)) + return; // Conditional on identity: a waiter that registered after this one emptied the list holds a // different instance, and removing by key alone would strand it. @@ -519,7 +576,8 @@ private void Unregister( /// Resolves the instance a native callback belongs to, or null if it is gone. private static unsafe PipeWireRegistry? FromData(void* data) { - if (data is null) return null; + if (data is null) + return null; try { @@ -582,16 +640,20 @@ private unsafe pw_registry* RegistryHandle /// /// public async IAsyncEnumerable WatchAsync( - [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default) + [System.Runtime.CompilerServices.EnumeratorCancellation] + CancellationToken cancellationToken = default + ) { var channel = System.Threading.Channels.Channel.CreateBounded( new System.Threading.Channels.BoundedChannelOptions(1) { FullMode = System.Threading.Channels.BoundedChannelFullMode.DropOldest, SingleReader = true, - }); + } + ); - void Forward(PipeWireRegistry _, PipeWireGraphSnapshot snapshot) => channel.Writer.TryWrite(snapshot); + void Forward(PipeWireRegistry _, PipeWireGraphSnapshot snapshot) => + channel.Writer.TryWrite(snapshot); void Finish() => channel.Writer.TryComplete(); GraphChanged += Forward; @@ -601,7 +663,8 @@ public async IAsyncEnumerable WatchAsync( Disposing += Finish; try { - if (_disposed) yield break; + if (_disposed) + yield break; // A publish between subscribing above and this first yield lands in the channel AND is // what Current now reads, so yielding both would deliver the same version twice. Track @@ -609,10 +672,14 @@ public async IAsyncEnumerable WatchAsync( // stale duplicate is the only disorder possible, never a gap filled from behind. long lastYielded = Current.Version; yield return Current; - await foreach (PipeWireGraphSnapshot snapshot in - channel.Reader.ReadAllAsync(cancellationToken).ConfigureAwait(false)) + await foreach ( + PipeWireGraphSnapshot snapshot in channel + .Reader.ReadAllAsync(cancellationToken) + .ConfigureAwait(false) + ) { - if (snapshot.Version <= lastYielded) continue; + if (snapshot.Version <= lastYielded) + continue; lastYielded = snapshot.Version; yield return snapshot; } @@ -689,10 +756,16 @@ private PipeWireGraphSnapshot Rebuild() lock (_publishGate) { long revision = Interlocked.Read(ref _revision); - if (revision == Interlocked.Read(ref _builtRevision)) return Volatile.Read(ref _current); + if (revision == Interlocked.Read(ref _builtRevision)) + return Volatile.Read(ref _current); var snapshot = new PipeWireGraphSnapshot( - ++_version, _sources.Values, _ports.Values, _links.Values, _objects.Values); + ++_version, + _sources.Values, + _ports.Values, + _links.Values, + _objects.Values + ); Volatile.Write(ref _current, snapshot); Interlocked.Exchange(ref _builtRevision, revision); @@ -706,14 +779,16 @@ private void RaiseGraphChanged() // subscribed - the whole of the initial enumeration, since a caller subscribes after // construction - there is nothing to build it for. GraphChangedHandler? handlers = GraphChanged; - if (handlers is null) return; + if (handlers is null) + return; PipeWireGraphSnapshot snapshot = Current; SafeCallback.Raise( handlers, (Registry: this, Snapshot: snapshot), static (h, s) => h(s.Registry, s.Snapshot), - static (s, ex) => s.Registry.LogHandlerFaulted(nameof(GraphChanged), ex)); + static (s, ex) => s.Registry.LogHandlerFaulted(nameof(GraphChanged), ex) + ); } /// @@ -759,8 +834,10 @@ public Task WaitForInitialEnumerationAsync(CancellationToken cancellationToken = /// /// public Task CreateVirtualSinkAsync( - string description, string? name = null, CancellationToken cancellationToken = default) => - CreateVirtualSink(description, name).ExecuteAsync(cancellationToken); + string description, + string? name = null, + CancellationToken cancellationToken = default + ) => CreateVirtualSink(description, name).ExecuteAsync(cancellationToken); /// /// Describes a virtual sink for creation, so options can be chained before it is made. @@ -795,8 +872,10 @@ public PipeWireNodeBuilder CreateVirtualSink(string description, string? name = /// clients somewhere to read from. /// public Task CreateVirtualSourceAsync( - string description, string? name = null, CancellationToken cancellationToken = default) => - CreateVirtualSource(description, name).ExecuteAsync(cancellationToken); + string description, + string? name = null, + CancellationToken cancellationToken = default + ) => CreateVirtualSource(description, name).ExecuteAsync(cancellationToken); /// /// Describes a virtual audio source: a node other clients capture from, rather than play into. @@ -811,22 +890,33 @@ public PipeWireNodeBuilder CreateVirtualSource(string description, string? name CreateVirtualSink(description, name).WithMediaClass("Audio/Source"); internal async Task ExecuteNodeCreationAsync( - string description, string? name, PipeWireObjectOptions options, CancellationToken cancellationToken) + string description, + string? name, + PipeWireObjectOptions options, + CancellationToken cancellationToken + ) { name ??= Guid.NewGuid().ToString(); Task? published = null; (uint id, PipeWireProxyHandle proxy) = await CreateObjectAsync( - isLink: false, description, name, default, default, options, - boundId => - { - // Marked before anything can be awaited, so a removal arriving while the graph - // catches up has somewhere to record itself. - _creating[boundId] = false; - published = AwaitPublishedAsync(boundId, GetNodeOrNull, cancellationToken); - }, - cancellationToken).ConfigureAwait(false); + isLink: false, + description, + name, + default, + default, + options, + boundId => + { + // Marked before anything can be awaited, so a removal arriving while the graph + // catches up has somewhere to record itself. + _creating[boundId] = false; + published = AwaitPublishedAsync(boundId, GetNodeOrNull, cancellationToken); + }, + cancellationToken + ) + .ConfigureAwait(false); try { @@ -854,8 +944,10 @@ internal async Task ExecuteNodeCreationAsync( /// the message. /// public Task CreateLinkAsync( - PipeWirePort output, PipeWirePort input, CancellationToken cancellationToken = default) => - CreateLink(output, input).ExecuteAsync(cancellationToken); + PipeWirePort output, + PipeWirePort input, + CancellationToken cancellationToken = default + ) => CreateLink(output, input).ExecuteAsync(cancellationToken); /// /// Describes a link for creation, so options can be chained before it is made. @@ -872,9 +964,15 @@ public PipeWireLinkBuilder CreateLink(PipeWirePort output, PipeWirePort input) ArgumentNullException.ThrowIfNull(input); if (output.PortDirection != PipeWirePortDirection.Out) - throw new ArgumentException($"Port {output.PortId} is not an output port.", nameof(output)); + throw new ArgumentException( + $"Port {output.PortId} is not an output port.", + nameof(output) + ); if (input.PortDirection != PipeWirePortDirection.In) - throw new ArgumentException($"Port {input.PortId} is not an input port.", nameof(input)); + throw new ArgumentException( + $"Port {input.PortId} is not an input port.", + nameof(input) + ); return new PipeWireLinkBuilder(this, output, input, default); } @@ -901,13 +999,19 @@ public PipeWireLinkBuilder CreateLink(uint outputPortId, uint inputPortId) PipeWireGraphSnapshot graph = Current; - PipeWirePort output = graph.GetPort(outputPortId) + PipeWirePort output = + graph.GetPort(outputPortId) ?? throw new ArgumentException( - $"{outputPortId} is not a port in the current graph.", nameof(outputPortId)); + $"{outputPortId} is not a port in the current graph.", + nameof(outputPortId) + ); - PipeWirePort input = graph.GetPort(inputPortId) + PipeWirePort input = + graph.GetPort(inputPortId) ?? throw new ArgumentException( - $"{inputPortId} is not a port in the current graph.", nameof(inputPortId)); + $"{inputPortId} is not a port in the current graph.", + nameof(inputPortId) + ); return CreateLink(output, input); } @@ -924,23 +1028,35 @@ public PipeWireLinkBuilder CreateLink(uint outputPortId, uint inputPortId) /// /// The daemon refused the request. public Task CreateLinkAsync( - uint outputPortId, uint inputPortId, CancellationToken cancellationToken = default) => - CreateLink(outputPortId, inputPortId).ExecuteAsync(cancellationToken); + uint outputPortId, + uint inputPortId, + CancellationToken cancellationToken = default + ) => CreateLink(outputPortId, inputPortId).ExecuteAsync(cancellationToken); internal async Task ExecuteLinkCreationAsync( - PipeWirePort output, PipeWirePort input, PipeWireObjectOptions options, - CancellationToken cancellationToken) + PipeWirePort output, + PipeWirePort input, + PipeWireObjectOptions options, + CancellationToken cancellationToken + ) { Task? published = null; (uint id, PipeWireProxyHandle proxy) = await CreateObjectAsync( - isLink: true, null, null, output, input, options, - boundId => - { - _creating[boundId] = false; - published = AwaitPublishedAsync(boundId, GetLinkOrNull, cancellationToken); - }, - cancellationToken).ConfigureAwait(false); + isLink: true, + null, + null, + output, + input, + options, + boundId => + { + _creating[boundId] = false; + published = AwaitPublishedAsync(boundId, GetLinkOrNull, cancellationToken); + }, + cancellationToken + ) + .ConfigureAwait(false); try { @@ -994,8 +1110,10 @@ public unsafe Task DestroyGlobalAsync(uint id, CancellationToken cancellationTok // is a race, not a caller mistake, and the daemon's answer is the honest one. if (id == NativeConstants.PW_ID_CORE) { - throw new ArgumentOutOfRangeException(nameof(id), - "the core is the connection itself and cannot be destroyed; dispose the context instead."); + throw new ArgumentOutOfRangeException( + nameof(id), + "the core is the connection itself and cannot be destroyed; dispose the context instead." + ); } // An object this client created is destroyed through the proxy it was created with, which @@ -1007,7 +1125,10 @@ public unsafe Task DestroyGlobalAsync(uint id, CancellationToken cancellationTok // its proxy unbinds it and reports success while the object lives on. Those go through // the registry destroy below like objects this client never owned, and the filed proxy is // left for the removal handler, exactly as if someone else had destroyed them. - if (_ownedProxies.TryGetValue(id, out PipeWireProxyHandle? owned) && !_lingering.ContainsKey(id)) + if ( + _ownedProxies.TryGetValue(id, out PipeWireProxyHandle? owned) + && !_lingering.ContainsKey(id) + ) { _ownedProxies.TryRemove(id, out _); owned.Dispose(); @@ -1025,7 +1146,10 @@ public unsafe Task DestroyGlobalAsync(uint id, CancellationToken cancellationTok // Issued through the round-trip so the listener is already attached: sending it first and // subscribing afterwards loses any refusal the daemon answered in between. return CoreSync.RoundTripAsync( - _ctx, () => Native.pw_registry_destroy_global(RegistryHandle, id), cancellationToken); + _ctx, + () => Native.pw_registry_destroy_global(RegistryHandle, id), + cancellationToken + ); } /// @@ -1066,13 +1190,16 @@ private unsafe void IssueDestroys(IEnumerable ids, CancellationToken cance // The core is never a destroy target, and ids recycle: without dedupe one id // could go out twice, the second answer landing on an object that is not ours. - if (id == NativeConstants.PW_ID_CORE || !seen.Add(id)) continue; + if (id == NativeConstants.PW_ID_CORE || !seen.Add(id)) + continue; // Owned proxies go through their own destruction, exactly as the single path // does (its lock is recursive on this thread); everything else is issued // without watching for its answer. - if (_ownedProxies.TryRemove(id, out PipeWireProxyHandle? owned) - && !_lingering.ContainsKey(id)) + if ( + _ownedProxies.TryRemove(id, out PipeWireProxyHandle? owned) + && !_lingering.ContainsKey(id) + ) { owned.Dispose(); continue; @@ -1091,9 +1218,15 @@ public Task RemoveLinkAsync(PipeWireLink link, CancellationToken cancellationTok } private unsafe Task<(uint Id, PipeWireProxyHandle Proxy)> CreateObjectAsync( - bool isLink, string? description, string? name, - PipeWirePort? output, PipeWirePort? input, PipeWireObjectOptions options, - Action onBound, CancellationToken cancellationToken) + bool isLink, + string? description, + string? name, + PipeWirePort? output, + PipeWirePort? input, + PipeWireObjectOptions options, + Action onBound, + CancellationToken cancellationToken + ) { ObjectDisposedException.ThrowIf(_disposed, this); @@ -1104,7 +1237,8 @@ public Task RemoveLinkAsync(PipeWireLink link, CancellationToken cancellationTok ? [] : options.Properties; - int needed = FixedPropertyBytes + int needed = + FixedPropertyBytes + (name is null ? 0 : Encoding.UTF8.GetByteCount(name) + 1) + (description is null ? 0 : Encoding.UTF8.GetByteCount(description) + 1); @@ -1114,18 +1248,20 @@ public Task RemoveLinkAsync(PipeWireLink link, CancellationToken cancellationTok // Pinned rather than pooled. The array's address is handed to native code, and a pooled // array is an ordinary movable one: a collection between Build and the native call would // leave the daemon reading whatever replaced it. - byte[]? rented = needed > StackScratchBytes - ? GC.AllocateUninitializedArray(needed, pinned: true) - : null; + byte[]? rented = + needed > StackScratchBytes + ? GC.AllocateUninitializedArray(needed, pinned: true) + : null; try { Span scratch = rented ?? stackalloc byte[StackScratchBytes]; // Eight covers everything the library sets itself; the caller's properties are on top // of that, and the count is theirs to decide. - Span items = extra.Length <= 8 - ? stackalloc spa_dict_item[16] - : GC.AllocateUninitializedArray(8 + extra.Length, pinned: true); + Span items = + extra.Length <= 8 + ? stackalloc spa_dict_item[16] + : GC.AllocateUninitializedArray(8 + extra.Length, pinned: true); // spa_dict_lookup walks the items and returns the FIRST match, so a repeated key is not // an override, it is a value that never gets read. The caller's properties therefore go @@ -1135,14 +1271,16 @@ bool Supplied(string key) { foreach ((string k, _) in extra) { - if (string.Equals(k, key, StringComparison.Ordinal)) return true; + if (string.Equals(k, key, StringComparison.Ordinal)) + return true; } return false; } var dict = new SpaDictBuilder(scratch, items); - foreach ((string key, string value) in extra) dict.Add(key, value); + foreach ((string key, string value) in extra) + dict.Add(key, value); if (options.Linger && !Supplied(PipeWireKeys.PW_KEY_OBJECT_LINGER)) dict.Add(NativeConstants.PW_KEY_OBJECT_LINGER, PipeWireValues.True); @@ -1160,25 +1298,41 @@ bool Supplied(string key) dict.Add(NativeConstants.PW_KEY_LINK_PASSIVE, PipeWireValues.True); return NativeObjectCreation.CreateAsync( - _ctx, PipeWireValues.LinkFactory, NativeConstants.PW_TYPE_INTERFACE_Link, - NativeConstants.PW_VERSION_LINK, dict.Build(), cancellationToken, onBound); + _ctx, + PipeWireValues.LinkFactory, + NativeConstants.PW_TYPE_INTERFACE_Link, + NativeConstants.PW_VERSION_LINK, + dict.Build(), + cancellationToken, + onBound + ); } // The factory is what kind of object this is rather than a property of it, so it is // not overridable. dict.Add(NativeConstants.PW_KEY_FACTORY_NAME, PipeWireValues.NullAudioSink); - if (!Supplied(PipeWireKeys.PW_KEY_NODE_NAME)) dict.Add(NativeConstants.PW_KEY_NODE_NAME, name!); - if (!Supplied(PipeWireKeys.PW_KEY_NODE_DESCRIPTION)) dict.Add(NativeConstants.PW_KEY_NODE_DESCRIPTION, description!); + if (!Supplied(PipeWireKeys.PW_KEY_NODE_NAME)) + dict.Add(NativeConstants.PW_KEY_NODE_NAME, name!); + if (!Supplied(PipeWireKeys.PW_KEY_NODE_DESCRIPTION)) + dict.Add(NativeConstants.PW_KEY_NODE_DESCRIPTION, description!); // Defaults, and only defaults: a stereo sink is the useful shape for a virtual device, // and a caller naming either key gets theirs instead. - if (!Supplied(PipeWireKeys.PW_KEY_MEDIA_CLASS)) dict.Add(NativeConstants.PW_KEY_MEDIA_CLASS, PipeWireValues.AudioSink); - if (!Supplied(PipeWireKeys.SPA_KEY_AUDIO_POSITION)) dict.Add(NativeConstants.SPA_KEY_AUDIO_POSITION, PipeWireValues.StereoPosition); + if (!Supplied(PipeWireKeys.PW_KEY_MEDIA_CLASS)) + dict.Add(NativeConstants.PW_KEY_MEDIA_CLASS, PipeWireValues.AudioSink); + if (!Supplied(PipeWireKeys.SPA_KEY_AUDIO_POSITION)) + dict.Add(NativeConstants.SPA_KEY_AUDIO_POSITION, PipeWireValues.StereoPosition); return NativeObjectCreation.CreateAsync( - _ctx, PipeWireValues.Adapter, NativeConstants.PW_TYPE_INTERFACE_Node, - NativeConstants.PW_VERSION_NODE, dict.Build(), cancellationToken, onBound); + _ctx, + PipeWireValues.Adapter, + NativeConstants.PW_TYPE_INTERFACE_Node, + NativeConstants.PW_VERSION_NODE, + dict.Build(), + cancellationToken, + onBound + ); } finally { @@ -1202,17 +1356,27 @@ bool Supplied(string key) /// linger and passive. Doubled for headroom, because exhausting it fails a creation that would /// otherwise have worked and the scratch is transient either way. /// - private static readonly int FixedPropertyBytes = 2 * ( - NativeConstants.PW_KEY_LINK_OUTPUT_NODE.Length + NativeConstants.PW_KEY_LINK_OUTPUT_PORT.Length - + NativeConstants.PW_KEY_LINK_INPUT_NODE.Length + NativeConstants.PW_KEY_LINK_INPUT_PORT.Length - + (4 * 11) // one uint32 per endpoint key - + NativeConstants.PW_KEY_OBJECT_LINGER.Length + NativeConstants.PW_KEY_LINK_PASSIVE.Length - + NativeConstants.PW_KEY_FACTORY_NAME.Length + PipeWireValues.NullAudioSink.Length - + NativeConstants.PW_KEY_NODE_NAME.Length + NativeConstants.PW_KEY_NODE_DESCRIPTION.Length - + NativeConstants.PW_KEY_MEDIA_CLASS.Length + PipeWireValues.AudioSink.Length - + NativeConstants.SPA_KEY_AUDIO_POSITION.Length + PipeWireValues.StereoPosition.Length - + PipeWireValues.True.Length - + 32); // one NUL per key and value + private static readonly int FixedPropertyBytes = + 2 + * ( + NativeConstants.PW_KEY_LINK_OUTPUT_NODE.Length + + NativeConstants.PW_KEY_LINK_OUTPUT_PORT.Length + + NativeConstants.PW_KEY_LINK_INPUT_NODE.Length + + NativeConstants.PW_KEY_LINK_INPUT_PORT.Length + + (4 * 11) // one uint32 per endpoint key + + NativeConstants.PW_KEY_OBJECT_LINGER.Length + + NativeConstants.PW_KEY_LINK_PASSIVE.Length + + NativeConstants.PW_KEY_FACTORY_NAME.Length + + PipeWireValues.NullAudioSink.Length + + NativeConstants.PW_KEY_NODE_NAME.Length + + NativeConstants.PW_KEY_NODE_DESCRIPTION.Length + + NativeConstants.PW_KEY_MEDIA_CLASS.Length + + PipeWireValues.AudioSink.Length + + NativeConstants.SPA_KEY_AUDIO_POSITION.Length + + PipeWireValues.StereoPosition.Length + + PipeWireValues.True.Length + + 32 + ); // one NUL per key and value /// /// Tears down synchronously. Disposal here does no I/O. @@ -1232,7 +1396,8 @@ public ValueTask DisposeAsync() private void DisposeCore() { - if (_disposed) return; + if (_disposed) + return; _disposed = true; // Watchers are released before the native teardown, so a consumer blocked on the stream is @@ -1259,11 +1424,21 @@ public unsafe PipeWireNodeProxy BindNode(uint nodeId) { ObjectDisposedException.ThrowIf(_disposed, this); - PipeWireNode node = Current.GetNode(nodeId) - ?? throw new ArgumentException($"{nodeId} is not a node in the current graph.", nameof(nodeId)); - - PipeWireNodeProxy control = - PipeWireNodeProxy.Bind(_ctx, RegistryHandle, nodeId, node.InterfaceVersion, _logger, EnrichGlobal); + PipeWireNode node = + Current.GetNode(nodeId) + ?? throw new ArgumentException( + $"{nodeId} is not a node in the current graph.", + nameof(nodeId) + ); + + PipeWireNodeProxy control = PipeWireNodeProxy.Bind( + _ctx, + RegistryHandle, + nodeId, + node.InterfaceVersion, + _logger, + EnrichGlobal + ); return control; } @@ -1277,11 +1452,21 @@ public unsafe PipeWireDeviceProxy BindDevice(uint deviceId) { ObjectDisposedException.ThrowIf(_disposed, this); - PipeWireDevice device = Current.GetDevice(deviceId) - ?? throw new ArgumentException($"{deviceId} is not a device in the current graph.", nameof(deviceId)); - - PipeWireDeviceProxy control = - PipeWireDeviceProxy.Bind(_ctx, RegistryHandle, deviceId, device.InterfaceVersion, _logger, EnrichGlobal); + PipeWireDevice device = + Current.GetDevice(deviceId) + ?? throw new ArgumentException( + $"{deviceId} is not a device in the current graph.", + nameof(deviceId) + ); + + PipeWireDeviceProxy control = PipeWireDeviceProxy.Bind( + _ctx, + RegistryHandle, + deviceId, + device.InterfaceVersion, + _logger, + EnrichGlobal + ); return control; } @@ -1300,11 +1485,21 @@ public unsafe PipeWireClientProxy BindClient(uint clientId) { ObjectDisposedException.ThrowIf(_disposed, this); - PipeWireClient client = Current.GetClient(clientId) - ?? throw new ArgumentException($"{clientId} is not a client in the current graph.", nameof(clientId)); + PipeWireClient client = + Current.GetClient(clientId) + ?? throw new ArgumentException( + $"{clientId} is not a client in the current graph.", + nameof(clientId) + ); return PipeWireClientProxy.Bind( - _ctx, RegistryHandle, clientId, client.InterfaceVersion, _logger, EnrichGlobal); + _ctx, + RegistryHandle, + clientId, + client.InterfaceVersion, + _logger, + EnrichGlobal + ); } /// @@ -1323,11 +1518,21 @@ public unsafe PipeWirePortProxy BindPort(uint portId) { ObjectDisposedException.ThrowIf(_disposed, this); - PipeWirePort port = Current.GetPort(portId) - ?? throw new ArgumentException($"{portId} is not a port in the current graph.", nameof(portId)); - - PipeWirePortProxy control = - PipeWirePortProxy.Bind(_ctx, RegistryHandle, portId, port.InterfaceVersion, _logger, EnrichGlobal); + PipeWirePort port = + Current.GetPort(portId) + ?? throw new ArgumentException( + $"{portId} is not a port in the current graph.", + nameof(portId) + ); + + PipeWirePortProxy control = PipeWirePortProxy.Bind( + _ctx, + RegistryHandle, + portId, + port.InterfaceVersion, + _logger, + EnrichGlobal + ); return control; } @@ -1346,14 +1551,21 @@ public unsafe PipeWireSecurityContextProxy BindSecurityContext(uint securityCont { ObjectDisposedException.ThrowIf(_disposed, this); - PipeWireSecurityContext context = Current.SecurityContext is { } found && found.Id == securityContextId - ? found - : throw new ArgumentException( - $"{securityContextId} is not a security context in the current graph.", - nameof(securityContextId)); + PipeWireSecurityContext context = + Current.SecurityContext is { } found && found.Id == securityContextId + ? found + : throw new ArgumentException( + $"{securityContextId} is not a security context in the current graph.", + nameof(securityContextId) + ); return PipeWireSecurityContextProxy.Bind( - _ctx, RegistryHandle, securityContextId, context.InterfaceVersion, _logger); + _ctx, + RegistryHandle, + securityContextId, + context.InterfaceVersion, + _logger + ); } /// @@ -1371,13 +1583,21 @@ public unsafe PipeWireProfilerProxy BindProfiler(uint profilerId) { ObjectDisposedException.ThrowIf(_disposed, this); - PipeWireProfiler profiler = Current.Profiler is { } found && found.Id == profilerId - ? found - : throw new ArgumentException( - $"{profilerId} is not the profiler in the current graph.", nameof(profilerId)); + PipeWireProfiler profiler = + Current.Profiler is { } found && found.Id == profilerId + ? found + : throw new ArgumentException( + $"{profilerId} is not the profiler in the current graph.", + nameof(profilerId) + ); return PipeWireProfilerProxy.Bind( - _ctx, RegistryHandle, profilerId, profiler.InterfaceVersion, _logger); + _ctx, + RegistryHandle, + profilerId, + profiler.InterfaceVersion, + _logger + ); } /// @@ -1398,11 +1618,21 @@ public unsafe PipeWireLinkProxy BindLink(uint linkId) { ObjectDisposedException.ThrowIf(_disposed, this); - PipeWireLink link = Current.GetLink(linkId) - ?? throw new ArgumentException($"{linkId} is not a link in the current graph.", nameof(linkId)); - - PipeWireLinkProxy control = - PipeWireLinkProxy.Bind(_ctx, RegistryHandle, linkId, link.InterfaceVersion, _logger, EnrichGlobal); + PipeWireLink link = + Current.GetLink(linkId) + ?? throw new ArgumentException( + $"{linkId} is not a link in the current graph.", + nameof(linkId) + ); + + PipeWireLinkProxy control = PipeWireLinkProxy.Bind( + _ctx, + RegistryHandle, + linkId, + link.InterfaceVersion, + _logger, + EnrichGlobal + ); return control; } @@ -1429,7 +1659,9 @@ public unsafe PipeWireMetadataProxy BindMetadata(uint storeId) if (store is not PipeWireMetadata metadata) { throw new ArgumentException( - $"{storeId} is not a metadata store in the current graph.", nameof(storeId)); + $"{storeId} is not a metadata store in the current graph.", + nameof(storeId) + ); } // A store this connection serves cannot be bound through it. module-metadata answers a @@ -1439,15 +1671,23 @@ public unsafe PipeWireMetadataProxy BindMetadata(uint storeId) // good, every request on it included. foreach (PipeWireMetadataProvider served in _ctx.ServedStores.Keys) { - if (served.ServedGlobalId != storeId) continue; + if (served.ServedGlobalId != storeId) + continue; throw new InvalidOperationException( $"metadata store {storeId} ('{metadata.MetadataName}') is served by this connection, and " - + "binding a store through the connection that serves it hangs the connection (the daemon " - + "waits on an answer from it while no longer reading from it). Use the " - + "PipeWireMetadataProvider itself, or bind the store from another context."); + + "binding a store through the connection that serves it hangs the connection (the daemon " + + "waits on an answer from it while no longer reading from it). Use the " + + "PipeWireMetadataProvider itself, or bind the store from another context." + ); } - return PipeWireMetadataProxy.Bind(_ctx, RegistryHandle, storeId, metadata.InterfaceVersion, _logger); + return PipeWireMetadataProxy.Bind( + _ctx, + RegistryHandle, + storeId, + metadata.InterfaceVersion, + _logger + ); } /// @@ -1483,26 +1723,66 @@ public unsafe PipeWireMetadataProxy BindMetadata(uint storeId) /// logged so a daemon growing a new interface shows up as a message rather than as silence. /// private unsafe void AddOtherObject( - uint id, PipeWirePermissions permissions, uint version, ReadOnlySpan kind, spa_dict* props) + uint id, + PipeWirePermissions permissions, + uint version, + ReadOnlySpan kind, + spa_dict* props + ) { IPipeWireObject? parsed = null; - if (kind.SequenceEqual(NativeConstants.PW_TYPE_INTERFACE_Device)) - parsed = PipeWireGlobalParser.ParseDevice(id, permissions, version, PipeWireProperties.From(props)); + if (kind.SequenceEqual(NativeConstants.PW_TYPE_INTERFACE_Device)) + parsed = PipeWireGlobalParser.ParseDevice( + id, + permissions, + version, + PipeWireProperties.From(props) + ); else if (kind.SequenceEqual(NativeConstants.PW_TYPE_INTERFACE_Client)) - parsed = PipeWireGlobalParser.ParseClient(id, permissions, version, PipeWireProperties.From(props)); + parsed = PipeWireGlobalParser.ParseClient( + id, + permissions, + version, + PipeWireProperties.From(props) + ); else if (kind.SequenceEqual(NativeConstants.PW_TYPE_INTERFACE_Factory)) - parsed = PipeWireGlobalParser.ParseFactory(id, permissions, version, PipeWireProperties.From(props)); + parsed = PipeWireGlobalParser.ParseFactory( + id, + permissions, + version, + PipeWireProperties.From(props) + ); else if (kind.SequenceEqual(NativeConstants.PW_TYPE_INTERFACE_Module)) - parsed = PipeWireGlobalParser.ParseModule(id, permissions, version, PipeWireProperties.From(props)); + parsed = PipeWireGlobalParser.ParseModule( + id, + permissions, + version, + PipeWireProperties.From(props) + ); else if (kind.SequenceEqual(NativeConstants.PW_TYPE_INTERFACE_Metadata)) - parsed = PipeWireGlobalParser.ParseMetadata(id, permissions, version, PipeWireProperties.From(props)); + parsed = PipeWireGlobalParser.ParseMetadata( + id, + permissions, + version, + PipeWireProperties.From(props) + ); else if (kind.SequenceEqual(NativeConstants.PW_TYPE_INTERFACE_Core)) - parsed = PipeWireGlobalParser.ParseCore(id, permissions, version, PipeWireProperties.From(props)); + parsed = PipeWireGlobalParser.ParseCore( + id, + permissions, + version, + PipeWireProperties.From(props) + ); else if (kind.SequenceEqual(NativeConstants.PW_TYPE_INTERFACE_Profiler)) parsed = new PipeWireProfiler(id, permissions, version, PipeWireProperties.From(props)); else if (kind.SequenceEqual(NativeConstants.PW_TYPE_INTERFACE_SecurityContext)) - parsed = new PipeWireSecurityContext(id, permissions, version, PipeWireProperties.From(props)); + parsed = new PipeWireSecurityContext( + id, + permissions, + version, + PipeWireProperties.From(props) + ); if (parsed is null) { @@ -1533,18 +1813,23 @@ private unsafe void AddOtherObject( /// The id is not a module in the current graph. /// The registry has been disposed. public async Task ReadModuleDetailsAsync( - uint moduleId, CancellationToken cancellationToken = default) + uint moduleId, + CancellationToken cancellationToken = default + ) { ObjectDisposedException.ThrowIf(_disposed, this); if (Current.GetModule(moduleId) is not { } module) - throw new ArgumentException($"{moduleId} is not a module in the current graph.", nameof(moduleId)); + throw new ArgumentException( + $"{moduleId} is not a module in the current graph.", + nameof(moduleId) + ); PipeWireModuleProxy reader = BindModuleReader(moduleId, module.InterfaceVersion); try { - PipeWireProperties full = await reader.Properties - .WaitAsync(cancellationToken) + PipeWireProperties full = await reader + .Properties.WaitAsync(cancellationToken) .ConfigureAwait(false); EnrichGlobal(moduleId, full); @@ -1581,48 +1866,83 @@ private unsafe PipeWireModuleProxy BindModuleReader(uint moduleId, uint version) /// private void EnrichGlobal(uint id, PipeWireProperties observed) { - if (_disposed || observed.Count == 0) return; + if (_disposed || observed.Count == 0) + return; bool replaced; if (_sources.TryGetValue(id, out PipeWireNode? node)) { - replaced = TryReplace(node.Properties, merged => - _sources[id] = PipeWireGlobalParser.ParseNode( - id, node.Permissions, node.InterfaceVersion, merged)); + replaced = TryReplace( + node.Properties, + merged => + _sources[id] = PipeWireGlobalParser.ParseNode( + id, + node.Permissions, + node.InterfaceVersion, + merged + ) + ); } else if (_ports.TryGetValue(id, out PipeWirePort? port)) { - replaced = TryReplace(port.Properties, merged => - { - if (PipeWireGlobalParser.TryParsePort( - id, port.Permissions, port.InterfaceVersion, merged, - out PipeWirePort? rebuilt, out _, out _)) - _ports[id] = rebuilt!; - }); + replaced = TryReplace( + port.Properties, + merged => + { + if ( + PipeWireGlobalParser.TryParsePort( + id, + port.Permissions, + port.InterfaceVersion, + merged, + out PipeWirePort? rebuilt, + out _, + out _ + ) + ) + _ports[id] = rebuilt!; + } + ); } else if (_links.TryGetValue(id, out PipeWireLink? link)) { - replaced = TryReplace(link.Properties, merged => - { - if (PipeWireGlobalParser.TryParseLink( - id, link.Permissions, link.InterfaceVersion, merged, - out PipeWireLink? rebuilt, out _, out _)) - _links[id] = rebuilt!; - }); + replaced = TryReplace( + link.Properties, + merged => + { + if ( + PipeWireGlobalParser.TryParseLink( + id, + link.Permissions, + link.InterfaceVersion, + merged, + out PipeWireLink? rebuilt, + out _, + out _ + ) + ) + _links[id] = rebuilt!; + } + ); } else if (_objects.TryGetValue(id, out IPipeWireObject? other)) { - replaced = TryReplace(other.Properties, merged => - { - if (RebuildOther(other, merged) is { } rebuilt) _objects[id] = rebuilt; - }); + replaced = TryReplace( + other.Properties, + merged => + { + if (RebuildOther(other, merged) is { } rebuilt) + _objects[id] = rebuilt; + } + ); } else { return; } - if (!replaced) return; + if (!replaced) + return; RaiseGraphChanged(); @@ -1636,7 +1956,8 @@ bool TryReplace(PipeWireProperties existing, Action store) return false; PipeWireProperties merged = existing.MergedWith(observed); - if (ReferenceEquals(merged, existing)) return false; + if (ReferenceEquals(merged, existing)) + return false; Change(() => store(merged)); return true; @@ -1644,7 +1965,10 @@ bool TryReplace(PipeWireProperties existing, Action store) } /// Rebuilds one of the non-graph objects from merged properties. - private static IPipeWireObject? RebuildOther(IPipeWireObject existing, PipeWireProperties merged) + private static IPipeWireObject? RebuildOther( + IPipeWireObject existing, + PipeWireProperties merged + ) { uint id = existing.Id; PipeWirePermissions permissions = existing.Permissions; @@ -1652,14 +1976,49 @@ bool TryReplace(PipeWireProperties existing, Action store) return existing.Kind switch { - PipeWireObjectKind.Device => PipeWireGlobalParser.ParseDevice(id, permissions, version, merged), - PipeWireObjectKind.Client => PipeWireGlobalParser.ParseClient(id, permissions, version, merged), - PipeWireObjectKind.Factory => PipeWireGlobalParser.ParseFactory(id, permissions, version, merged), - PipeWireObjectKind.Module => PipeWireGlobalParser.ParseModule(id, permissions, version, merged), - PipeWireObjectKind.Metadata => PipeWireGlobalParser.ParseMetadata(id, permissions, version, merged), - PipeWireObjectKind.Core => PipeWireGlobalParser.ParseCore(id, permissions, version, merged), + PipeWireObjectKind.Device => PipeWireGlobalParser.ParseDevice( + id, + permissions, + version, + merged + ), + PipeWireObjectKind.Client => PipeWireGlobalParser.ParseClient( + id, + permissions, + version, + merged + ), + PipeWireObjectKind.Factory => PipeWireGlobalParser.ParseFactory( + id, + permissions, + version, + merged + ), + PipeWireObjectKind.Module => PipeWireGlobalParser.ParseModule( + id, + permissions, + version, + merged + ), + PipeWireObjectKind.Metadata => PipeWireGlobalParser.ParseMetadata( + id, + permissions, + version, + merged + ), + PipeWireObjectKind.Core => PipeWireGlobalParser.ParseCore( + id, + permissions, + version, + merged + ), PipeWireObjectKind.Profiler => new PipeWireProfiler(id, permissions, version, merged), - PipeWireObjectKind.SecurityContext => new PipeWireSecurityContext(id, permissions, version, merged), + PipeWireObjectKind.SecurityContext => new PipeWireSecurityContext( + id, + permissions, + version, + merged + ), _ => null, }; } @@ -1667,12 +2026,20 @@ bool TryReplace(PipeWireProperties existing, Action store) // - Native callbacks - [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] - private static unsafe void OnGlobal(void* data, uint id, uint permissions, sbyte* type, uint version, spa_dict* props) + private static unsafe void OnGlobal( + void* data, + uint id, + uint permissions, + sbyte* type, + uint version, + spa_dict* props + ) { // An exception escaping a reverse P/Invoke aborts the process, so nothing below may throw - // including the handle lookup, which throws for a handle that has already been freed. PipeWireRegistry? self = FromData(data); - if (self is null || self._disposed) return; + if (self is null || self._disposed) + return; try { @@ -1690,10 +2057,14 @@ private static unsafe void OnGlobal(void* data, uint id, uint permissions, sbyte ReadOnlySpan kind = DaemonText.Bytes(type); - if (kind.SequenceEqual(NativeConstants.PW_TYPE_INTERFACE_Node)) self.AddNode(id, perms, version, props); - else if (kind.SequenceEqual(NativeConstants.PW_TYPE_INTERFACE_Port)) self.AddPort(id, perms, version, props); - else if (kind.SequenceEqual(NativeConstants.PW_TYPE_INTERFACE_Link)) self.AddLink(id, perms, version, props); - else self.AddOtherObject(id, perms, version, kind, props); + if (kind.SequenceEqual(NativeConstants.PW_TYPE_INTERFACE_Node)) + self.AddNode(id, perms, version, props); + else if (kind.SequenceEqual(NativeConstants.PW_TYPE_INTERFACE_Port)) + self.AddPort(id, perms, version, props); + else if (kind.SequenceEqual(NativeConstants.PW_TYPE_INTERFACE_Link)) + self.AddLink(id, perms, version, props); + else + self.AddOtherObject(id, perms, version, kind, props); } catch (Exception ex) { @@ -1705,9 +2076,19 @@ private static unsafe void OnGlobal(void* data, uint id, uint permissions, sbyte } } - private unsafe void AddNode(uint id, PipeWirePermissions permissions, uint version, spa_dict* props) + private unsafe void AddNode( + uint id, + PipeWirePermissions permissions, + uint version, + spa_dict* props + ) { - PipeWireNode source = PipeWireGlobalParser.ParseNode(id, permissions, version, PipeWireProperties.From(props)); + PipeWireNode source = PipeWireGlobalParser.ParseNode( + id, + permissions, + version, + PipeWireProperties.From(props) + ); string? name = source.NodeName; string? mediaClass = source.MediaClass; @@ -1717,7 +2098,7 @@ private unsafe void AddNode(uint id, PipeWirePermissions permissions, uint versi Change(() => { _sources[id] = source; - _removedNodes.Remove(id); // the id is a node again; ports naming it belong to this one + _removedNodes.Remove(id); // the id is a node again; ports naming it belong to this one }); LogNodeAdded(id, name, mediaClass); CompletePublishWaiter(id, source); @@ -1726,11 +2107,24 @@ private unsafe void AddNode(uint id, PipeWirePermissions permissions, uint versi RaiseGraphChanged(); } - private unsafe void AddPort(uint id, PipeWirePermissions permissions, uint version, spa_dict* props) + private unsafe void AddPort( + uint id, + PipeWirePermissions permissions, + uint version, + spa_dict* props + ) { - if (!PipeWireGlobalParser.TryParsePort( - id, permissions, version, PipeWireProperties.From(props), - out PipeWirePort? parsed, out string reason, out string? offending)) + if ( + !PipeWireGlobalParser.TryParsePort( + id, + permissions, + version, + PipeWireProperties.From(props), + out PipeWirePort? parsed, + out string reason, + out string? offending + ) + ) { LogPortSkipped(id, reason, offending); FailPublishWaiter(id, reason); @@ -1756,11 +2150,24 @@ private unsafe void AddPort(uint id, PipeWirePermissions permissions, uint versi RaiseGraphChanged(); } - private unsafe void AddLink(uint id, PipeWirePermissions permissions, uint version, spa_dict* props) + private unsafe void AddLink( + uint id, + PipeWirePermissions permissions, + uint version, + spa_dict* props + ) { - if (!PipeWireGlobalParser.TryParseLink( - id, permissions, version, PipeWireProperties.From(props), - out PipeWireLink? parsed, out string reason, out string? offending)) + if ( + !PipeWireGlobalParser.TryParseLink( + id, + permissions, + version, + PipeWireProperties.From(props), + out PipeWireLink? parsed, + out string reason, + out string? offending + ) + ) { LogLinkSkipped(id, reason, offending); FailPublishWaiter(id, reason); @@ -1768,8 +2175,10 @@ private unsafe void AddLink(uint id, PipeWirePermissions permissions, uint versi } PipeWireLink link = parsed!; - uint outputNode = link.OutputNodeId, outputPort = link.OutputPortId; - uint inputNode = link.InputNodeId, inputPort = link.InputPortId; + uint outputNode = link.OutputNodeId, + outputPort = link.OutputPortId; + uint inputNode = link.InputNodeId, + inputPort = link.InputPortId; Change(() => _links[id] = link); LogLinkAdded(id, outputNode, outputPort, inputNode, inputPort); @@ -1783,7 +2192,8 @@ private unsafe void AddLink(uint id, PipeWirePermissions permissions, uint versi private static unsafe void OnGlobalRemove(void* data, uint id) { PipeWireRegistry? self = FromData(data); - if (self is null || self._disposed) return; + if (self is null || self._disposed) + return; try { @@ -1797,7 +2207,8 @@ private static unsafe void OnGlobalRemove(void* data, uint id) // The kind is decided and the change applied in one hold of the gate: the loop thread is // the only writer, but a reader building a snapshot must see the node and everything that // goes with it leave together. - List removedLinks = [], removedPorts = []; + List removedLinks = [], + removedPorts = []; string? kind = null; self.Change(() => { @@ -1815,12 +2226,16 @@ private static unsafe void OnGlobalRemove(void* data, uint id) // nothing left to do. self.CascadeRemove(id, out removedLinks, out removedPorts); } - else if (self._ports.TryRemove(id, out _)) kind = "port"; - else if (self._links.TryRemove(id, out _)) kind = "link"; - else if (self._objects.TryRemove(id, out IPipeWireObject? gone)) kind = gone.Kind.ToString(); + else if (self._ports.TryRemove(id, out _)) + kind = "port"; + else if (self._links.TryRemove(id, out _)) + kind = "link"; + else if (self._objects.TryRemove(id, out IPipeWireObject? gone)) + kind = gone.Kind.ToString(); }); - if (kind is null) return; + if (kind is null) + return; self.LogRemoved(kind, id); if (kind == "node") @@ -1851,33 +2266,74 @@ private static unsafe void OnGlobalRemove(void* data, uint id) // - Diagnostics (source-generated, level-gated). Enabled via the logger factory passed to // PipeWireContext; silent by default. - - [LoggerMessage(EventId = 1, Level = LogLevel.Trace, Message = "node {Id} '{Name}' ({MediaClass})")] + [LoggerMessage( + EventId = 1, + Level = LogLevel.Trace, + Message = "node {Id} '{Name}' ({MediaClass})" + )] private partial void LogNodeAdded(uint id, string? name, string? mediaClass); - [LoggerMessage(EventId = 2, Level = LogLevel.Trace, Message = "port {Id} '{Name}' ({Direction}) of node {NodeId}")] - private partial void LogPortAdded(uint id, string? name, PipeWirePortDirection direction, uint nodeId); - - [LoggerMessage(EventId = 10, Level = LogLevel.Error, Message = "a graph watcher's completion handler threw during disposal")] + [LoggerMessage( + EventId = 2, + Level = LogLevel.Trace, + Message = "port {Id} '{Name}' ({Direction}) of node {NodeId}" + )] + private partial void LogPortAdded( + uint id, + string? name, + PipeWirePortDirection direction, + uint nodeId + ); + + [LoggerMessage( + EventId = 10, + Level = LogLevel.Error, + Message = "a graph watcher's completion handler threw during disposal" + )] private partial void LogWatchCompletionThrew(Exception ex); - [LoggerMessage(EventId = 3, Level = LogLevel.Trace, Message = "link {Id} {OutputNode}.{OutputPort} -> {InputNode}.{InputPort}")] - private partial void LogLinkAdded(uint id, uint outputNode, uint outputPort, uint inputNode, uint inputPort); + [LoggerMessage( + EventId = 3, + Level = LogLevel.Trace, + Message = "link {Id} {OutputNode}.{OutputPort} -> {InputNode}.{InputPort}" + )] + private partial void LogLinkAdded( + uint id, + uint outputNode, + uint outputPort, + uint inputNode, + uint inputPort + ); [LoggerMessage(EventId = 4, Level = LogLevel.Trace, Message = "removed {Kind} {Id}")] private partial void LogRemoved(string kind, uint id); - [LoggerMessage(EventId = 5, Level = LogLevel.Warning, Message = "skipped port {Id}: {Reason} ({Value})")] + [LoggerMessage( + EventId = 5, + Level = LogLevel.Warning, + Message = "skipped port {Id}: {Reason} ({Value})" + )] private partial void LogPortSkipped(uint id, string reason, string? value); - [LoggerMessage(EventId = 12, Level = LogLevel.Debug, - Message = "port {Id} announced for node {NodeId}, which was already removed; not filed")] + [LoggerMessage( + EventId = 12, + Level = LogLevel.Debug, + Message = "port {Id} announced for node {NodeId}, which was already removed; not filed" + )] private partial void LogPortOfRemovedNode(uint id, uint nodeId); - [LoggerMessage(EventId = 6, Level = LogLevel.Warning, Message = "skipped link {Id}: {Reason} ({Value})")] + [LoggerMessage( + EventId = 6, + Level = LogLevel.Warning, + Message = "skipped link {Id}: {Reason} ({Value})" + )] private partial void LogLinkSkipped(uint id, string reason, string? value); - [LoggerMessage(EventId = 11, Level = LogLevel.Warning, - Message = "the daemon published global {Id} with no interface type")] + [LoggerMessage( + EventId = 11, + Level = LogLevel.Warning, + Message = "the daemon published global {Id} with no interface type" + )] private partial void LogGlobalWithoutType(uint id); [LoggerMessage(EventId = 7, Level = LogLevel.Error, Message = "a {Event} handler threw")] @@ -1886,8 +2342,10 @@ private static unsafe void OnGlobalRemove(void* data, uint id) [LoggerMessage(EventId = 8, Level = LogLevel.Trace, Message = "{Kind} {Id}")] private partial void LogObjectAdded(string kind, uint id); - [LoggerMessage(EventId = 9, Level = LogLevel.Debug, - Message = "global {Id} dropped: this version does not model {Interface}")] + [LoggerMessage( + EventId = 9, + Level = LogLevel.Debug, + Message = "global {Id} dropped: this version does not model {Interface}" + )] private partial void LogUnmodelledGlobal(uint id, string @interface); - } diff --git a/src/PipeWire.NET/Graph/PipeWireSecurityContext.cs b/src/PipeWire.NET/Graph/PipeWireSecurityContext.cs index 70eac3b..b55ca32 100644 --- a/src/PipeWire.NET/Graph/PipeWireSecurityContext.cs +++ b/src/PipeWire.NET/Graph/PipeWireSecurityContext.cs @@ -17,7 +17,8 @@ internal PipeWireSecurityContext( uint Id, PipeWirePermissions Permissions, uint InterfaceVersion, - PipeWireProperties? Properties = null) + PipeWireProperties? Properties = null + ) { this.Properties = Properties ?? PipeWireProperties.Empty; this.ObjectSerial = this.Properties.Serial; @@ -43,5 +44,4 @@ internal PipeWireSecurityContext( /// public ulong? ObjectSerial { get; } - } diff --git a/src/PipeWire.NET/Graph/PipeWireSecurityContextProxy.cs b/src/PipeWire.NET/Graph/PipeWireSecurityContextProxy.cs index c55025a..008502f 100644 --- a/src/PipeWire.NET/Graph/PipeWireSecurityContextProxy.cs +++ b/src/PipeWire.NET/Graph/PipeWireSecurityContextProxy.cs @@ -40,18 +40,29 @@ private PipeWireSecurityContextProxy(PipeWireContext ctx, uint id, ILogger logge public uint Id { get; } internal static unsafe PipeWireSecurityContextProxy Bind( - PipeWireContext ctx, pw_registry* registry, uint id, uint version, ILogger logger) + PipeWireContext ctx, + pw_registry* registry, + uint id, + uint version, + ILogger logger + ) { var control = new PipeWireSecurityContextProxy(ctx, id, logger); // No events are subscribed: the interface's only event is a lifecycle signal this type has // no use for, and the zeroed table keeps the binding shape the same as every other. control._bound = BoundProxy.Bind( - ctx, registry, id, PipeWireKeys.PW_TYPE_INTERFACE_SecurityContext, version, NativeConstants.PW_VERSION_SECURITY_CONTEXT, + ctx, + registry, + id, + PipeWireKeys.PW_TYPE_INTERFACE_SecurityContext, + version, + NativeConstants.PW_VERSION_SECURITY_CONTEXT, sizeof(pw_security_context_events), events => ((pw_security_context_events*)events)->version = 0, static (_, _, _, _) => 0, - control); + control + ); control._bound.Removed = control.RaiseRemoved; @@ -72,14 +83,19 @@ internal static unsafe PipeWireSecurityContextProxy Bind( private void RaiseRemoved() { Action? handler = Removed; - if (handler is null) return; + if (handler is null) + return; // A native callback frame, so nothing may escape it. - try { handler(); } - catch (Exception) { /* a subscriber that throws must not reach the daemon */ } + try + { + handler(); + } + catch (Exception) + { /* a subscriber that throws must not reach the daemon */ + } } - /// /// Opens a sandboxed connection point. /// @@ -103,19 +119,29 @@ public Task CreateAsync( SafeHandle listenFd, SafeHandle closeFd, IReadOnlyDictionary properties, - CancellationToken cancellationToken = default) + CancellationToken cancellationToken = default + ) { ArgumentNullException.ThrowIfNull(listenFd); ArgumentNullException.ThrowIfNull(closeFd); if (listenFd.IsInvalid) - throw new ArgumentException("the handle does not carry a valid descriptor", nameof(listenFd)); + throw new ArgumentException( + "the handle does not carry a valid descriptor", + nameof(listenFd) + ); if (closeFd.IsInvalid) - throw new ArgumentException("the handle does not carry a valid descriptor", nameof(closeFd)); + throw new ArgumentException( + "the handle does not carry a valid descriptor", + nameof(closeFd) + ); // Held for the whole round trip, not just the call that starts it: the daemon receives // copies over the socket and closes those, never these. - return FdInterop.BorrowAsync(listenFd, closeFd, - (listen, close) => CreateAsync(listen, close, properties, cancellationToken)); + return FdInterop.BorrowAsync( + listenFd, + closeFd, + (listen, close) => CreateAsync(listen, close, properties, cancellationToken) + ); } /// @@ -130,7 +156,8 @@ internal async Task CreateAsync( int listenFd, int closeFd, IReadOnlyDictionary properties, - CancellationToken cancellationToken = default) + CancellationToken cancellationToken = default + ) { ArgumentNullException.ThrowIfNull(properties); ArgumentOutOfRangeException.ThrowIfNegative(listenFd); @@ -142,15 +169,22 @@ internal async Task CreateAsync( // whatever it was given, forever, which costs the whole session. if (!FdInterop.IsListeningSocket(listenFd)) throw new ArgumentException( - "the descriptor must be a listening socket", nameof(listenFd)); + "the descriptor must be a listening socket", + nameof(listenFd) + ); - await CoreSync.RoundTripAsync(_ctx, () => Create(listenFd, closeFd, properties), cancellationToken) + await CoreSync + .RoundTripAsync(_ctx, () => Create(listenFd, closeFd, properties), cancellationToken) .ConfigureAwait(false); LogCreated(Id, properties.Count); } - private unsafe int Create(int listenFd, int closeFd, IReadOnlyDictionary properties) + private unsafe int Create( + int listenFd, + int closeFd, + IReadOnlyDictionary properties + ) { int bytes = 0; foreach ((string key, string value) in properties) @@ -158,12 +192,14 @@ private unsafe int Create(int listenFd, int closeFd, IReadOnlyDictionary scratch = bytes <= 512 - ? stackalloc byte[512] - : GC.AllocateUninitializedArray(bytes, pinned: true); - Span items = properties.Count <= 16 - ? stackalloc spa_dict_item[16] - : GC.AllocateUninitializedArray(properties.Count, pinned: true); + Span scratch = + bytes <= 512 + ? stackalloc byte[512] + : GC.AllocateUninitializedArray(bytes, pinned: true); + Span items = + properties.Count <= 16 + ? stackalloc spa_dict_item[16] + : GC.AllocateUninitializedArray(properties.Count, pinned: true); var builder = new SpaDictBuilder(scratch, items); foreach ((string key, string value) in properties) @@ -192,14 +228,18 @@ public ValueTask DisposeAsync() private void DisposeCore() { - if (_disposed) return; + if (_disposed) + return; _disposed = true; _bound?.Dispose(); _bound = null; } - [LoggerMessage(EventId = 34100, Level = LogLevel.Information, - Message = "security context {ContextId} opened a sandbox with {PropertyCount} propertie(s)")] + [LoggerMessage( + EventId = 34100, + Level = LogLevel.Information, + Message = "security context {ContextId} opened a sandbox with {PropertyCount} propertie(s)" + )] private partial void LogCreated(uint contextId, int propertyCount); } diff --git a/src/PipeWire.NET/Graph/PipeWireStreamQueue.cs b/src/PipeWire.NET/Graph/PipeWireStreamQueue.cs index 4269773..7a92c53 100644 --- a/src/PipeWire.NET/Graph/PipeWireStreamQueue.cs +++ b/src/PipeWire.NET/Graph/PipeWireStreamQueue.cs @@ -32,4 +32,5 @@ public readonly record struct PipeWireStreamQueue( ulong Queued, ulong Buffered, uint QueuedBuffers, - uint AvailableBuffers); + uint AvailableBuffers +); diff --git a/src/PipeWire.NET/Graph/PipeWireTag.cs b/src/PipeWire.NET/Graph/PipeWireTag.cs index dd7e6e3..49c3304 100644 --- a/src/PipeWire.NET/Graph/PipeWireTag.cs +++ b/src/PipeWire.NET/Graph/PipeWireTag.cs @@ -13,7 +13,8 @@ namespace PipeWire.NET.Graph; /// The key and value pairs, in the order the producer wrote them. public sealed record PipeWireTag( SpaDirection Direction, - ImmutableArray> Info) + ImmutableArray> Info +) { /// Reads one out of a SPA_PARAM_Tag object, or null if it is not one. /// @@ -23,7 +24,8 @@ public sealed record PipeWireTag( /// public static PipeWireTag? From(SpaObject? param) { - if (param is null || param.ObjectType != SpaType.ObjectParamTag) return null; + if (param is null || param.ObjectType != SpaType.ObjectParamTag) + return null; var pairs = ImmutableArray.CreateBuilder>(); @@ -40,7 +42,8 @@ public sealed record PipeWireTag( return new PipeWireTag( (SpaDirection)(param[(uint)SpaParamTag.Direction] is SpaId d ? d.Value : 0), - pairs.ToImmutable()); + pairs.ToImmutable() + ); } /// This tag as the parameter object the daemon expects. @@ -54,10 +57,17 @@ public SpaObject ToParameter() fields.Add(new SpaString(pair.Value)); } - return new SpaObject(SpaType.ObjectParamTag, SpaParamType.Tag, - [ - new SpaPodProperty((uint)SpaParamTag.Direction, 0, new SpaId((uint)Direction)), - new SpaPodProperty((uint)SpaParamTag.Info, 0, new SpaStruct(fields.MoveToImmutable())), - ]); + return new SpaObject( + SpaType.ObjectParamTag, + SpaParamType.Tag, + [ + new SpaPodProperty((uint)SpaParamTag.Direction, 0, new SpaId((uint)Direction)), + new SpaPodProperty( + (uint)SpaParamTag.Info, + 0, + new SpaStruct(fields.MoveToImmutable()) + ), + ] + ); } } diff --git a/src/PipeWire.NET/Graph/PipeWireVideoCycle.cs b/src/PipeWire.NET/Graph/PipeWireVideoCycle.cs index 678919d..9fad1cf 100644 --- a/src/PipeWire.NET/Graph/PipeWireVideoCycle.cs +++ b/src/PipeWire.NET/Graph/PipeWireVideoCycle.cs @@ -30,4 +30,5 @@ public readonly record struct PipeWireVideoCycle( uint Height, uint Stride, uint FrameRateNum, - uint FrameRateDen); + uint FrameRateDen +); diff --git a/src/PipeWire.NET/Graph/SpaFactoryExport.cs b/src/PipeWire.NET/Graph/SpaFactoryExport.cs index 9750aff..2d40fb6 100644 --- a/src/PipeWire.NET/Graph/SpaFactoryExport.cs +++ b/src/PipeWire.NET/Graph/SpaFactoryExport.cs @@ -44,12 +44,14 @@ internal static unsafe class SpaFactoryExport string interfaceLabel, IReadOnlyDictionary? properties, string? libraryName, - out spa_handle* handle) + out spa_handle* handle + ) { var props = new Dictionary(StringComparer.Ordinal); if (properties is not null) { - foreach (KeyValuePair pair in properties) props[pair.Key] = pair.Value; + foreach (KeyValuePair pair in properties) + props[pair.Key] = pair.Value; } // SPA_KEY_LIBRARY_NAME. A client's context.spa-libs map is far smaller than the daemon's - @@ -58,7 +60,8 @@ internal static unsafe class SpaFactoryExport // even though the plugin is installed, and the failure reads as "not installed". Naming the // library outright skips the lookup, which is what upstream's export-spa does: it takes the // library as one argument and the factory as the next. - if (!string.IsNullOrEmpty(libraryName)) props["library.name"] = libraryName; + if (!string.IsNullOrEmpty(libraryName)) + props["library.name"] = libraryName; ReadOnlySpan factoryUtf8 = Encoding.UTF8.GetBytes(factoryName + '\0'); @@ -74,14 +77,17 @@ internal static unsafe class SpaFactoryExport Span scratch = stackalloc byte[1024]; Span items = stackalloc spa_dict_item[16]; var dict = new SpaDictBuilder(scratch, items); - foreach (KeyValuePair pair in props) dict.Add(pair.Key, pair.Value); + foreach (KeyValuePair pair in props) + dict.Add(pair.Key, pair.Value); spa_dict built = dict.Build(); fixed (byte* f = factoryUtf8) loaded = Native.pw_context_load_spa_handle(ctx.ContextHandle, (sbyte*)f, &built); if (loaded is null) - throw new InvalidOperationException($"the SPA factory '{factoryName}' is not installed."); + throw new InvalidOperationException( + $"the SPA factory '{factoryName}' is not installed." + ); void* iface = null; int res; @@ -90,9 +96,11 @@ internal static unsafe class SpaFactoryExport if (res < 0 || iface is null) { - if (loaded->clear is not null) _ = loaded->clear(loaded); + if (loaded->clear is not null) + _ = loaded->clear(loaded); throw new InvalidOperationException( - $"the SPA factory '{factoryName}' provides no {interfaceLabel} interface."); + $"the SPA factory '{factoryName}' provides no {interfaceLabel} interface." + ); } fixed (byte* type = interfaceType) @@ -101,9 +109,11 @@ internal static unsafe class SpaFactoryExport if (proxy is null) { - if (loaded->clear is not null) _ = loaded->clear(loaded); + if (loaded->clear is not null) + _ = loaded->clear(loaded); throw new InvalidOperationException( - $"pw_core_export refused the {interfaceLabel} from factory '{factoryName}'."); + $"pw_core_export refused the {interfaceLabel} from factory '{factoryName}'." + ); } handle = loaded; diff --git a/src/PipeWire.NET/Interop/BoundProxy.cs b/src/PipeWire.NET/Interop/BoundProxy.cs index 960a3d2..71964fc 100644 --- a/src/PipeWire.NET/Interop/BoundProxy.cs +++ b/src/PipeWire.NET/Interop/BoundProxy.cs @@ -70,7 +70,8 @@ internal bool TryUse(out Use scope) scope = default; PipeWireProxyHandle? proxy = _proxy; - if (_disposed != 0 || proxy is null) return false; + if (_disposed != 0 || proxy is null) + return false; bool referenced = false; try @@ -82,7 +83,8 @@ internal bool TryUse(out Use scope) return false; } - if (!referenced) return false; + if (!referenced) + return false; if (proxy.IsInvalid) { proxy.DangerousRelease(); @@ -150,7 +152,8 @@ internal static BoundProxy Bind( int eventsSize, Action fillEvents, Func addListener, - object owner) + object owner + ) { ArgumentNullException.ThrowIfNull(ctx); ArgumentNullException.ThrowIfNull(fillEvents); @@ -177,7 +180,8 @@ internal static BoundProxy Bind( if (proxy is null) { throw new InvalidOperationException( - $"the daemon refused to bind global {id} as {interfaceType}."); + $"the daemon refused to bind global {id} as {interfaceType}." + ); } var handle = new PipeWireProxyHandle(proxy, ctx.LoopOwner, ctx.CoreOwner); @@ -201,14 +205,19 @@ internal static BoundProxy Bind( handle.OwnListener(events, hook, self); int rc = addListener( - (IntPtr)proxy, (IntPtr)hook, (IntPtr)events, GCHandle.ToIntPtr(self)); + (IntPtr)proxy, + (IntPtr)hook, + (IntPtr)events, + GCHandle.ToIntPtr(self) + ); if (rc < 0) throw new PipeWireInteropException("add_listener", rc); // A second listener, on pw_proxy itself rather than on the interface: `removed` is // how the daemon says the object is gone, and it arrives nowhere else. - var proxyEvents = (pw_proxy_events*)NativeMemory.AllocZeroed((nuint)sizeof(pw_proxy_events)); + var proxyEvents = (pw_proxy_events*) + NativeMemory.AllocZeroed((nuint)sizeof(pw_proxy_events)); proxyEvents->version = NativeConstants.PW_VERSION_PROXY_EVENTS; proxyEvents->removed = &OnProxyRemoved; @@ -217,7 +226,11 @@ internal static BoundProxy Bind( handle.OwnProxyListener(proxyEvents, proxyHook, proxySelf); Native.pw_proxy_add_listener( - proxy, proxyHook, proxyEvents, (void*)GCHandle.ToIntPtr(proxySelf)); + proxy, + proxyHook, + proxyEvents, + (void*)GCHandle.ToIntPtr(proxySelf) + ); } return bound; @@ -235,8 +248,10 @@ private static void OnProxyRemoved(void* data) // An exception escaping a reverse P/Invoke aborts the process, so nothing here may throw. try { - if (GCHandle.FromIntPtr((nint)data).Target is not BoundProxy self) return; - if (Interlocked.Exchange(ref self._removed, 1) != 0) return; + if (GCHandle.FromIntPtr((nint)data).Target is not BoundProxy self) + return; + if (Interlocked.Exchange(ref self._removed, 1) != 0) + return; self.Removed?.Invoke(); } @@ -251,7 +266,8 @@ public void Dispose() { // Claimed atomically. A check followed by a set lets two concurrent disposals both pass and // schedule a teardown each. - if (Interlocked.Exchange(ref _disposed, 1) != 0) return; + if (Interlocked.Exchange(ref _disposed, 1) != 0) + return; // Deferred when called from the loop thread. The proxy itself survives being destroyed // during its own dispatch - the protocol module holds a reference across the demarshal diff --git a/src/PipeWire.NET/Interop/CoreSync.cs b/src/PipeWire.NET/Interop/CoreSync.cs index e90f0d6..d3bb92d 100644 --- a/src/PipeWire.NET/Interop/CoreSync.cs +++ b/src/PipeWire.NET/Interop/CoreSync.cs @@ -19,7 +19,9 @@ namespace PipeWire.NET.Interop; internal sealed class CoreSync : IDisposable { private readonly PipeWireContext _ctx; - private readonly TaskCompletionSource _done = new(TaskCreationOptions.RunContinuationsAsynchronously); + private readonly TaskCompletionSource _done = new( + TaskCreationOptions.RunContinuationsAsynchronously + ); private unsafe pw_core_events* _events; private unsafe spa_hook* _hook; @@ -76,7 +78,10 @@ internal static Task RoundTripAsync(PipeWireContext ctx, CancellationToken cance /// /// Abandons the wait. internal static Task RoundTripAsync( - PipeWireContext ctx, Func request, CancellationToken cancellationToken) + PipeWireContext ctx, + Func request, + CancellationToken cancellationToken + ) { var sync = new CoreSync(ctx); try @@ -99,7 +104,11 @@ internal static Task RoundTripAsync( /// For requests already issued. Prefer the overload taking the request itself, which cannot /// miss a refusal answered before the listener went on. /// - internal static Task RoundTripAsync(PipeWireContext ctx, int? watchedSeq, CancellationToken cancellationToken) + internal static Task RoundTripAsync( + PipeWireContext ctx, + int? watchedSeq, + CancellationToken cancellationToken + ) { var sync = new CoreSync(ctx) { @@ -148,7 +157,8 @@ private unsafe void Start() // that attaches its listener to a core that will never speak again, so there is no event // left to fault it and it would wait for its caller's token instead. The context records // the fault when it arrives; this is where a later request learns of it. - if (_ctx.ConnectionFault is { } dead) throw dead; + if (_ctx.ConnectionFault is { } dead) + throw dead; AllocateListener(); @@ -157,7 +167,12 @@ private unsafe void Start() // Both are checked. Without the listener nothing completes the wait, and without the // sync no done event is coming - either way the caller waits for an answer that cannot // arrive, and only its cancellation token ever ends it. - int added = Native.pw_core_add_listener(_ctx.CoreHandle, _hook, _events, (void*)GCHandle.ToIntPtr(_self)); + int added = Native.pw_core_add_listener( + _ctx.CoreHandle, + _hook, + _events, + (void*)GCHandle.ToIntPtr(_self) + ); if (added < 0) throw new PipeWireInteropException("pw_core_add_listener", added); @@ -175,7 +190,12 @@ private unsafe void StartAround(Func request) { // Attached before the request is issued, so nothing the daemon says about it can arrive // before there is somebody listening. - int added = Native.pw_core_add_listener(_ctx.CoreHandle, _hook, _events, (void*)GCHandle.ToIntPtr(_self)); + int added = Native.pw_core_add_listener( + _ctx.CoreHandle, + _hook, + _events, + (void*)GCHandle.ToIntPtr(_self) + ); if (added < 0) throw new PipeWireInteropException("pw_core_add_listener", added); @@ -201,12 +221,24 @@ private async Task AwaitAsync(CancellationToken cancellationToken) // The caller's token ends the wait early; the context's ends it at all. A round-trip // completes when the daemon answers on the loop, so once the context is disposing there // is no answer coming - and a caller that passed no token of its own would wait forever. - using (cancellationToken.UnsafeRegister(static s => ((CoreSync)s!)._done.TrySetCanceled(), this)) - using (_ctx.Shutdown.UnsafeRegister( - static s => ((CoreSync)s!)._done.TrySetException( - new ObjectDisposedException(nameof(PipeWireContext), - "the context was disposed while a round-trip to the daemon was outstanding.")), - this)) + using ( + cancellationToken.UnsafeRegister( + static s => ((CoreSync)s!)._done.TrySetCanceled(), + this + ) + ) + using ( + _ctx.Shutdown.UnsafeRegister( + static s => + ((CoreSync)s!)._done.TrySetException( + new ObjectDisposedException( + nameof(PipeWireContext), + "the context was disposed while a round-trip to the daemon was outstanding." + ) + ), + this + ) + ) { await _done.Task.ConfigureAwait(false); } @@ -224,8 +256,10 @@ private static unsafe void OnError(void* data, uint id, int seq, int res, sbyte* // did not allocate, and an exception escaping a native frame aborts rather than unwinding. try { - if (data is null) return; - if (GCHandle.FromIntPtr((nint)data).Target is not CoreSync self) return; + if (data is null) + return; + if (GCHandle.FromIntPtr((nint)data).Target is not CoreSync self) + return; string text = DaemonText.String(message) ?? $"code {res}"; @@ -236,7 +270,8 @@ private static unsafe void OnError(void* data, uint id, int seq, int res, sbyte* if (IsConnectionFatal(res)) { self._done.TrySetException( - new PipeWireConnectionClosedException("request", res, id, text)); + new PipeWireConnectionClosedException("request", res, id, text) + ); return; } @@ -247,19 +282,24 @@ private static unsafe void OnError(void* data, uint id, int seq, int res, sbyte* // for an answer that had already come. if (Native.SPA_RESULT_ASYNC_SEQ(seq) == Native.SPA_RESULT_ASYNC_SEQ(self._seq)) { - self._done.TrySetException(new PipeWireRequestRefusedException("sync", res, id, text)); + self._done.TrySetException( + new PipeWireRequestRefusedException("sync", res, id, text) + ); return; } // Past here it is an answer to a request, so a barrier has nothing to report. - if (!self._carriesRequest) return; + if (!self._carriesRequest) + return; int watched = self._watchedSeq; // Compared with the async bit masked off at both ends: the value handed to us came from // a request's return code, and what arrives here carries the tag. - if (watched != NoWatchedSequence - && Native.SPA_RESULT_ASYNC_SEQ(seq) != Native.SPA_RESULT_ASYNC_SEQ(watched)) + if ( + watched != NoWatchedSequence + && Native.SPA_RESULT_ASYNC_SEQ(seq) != Native.SPA_RESULT_ASYNC_SEQ(watched) + ) { return; } @@ -269,7 +309,9 @@ private static unsafe void OnError(void* data, uint id, int seq, int res, sbyte* // error in this window is taken as its own. Two such requests overlapping on one // connection can cross-attribute; reporting the wrong operation beats the alternative, // which is a refused operation returning success. - self._done.TrySetException(new PipeWireRequestRefusedException("request", res, id, text)); + self._done.TrySetException( + new PipeWireRequestRefusedException("request", res, id, text) + ); } catch (Exception) { @@ -285,20 +327,25 @@ private static unsafe void OnError(void* data, uint id, int seq, int res, sbyte* /// EBADF is deliberately absent: it also means a descriptor the caller passed was bad, which /// is a refusal. /// - private static bool IsConnectionFatal(int result) => result is - -NativeLibc.EPIPE or - -NativeLibc.ECONNABORTED or - -NativeLibc.ECONNRESET or - -NativeLibc.ENOTCONN; + private static bool IsConnectionFatal(int result) => + result + is -NativeLibc.EPIPE + or -NativeLibc.ECONNABORTED + or -NativeLibc.ECONNRESET + or -NativeLibc.ENOTCONN; [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] private static unsafe void OnDone(void* data, uint id, int seq) { try { - if (data is null) return; - if (GCHandle.FromIntPtr((nint)data).Target is CoreSync self && - id == NativeConstants.PW_ID_CORE && seq == self._seq) + if (data is null) + return; + if ( + GCHandle.FromIntPtr((nint)data).Target is CoreSync self + && id == NativeConstants.PW_ID_CORE + && seq == self._seq + ) self._done.TrySetResult(); } catch (Exception) @@ -309,7 +356,8 @@ private static unsafe void OnDone(void* data, uint id, int seq) public unsafe void Dispose() { - if (Interlocked.Exchange(ref _disposed, 1) != 0) return; + if (Interlocked.Exchange(ref _disposed, 1) != 0) + return; // Detached through this object's own loop reference rather than the context's lock. A // disposed context refuses its lock while the native core is still alive and still holding @@ -333,9 +381,18 @@ public unsafe void Dispose() } finally { - if (_hook is not null) { NativeMemory.Free(_hook); _hook = null; } - if (_events is not null) { NativeMemory.Free(_events); _events = null; } - if (_self.IsAllocated) _self.Free(); + if (_hook is not null) + { + NativeMemory.Free(_hook); + _hook = null; + } + if (_events is not null) + { + NativeMemory.Free(_events); + _events = null; + } + if (_self.IsAllocated) + _self.Free(); if (_loopReferenced) { diff --git a/src/PipeWire.NET/Interop/DaemonText.cs b/src/PipeWire.NET/Interop/DaemonText.cs index b547274..386f0be 100644 --- a/src/PipeWire.NET/Interop/DaemonText.cs +++ b/src/PipeWire.NET/Interop/DaemonText.cs @@ -29,7 +29,8 @@ internal static unsafe class DaemonText /// A NUL-terminated pointer, or null. internal static ReadOnlySpan Bytes(sbyte* text) { - if (text is null) return default; + if (text is null) + return default; var bytes = (byte*)text; int length = 0; diff --git a/src/PipeWire.NET/Interop/FdInterop.cs b/src/PipeWire.NET/Interop/FdInterop.cs index 636a0b4..4b89def 100644 --- a/src/PipeWire.NET/Interop/FdInterop.cs +++ b/src/PipeWire.NET/Interop/FdInterop.cs @@ -30,7 +30,15 @@ internal static unsafe bool IsListeningSocket(int fd) { int listening = 0; int size = sizeof(int); - if (NativeLibc.getsockopt(fd, NativeLibc.SOL_SOCKET, NativeLibc.SO_ACCEPTCONN, &listening, &size) < 0) + if ( + NativeLibc.getsockopt( + fd, + NativeLibc.SOL_SOCKET, + NativeLibc.SO_ACCEPTCONN, + &listening, + &size + ) < 0 + ) return false; return listening != 0; @@ -60,7 +68,10 @@ internal static SafeFileHandle DuplicateWithCloseOnExec(int fd) { int duplicate = NativeLibc.fcntl(fd, NativeLibc.F_DUPFD_CLOEXEC, LowestDuplicate); if (duplicate < 0) - throw new PipeWireInteropException("fcntl(F_DUPFD_CLOEXEC)", -Marshal.GetLastPInvokeError()); + throw new PipeWireInteropException( + "fcntl(F_DUPFD_CLOEXEC)", + -Marshal.GetLastPInvokeError() + ); return new SafeFileHandle(duplicate, ownsHandle: true); } @@ -81,7 +92,8 @@ internal static T Borrow(SafeHandle handle, Func use) } finally { - if (referenced) handle.DangerousRelease(); + if (referenced) + handle.DangerousRelease(); } } @@ -94,7 +106,11 @@ internal static T Borrow(SafeHandle handle, Func use) /// first await, and releasing there would leave the descriptors unheld for the part of the /// operation that actually uses them. /// - internal static async Task BorrowAsync(SafeHandle first, SafeHandle second, Func use) + internal static async Task BorrowAsync( + SafeHandle first, + SafeHandle second, + Func use + ) { bool firstHeld = false; bool secondHeld = false; @@ -107,8 +123,10 @@ await use((int)first.DangerousGetHandle(), (int)second.DangerousGetHandle()) } finally { - if (secondHeld) second.DangerousRelease(); - if (firstHeld) first.DangerousRelease(); + if (secondHeld) + second.DangerousRelease(); + if (firstHeld) + first.DangerousRelease(); } } } diff --git a/src/PipeWire.NET/Interop/Native.Extensions.cs b/src/PipeWire.NET/Interop/Native.Extensions.cs index 92ec885..cc896bc 100644 --- a/src/PipeWire.NET/Interop/Native.Extensions.cs +++ b/src/PipeWire.NET/Interop/Native.Extensions.cs @@ -38,11 +38,13 @@ internal static unsafe partial class Native internal static void GetInterface( void* obj, out TMethods* methods, - out void* userData) where TMethods : unmanaged + out void* userData + ) + where TMethods : unmanaged { ArgumentNullException.ThrowIfNull(obj); var iface = (spa_interface*)obj; - methods = (TMethods*)iface->cb.funcs; + methods = (TMethods*)iface->cb.funcs; userData = iface->cb.data; } @@ -64,7 +66,8 @@ internal static void GetInterface( /// internal static int pw_loop_get_fd(pw_loop* loop) { - if (loop is null || loop->control is null) return -1; + if (loop is null || loop->control is null) + return -1; GetInterface(loop->control, out spa_loop_control_methods* m, out void* data); return m is null || m->get_fd is null ? -1 : m->get_fd(data); } @@ -73,7 +76,8 @@ internal static int pw_loop_get_fd(pw_loop* loop) /// A zero timeout is the non-blocking form a host loop uses after the fd signalled. internal static int pw_loop_iterate(pw_loop* loop, int timeoutMs) { - if (loop is null || loop->control is null) return -1; + if (loop is null || loop->control is null) + return -1; GetInterface(loop->control, out spa_loop_control_methods* m, out void* data); return m is null || m->iterate is null ? -1 : m->iterate(data, timeoutMs); } @@ -86,17 +90,21 @@ internal static int pw_loop_iterate(pw_loop* loop, int timeoutMs) /// internal static void pw_loop_enter(pw_loop* loop) { - if (loop is null || loop->control is null) return; + if (loop is null || loop->control is null) + return; GetInterface(loop->control, out spa_loop_control_methods* m, out void* data); - if (m is not null && m->enter is not null) m->enter(data); + if (m is not null && m->enter is not null) + m->enter(data); } /// internal static void pw_loop_leave(pw_loop* loop) { - if (loop is null || loop->control is null) return; + if (loop is null || loop->control is null) + return; GetInterface(loop->control, out spa_loop_control_methods* m, out void* data); - if (m is not null && m->leave is not null) m->leave(data); + if (m is not null && m->leave is not null) + m->leave(data); } /// @@ -114,9 +122,11 @@ internal static void pw_loop_leave(pw_loop* loop) /// internal static int pw_loop_unlock(pw_loop* loop) { - if (loop is null || loop->control is null) return -NativeLibc.EINVAL; + if (loop is null || loop->control is null) + return -NativeLibc.EINVAL; GetInterface(loop->control, out spa_loop_control_methods* m, out void* data); - if (m is null || m->unlock is null) return -NativeLibc.EOPNOTSUPP; + if (m is null || m->unlock is null) + return -NativeLibc.EOPNOTSUPP; return m->unlock(data); } @@ -140,11 +150,14 @@ internal static int pw_thread_loop_unlock_checked(pw_thread_loop* loop) => internal static int pw_loop_locked( pw_loop* loop, delegate* unmanaged[Cdecl] func, - void* userData) + void* userData + ) { - if (loop is null || loop->loop is null) return -NativeLibc.EOPNOTSUPP; + if (loop is null || loop->loop is null) + return -NativeLibc.EOPNOTSUPP; GetInterface(loop->loop, out spa_loop_methods* m, out void* data); - if (m is null || m->locked is null) return -NativeLibc.EOPNOTSUPP; + if (m is null || m->locked is null) + return -NativeLibc.EOPNOTSUPP; return m->locked(data, func, NativeConstants.SPA_ID_INVALID, null, 0, userData); } @@ -163,13 +176,18 @@ internal static bool SPA_RESULT_IS_ASYNC(int result) => (result & NativeConstants.SPA_ASYNC_MASK) == NativeConstants.SPA_ASYNC_BIT; /// The sequence number carried by an async result. - internal static int SPA_RESULT_ASYNC_SEQ(int result) => result & NativeConstants.SPA_ASYNC_SEQ_MASK; + internal static int SPA_RESULT_ASYNC_SEQ(int result) => + result & NativeConstants.SPA_ASYNC_SEQ_MASK; /// /// Calls pw_core_methods.get_registry via SPA interface dispatch. /// Equivalent to the C macro pw_core_get_registry(). /// - internal static pw_registry* pw_core_get_registry(pw_core* core, uint version, nuint userDataSize) + internal static pw_registry* pw_core_get_registry( + pw_core* core, + uint version, + nuint userDataSize + ) { GetInterface(core, out pw_core_methods* methods, out void* data); if (methods is null || methods->get_registry is null) @@ -185,7 +203,8 @@ internal static int pw_registry_add_listener( pw_registry* registry, spa_hook* listener, pw_registry_events* events, - void* data) + void* data + ) { GetInterface(registry, out pw_registry_methods* methods, out void* userData); if (methods is null || methods->add_listener is null) @@ -204,12 +223,14 @@ internal static int pw_registry_add_listener( sbyte* type, uint version, spa_dict* props, - nuint userDataSize) + nuint userDataSize + ) { GetInterface(core, out pw_core_methods* methods, out void* data); if (methods is null || methods->create_object is null) throw new PipeWireInteropException("pw_core_create_object", -NativeLibc.ENOSYS); - return (pw_proxy*)methods->create_object(data, factoryName, type, version, props, userDataSize); + return (pw_proxy*) + methods->create_object(data, factoryName, type, version, props, userDataSize); } /// @@ -247,7 +268,8 @@ internal static int pw_core_add_listener( pw_core* core, spa_hook* listener, pw_core_events* events, - void* data) + void* data + ) { GetInterface(core, out pw_core_methods* methods, out void* userData); if (methods is null || methods->add_listener is null) @@ -267,7 +289,12 @@ internal static int pw_core_add_listener( /// /// The proxy, or if the daemon refused. internal static pw_proxy* pw_registry_bind( - pw_registry* registry, uint id, sbyte* type, uint version, nuint userDataSize) + pw_registry* registry, + uint id, + sbyte* type, + uint version, + nuint userDataSize + ) { GetInterface(registry, out pw_registry_methods* methods, out void* data); if (methods is null || methods->bind is null) @@ -278,7 +305,11 @@ internal static int pw_core_add_listener( // - Module - internal static int pw_module_add_listener( - pw_module* module, spa_hook* listener, pw_module_events* events, void* data) + pw_module* module, + spa_hook* listener, + pw_module_events* events, + void* data + ) { GetInterface(module, out pw_module_methods* methods, out void* userData); if (methods is null || methods->add_listener is null) @@ -289,7 +320,11 @@ internal static int pw_module_add_listener( // - Node - internal static int pw_node_add_listener( - pw_node* node, spa_hook* listener, pw_node_events* events, void* data) + pw_node* node, + spa_hook* listener, + pw_node_events* events, + void* data + ) { GetInterface(node, out pw_node_methods* methods, out void* userData); if (methods is null || methods->add_listener is null) @@ -307,7 +342,13 @@ internal static int pw_node_add_listener( /// param the request produced. /// internal static int pw_node_enum_params( - pw_node* node, int seq, uint id, uint start, uint num, spa_pod* filter) + pw_node* node, + int seq, + uint id, + uint start, + uint num, + spa_pod* filter + ) { GetInterface(node, out pw_node_methods* methods, out void* data); if (methods is null || methods->enum_params is null) @@ -338,7 +379,11 @@ internal static int pw_node_subscribe_params(pw_node* node, uint* ids, uint nIds // - Device - internal static int pw_device_add_listener( - pw_device* device, spa_hook* listener, pw_device_events* events, void* data) + pw_device* device, + spa_hook* listener, + pw_device_events* events, + void* data + ) { GetInterface(device, out pw_device_methods* methods, out void* userData); if (methods is null || methods->add_listener is null) @@ -348,7 +393,13 @@ internal static int pw_device_add_listener( /// internal static int pw_device_enum_params( - pw_device* device, int seq, uint id, uint start, uint num, spa_pod* filter) + pw_device* device, + int seq, + uint id, + uint start, + uint num, + spa_pod* filter + ) { GetInterface(device, out pw_device_methods* methods, out void* data); if (methods is null || methods->enum_params is null) @@ -385,14 +436,19 @@ internal static int pw_device_subscribe_params(pw_device* device, uint* ids, uin /// [System.Runtime.InteropServices.LibraryImport("libpipewire-0.3")] [System.Runtime.InteropServices.UnmanagedCallConv( - CallConvs = [typeof(System.Runtime.CompilerServices.CallConvCdecl)])] + CallConvs = [typeof(System.Runtime.CompilerServices.CallConvCdecl)] + )] internal static partial void pw_log_set_level(int level); // - Port - /// Attaches a listener to a port proxy. internal static int pw_port_add_listener( - pw_port* port, spa_hook* listener, pw_port_events* events, void* data) + pw_port* port, + spa_hook* listener, + pw_port_events* events, + void* data + ) { GetInterface(port, out pw_port_methods* methods, out void* userData); if (methods is null || methods->add_listener is null) @@ -402,7 +458,13 @@ internal static int pw_port_add_listener( /// Asks a port for a parameter. The answers arrive on the param event. internal static int pw_port_enum_params( - pw_port* port, int seq, uint id, uint start, uint num, spa_pod* filter) + pw_port* port, + int seq, + uint id, + uint start, + uint num, + spa_pod* filter + ) { GetInterface(port, out pw_port_methods* methods, out void* userData); if (methods is null || methods->enum_params is null) @@ -429,7 +491,11 @@ internal static int pw_port_subscribe_params(pw_port* port, uint* ids, uint coun /// dispatched here the same way the node, device and client listeners are. /// internal static int pw_link_add_listener( - pw_link* link, spa_hook* listener, pw_link_events* events, void* data) + pw_link* link, + spa_hook* listener, + pw_link_events* events, + void* data + ) { GetInterface(link, out pw_link_methods* methods, out void* userData); if (methods is null || methods->add_listener is null) @@ -440,7 +506,11 @@ internal static int pw_link_add_listener( // - Client - internal static int pw_client_add_listener( - pw_client* client, spa_hook* listener, pw_client_events* events, void* data) + pw_client* client, + spa_hook* listener, + pw_client_events* events, + void* data + ) { GetInterface(client, out pw_client_methods* methods, out void* userData); if (methods is null || methods->add_listener is null) @@ -458,7 +528,10 @@ internal static int pw_client_add_listener( /// bits than it had loses the difference. /// internal static int pw_client_update_permissions( - pw_client* client, uint nPermissions, pw_permission* permissions) + pw_client* client, + uint nPermissions, + pw_permission* permissions + ) { GetInterface(client, out pw_client_methods* methods, out void* data); if (methods is null || methods->update_permissions is null) @@ -487,7 +560,11 @@ internal static int pw_client_update_properties(pw_client* client, spa_dict* pro /// Attaches a listener to a profiler, whose only event is the profiling pod. internal static int pw_profiler_add_listener( - void* profiler, spa_hook* listener, pw_profiler_events* events, void* data) + void* profiler, + spa_hook* listener, + pw_profiler_events* events, + void* data + ) { GetInterface(profiler, out pw_profiler_methods* methods, out void* userData); if (methods is null || methods->add_listener is null) @@ -505,7 +582,11 @@ internal static int pw_profiler_add_listener( /// permissions described by the properties, not the creator's. /// internal static int pw_security_context_create( - void* context, int listenFd, int closeFd, spa_dict* props) + void* context, + int listenFd, + int closeFd, + spa_dict* props + ) { GetInterface(context, out pw_security_context_methods* methods, out void* userData); if (methods is null || methods->create is null) @@ -514,7 +595,11 @@ internal static int pw_security_context_create( } internal static int pw_metadata_add_listener( - pw_metadata* metadata, spa_hook* listener, pw_metadata_events* events, void* data) + pw_metadata* metadata, + spa_hook* listener, + pw_metadata_events* events, + void* data + ) { GetInterface(metadata, out pw_metadata_methods* methods, out void* userData); if (methods is null || methods->add_listener is null) @@ -531,7 +616,12 @@ internal static int pw_metadata_add_listener( /// is the subject for daemon-wide settings such as the default sink. /// internal static int pw_metadata_set_property( - pw_metadata* metadata, uint subject, sbyte* key, sbyte* type, sbyte* value) + pw_metadata* metadata, + uint subject, + sbyte* key, + sbyte* type, + sbyte* value + ) { GetInterface(metadata, out pw_metadata_methods* methods, out void* data); if (methods is null || methods->set_property is null) @@ -562,8 +652,17 @@ internal static int pw_metadata_clear(pw_metadata* metadata) /// quiet, and the peer waits for a negotiation that will never finish. /// /// - [DllImport("libpipewire-0.3", EntryPoint = "pw_stream_set_error", CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)] - internal static extern unsafe int pw_stream_set_error_raw(pw_stream* stream, int res, sbyte* error); + [DllImport( + "libpipewire-0.3", + EntryPoint = "pw_stream_set_error", + CallingConvention = CallingConvention.Cdecl, + ExactSpelling = true + )] + internal static extern unsafe int pw_stream_set_error_raw( + pw_stream* stream, + int res, + sbyte* error + ); /// The version string of the libpipewire this process actually loaded. /// @@ -571,7 +670,12 @@ internal static int pw_metadata_clear(pw_metadata* metadata) /// because the bindings are produced against one release and the library resolved at runtime is /// whatever the machine has, and the two disagreeing is not always a clean failure. /// - [DllImport("libpipewire-0.3", EntryPoint = "pw_get_library_version", CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)] + [DllImport( + "libpipewire-0.3", + EntryPoint = "pw_get_library_version", + CallingConvention = CallingConvention.Cdecl, + ExactSpelling = true + )] internal static extern unsafe sbyte* pw_get_library_version(); /// @@ -582,8 +686,17 @@ internal static int pw_metadata_clear(pw_metadata* metadata) /// the same reason. A filter that cannot proceed and says nothing leaves its peers waiting on /// a graph cycle that will not come. /// - [DllImport("libpipewire-0.3", EntryPoint = "pw_filter_set_error", CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)] - internal static extern unsafe int pw_filter_set_error_raw(pw_filter* filter, int res, sbyte* error); + [DllImport( + "libpipewire-0.3", + EntryPoint = "pw_filter_set_error", + CallingConvention = CallingConvention.Cdecl, + ExactSpelling = true + )] + internal static extern unsafe int pw_filter_set_error_raw( + pw_filter* filter, + int res, + sbyte* error + ); /// Reports a filter error, with the message escaped so it cannot be read as a format. internal static unsafe int pw_filter_set_error(pw_filter* filter, int res, string message) @@ -614,7 +727,8 @@ internal static unsafe int pw_stream_set_error(pw_stream* stream, int res, strin /// internal static void spa_hook_remove(spa_hook* hook) { - if (hook is null) return; + if (hook is null) + return; // spa_list_is_initialized: a hook that was never attached has a null prev. Both ends are // checked because a half-unlinked hook would otherwise be dereferenced through a null next. @@ -632,28 +746,42 @@ internal static void spa_hook_remove(spa_hook* hook) /// Adds a timer source to a loop, returning null when the loop cannot be dispatched. internal static unsafe spa_source* spa_loop_utils_add_timer( - spa_loop_utils* utils, delegate* unmanaged[Cdecl] func, void* data) + spa_loop_utils* utils, + delegate* unmanaged[Cdecl] func, + void* data + ) { spa_loop_utils_methods* m = LoopUtilsMethods(utils); - if (m is null || m->add_timer is null) return null; + if (m is null || m->add_timer is null) + return null; return m->add_timer(utils->iface.cb.data, func, data); } /// Arms or disarms a timer source. /// 0 on success, a negative errno otherwise. internal static unsafe int spa_loop_utils_update_timer( - spa_loop_utils* utils, spa_source* source, PosixTimespec* value, PosixTimespec* interval, bool absolute) + spa_loop_utils* utils, + spa_source* source, + PosixTimespec* value, + PosixTimespec* interval, + bool absolute + ) { spa_loop_utils_methods* m = LoopUtilsMethods(utils); - if (m is null || m->update_timer is null) return -NativeLibc.EOPNOTSUPP; + if (m is null || m->update_timer is null) + return -NativeLibc.EOPNOTSUPP; return m->update_timer(utils->iface.cb.data, source, value, interval, absolute); } /// Destroys a source previously added to a loop. - internal static unsafe void spa_loop_utils_destroy_source(spa_loop_utils* utils, spa_source* source) + internal static unsafe void spa_loop_utils_destroy_source( + spa_loop_utils* utils, + spa_source* source + ) { spa_loop_utils_methods* m = LoopUtilsMethods(utils); - if (m is null || m->destroy_source is null) return; + if (m is null || m->destroy_source is null) + return; m->destroy_source(utils->iface.cb.data, source); } @@ -668,10 +796,12 @@ internal static unsafe void spa_loop_utils_destroy_source(spa_loop_utils* utils, /// private static unsafe spa_loop_utils_methods* LoopUtilsMethods(spa_loop_utils* utils) { - if (utils is null) return null; + if (utils is null) + return null; var m = (spa_loop_utils_methods*)utils->iface.cb.funcs; - if (m is null || m->version != NativeConstants.SPA_VERSION_LOOP_UTILS_METHODS) return null; + if (m is null || m->version != NativeConstants.SPA_VERSION_LOOP_UTILS_METHODS) + return null; return m; } diff --git a/src/PipeWire.NET/Interop/NativeLibc.Imports.cs b/src/PipeWire.NET/Interop/NativeLibc.Imports.cs index 86c2f2f..1a4ca03 100644 --- a/src/PipeWire.NET/Interop/NativeLibc.Imports.cs +++ b/src/PipeWire.NET/Interop/NativeLibc.Imports.cs @@ -32,7 +32,13 @@ internal static unsafe partial class NativeLibc /// getsockopt(2), for asking whether a descriptor is a listening socket. [LibraryImport("libc", EntryPoint = "getsockopt", SetLastError = true)] - internal static partial int getsockopt(int fd, int level, int optname, int* optval, int* optlen); + internal static partial int getsockopt( + int fd, + int level, + int optname, + int* optval, + int* optlen + ); /// eventfd(2), the counter behind an explicit-sync timeline. [LibraryImport("libc", EntryPoint = "eventfd", SetLastError = true)] @@ -59,5 +65,4 @@ internal static unsafe partial class NativeLibc /// [LibraryImport("libc", EntryPoint = "close")] internal static partial int close(int fd); - } diff --git a/src/PipeWire.NET/Interop/NativeLibdrm.Imports.cs b/src/PipeWire.NET/Interop/NativeLibdrm.Imports.cs index 4dd458a..3ec8477 100644 --- a/src/PipeWire.NET/Interop/NativeLibdrm.Imports.cs +++ b/src/PipeWire.NET/Interop/NativeLibdrm.Imports.cs @@ -46,10 +46,22 @@ internal static unsafe partial class NativeLibdrm /// drmSyncobjTimelineSignal: signals points on timelines from the CPU. [LibraryImport("libdrm", EntryPoint = "drmSyncobjTimelineSignal", SetLastError = true)] - internal static partial int drmSyncobjTimelineSignal(int fd, uint* handles, ulong* points, uint handleCount); + internal static partial int drmSyncobjTimelineSignal( + int fd, + uint* handles, + ulong* points, + uint handleCount + ); /// drmSyncobjTimelineWait: waits for points, up to an absolute deadline. [LibraryImport("libdrm", EntryPoint = "drmSyncobjTimelineWait", SetLastError = true)] internal static partial int drmSyncobjTimelineWait( - int fd, uint* handles, ulong* points, uint numHandles, long timeoutNsec, uint flags, uint* firstSignaled); + int fd, + uint* handles, + ulong* points, + uint numHandles, + long timeoutNsec, + uint flags, + uint* firstSignaled + ); } diff --git a/src/PipeWire.NET/Interop/NativeObjectCreation.cs b/src/PipeWire.NET/Interop/NativeObjectCreation.cs index 82c2c94..f7653f4 100644 --- a/src/PipeWire.NET/Interop/NativeObjectCreation.cs +++ b/src/PipeWire.NET/Interop/NativeObjectCreation.cs @@ -20,40 +20,45 @@ namespace PipeWire.NET.Interop; internal sealed class NativeObjectCreation : IDisposable { private readonly PipeWireContext _ctx; - private readonly TaskCompletionSource _bound = new(TaskCreationOptions.RunContinuationsAsynchronously); - private readonly TaskCompletionSource _synced = new(TaskCreationOptions.RunContinuationsAsynchronously); + private readonly TaskCompletionSource _bound = new( + TaskCreationOptions.RunContinuationsAsynchronously + ); + private readonly TaskCompletionSource _synced = new( + TaskCreationOptions.RunContinuationsAsynchronously + ); private unsafe pw_proxy_events* _proxyEvents; - private unsafe pw_core_events* _coreEvents; - private unsafe spa_hook* _proxyHook; - private unsafe spa_hook* _coreHook; + private unsafe pw_core_events* _coreEvents; + private unsafe spa_hook* _proxyHook; + private unsafe spa_hook* _coreHook; private PipeWireLoopHandle? _loop; private bool _loopReferenced; - private GCHandle _self; - private IntPtr _proxy; + private GCHandle _self; + private IntPtr _proxy; // Written by the thread that starts the creation and read by the loop thread in the callbacks // below. The native loop mutex orders them in practice, but nothing in the .NET memory model // knows about it, so the accesses say so themselves. - private volatile uint _proxyId = NativeConstants.SPA_ID_INVALID; + private volatile uint _proxyId = NativeConstants.SPA_ID_INVALID; // The daemon reports a refused creation on the core rather than on a proxy that was never // bound, but the core stream carries every client's errors. Kept as the reason to quote if this // creation turns out to have failed, never as the trigger for deciding that it did. - private volatile int _lastCoreResult; + private volatile int _lastCoreResult; private volatile string? _lastCoreMessage; + // Two barriers, two sequence numbers. The first proves the daemon has dealt with the create // request, so a bound that has not arrived by then never will. The second proves the new object // has reached our own registry. Sharing one field would let the first one satisfy the second's // await, and the caller would be handed an id the graph does not know yet. - private volatile int _probeSeq = NoSequence; - private volatile int _syncSeq = NoSequence; - private volatile bool _probed; + private volatile int _probeSeq = NoSequence; + private volatile int _syncSeq = NoSequence; + private volatile bool _probed; // Claimed with an interlocked exchange: CompleteAsync disposes in its finally and a caller can // dispose from outside, and both freeing the same native blocks is heap corruption rather than // a wasted call. - private int _disposed; + private int _disposed; private NativeObjectCreation(PipeWireContext ctx) => _ctx = ctx; @@ -74,13 +79,15 @@ internal sealed class NativeObjectCreation : IDisposable uint interfaceVersion, spa_dict props, CancellationToken cancellationToken, - Action? onBound = null) + Action? onBound = null + ) { // A connection the daemon has already taken away answers nothing: bound and the probe sync // both wait on a socket that is closed, and the caller would sit there until its own token // fired. The context records the death when it is announced; refusing here is what turns // that into an error instead of a hang. - if (ctx.ConnectionFault is { } dead) throw dead; + if (ctx.ConnectionFault is { } dead) + throw dead; // The native setup is synchronous so the spans never cross an await. var pending = new NativeObjectCreation(ctx); @@ -111,12 +118,24 @@ internal sealed class NativeObjectCreation : IDisposable /// /// Abandons the wait and destroys the half-created object. private async Task<(uint Id, PipeWireProxyHandle Proxy)> CompleteAsync( - Action? onBound, CancellationToken cancellationToken) + Action? onBound, + CancellationToken cancellationToken + ) { try { - using (cancellationToken.UnsafeRegister(static s => ((NativeObjectCreation)s!).Cancel(), this)) - using (_ctx.Shutdown.UnsafeRegister(static s => ((NativeObjectCreation)s!).OnContextShutdown(), this)) + using ( + cancellationToken.UnsafeRegister( + static s => ((NativeObjectCreation)s!).Cancel(), + this + ) + ) + using ( + _ctx.Shutdown.UnsafeRegister( + static s => ((NativeObjectCreation)s!).OnContextShutdown(), + this + ) + ) { uint id = await _bound.Task.ConfigureAwait(false); onBound?.Invoke(id); @@ -147,14 +166,20 @@ internal sealed class NativeObjectCreation : IDisposable /// private void OnContextShutdown() { - var ex = new ObjectDisposedException(nameof(PipeWireContext), - "the context was disposed while object creation was in flight."); + var ex = new ObjectDisposedException( + nameof(PipeWireContext), + "the context was disposed while object creation was in flight." + ); _bound.TrySetException(ex); _synced.TrySetException(ex); } - private unsafe void Start(ReadOnlySpan factoryName, ReadOnlySpan interfaceType, - uint interfaceVersion, spa_dict props) + private unsafe void Start( + ReadOnlySpan factoryName, + ReadOnlySpan interfaceType, + uint interfaceVersion, + spa_dict props + ) { // A reference of its own on the loop, held for as long as the listeners are attached. The // context is not enough: it clears its handle fields while the created proxy is still @@ -174,14 +199,14 @@ private unsafe void Start(ReadOnlySpan factoryName, ReadOnlySpan int _proxyEvents = (pw_proxy_events*)NativeMemory.AllocZeroed((nuint)sizeof(pw_proxy_events)); _proxyEvents->version = NativeConstants.PW_VERSION_PROXY_EVENTS; - _proxyEvents->bound = &OnBound; - _proxyEvents->error = &OnProxyError; + _proxyEvents->bound = &OnBound; + _proxyEvents->error = &OnProxyError; _proxyHook = (spa_hook*)NativeMemory.AllocZeroed((nuint)sizeof(spa_hook)); _coreEvents = (pw_core_events*)NativeMemory.AllocZeroed((nuint)sizeof(pw_core_events)); _coreEvents->version = NativeConstants.PW_VERSION_CORE_EVENTS; - _coreEvents->done = &OnDone; - _coreEvents->error = &OnCoreError; + _coreEvents->done = &OnDone; + _coreEvents->error = &OnCoreError; _coreHook = (spa_hook*)NativeMemory.AllocZeroed((nuint)sizeof(spa_hook)); using (_ctx.Lock()) @@ -192,8 +217,15 @@ private unsafe void Start(ReadOnlySpan factoryName, ReadOnlySpan int fixed (byte* type = interfaceType) { spa_dict local = props; - _proxy = (IntPtr)Native.pw_core_create_object( - _ctx.CoreHandle, (sbyte*)factory, (sbyte*)type, interfaceVersion, &local, 0); + _proxy = (IntPtr) + Native.pw_core_create_object( + _ctx.CoreHandle, + (sbyte*)factory, + (sbyte*)type, + interfaceVersion, + &local, + 0 + ); } if (_proxy == IntPtr.Zero) @@ -249,7 +281,8 @@ private unsafe PipeWireProxyHandle TakeProxy() private unsafe void DestroyProxy() { - if (_proxy == IntPtr.Zero) return; + if (_proxy == IntPtr.Zero) + return; // TryLock, not Lock. This runs on the failure and cancellation paths, where the context may // already be disposing; a throwing lock there replaces the caller's original error with an @@ -269,7 +302,8 @@ private static unsafe void OnBound(void* data, uint globalId) { try { - if (FromData(data) is { } self) self._bound.TrySetResult(globalId); + if (FromData(data) is { } self) + self._bound.TrySetResult(globalId); } catch (Exception) { @@ -293,8 +327,10 @@ private static unsafe void OnDone(void* data, uint id, int seq) private static unsafe void OnDoneCore(void* data, uint id, int seq) { - if (FromData(data) is not { } self) return; - if (id != NativeConstants.PW_ID_CORE) return; + if (FromData(data) is not { } self) + return; + if (id != NativeConstants.PW_ID_CORE) + return; // The daemon has processed everything sent before the probe. If the object had been // created, bound would already have arrived, because events are ordered. So a probe that @@ -303,11 +339,14 @@ private static unsafe void OnDoneCore(void* data, uint id, int seq) { if (!self._bound.Task.IsCompleted) { - self._bound.TrySetException(new PipeWireRequestRefusedException( - "create", - self._lastCoreResult != 0 ? self._lastCoreResult : -NativeLibc.EINVAL, - objectId: null, - self._lastCoreMessage ?? "the daemon did not create the object")); + self._bound.TrySetException( + new PipeWireRequestRefusedException( + "create", + self._lastCoreResult != 0 ? self._lastCoreResult : -NativeLibc.EINVAL, + objectId: null, + self._lastCoreMessage ?? "the daemon did not create the object" + ) + ); } return; @@ -317,7 +356,8 @@ private static unsafe void OnDoneCore(void* data, uint id, int seq) // a done the daemon sent for something else before RequestSync ran would otherwise satisfy // this wait and hand the caller an id its own registry has not seen yet. int syncSeq = self._syncSeq; - if (syncSeq != NoSequence && seq == syncSeq) self._synced.TrySetResult(); + if (syncSeq != NoSequence && seq == syncSeq) + self._synced.TrySetResult(); } [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] @@ -348,7 +388,8 @@ private static unsafe void OnCoreError(void* data, uint id, int seq, int res, sb private static unsafe void OnCoreErrorCore(void* data, uint id, int res, sbyte* message) { - if (FromData(data) is not { } self) return; + if (FromData(data) is not { } self) + return; string? text = DaemonText.String(message); @@ -372,7 +413,8 @@ private static unsafe void OnCoreErrorCore(void* data, uint id, int res, sbyte* private static unsafe void Fail(void* data, int res, sbyte* message, uint? objectId) { - if (FromData(data) is not { } self) return; + if (FromData(data) is not { } self) + return; string? text = DaemonText.String(message); @@ -392,7 +434,8 @@ private static unsafe void Fail(void* data, int res, sbyte* message, uint? objec /// private static unsafe NativeObjectCreation? FromData(void* data) { - if (data is null) return null; + if (data is null) + return null; try { @@ -406,7 +449,8 @@ private static unsafe void Fail(void* data, int res, sbyte* message, uint? objec public unsafe void Dispose() { - if (Interlocked.Exchange(ref _disposed, 1) != 0) return; + if (Interlocked.Exchange(ref _disposed, 1) != 0) + return; // Deferred off the loop thread, the way a bound proxy's teardown is. Detaching a hook while // the daemon is walking the list it is in, then freeing the events table behind it, is a @@ -424,7 +468,6 @@ public unsafe void Dispose() private unsafe void ReleaseNative() { - // Both hooks must be detached before their memory is freed. On success the caller keeps the // proxy, so its listener would otherwise be left pointing at freed memory. spa_hook_remove // is a no-op on a hook that was never attached. @@ -452,11 +495,28 @@ private unsafe void ReleaseNative() } finally { - if (_proxyHook is not null) { NativeMemory.Free(_proxyHook); _proxyHook = null; } - if (_coreHook is not null) { NativeMemory.Free(_coreHook); _coreHook = null; } - if (_proxyEvents is not null) { NativeMemory.Free(_proxyEvents); _proxyEvents = null; } - if (_coreEvents is not null) { NativeMemory.Free(_coreEvents); _coreEvents = null; } - if (_self.IsAllocated) _self.Free(); + if (_proxyHook is not null) + { + NativeMemory.Free(_proxyHook); + _proxyHook = null; + } + if (_coreHook is not null) + { + NativeMemory.Free(_coreHook); + _coreHook = null; + } + if (_proxyEvents is not null) + { + NativeMemory.Free(_proxyEvents); + _proxyEvents = null; + } + if (_coreEvents is not null) + { + NativeMemory.Free(_coreEvents); + _coreEvents = null; + } + if (_self.IsAllocated) + _self.Free(); if (_loopReferenced) { diff --git a/src/PipeWire.NET/Interop/NativeReaper.cs b/src/PipeWire.NET/Interop/NativeReaper.cs index 9739123..cd579cc 100644 --- a/src/PipeWire.NET/Interop/NativeReaper.cs +++ b/src/PipeWire.NET/Interop/NativeReaper.cs @@ -56,8 +56,7 @@ internal static void EnqueueLoop(object owner, nint loop, Func release) => _queue.Add(new WorkItem(release, loop, WaitForDrain: true)); /// How many enqueued releases for this loop have not run yet. - internal static int PendingFor(nint loop) => - _pending.TryGetValue(loop, out int n) ? n : 0; + internal static int PendingFor(nint loop) => _pending.TryGetValue(loop, out int n) ? n : 0; private static void Run() { @@ -82,8 +81,11 @@ private static void Run() } finally { - if (!work.WaitForDrain && work.Loop != 0 - && _pending.AddOrUpdate(work.Loop, 0, static (_, n) => n - 1) <= 0) + if ( + !work.WaitForDrain + && work.Loop != 0 + && _pending.AddOrUpdate(work.Loop, 0, static (_, n) => n - 1) <= 0 + ) _pending.TryRemove(work.Loop, out _); } } diff --git a/src/PipeWire.NET/Interop/PipeWireContextHandle.cs b/src/PipeWire.NET/Interop/PipeWireContextHandle.cs index 79aa31d..8fa168d 100644 --- a/src/PipeWire.NET/Interop/PipeWireContextHandle.cs +++ b/src/PipeWire.NET/Interop/PipeWireContextHandle.cs @@ -50,7 +50,8 @@ internal PipeWireContextHandle(pw_context* context, PipeWireLoopHandle loop) protected override void Dispose(bool disposing) { - if (disposing) _deterministic = true; + if (disposing) + _deterministic = true; base.Dispose(disposing); } @@ -70,7 +71,8 @@ protected override bool ReleaseHandle() private bool NeedsLoop(out nint loop) { loop = 0; - if (handle == IntPtr.Zero || !_loopReferenced || _loop.IsInvalid) return false; + if (handle == IntPtr.Zero || !_loopReferenced || _loop.IsInvalid) + return false; loop = (nint)_loop.Loop; return loop != 0; } diff --git a/src/PipeWire.NET/Interop/PipeWireCoreHandle.cs b/src/PipeWire.NET/Interop/PipeWireCoreHandle.cs index ea18c4d..86e7747 100644 --- a/src/PipeWire.NET/Interop/PipeWireCoreHandle.cs +++ b/src/PipeWire.NET/Interop/PipeWireCoreHandle.cs @@ -30,7 +30,11 @@ internal sealed unsafe class PipeWireCoreHandle : SafeHandle private bool _loopReferenced; private bool _contextReferenced; - internal PipeWireCoreHandle(pw_core* core, PipeWireLoopHandle loop, PipeWireContextHandle context) + internal PipeWireCoreHandle( + pw_core* core, + PipeWireLoopHandle loop, + PipeWireContextHandle context + ) : base((IntPtr)core, ownsHandle: true) { ArgumentNullException.ThrowIfNull(loop); @@ -48,8 +52,16 @@ internal PipeWireCoreHandle(pw_core* core, PipeWireLoopHandle loop, PipeWireCont } catch { - if (_contextReferenced) { context.DangerousRelease(); _contextReferenced = false; } - if (_loopReferenced) { loop.DangerousRelease(); _loopReferenced = false; } + if (_contextReferenced) + { + context.DangerousRelease(); + _contextReferenced = false; + } + if (_loopReferenced) + { + loop.DangerousRelease(); + _loopReferenced = false; + } throw; } } @@ -67,7 +79,8 @@ internal PipeWireCoreHandle(pw_core* core, PipeWireLoopHandle loop, PipeWireCont protected override void Dispose(bool disposing) { - if (disposing) _deterministic = true; + if (disposing) + _deterministic = true; base.Dispose(disposing); } @@ -87,7 +100,8 @@ protected override bool ReleaseHandle() private bool NeedsLoop(out nint loop) { loop = 0; - if (handle == IntPtr.Zero || !_loopReferenced || _loop.IsInvalid) return false; + if (handle == IntPtr.Zero || !_loopReferenced || _loop.IsInvalid) + return false; loop = (nint)_loop.Loop; return loop != 0; } diff --git a/src/PipeWire.NET/Interop/PipeWireFilterHandle.cs b/src/PipeWire.NET/Interop/PipeWireFilterHandle.cs index e47b3e3..cc5539a 100644 --- a/src/PipeWire.NET/Interop/PipeWireFilterHandle.cs +++ b/src/PipeWire.NET/Interop/PipeWireFilterHandle.cs @@ -22,7 +22,11 @@ internal sealed unsafe class PipeWireFilterHandle : SafeHandle private spa_hook* _hook; private GCHandle _self; - internal PipeWireFilterHandle(pw_filter* filter, PipeWireLoopHandle loop, PipeWireCoreHandle? core) + internal PipeWireFilterHandle( + pw_filter* filter, + PipeWireLoopHandle loop, + PipeWireCoreHandle? core + ) : base((IntPtr)filter, ownsHandle: true) { ArgumentNullException.ThrowIfNull(loop); @@ -41,13 +45,20 @@ internal PipeWireFilterHandle(pw_filter* filter, PipeWireLoopHandle loop, PipeWi } catch { - if (_coreReferenced) { core!.DangerousRelease(); _coreReferenced = false; } - if (_loopReferenced) { loop.DangerousRelease(); _loopReferenced = false; } + if (_coreReferenced) + { + core!.DangerousRelease(); + _coreReferenced = false; + } + if (_loopReferenced) + { + loop.DangerousRelease(); + _loopReferenced = false; + } throw; } } - /// /// Hands the handle the listener memory, freed once the native object has been destroyed. /// @@ -82,7 +93,8 @@ internal void OwnListener(void* events, spa_hook* hook, GCHandle self) protected override void Dispose(bool disposing) { - if (disposing) _deterministic = true; + if (disposing) + _deterministic = true; base.Dispose(disposing); } @@ -102,7 +114,8 @@ protected override bool ReleaseHandle() private bool NeedsLoop(out nint loop) { loop = 0; - if (handle == IntPtr.Zero || !_loopReferenced || _loop.IsInvalid) return false; + if (handle == IntPtr.Zero || !_loopReferenced || _loop.IsInvalid) + return false; loop = (nint)_loop.Loop; return loop != 0; } @@ -156,7 +169,6 @@ private bool ReleaseCore() } if (_self.IsAllocated) _self.Free(); - } return true; diff --git a/src/PipeWire.NET/Interop/PipeWireImplMetadataHandle.cs b/src/PipeWire.NET/Interop/PipeWireImplMetadataHandle.cs index c580911..b099952 100644 --- a/src/PipeWire.NET/Interop/PipeWireImplMetadataHandle.cs +++ b/src/PipeWire.NET/Interop/PipeWireImplMetadataHandle.cs @@ -52,7 +52,8 @@ internal void OwnListener(void* events, spa_hook* hook, GCHandle self) protected override void Dispose(bool disposing) { - if (disposing) _deterministic = true; + if (disposing) + _deterministic = true; base.Dispose(disposing); } @@ -72,7 +73,8 @@ protected override bool ReleaseHandle() private bool NeedsLoop(out nint loop) { loop = 0; - if (handle == IntPtr.Zero || !_loopReferenced || _loop.IsInvalid) return false; + if (handle == IntPtr.Zero || !_loopReferenced || _loop.IsInvalid) + return false; loop = (nint)_loop.Loop; return loop != 0; } diff --git a/src/PipeWire.NET/Interop/PipeWireLoopHandle.cs b/src/PipeWire.NET/Interop/PipeWireLoopHandle.cs index 8f3a3c5..734e74b 100644 --- a/src/PipeWire.NET/Interop/PipeWireLoopHandle.cs +++ b/src/PipeWire.NET/Interop/PipeWireLoopHandle.cs @@ -17,7 +17,8 @@ namespace PipeWire.NET.Interop; [SupportedOSPlatform("linux")] internal sealed unsafe class PipeWireLoopHandle : SafeHandle { - internal PipeWireLoopHandle(pw_thread_loop* loop) : base((IntPtr)loop, ownsHandle: true) { } + internal PipeWireLoopHandle(pw_thread_loop* loop) + : base((IntPtr)loop, ownsHandle: true) { } public override bool IsInvalid => handle == IntPtr.Zero; @@ -34,7 +35,8 @@ internal PipeWireLoopHandle(pw_thread_loop* loop) : base((IntPtr)loop, ownsHandl internal void Unlock(pw_thread_loop* loop) { int rc = Native.pw_thread_loop_unlock_checked(loop); - if (rc < 0) RefusedUnlock?.Invoke(rc); + if (rc < 0) + RefusedUnlock?.Invoke(rc); } // Set by deterministic disposal before the base runs. Stopping joins the loop thread, so a @@ -47,7 +49,8 @@ internal void Unlock(pw_thread_loop* loop) protected override void Dispose(bool disposing) { - if (disposing) _deterministic = true; + if (disposing) + _deterministic = true; base.Dispose(disposing); } @@ -61,7 +64,8 @@ protected override bool ReleaseHandle() { var loop = (pw_thread_loop*)handle; handle = IntPtr.Zero; - if (loop is null) return true; + if (loop is null) + return true; if (!_deterministic && !_enqueued) { @@ -90,7 +94,8 @@ private void ReleaseLoop(pw_thread_loop* loop) // deliberately left running and leaked: a leaked thread is recoverable and a hung process // is not. PipeWireContext refuses disposal on this thread for the same reason, which is why // reaching here means something bypassed it. - if (Native.pw_thread_loop_in_thread(loop)) return; + if (Native.pw_thread_loop_in_thread(loop)) + return; Native.pw_thread_loop_stop(loop); Native.pw_thread_loop_destroy(loop); diff --git a/src/PipeWire.NET/Interop/PipeWireProxyHandle.cs b/src/PipeWire.NET/Interop/PipeWireProxyHandle.cs index db84d6c..daec1bb 100644 --- a/src/PipeWire.NET/Interop/PipeWireProxyHandle.cs +++ b/src/PipeWire.NET/Interop/PipeWireProxyHandle.cs @@ -45,7 +45,11 @@ internal sealed unsafe class PipeWireProxyHandle : SafeHandle private spa_hook* _proxyHook; private GCHandle _proxySelf; - internal PipeWireProxyHandle(pw_proxy* proxy, PipeWireLoopHandle loop, PipeWireCoreHandle? core = null) + internal PipeWireProxyHandle( + pw_proxy* proxy, + PipeWireLoopHandle loop, + PipeWireCoreHandle? core = null + ) : base((IntPtr)proxy, ownsHandle: true) { ArgumentNullException.ThrowIfNull(loop); @@ -63,8 +67,16 @@ internal PipeWireProxyHandle(pw_proxy* proxy, PipeWireLoopHandle loop, PipeWireC } catch { - if (_coreReferenced) { core!.DangerousRelease(); _coreReferenced = false; } - if (_loopReferenced) { loop.DangerousRelease(); _loopReferenced = false; } + if (_coreReferenced) + { + core!.DangerousRelease(); + _coreReferenced = false; + } + if (_loopReferenced) + { + loop.DangerousRelease(); + _loopReferenced = false; + } throw; } } @@ -99,7 +111,8 @@ internal void OwnProxyListener(void* events, spa_hook* hook, GCHandle self) protected override void Dispose(bool disposing) { - if (disposing) _deterministic = true; + if (disposing) + _deterministic = true; base.Dispose(disposing); } @@ -118,7 +131,8 @@ protected override bool ReleaseHandle() private bool NeedsLoop(out nint loop) { loop = 0; - if (handle == IntPtr.Zero || !_loopReferenced || _loop.IsInvalid) return false; + if (handle == IntPtr.Zero || !_loopReferenced || _loop.IsInvalid) + return false; loop = (nint)_loop.Loop; return loop != 0; } diff --git a/src/PipeWire.NET/Interop/PipeWireStreamHandle.cs b/src/PipeWire.NET/Interop/PipeWireStreamHandle.cs index e3549a1..ff000b6 100644 --- a/src/PipeWire.NET/Interop/PipeWireStreamHandle.cs +++ b/src/PipeWire.NET/Interop/PipeWireStreamHandle.cs @@ -25,7 +25,11 @@ internal sealed unsafe class PipeWireStreamHandle : SafeHandle private spa_hook* _hook; private GCHandle _self; - internal PipeWireStreamHandle(pw_stream* stream, PipeWireLoopHandle loop, PipeWireCoreHandle? core) + internal PipeWireStreamHandle( + pw_stream* stream, + PipeWireLoopHandle loop, + PipeWireCoreHandle? core + ) : base((IntPtr)stream, ownsHandle: true) { ArgumentNullException.ThrowIfNull(loop); @@ -41,13 +45,20 @@ internal PipeWireStreamHandle(pw_stream* stream, PipeWireLoopHandle loop, PipeWi } catch { - if (_coreReferenced) { core!.DangerousRelease(); _coreReferenced = false; } - if (_loopReferenced) { loop.DangerousRelease(); _loopReferenced = false; } + if (_coreReferenced) + { + core!.DangerousRelease(); + _coreReferenced = false; + } + if (_loopReferenced) + { + loop.DangerousRelease(); + _loopReferenced = false; + } throw; } } - /// /// Hands the handle the listener memory, freed once the native object has been destroyed. /// @@ -75,7 +86,8 @@ internal void OwnListener(void* events, spa_hook* hook, GCHandle self) protected override void Dispose(bool disposing) { - if (disposing) _deterministic = true; + if (disposing) + _deterministic = true; base.Dispose(disposing); } @@ -95,7 +107,8 @@ protected override bool ReleaseHandle() private bool NeedsLoop(out nint loop) { loop = 0; - if (handle == IntPtr.Zero || !_loopReferenced || _loop.IsInvalid) return false; + if (handle == IntPtr.Zero || !_loopReferenced || _loop.IsInvalid) + return false; loop = (nint)_loop.Loop; return loop != 0; } @@ -149,7 +162,6 @@ private bool ReleaseCore() } if (_self.IsAllocated) _self.Free(); - } return true; } diff --git a/src/PipeWire.NET/Interop/SafeCallback.cs b/src/PipeWire.NET/Interop/SafeCallback.cs index 9250ea9..787e670 100644 --- a/src/PipeWire.NET/Interop/SafeCallback.cs +++ b/src/PipeWire.NET/Interop/SafeCallback.cs @@ -17,10 +17,12 @@ internal static class SafeCallback internal static void Raise( TDelegate? handlers, Action invoke, - Action onFault) + Action onFault + ) where TDelegate : Delegate { - if (handlers is null) return; + if (handlers is null) + return; foreach (Delegate handler in handlers.GetInvocationList()) { @@ -31,8 +33,13 @@ internal static void Raise( catch (Exception ex) { // The reporter runs on the same native frame, so it has the same obligation. - try { onFault(ex); } - catch (Exception) { /* Deliberately not logged: nothing left that could report it. */ } + try + { + onFault(ex); + } + catch (Exception) + { /* Deliberately not logged: nothing left that could report it. */ + } } } } @@ -53,10 +60,12 @@ internal static void Raise( TDelegate? handlers, TState state, Action invoke, - Action onFault) + Action onFault + ) where TDelegate : Delegate { - if (handlers is null) return; + if (handlers is null) + return; foreach (Delegate handler in handlers.GetInvocationList()) { @@ -66,8 +75,13 @@ internal static void Raise( } catch (Exception ex) { - try { onFault(state, ex); } - catch (Exception) { /* Deliberately not logged: nothing left that could report it. */ } + try + { + onFault(state, ex); + } + catch (Exception) + { /* Deliberately not logged: nothing left that could report it. */ + } } } } diff --git a/src/PipeWire.NET/Interop/SpaHookList.cs b/src/PipeWire.NET/Interop/SpaHookList.cs index 09d1cb3..d883af1 100644 --- a/src/PipeWire.NET/Interop/SpaHookList.cs +++ b/src/PipeWire.NET/Interop/SpaHookList.cs @@ -41,7 +41,13 @@ internal static void Append(spa_hook_list* list, spa_hook* hook, void* funcs, vo /// spa_hook_list_isolate: moves every hook to and leaves /// alone on the list, so what is emitted next reaches only the new listener. /// - internal static void Isolate(spa_hook_list* list, spa_hook_list* save, spa_hook* hook, void* funcs, void* data) + internal static void Isolate( + spa_hook_list* list, + spa_hook_list* save, + spa_hook* hook, + void* funcs, + void* data + ) { Init(save); ListInsertList(&save->list, &list->list); @@ -72,7 +78,8 @@ private static void ListInsert(spa_list* list, spa_list* elem) // spa_list_insert_list private static void ListInsertList(spa_list* list, spa_list* other) { - if (other->next == other) return; + if (other->next == other) + return; other->next->prev = list; other->prev->next = list->next; diff --git a/src/PipeWire.NET/PipeWire.NET.csproj b/src/PipeWire.NET/PipeWire.NET.csproj index 8286cc0..db7bb20 100644 --- a/src/PipeWire.NET/PipeWire.NET.csproj +++ b/src/PipeWire.NET/PipeWire.NET.csproj @@ -43,5 +43,4 @@ - diff --git a/src/PipeWire.NET/PipeWireConnectFailedException.cs b/src/PipeWire.NET/PipeWireConnectFailedException.cs index e560f6e..4e73b3f 100644 --- a/src/PipeWire.NET/PipeWireConnectFailedException.cs +++ b/src/PipeWire.NET/PipeWireConnectFailedException.cs @@ -4,23 +4,23 @@ namespace PipeWire.NET; public sealed class PipeWireConnectFailedException : PipeWireConnectionException { /// - public PipeWireConnectFailedException(string operation, int result, uint? objectId = null, string? daemonMessage = null) - : base(operation, result, objectId, daemonMessage) - { - } + public PipeWireConnectFailedException( + string operation, + int result, + uint? objectId = null, + string? daemonMessage = null + ) + : base(operation, result, objectId, daemonMessage) { } /// - public PipeWireConnectFailedException() : base() - { - } + public PipeWireConnectFailedException() + : base() { } /// - public PipeWireConnectFailedException(string message) : base(message) - { - } + public PipeWireConnectFailedException(string message) + : base(message) { } /// - public PipeWireConnectFailedException(string message, Exception innerException) : base(message, innerException) - { - } + public PipeWireConnectFailedException(string message, Exception innerException) + : base(message, innerException) { } } diff --git a/src/PipeWire.NET/PipeWireConnectionClosedException.cs b/src/PipeWire.NET/PipeWireConnectionClosedException.cs index faa86f8..249253e 100644 --- a/src/PipeWire.NET/PipeWireConnectionClosedException.cs +++ b/src/PipeWire.NET/PipeWireConnectionClosedException.cs @@ -4,23 +4,23 @@ namespace PipeWire.NET; public sealed class PipeWireConnectionClosedException : PipeWireConnectionException { /// - public PipeWireConnectionClosedException(string operation, int result, uint? objectId = null, string? daemonMessage = null) - : base(operation, result, objectId, daemonMessage) - { - } + public PipeWireConnectionClosedException( + string operation, + int result, + uint? objectId = null, + string? daemonMessage = null + ) + : base(operation, result, objectId, daemonMessage) { } /// - public PipeWireConnectionClosedException() : base() - { - } + public PipeWireConnectionClosedException() + : base() { } /// - public PipeWireConnectionClosedException(string message) : base(message) - { - } + public PipeWireConnectionClosedException(string message) + : base(message) { } /// - public PipeWireConnectionClosedException(string message, Exception innerException) : base(message, innerException) - { - } + public PipeWireConnectionClosedException(string message, Exception innerException) + : base(message, innerException) { } } diff --git a/src/PipeWire.NET/PipeWireConnectionException.cs b/src/PipeWire.NET/PipeWireConnectionException.cs index d57f3bb..5b00b21 100644 --- a/src/PipeWire.NET/PipeWireConnectionException.cs +++ b/src/PipeWire.NET/PipeWireConnectionException.cs @@ -4,23 +4,23 @@ namespace PipeWire.NET; public class PipeWireConnectionException : PipeWireException { /// - public PipeWireConnectionException(string operation, int result, uint? objectId = null, string? daemonMessage = null) - : base(operation, result, objectId, daemonMessage) - { - } + public PipeWireConnectionException( + string operation, + int result, + uint? objectId = null, + string? daemonMessage = null + ) + : base(operation, result, objectId, daemonMessage) { } /// - public PipeWireConnectionException() : base() - { - } + public PipeWireConnectionException() + : base() { } /// - public PipeWireConnectionException(string message) : base(message) - { - } + public PipeWireConnectionException(string message) + : base(message) { } /// - public PipeWireConnectionException(string message, Exception innerException) : base(message, innerException) - { - } + public PipeWireConnectionException(string message, Exception innerException) + : base(message, innerException) { } } diff --git a/src/PipeWire.NET/PipeWireException.cs b/src/PipeWire.NET/PipeWireException.cs index 8f31def..f453f83 100644 --- a/src/PipeWire.NET/PipeWireException.cs +++ b/src/PipeWire.NET/PipeWireException.cs @@ -24,7 +24,12 @@ public class PipeWireException : Exception /// The result code, negative for a failure and normally a negative errno. /// The global the operation was against, if it had one. /// What the daemon reported, if it reported anything. - public PipeWireException(string operation, int result, uint? objectId = null, string? daemonMessage = null) + public PipeWireException( + string operation, + int result, + uint? objectId = null, + string? daemonMessage = null + ) : base(Describe(operation, result, objectId, daemonMessage)) { Operation = operation; @@ -35,9 +40,7 @@ public PipeWireException(string operation, int result, uint? objectId = null, st /// public PipeWireException() - : this("unknown", 0) - { - } + : this("unknown", 0) { } /// public PipeWireException(string message) @@ -101,10 +104,16 @@ public PipeWireException(string message, Exception innerException) /// Throws if reports a failure. internal static void ThrowIfFailed(int result, string operation, uint? objectId = null) { - if (result < 0) throw new PipeWireInteropException(operation, result, objectId); + if (result < 0) + throw new PipeWireInteropException(operation, result, objectId); } - private static string Describe(string operation, int result, uint? objectId, string? daemonMessage) + private static string Describe( + string operation, + int result, + uint? objectId, + string? daemonMessage + ) { var text = new System.Text.StringBuilder(operation); @@ -115,8 +124,10 @@ private static string Describe(string operation, int result, uint? objectId, str string? name = ErrnoName(result); - if (name is not null) text.Append(CultureInfo.InvariantCulture, $" ({name})"); - if (daemonMessage is not null) text.Append(CultureInfo.InvariantCulture, $": {daemonMessage}"); + if (name is not null) + text.Append(CultureInfo.InvariantCulture, $" ({name})"); + if (daemonMessage is not null) + text.Append(CultureInfo.InvariantCulture, $": {daemonMessage}"); return text.ToString(); } @@ -127,45 +138,46 @@ private static string Describe(string operation, int result, uint? objectId, str /// through them. Not the whole of errno.h: a name is only worth printing where it tells a reader /// something the number does not, and an unmapped code still prints as a number. /// - private static string? ErrnoName(int result) => result switch - { - -1 => "EPERM", - -2 => "ENOENT", - -4 => "EINTR", - -5 => "EIO", - -9 => "EBADF", - -11 => "EAGAIN", - -12 => "ENOMEM", - -13 => "EACCES", - -14 => "EFAULT", - -16 => "EBUSY", - -17 => "EEXIST", - -19 => "ENODEV", - -22 => "EINVAL", - -24 => "EMFILE", - -25 => "ENOTTY", - -28 => "ENOSPC", - -32 => "EPIPE", - -34 => "ERANGE", - -38 => "ENOSYS", - -39 => "ENOTEMPTY", - -71 => "EPROTO", - -74 => "EBADMSG", - -75 => "EOVERFLOW", - -84 => "EILSEQ", - -88 => "ENOTSOCK", - -90 => "EMSGSIZE", - -93 => "EPROTONOSUPPORT", - -95 => "EOPNOTSUPP", - -98 => "EADDRINUSE", - -103 => "ECONNABORTED", - -104 => "ECONNRESET", - -105 => "ENOBUFS", - -107 => "ENOTCONN", - -108 => "ESHUTDOWN", - -110 => "ETIMEDOUT", - -111 => "ECONNREFUSED", - -125 => "ECANCELED", - _ => null, - }; + private static string? ErrnoName(int result) => + result switch + { + -1 => "EPERM", + -2 => "ENOENT", + -4 => "EINTR", + -5 => "EIO", + -9 => "EBADF", + -11 => "EAGAIN", + -12 => "ENOMEM", + -13 => "EACCES", + -14 => "EFAULT", + -16 => "EBUSY", + -17 => "EEXIST", + -19 => "ENODEV", + -22 => "EINVAL", + -24 => "EMFILE", + -25 => "ENOTTY", + -28 => "ENOSPC", + -32 => "EPIPE", + -34 => "ERANGE", + -38 => "ENOSYS", + -39 => "ENOTEMPTY", + -71 => "EPROTO", + -74 => "EBADMSG", + -75 => "EOVERFLOW", + -84 => "EILSEQ", + -88 => "ENOTSOCK", + -90 => "EMSGSIZE", + -93 => "EPROTONOSUPPORT", + -95 => "EOPNOTSUPP", + -98 => "EADDRINUSE", + -103 => "ECONNABORTED", + -104 => "ECONNRESET", + -105 => "ENOBUFS", + -107 => "ENOTCONN", + -108 => "ESHUTDOWN", + -110 => "ETIMEDOUT", + -111 => "ECONNREFUSED", + -125 => "ECANCELED", + _ => null, + }; } diff --git a/src/PipeWire.NET/PipeWireInteropException.cs b/src/PipeWire.NET/PipeWireInteropException.cs index 9e9dc90..c89d2bd 100644 --- a/src/PipeWire.NET/PipeWireInteropException.cs +++ b/src/PipeWire.NET/PipeWireInteropException.cs @@ -4,23 +4,23 @@ namespace PipeWire.NET; public sealed class PipeWireInteropException : PipeWireException { /// - public PipeWireInteropException(string operation, int result, uint? objectId = null, string? daemonMessage = null) - : base(operation, result, objectId, daemonMessage) - { - } + public PipeWireInteropException( + string operation, + int result, + uint? objectId = null, + string? daemonMessage = null + ) + : base(operation, result, objectId, daemonMessage) { } /// - public PipeWireInteropException() : base() - { - } + public PipeWireInteropException() + : base() { } /// - public PipeWireInteropException(string message) : base(message) - { - } + public PipeWireInteropException(string message) + : base(message) { } /// - public PipeWireInteropException(string message, Exception innerException) : base(message, innerException) - { - } + public PipeWireInteropException(string message, Exception innerException) + : base(message, innerException) { } } diff --git a/src/PipeWire.NET/PipeWireRequestRefusedException.cs b/src/PipeWire.NET/PipeWireRequestRefusedException.cs index 2d2c19b..6e10af3 100644 --- a/src/PipeWire.NET/PipeWireRequestRefusedException.cs +++ b/src/PipeWire.NET/PipeWireRequestRefusedException.cs @@ -4,23 +4,23 @@ namespace PipeWire.NET; public sealed class PipeWireRequestRefusedException : PipeWireException { /// - public PipeWireRequestRefusedException(string operation, int result, uint? objectId = null, string? daemonMessage = null) - : base(operation, result, objectId, daemonMessage) - { - } + public PipeWireRequestRefusedException( + string operation, + int result, + uint? objectId = null, + string? daemonMessage = null + ) + : base(operation, result, objectId, daemonMessage) { } /// - public PipeWireRequestRefusedException() : base() - { - } + public PipeWireRequestRefusedException() + : base() { } /// - public PipeWireRequestRefusedException(string message) : base(message) - { - } + public PipeWireRequestRefusedException(string message) + : base(message) { } /// - public PipeWireRequestRefusedException(string message, Exception innerException) : base(message, innerException) - { - } + public PipeWireRequestRefusedException(string message, Exception innerException) + : base(message, innerException) { } } diff --git a/src/PipeWire.NET/SafeDescriptorHandle.cs b/src/PipeWire.NET/SafeDescriptorHandle.cs index 361efa0..049721d 100644 --- a/src/PipeWire.NET/SafeDescriptorHandle.cs +++ b/src/PipeWire.NET/SafeDescriptorHandle.cs @@ -11,24 +11,23 @@ namespace PipeWire.NET; public sealed class SafeDescriptorHandle : SafeHandle { /// Wraps , which this handle then owns. - public SafeDescriptorHandle(int descriptor) : base(new IntPtr(-1), ownsHandle: true) - => SetHandle(new IntPtr(descriptor)); + public SafeDescriptorHandle(int descriptor) + : base(new IntPtr(-1), ownsHandle: true) => SetHandle(new IntPtr(descriptor)); /// An owning handle over no descriptor. - public SafeDescriptorHandle() : base(new IntPtr(-1), ownsHandle: true) - { - } + public SafeDescriptorHandle() + : base(new IntPtr(-1), ownsHandle: true) { } /// public override bool IsInvalid => handle == new IntPtr(-1); /// The descriptor, for a call that takes one. Does not transfer ownership. /// The handle has been disposed. - public int Descriptor => IsClosed || IsInvalid - ? throw new ObjectDisposedException(nameof(SafeDescriptorHandle)) - : (int)handle; + public int Descriptor => + IsClosed || IsInvalid + ? throw new ObjectDisposedException(nameof(SafeDescriptorHandle)) + : (int)handle; /// protected override bool ReleaseHandle() => NativeLibc.close((int)handle) == 0; - } diff --git a/src/PipeWire.NET/Spa/SpaDictBuilder.cs b/src/PipeWire.NET/Spa/SpaDictBuilder.cs index 46bde03..ea2f083 100644 --- a/src/PipeWire.NET/Spa/SpaDictBuilder.cs +++ b/src/PipeWire.NET/Spa/SpaDictBuilder.cs @@ -97,27 +97,30 @@ public void Add(scoped ReadOnlySpan key, uint value) => /// The completed dictionary, pointing into the caller's buffers and valid only while they are /// in scope. /// - public readonly spa_dict Build() => new() - { - // Must be zero: SPA_DICT_FLAG_SORTED is bit 0, and a stray set bit tells PipeWire the items - // are sorted, after which it may binary-search an unsorted array and miss properties. - flags = 0, - n_items = (uint)_count, - - // An empty dictionary gets a null array rather than the address of a buffer holding - // nothing. Native code is entitled to look at the pointer before the count, and the address - // of an empty span is whatever the storage behind it happens to be - live stack, in the - // stackalloc case, which outlives nothing. - items = _count == 0 - ? null - : (spa_dict_item*)Unsafe.AsPointer(ref MemoryMarshal.GetReference(_items)), - }; + public readonly spa_dict Build() => + new() + { + // Must be zero: SPA_DICT_FLAG_SORTED is bit 0, and a stray set bit tells PipeWire the items + // are sorted, after which it may binary-search an unsorted array and miss properties. + flags = 0, + n_items = (uint)_count, + + // An empty dictionary gets a null array rather than the address of a buffer holding + // nothing. Native code is entitled to look at the pointer before the count, and the address + // of an empty span is whatever the storage behind it happens to be - live stack, in the + // stackalloc case, which outlives nothing. + items = + _count == 0 + ? null + : (spa_dict_item*)Unsafe.AsPointer(ref MemoryMarshal.GetReference(_items)), + }; private void Append(sbyte* key, sbyte* value) { if (_count == _items.Length) throw new InvalidOperationException( - $"SpaDictBuilder item buffer holds {_items.Length} entries; size it to the property count."); + $"SpaDictBuilder item buffer holds {_items.Length} entries; size it to the property count." + ); _items[_count++] = new spa_dict_item { key = key, value = value }; } @@ -129,7 +132,8 @@ private void Append(sbyte* key, sbyte* value) { throw new ArgumentException( "a property reaches native code as a C string, so it cannot contain a NUL.", - nameof(utf8)); + nameof(utf8) + ); } Span dst = Reserve(utf8.Length + 1); @@ -147,7 +151,8 @@ private void Append(sbyte* key, sbyte* value) { throw new ArgumentException( "a property value reaches native code as a C string, so it cannot contain a NUL.", - nameof(value)); + nameof(value) + ); } Span dst = Reserve(Encoding.UTF8.GetByteCount(value) + 1); @@ -168,7 +173,8 @@ private Span Reserve(int bytes) { if (_scratch.Length - _used < bytes) throw new InvalidOperationException( - $"SpaDictBuilder scratch buffer of {_scratch.Length} bytes is exhausted; enlarge it."); + $"SpaDictBuilder scratch buffer of {_scratch.Length} bytes is exhausted; enlarge it." + ); Span slice = _scratch.Slice(_used, bytes); _used += bytes; return slice; diff --git a/src/PipeWire.NET/Spa/SpaIdValue.cs b/src/PipeWire.NET/Spa/SpaIdValue.cs index c002063..12e4b7d 100644 --- a/src/PipeWire.NET/Spa/SpaIdValue.cs +++ b/src/PipeWire.NET/Spa/SpaIdValue.cs @@ -35,13 +35,16 @@ public readonly record struct SpaIdValue /// error, reported as one here rather than as a NotSupportedException out of the reinterpret /// that names neither type. /// - public unsafe TEnum As() where TEnum : unmanaged, Enum + public unsafe TEnum As() + where TEnum : unmanaged, Enum { if (sizeof(TEnum) != sizeof(uint)) { throw new ArgumentException( $"{typeof(TEnum).Name} is {sizeof(TEnum)} bytes; a SPA id enum is four. " - + "This id does not belong to that enum.", nameof(TEnum)); + + "This id does not belong to that enum.", + nameof(TEnum) + ); } return System.Runtime.CompilerServices.Unsafe.BitCast(Value); @@ -136,5 +139,6 @@ public unsafe TEnum As() where TEnum : unmanaged, Enum public static implicit operator SpaIdValue(SpaMetaVideotransformValue id) => new((uint)id); /// - public override string ToString() => Value.ToString(System.Globalization.CultureInfo.InvariantCulture); + public override string ToString() => + Value.ToString(System.Globalization.CultureInfo.InvariantCulture); } diff --git a/src/PipeWire.NET/Spa/SpaKey.cs b/src/PipeWire.NET/Spa/SpaKey.cs index 465cc1a..bdccbc1 100644 --- a/src/PipeWire.NET/Spa/SpaKey.cs +++ b/src/PipeWire.NET/Spa/SpaKey.cs @@ -38,13 +38,16 @@ public readonly record struct SpaKey /// throws a naming neither type. Since the size is a /// constant for any given , the check costs nothing once inlined. /// - public unsafe TEnum As() where TEnum : unmanaged, Enum + public unsafe TEnum As() + where TEnum : unmanaged, Enum { if (sizeof(TEnum) != sizeof(uint)) { throw new ArgumentException( $"{typeof(TEnum).Name} is {sizeof(TEnum)} bytes; a SPA key enum is four. " - + "This key does not belong to that enum.", nameof(TEnum)); + + "This key does not belong to that enum.", + nameof(TEnum) + ); } return System.Runtime.CompilerServices.Unsafe.BitCast(Value); @@ -107,5 +110,6 @@ public unsafe TEnum As() where TEnum : unmanaged, Enum public static implicit operator SpaKey(SpaParamDict key) => new((uint)key); /// - public override string ToString() => Value.ToString(System.Globalization.CultureInfo.InvariantCulture); + public override string ToString() => + Value.ToString(System.Globalization.CultureInfo.InvariantCulture); } diff --git a/src/PipeWire.NET/Spa/SpaPod.cs b/src/PipeWire.NET/Spa/SpaPod.cs index c50242c..e8c72b2 100644 --- a/src/PipeWire.NET/Spa/SpaPod.cs +++ b/src/PipeWire.NET/Spa/SpaPod.cs @@ -94,7 +94,12 @@ public static byte[] ToBytes(SpaValue value) // - Parsing - - private static bool TryParseValue(ReadOnlySpan pod, int depth, out SpaValue? value, out int consumed) + private static bool TryParseValue( + ReadOnlySpan pod, + int depth, + out SpaValue? value, + out int consumed + ) { value = null; consumed = 0; @@ -118,12 +123,25 @@ private static bool TryParseValue(ReadOnlySpan pod, int depth, out SpaValu int unpadded = 8 + (int)size; int padded = Pad(unpadded); - if (padded <= pod.Length) { consumed = padded; return true; } - if (unpadded <= pod.Length) { consumed = unpadded; return true; } + if (padded <= pod.Length) + { + consumed = padded; + return true; + } + if (unpadded <= pod.Length) + { + consumed = unpadded; + return true; + } return false; } - private static bool TryParseBody(SpaType type, ReadOnlySpan body, int depth, out SpaValue? value) + private static bool TryParseBody( + SpaType type, + ReadOnlySpan body, + int depth, + out SpaValue? value + ) { value = null; @@ -134,32 +152,38 @@ private static bool TryParseBody(SpaType type, ReadOnlySpan body, int dept return true; case SpaType.Bool: - if (body.Length < 4) return false; + if (body.Length < 4) + return false; value = new SpaBool(MemoryMarshal.Read(body) != 0); return true; case SpaType.Id: - if (body.Length < 4) return false; + if (body.Length < 4) + return false; value = new SpaId(MemoryMarshal.Read(body)); return true; case SpaType.Int: - if (body.Length < 4) return false; + if (body.Length < 4) + return false; value = new SpaInt(MemoryMarshal.Read(body)); return true; case SpaType.Long: - if (body.Length < 8) return false; + if (body.Length < 8) + return false; value = new SpaLong(MemoryMarshal.Read(body)); return true; case SpaType.Float: - if (body.Length < 4) return false; + if (body.Length < 4) + return false; value = new SpaFloat(MemoryMarshal.Read(body)); return true; case SpaType.Double: - if (body.Length < 8) return false; + if (body.Length < 8) + return false; value = new SpaDouble(MemoryMarshal.Read(body)); return true; @@ -179,17 +203,21 @@ private static bool TryParseBody(SpaType type, ReadOnlySpan body, int dept return true; case SpaType.Rectangle: - if (body.Length < 8) return false; + if (body.Length < 8) + return false; value = new SpaRectangle( MemoryMarshal.Read(body), - MemoryMarshal.Read(body[4..])); + MemoryMarshal.Read(body[4..]) + ); return true; case SpaType.Fraction: - if (body.Length < 8) return false; + if (body.Length < 8) + return false; value = new SpaFraction( MemoryMarshal.Read(body), - MemoryMarshal.Read(body[4..])); + MemoryMarshal.Read(body[4..]) + ); return true; case SpaType.Bitmap: @@ -197,18 +225,21 @@ private static bool TryParseBody(SpaType type, ReadOnlySpan body, int dept return true; case SpaType.Fd: - if (body.Length < 8) return false; + if (body.Length < 8) + return false; value = new SpaFd(MemoryMarshal.Read(body)); return true; case SpaType.Pointer: { // [uint32 type][uint32 padding][pointer], the pointer being native-word sized. - if (body.Length < 8 + IntPtr.Size) return false; + if (body.Length < 8 + IntPtr.Size) + return false; var pointerType = (SpaType)MemoryMarshal.Read(body); - ulong address = IntPtr.Size == 8 - ? MemoryMarshal.Read(body[8..]) - : MemoryMarshal.Read(body[8..]); + ulong address = + IntPtr.Size == 8 + ? MemoryMarshal.Read(body[8..]) + : MemoryMarshal.Read(body[8..]); value = new SpaPointer(pointerType, address); return true; } @@ -247,7 +278,8 @@ private static bool TryParseBody(SpaType type, ReadOnlySpan body, int dept private static bool TryParseArray(ReadOnlySpan body, int depth, out SpaValue? value) { value = null; - if (body.Length < 8) return false; + if (body.Length < 8) + return false; uint childSize = MemoryMarshal.Read(body); var childType = (SpaType)MemoryMarshal.Read(body[4..]); @@ -256,9 +288,7 @@ private static bool TryParseArray(ReadOnlySpan body, int depth, out SpaVal // A zero child size with a non-empty body would loop forever; an empty array is legal. if (childSize == 0) { - value = items.IsEmpty - ? new SpaArray(childType, []) - : null; + value = items.IsEmpty ? new SpaArray(childType, []) : null; return items.IsEmpty; } @@ -266,8 +296,10 @@ private static bool TryParseArray(ReadOnlySpan body, int depth, out SpaVal // int.MaxValue negative, which makes the item count negative, and the builder is then asked // for a negative capacity: an exception out of a parser whose whole contract is to return // false. Nothing legitimate is anywhere near this large. - if (childSize > int.MaxValue || childSize > (uint)items.Length) return false; - if (!ChildrenTileExactly(childSize, childType, items.Length)) return false; + if (childSize > int.MaxValue || childSize > (uint)items.Length) + return false; + if (!ChildrenTileExactly(childSize, childType, items.Length)) + return false; int count = items.Length / (int)childSize; @@ -294,7 +326,8 @@ private static bool TryParseArray(ReadOnlySpan body, int depth, out SpaVal /// private static bool ChildrenTileExactly(uint childSize, SpaType childType, int itemsLength) { - if ((uint)itemsLength % childSize != 0) return false; + if ((uint)itemsLength % childSize != 0) + return false; int fixedSize = FixedBodySize(childType); return fixedSize < 0 || childSize == (uint)fixedSize; @@ -308,9 +341,13 @@ private static bool TryParseStruct(ReadOnlySpan body, int depth, out SpaVa int offset = 0; while (offset + 8 <= body.Length) { - if (!TryParseValue(body[offset..], depth + 1, out SpaValue? field, out int consumed) || field is null) + if ( + !TryParseValue(body[offset..], depth + 1, out SpaValue? field, out int consumed) + || field is null + ) + return false; + if (consumed <= 0) return false; - if (consumed <= 0) return false; fields.Add(field); offset += consumed; } @@ -318,7 +355,8 @@ private static bool TryParseStruct(ReadOnlySpan body, int depth, out SpaVa // Same rule as an object: the loop stops when fewer than a header remains, so a remainder is // a body that ended mid-field. Accepting it dropped the tail and reported success, which // reads downstream as a producer that simply sent fewer fields. - if (offset != body.Length) return false; + if (offset != body.Length) + return false; value = new SpaStruct(fields.ToImmutable()); return true; @@ -327,7 +365,8 @@ private static bool TryParseStruct(ReadOnlySpan body, int depth, out SpaVa private static bool TryParseObject(ReadOnlySpan body, int depth, out SpaValue? value) { value = null; - if (body.Length < 8) return false; + if (body.Length < 8) + return false; var objectType = (SpaType)MemoryMarshal.Read(body); var objectId = (SpaParamType)MemoryMarshal.Read(body[4..]); @@ -339,8 +378,16 @@ private static bool TryParseObject(ReadOnlySpan body, int depth, out SpaVa uint key = MemoryMarshal.Read(body[offset..]); var flags = (SpaPodPropFlags)MemoryMarshal.Read(body[(offset + 4)..]); - if (!TryParseValue(body[(offset + 8)..], depth + 1, out SpaValue? propertyValue, out int consumed) - || propertyValue is null || consumed <= 0) + if ( + !TryParseValue( + body[(offset + 8)..], + depth + 1, + out SpaValue? propertyValue, + out int consumed + ) + || propertyValue is null + || consumed <= 0 + ) { return false; } @@ -352,7 +399,8 @@ private static bool TryParseObject(ReadOnlySpan body, int depth, out SpaVa // The loop stops as soon as fewer than a property header remains, so anything left over is // a body that ended mid-property. Accepting it returned an object missing its last property // and reported success, which reads downstream as a producer that simply did not offer it. - if (offset != body.Length) return false; + if (offset != body.Length) + return false; value = new SpaObject(objectType, objectId, properties.ToImmutable()); return true; @@ -361,7 +409,8 @@ private static bool TryParseObject(ReadOnlySpan body, int depth, out SpaVa private static bool TryParseChoice(ReadOnlySpan body, int depth, out SpaValue? value) { value = null; - if (body.Length < 16) return false; + if (body.Length < 16) + return false; var kind = (SpaChoiceType)MemoryMarshal.Read(body); uint childSize = MemoryMarshal.Read(body[8..]); @@ -370,7 +419,8 @@ private static bool TryParseChoice(ReadOnlySpan body, int depth, out SpaVa if (childSize == 0) { - if (!items.IsEmpty) return false; + if (!items.IsEmpty) + return false; value = new SpaChoice(kind, childType, []); return true; } @@ -379,8 +429,10 @@ private static bool TryParseChoice(ReadOnlySpan body, int depth, out SpaVa // int.MaxValue negative, which makes the item count negative, and the builder is then asked // for a negative capacity: an exception out of a parser whose whole contract is to return // false. Nothing legitimate is anywhere near this large. - if (childSize > int.MaxValue || childSize > (uint)items.Length) return false; - if (!ChildrenTileExactly(childSize, childType, items.Length)) return false; + if (childSize > int.MaxValue || childSize > (uint)items.Length) + return false; + if (!ChildrenTileExactly(childSize, childType, items.Length)) + return false; int count = items.Length / (int)childSize; @@ -395,7 +447,8 @@ private static bool TryParseChoice(ReadOnlySpan body, int depth, out SpaVa int required = SpaChoice.RequiredCount(kind); if (required > 0) { - if (count < required) return false; + if (count < required) + return false; count = required; } @@ -415,7 +468,8 @@ private static bool TryParseChoice(ReadOnlySpan body, int depth, out SpaVa private static bool TryParseSequence(ReadOnlySpan body, int depth, out SpaValue? value) { value = null; - if (body.Length < 8) return false; + if (body.Length < 8) + return false; uint unit = MemoryMarshal.Read(body); var controls = ImmutableArray.CreateBuilder(); @@ -426,8 +480,16 @@ private static bool TryParseSequence(ReadOnlySpan body, int depth, out Spa uint controlOffset = MemoryMarshal.Read(body[offset..]); uint controlType = MemoryMarshal.Read(body[(offset + 4)..]); - if (!TryParseValue(body[(offset + 8)..], depth + 1, out SpaValue? controlValue, out int consumed) - || controlValue is null || consumed <= 0) + if ( + !TryParseValue( + body[(offset + 8)..], + depth + 1, + out SpaValue? controlValue, + out int consumed + ) + || controlValue is null + || consumed <= 0 + ) { return false; } @@ -436,7 +498,8 @@ private static bool TryParseSequence(ReadOnlySpan body, int depth, out Spa offset += 8 + consumed; } - if (offset != body.Length) return false; + if (offset != body.Length) + return false; value = new SpaSequence(unit, controls.ToImmutable()); return true; @@ -450,24 +513,28 @@ private static bool TryParseSequence(ReadOnlySpan body, int depth, out Spa /// reaches a new byte[] and a span slice as a negative length, throwing something nobody /// documented from a method whose whole contract is a byte count. /// - private static int BodySize(SpaValue value) => checked(value switch - { - SpaNone => 0, - SpaBool or SpaId or SpaInt or SpaFloat => 4, - SpaLong or SpaDouble or SpaFd or SpaRectangle or SpaFraction => 8, - SpaString s => Encoding.UTF8.GetByteCount(s.Value) + 1, - SpaBytes b => b.Value.Length, - SpaBitmap b => b.Bits.Length, - SpaPointer => 8 + IntPtr.Size, - SpaArray a => 8 + (a.Items.Length * ChildSize(a.ChildType, a.Items)), - SpaChoice c => 16 + (c.Alternatives.Length * ChildSize(c.ChildType, c.Alternatives)), - SpaStruct s => SumPadded(s.Fields), - SpaObject o => 8 + SumProperties(o.Properties), - SpaSequence s => 8 + SumControls(s.Controls), - SpaNestedPod p => Pad(8 + BodySize(p.Value)), - SpaUnknown u => u.Body.Length, - _ => 0, - }); + private static int BodySize(SpaValue value) => + checked( + value switch + { + SpaNone => 0, + SpaBool or SpaId or SpaInt or SpaFloat => 4, + SpaLong or SpaDouble or SpaFd or SpaRectangle or SpaFraction => 8, + SpaString s => Encoding.UTF8.GetByteCount(s.Value) + 1, + SpaBytes b => b.Value.Length, + SpaBitmap b => b.Bits.Length, + SpaPointer => 8 + IntPtr.Size, + SpaArray a => 8 + (a.Items.Length * ChildSize(a.ChildType, a.Items)), + SpaChoice c => 16 + + (c.Alternatives.Length * ChildSize(c.ChildType, c.Alternatives)), + SpaStruct s => SumPadded(s.Fields), + SpaObject o => 8 + SumProperties(o.Properties), + SpaSequence s => 8 + SumControls(s.Controls), + SpaNestedPod p => Pad(8 + BodySize(p.Value)), + SpaUnknown u => u.Body.Length, + _ => 0, + } + ); // Array and choice children are stored bare, so every child must agree on one size. The // declared child type decides it; a value that disagrees is written truncated or padded to fit, @@ -484,14 +551,15 @@ private static int ChildSize(SpaType childType, ImmutableArray childre return max; } - private static int FixedBodySize(SpaType type) => type switch - { - SpaType.None => 0, - SpaType.Bool or SpaType.Id or SpaType.Int or SpaType.Float => 4, - SpaType.Long or SpaType.Double or SpaType.Fd - or SpaType.Rectangle or SpaType.Fraction => 8, - _ => -1, - }; + private static int FixedBodySize(SpaType type) => + type switch + { + SpaType.None => 0, + SpaType.Bool or SpaType.Id or SpaType.Int or SpaType.Float => 4, + SpaType.Long or SpaType.Double or SpaType.Fd or SpaType.Rectangle or SpaType.Fraction => + 8, + _ => -1, + }; private static int SumPadded(ImmutableArray values) { @@ -670,7 +738,11 @@ private static void WriteBody(SpaValue value, Span body) } } - private static void WriteChildren(ImmutableArray children, int childSize, Span destination) + private static void WriteChildren( + ImmutableArray children, + int childSize, + Span destination + ) { int offset = 0; foreach (SpaValue child in children) diff --git a/src/PipeWire.NET/Spa/SpaPodBuilder.cs b/src/PipeWire.NET/Spa/SpaPodBuilder.cs index b235385..db5b3f3 100644 --- a/src/PipeWire.NET/Spa/SpaPodBuilder.cs +++ b/src/PipeWire.NET/Spa/SpaPodBuilder.cs @@ -38,33 +38,75 @@ internal ref struct SpaPodBuilder private int _depth; [InlineArray(8)] - private struct OffsetStack { private int _e; } + private struct OffsetStack + { + private int _e; + } public SpaPodBuilder(Span buffer) { - _buf = buffer; - _pos = 0; + _buf = buffer; + _pos = 0; _depth = 0; } // - Bare primitive values (rarely used directly; prefer the keyed overloads) - - public void AddInt(int value) => WritePod(SpaType.Int, value); - public void AddFloat(float value) => WritePod(SpaType.Float, value); + public void AddInt(int value) => WritePod(SpaType.Int, value); + + public void AddFloat(float value) => WritePod(SpaType.Float, value); + public void AddDouble(double value) => WritePod(SpaType.Double, value); - public void AddBool(bool value) => WritePod(SpaType.Bool, value ? 1 : 0); - public void AddId(SpaIdValue id) => WritePod(SpaType.Id, (int)id.Value); - public void AddLong(long value) => WritePodLong(value); + + public void AddBool(bool value) => WritePod(SpaType.Bool, value ? 1 : 0); + + public void AddId(SpaIdValue id) => WritePod(SpaType.Id, (int)id.Value); + + public void AddLong(long value) => WritePodLong(value); // - Keyed properties (inside an Object) - - public void AddId(SpaKey key, SpaIdValue value) { WritePropHeader(key); AddId(value); } - public void AddInt(SpaKey key, int value) { WritePropHeader(key); AddInt(value); } - public void AddInt(SpaKey key, int value, SpaPodPropFlags propFlags) { WritePropHeader(key, propFlags); AddInt(value); } - public void AddLong(SpaKey key, long value) { WritePropHeader(key); AddLong(value); } - public void AddLong(SpaKey key, long value, SpaPodPropFlags propFlags) { WritePropHeader(key, propFlags); AddLong(value); } - public void AddFraction(SpaKey key, uint n, uint d) { WritePropHeader(key); WriteFraction(n, d); } - public void AddRectangle(SpaKey key, uint w, uint h) { WritePropHeader(key); WriteRectangle(w, h); } + public void AddId(SpaKey key, SpaIdValue value) + { + WritePropHeader(key); + AddId(value); + } + + public void AddInt(SpaKey key, int value) + { + WritePropHeader(key); + AddInt(value); + } + + public void AddInt(SpaKey key, int value, SpaPodPropFlags propFlags) + { + WritePropHeader(key, propFlags); + AddInt(value); + } + + public void AddLong(SpaKey key, long value) + { + WritePropHeader(key); + AddLong(value); + } + + public void AddLong(SpaKey key, long value, SpaPodPropFlags propFlags) + { + WritePropHeader(key, propFlags); + AddLong(value); + } + + public void AddFraction(SpaKey key, uint n, uint d) + { + WritePropHeader(key); + WriteFraction(n, d); + } + + public void AddRectangle(SpaKey key, uint w, uint h) + { + WritePropHeader(key); + WriteRectangle(w, h); + } // - Choice: Enum over Long (DRM format modifiers) - @@ -76,7 +118,11 @@ public SpaPodBuilder(Span buffer) /// to what it supports without fixating, then re-offer a single modifier to fixate. /// /// is empty. - public void AddChoiceEnumLong(SpaKey key, ReadOnlySpan values, SpaPodPropFlags propFlags = SpaPodPropFlags.None) + public void AddChoiceEnumLong( + SpaKey key, + ReadOnlySpan values, + SpaPodPropFlags propFlags = SpaPodPropFlags.None + ) { // A choice with no children is a header claiming alternatives it does not carry. Our own // reader rejects it, and a producer reading it has nothing to select, so refuse to write @@ -86,12 +132,12 @@ public void AddChoiceEnumLong(SpaKey key, ReadOnlySpan values, SpaPodPropF WritePropHeader(key, propFlags); int start = _pos; - WriteU32(0u); // pod size - back-patched + WriteU32(0u); // pod size - back-patched WriteU32(SpaType.Choice); WriteU32(SpaChoiceType.Enum); - WriteU32(0u); // choice flags - WriteU32(8); // child.size - Long is 8 bytes - WriteU32(SpaType.Long); // child.type + WriteU32(0u); // choice flags + WriteU32(8); // child.size - Long is 8 bytes + WriteU32(SpaType.Long); // child.type // SPA Choice Enum layout is { default, alt0, alt1, ... }: the FIRST child is the default/preferred // value and the rest are the selectable alternatives, so the default must also appear among the // alternatives. Emit values[0] once as the default and then every value. A single modifier written @@ -121,12 +167,12 @@ public void AddChoiceEnum(SpaKey key, params ReadOnlySpan values) // [value0][value1][...] <- raw values, child.size each WritePropHeader(key); int start = _pos; - WriteU32(0u); // pod size - back-patched + WriteU32(0u); // pod size - back-patched WriteU32(SpaType.Choice); WriteU32(SpaChoiceType.Enum); - WriteU32(0u); // flags - WriteU32(4); // child.size - Id is 4 bytes - WriteU32(SpaType.Id); // child.type + WriteU32(0u); // flags + WriteU32(4); // child.size - Id is 4 bytes + WriteU32(SpaType.Id); // child.type // { default, alt0, alt1, ... }: the first child is the preferred value and the rest are the // selectable alternatives, so the default has to appear again among them. Written once, the @@ -148,10 +194,12 @@ public void AddChoiceRangeInt(SpaKey key, int def, int min, int max) WriteU32(0u); WriteU32(SpaType.Choice); WriteU32(SpaChoiceType.Range); - WriteU32(0u); // flags - WriteU32(4); // child.size - Int = 4 + WriteU32(0u); // flags + WriteU32(4); // child.size - Int = 4 WriteU32(SpaType.Int); - WriteU32((uint)def); WriteU32((uint)min); WriteU32((uint)max); + WriteU32((uint)def); + WriteU32((uint)min); + WriteU32((uint)max); PatchSize(start); Align8(); } @@ -169,8 +217,8 @@ public void AddChoiceFlagsInt(SpaKey key, int flags) WriteU32(0u); WriteU32(SpaType.Choice); WriteU32(SpaChoiceType.Flags); - WriteU32(0u); // flags - WriteU32(4); // child.size - Int = 4 + WriteU32(0u); // flags + WriteU32(4); // child.size - Int = 4 WriteU32(SpaType.Int); WriteU32((uint)flags); PatchSize(start); @@ -179,42 +227,58 @@ public void AddChoiceFlagsInt(SpaKey key, int flags) // - Choice: Range (default, min, max) - - public void AddChoiceRangeRectangle(SpaKey key, - uint defaultW, uint defaultH, - uint minW, uint minH, - uint maxW, uint maxH) + public void AddChoiceRangeRectangle( + SpaKey key, + uint defaultW, + uint defaultH, + uint minW, + uint minH, + uint maxW, + uint maxH + ) { WritePropHeader(key); int start = _pos; WriteU32(0u); WriteU32(SpaType.Choice); WriteU32(SpaChoiceType.Range); - WriteU32(0u); // flags - WriteU32(8); // child.size - Rectangle = 8 bytes + WriteU32(0u); // flags + WriteU32(8); // child.size - Rectangle = 8 bytes WriteU32(SpaType.Rectangle); - WriteU32(defaultW); WriteU32(defaultH); - WriteU32(minW); WriteU32(minH); - WriteU32(maxW); WriteU32(maxH); + WriteU32(defaultW); + WriteU32(defaultH); + WriteU32(minW); + WriteU32(minH); + WriteU32(maxW); + WriteU32(maxH); PatchSize(start); Align8(); } - public void AddChoiceRangeFraction(SpaKey key, - uint defaultNum, uint defaultDenom, - uint minNum, uint minDenom, - uint maxNum, uint maxDenom) + public void AddChoiceRangeFraction( + SpaKey key, + uint defaultNum, + uint defaultDenom, + uint minNum, + uint minDenom, + uint maxNum, + uint maxDenom + ) { WritePropHeader(key); int start = _pos; WriteU32(0u); WriteU32(SpaType.Choice); WriteU32(SpaChoiceType.Range); - WriteU32(0u); // flags - WriteU32(8); // child.size - Fraction = 8 bytes + WriteU32(0u); // flags + WriteU32(8); // child.size - Fraction = 8 bytes WriteU32(SpaType.Fraction); - WriteU32(defaultNum); WriteU32(defaultDenom); - WriteU32(minNum); WriteU32(minDenom); - WriteU32(maxNum); WriteU32(maxDenom); + WriteU32(defaultNum); + WriteU32(defaultDenom); + WriteU32(minNum); + WriteU32(minDenom); + WriteU32(maxNum); + WriteU32(maxDenom); PatchSize(start); Align8(); } @@ -224,7 +288,7 @@ public void AddChoiceRangeFraction(SpaKey key, public void PushObject(SpaType objectType, SpaParamType paramId) { Push(_pos); - WriteU32(0u); // size placeholder - back-patched in Pop + WriteU32(0u); // size placeholder - back-patched in Pop WriteU32(SpaType.Object); WriteU32((uint)objectType); WriteU32((uint)paramId); @@ -251,10 +315,10 @@ public void PushObject(SpaType objectType, SpaParamType paramId) public void PushSequence(uint unit = 0) { Push(_pos); - WriteU32(0u); // size placeholder - back-patched in Pop + WriteU32(0u); // size placeholder - back-patched in Pop WriteU32(SpaType.Sequence); WriteU32(unit); - WriteU32(0u); // pad + WriteU32(0u); // pad } /// Writes a control header. The caller writes its value pod next. @@ -293,7 +357,11 @@ public void AddControl(uint offset, SpaControlType type, scoped ReadOnlySpanspa_pod_builder_bytes(b, &device_id, /// sizeof device_id)). /// - public void AddBytes(SpaKey key, scoped ReadOnlySpan data, SpaPodPropFlags propFlags = SpaPodPropFlags.None) + public void AddBytes( + SpaKey key, + scoped ReadOnlySpan data, + SpaPodPropFlags propFlags = SpaPodPropFlags.None + ) { WritePropHeader(key, propFlags); AddBytes(data); @@ -327,13 +395,15 @@ public void Pop() /// Closes anything still open and returns the complete POD bytes. public ReadOnlySpan GetPod() { - while (_depth > 0) Pop(); + while (_depth > 0) + Pop(); return _buf[.._pos]; } // - Private helpers - - private void WritePod(SpaType type, T value) where T : struct + private void WritePod(SpaType type, T value) + where T : struct { int size = Unsafe.SizeOf(); WriteU32((uint)size); @@ -348,23 +418,23 @@ private void WritePodLong(long value) { WriteU32(8); WriteU32(SpaType.Long); - WriteI64(value); // already 8-byte aligned + WriteI64(value); // already 8-byte aligned } private void WriteFraction(uint num, uint denom) { - WriteU32(8); // sizeof(spa_fraction) = 2xuint32 + WriteU32(8); // sizeof(spa_fraction) = 2xuint32 WriteU32(SpaType.Fraction); WriteU32(num); - WriteU32(denom); // already aligned + WriteU32(denom); // already aligned } private void WriteRectangle(uint width, uint height) { - WriteU32(8); // sizeof(spa_rectangle) = 2xuint32 + WriteU32(8); // sizeof(spa_rectangle) = 2xuint32 WriteU32(SpaType.Rectangle); WriteU32(width); - WriteU32(height); // already aligned + WriteU32(height); // already aligned } private void WritePropHeader(SpaKey key, SpaPodPropFlags flags = SpaPodPropFlags.None) @@ -390,7 +460,8 @@ private void PatchSize(int start) [MethodImpl(MethodImplOptions.AggressiveInlining)] // Typed overloads so the body of a writer names the SPA type it is emitting rather than // casting it to a number at every call. - private void WriteU32(SpaType value) => WriteU32((uint)value); + private void WriteU32(SpaType value) => WriteU32((uint)value); + private void WriteU32(SpaChoiceType value) => WriteU32((uint)value); private void WriteU32(uint value) @@ -417,7 +488,8 @@ private void WriteI64(long value) private void Align8() { int rem = _pos & 7; - if (rem == 0) return; + if (rem == 0) + return; int pad = 8 - rem; EnsureRoom(pad); @@ -440,11 +512,13 @@ private readonly void EnsureRoom(int bytes) private static void ThrowBufferExhausted(int needed, int left, int capacity) => throw new InvalidOperationException( $"SpaPodBuilder needs {needed} more bytes and {left} of {capacity} remain; " - + "size the buffer to the pod."); + + "size the buffer to the pod." + ); private void Push(int offset) { - if (_depth >= 8) ThrowNestingOverflow(); + if (_depth >= 8) + ThrowNestingOverflow(); _stack[_depth++] = offset; } diff --git a/src/PipeWire.NET/Spa/SpaPodCompare.cs b/src/PipeWire.NET/Spa/SpaPodCompare.cs index eaaad39..b44fb28 100644 --- a/src/PipeWire.NET/Spa/SpaPodCompare.cs +++ b/src/PipeWire.NET/Spa/SpaPodCompare.cs @@ -33,25 +33,29 @@ internal static class SpaPodCompare /// arrays - is compared the way upstream's default arm compares it, by its bytes, which can /// only say equal or not. /// - internal static int CompareValue(SpaValue a, SpaValue b) => (a, b) switch - { - (SpaBool x, SpaBool y) => (x.Value ? 1 : 0).CompareTo(y.Value ? 1 : 0), - (SpaId x, SpaId y) => x.Value.CompareTo(y.Value), - (SpaInt x, SpaInt y) => x.Value.CompareTo(y.Value), - (SpaLong x, SpaLong y) => x.Value.CompareTo(y.Value), - (SpaFloat x, SpaFloat y) => x.Value.CompareTo(y.Value), - (SpaDouble x, SpaDouble y) => x.Value.CompareTo(y.Value), - (SpaString x, SpaString y) => Math.Sign(string.CompareOrdinal(x.Value, y.Value)), - (SpaRectangle x, SpaRectangle y) => CompareRectangle(x, y), - (SpaFraction x, SpaFraction y) => - ((ulong)x.Numerator * y.Denominator).CompareTo((ulong)y.Numerator * x.Denominator), - _ => BytesEqual(a, b) ? 0 : 1, - }; + internal static int CompareValue(SpaValue a, SpaValue b) => + (a, b) switch + { + (SpaBool x, SpaBool y) => (x.Value ? 1 : 0).CompareTo(y.Value ? 1 : 0), + (SpaId x, SpaId y) => x.Value.CompareTo(y.Value), + (SpaInt x, SpaInt y) => x.Value.CompareTo(y.Value), + (SpaLong x, SpaLong y) => x.Value.CompareTo(y.Value), + (SpaFloat x, SpaFloat y) => x.Value.CompareTo(y.Value), + (SpaDouble x, SpaDouble y) => x.Value.CompareTo(y.Value), + (SpaString x, SpaString y) => Math.Sign(string.CompareOrdinal(x.Value, y.Value)), + (SpaRectangle x, SpaRectangle y) => CompareRectangle(x, y), + (SpaFraction x, SpaFraction y) => ((ulong)x.Numerator * y.Denominator).CompareTo( + (ulong)y.Numerator * x.Denominator + ), + _ => BytesEqual(a, b) ? 0 : 1, + }; private static int CompareRectangle(SpaRectangle x, SpaRectangle y) { - ulong a1 = (ulong)x.Width * x.Height, a2 = (ulong)y.Width * y.Height; - if (a1 != a2) return a1 < a2 ? -1 : 1; + ulong a1 = (ulong)x.Width * x.Height, + a2 = (ulong)y.Width * y.Height; + if (a1 != a2) + return a1 < a2 ? -1 : 1; return x.Width.CompareTo(y.Width); } @@ -67,20 +71,22 @@ private static bool BytesEqual(SpaValue a, SpaValue b) /// spa_pod_compare_is_step_of: 1 when a whole number of steps, 0 when not, /// -1 when the step itself is invalid (below 1) or the type cannot step. - internal static int IsStepOf(SpaValue value, SpaValue step) => (value, step) switch - { - (SpaInt v, SpaInt s) => s.Value < 1 ? -1 : (v.Value % s.Value == 0 ? 1 : 0), - (SpaLong v, SpaLong s) => s.Value < 1 ? -1 : (v.Value % s.Value == 0 ? 1 : 0), - (SpaRectangle v, SpaRectangle s) => s.Width < 1 || s.Height < 1 - ? -1 - : (v.Width % s.Width == 0 && v.Height % s.Height == 0 ? 1 : 0), - _ => -1, - }; + internal static int IsStepOf(SpaValue value, SpaValue step) => + (value, step) switch + { + (SpaInt v, SpaInt s) => s.Value < 1 ? -1 : (v.Value % s.Value == 0 ? 1 : 0), + (SpaLong v, SpaLong s) => s.Value < 1 ? -1 : (v.Value % s.Value == 0 ? 1 : 0), + (SpaRectangle v, SpaRectangle s) => s.Width < 1 || s.Height < 1 + ? -1 + : (v.Width % s.Width == 0 && v.Height % s.Height == 0 ? 1 : 0), + _ => -1, + }; /// spa_pod_compare_is_in_range: 1 inside, 0 outside, -1 on an invalid step. internal static int IsInRange(SpaValue value, SpaValue min, SpaValue max, SpaValue? step) { - if (CompareValue(value, min) < 0 || CompareValue(value, max) > 0) return 0; + if (CompareValue(value, min) < 0 || CompareValue(value, max) > 0) + return 0; return step is null ? 1 : IsStepOf(value, step); } @@ -90,7 +96,11 @@ internal static int IsInRange(SpaValue value, SpaValue min, SpaValue max, SpaVal /// An enumeration is tested against its members only, from index 1: index 0 is the default, and /// upstream does not count a default that is not also listed. Flags accept anything. /// - internal static bool IsValidChoice(SpaValue value, ImmutableArray alts, SpaChoiceType kind) + internal static bool IsValidChoice( + SpaValue value, + ImmutableArray alts, + SpaChoiceType kind + ) { switch (kind) { @@ -99,7 +109,8 @@ internal static bool IsValidChoice(SpaValue value, ImmutableArray alts case SpaChoiceType.Enum: for (var i = 1; i < alts.Length; i++) - if (CompareValue(value, alts[i]) == 0) return true; + if (CompareValue(value, alts[i]) == 0) + return true; return false; case SpaChoiceType.Range: @@ -118,10 +129,11 @@ internal static bool IsValidChoice(SpaValue value, ImmutableArray alts /// spa_pod_filter_flags_value: the bitwise AND of two flag values, or null when /// it is zero or the type cannot hold flags. - internal static SpaValue? AndFlags(SpaValue a, SpaValue b) => (a, b) switch - { - (SpaInt x, SpaInt y) when (x.Value & y.Value) != 0 => new SpaInt(x.Value & y.Value), - (SpaLong x, SpaLong y) when (x.Value & y.Value) != 0 => new SpaLong(x.Value & y.Value), - _ => null, - }; + internal static SpaValue? AndFlags(SpaValue a, SpaValue b) => + (a, b) switch + { + (SpaInt x, SpaInt y) when (x.Value & y.Value) != 0 => new SpaInt(x.Value & y.Value), + (SpaLong x, SpaLong y) when (x.Value & y.Value) != 0 => new SpaLong(x.Value & y.Value), + _ => null, + }; } diff --git a/src/PipeWire.NET/Spa/SpaPodProjection.cs b/src/PipeWire.NET/Spa/SpaPodProjection.cs index 453a1ac..4ccb357 100644 --- a/src/PipeWire.NET/Spa/SpaPodProjection.cs +++ b/src/PipeWire.NET/Spa/SpaPodProjection.cs @@ -52,7 +52,8 @@ internal static class SpaPodProjection SpaPodProperty? p2 = filter.Find(p1.Key); if (p2 is not null) { - if (FilterProp(p1, p2) is not { } narrowed) return null; + if (FilterProp(p1, p2) is not { } narrowed) + return null; result.Add(narrowed); } else if ((p1.Flags & SpaPodPropFlags.Mandatory) != 0) @@ -67,12 +68,18 @@ internal static class SpaPodProjection foreach (SpaPodProperty p2 in filter.Properties) { - if (candidate.Find(p2.Key) is not null) continue; - if ((p2.Flags & SpaPodPropFlags.Mandatory) != 0) return null; - if ((p2.Flags & SpaPodPropFlags.Drop) == 0) result.Add(p2); + if (candidate.Find(p2.Key) is not null) + continue; + if ((p2.Flags & SpaPodPropFlags.Mandatory) != 0) + return null; + if ((p2.Flags & SpaPodPropFlags.Drop) == 0) + result.Add(p2); } - return candidate with { Properties = result.ToImmutable() }; + return candidate with + { + Properties = result.ToImmutable(), + }; } /// @@ -103,10 +110,12 @@ internal static class SpaPodProjection (SpaChoiceType p2c, ImmutableArray alt2, _) = Values(filter); // filter.h 85-86: an empty choice is invalid. - if (alt1.IsDefaultOrEmpty || alt2.IsDefaultOrEmpty) return null; + if (alt1.IsDefaultOrEmpty || alt2.IsDefaultOrEmpty) + return null; // filter.h 94-96: incompatible property types. - if (!SpaPodCompare.SameType(alt1[0], alt2[0])) return null; + if (!SpaPodCompare.SameType(alt1[0], alt2[0])) + return null; // filter.h 110-116: prefer the filter's values, but only if the filter's own default is // valid for its own choice; otherwise the roles swap and the candidate's lead. @@ -134,8 +143,10 @@ internal static class SpaPodProjection { foreach (SpaValue a1 in alt1) { - if (SpaPodCompare.CompareValue(a1, a2) != 0) continue; - if (nCopied++ == 0) copied.Add(a1); + if (SpaPodCompare.CompareValue(a1, a2) != 0) + continue; + if (nCopied++ == 0) + copied.Add(a1); copied.Add(a1); } } @@ -144,16 +155,22 @@ internal static class SpaPodProjection { // filter.h 133-163: the candidate's values that fall inside the filter's range. The // filter's range default leads if it is itself in range and one of the candidate's. - if (alt2.Length < (p2c == SpaChoiceType.Step ? 4 : 3)) return null; - SpaValue min = alt2[1], max = alt2[2]; + if (alt2.Length < (p2c == SpaChoiceType.Step ? 4 : 3)) + return null; + SpaValue min = alt2[1], + max = alt2[2]; SpaValue? step = p2c == SpaChoiceType.Step ? alt2[3] : null; var foundDefault = false; - if (SpaPodCompare.CompareValue(alt2[0], min) >= 0 && SpaPodCompare.CompareValue(alt2[0], max) <= 0) + if ( + SpaPodCompare.CompareValue(alt2[0], min) >= 0 + && SpaPodCompare.CompareValue(alt2[0], max) <= 0 + ) { foreach (SpaValue a1 in alt1) { - if (SpaPodCompare.CompareValue(a1, alt2[0]) != 0) continue; + if (SpaPodCompare.CompareValue(a1, alt2[0]) != 0) + continue; copied.Add(a1); foundDefault = true; break; @@ -163,9 +180,12 @@ internal static class SpaPodProjection foreach (SpaValue a1 in alt1) { int inRange = SpaPodCompare.IsInRange(a1, min, max, step); - if (inRange < 0) return null; - if (inRange == 0) continue; - if (nCopied++ == 0 && !foundDefault) copied.Add(a1); + if (inRange < 0) + return null; + if (inRange == 0) + continue; + if (nCopied++ == 0 && !foundDefault) + copied.Add(a1); copied.Add(a1); } } @@ -173,16 +193,21 @@ internal static class SpaPodProjection { // filter.h 165-183: the filter's values that fall inside the candidate's range, in the // filter's order, which is how its preference wins. - if (alt1.Length < (p1c == SpaChoiceType.Step ? 4 : 3)) return null; - SpaValue min = alt1[1], max = alt1[2]; + if (alt1.Length < (p1c == SpaChoiceType.Step ? 4 : 3)) + return null; + SpaValue min = alt1[1], + max = alt1[2]; SpaValue? step = p1c == SpaChoiceType.Step ? alt1[3] : null; foreach (SpaValue a2 in alt2) { int inRange = SpaPodCompare.IsInRange(a2, min, max, step); - if (inRange < 0) return null; - if (inRange == 0) continue; - if (nCopied++ == 0) copied.Add(a2); + if (inRange < 0) + return null; + if (inRange == 0) + continue; + if (nCopied++ == 0) + copied.Add(a2); copied.Add(a2); } } @@ -192,28 +217,39 @@ internal static class SpaPodProjection // lands in the overlap, else the candidate's, else the overlap's minimum. The answer is // always a plain range: upstream writes default/min/max and marks it Range even when // either side was a Step. - if (alt1.Length < 3 || alt2.Length < 3) return null; + if (alt1.Length < 3 || alt2.Length < 3) + return null; SpaValue min = SpaPodCompare.CompareValue(alt1[1], alt2[1]) < 0 ? alt2[1] : alt1[1]; SpaValue max = SpaPodCompare.CompareValue(alt2[2], alt1[2]) < 0 ? alt2[2] : alt1[2]; - if (SpaPodCompare.CompareValue(max, min) < 0) return null; + if (SpaPodCompare.CompareValue(max, min) < 0) + return null; SpaValue def = alt2[0]; int inRange = SpaPodCompare.IsInRange(def, min, max, null); - if (inRange < 0) return null; + if (inRange < 0) + return null; if (inRange == 0) { def = alt1[0]; inRange = SpaPodCompare.IsInRange(def, min, max, null); - if (inRange < 0) return null; - if (inRange == 0) def = min; + if (inRange < 0) + return null; + if (inRange == 0) + def = min; } return new SpaChoice(SpaChoiceType.Range, childType, [def, min, max]); } - else if ((p1c, p2c) is (SpaChoiceType.None, SpaChoiceType.Flags) - or (SpaChoiceType.Flags, SpaChoiceType.None) - or (SpaChoiceType.Flags, SpaChoiceType.Flags)) + else if ( + (p1c, p2c) + is + (SpaChoiceType.None, SpaChoiceType.Flags) + or + (SpaChoiceType.Flags, SpaChoiceType.None) + or + (SpaChoiceType.Flags, SpaChoiceType.Flags) + ) { // filter.h 225-232: the AND of the two flag sets; nothing in common is a refusal. return SpaPodCompare.AndFlags(alt1[0], alt2[0]) is { } flags @@ -237,7 +273,9 @@ internal static class SpaPodProjection /// spa_pod_get_values: a value's alternatives, choice kind and child type. /// A bare value is a choice of kind None with itself as the one alternative. - private static (SpaChoiceType Kind, ImmutableArray Alts, SpaType ChildType) Values(SpaValue value) => + private static (SpaChoiceType Kind, ImmutableArray Alts, SpaType ChildType) Values( + SpaValue value + ) => value is SpaChoice choice ? (choice.Kind, choice.Alternatives, choice.ChildType) : (SpaChoiceType.None, [value], value.Type); diff --git a/src/PipeWire.NET/Spa/SpaPodReader.cs b/src/PipeWire.NET/Spa/SpaPodReader.cs index 3fe2c61..aee388d 100644 --- a/src/PipeWire.NET/Spa/SpaPodReader.cs +++ b/src/PipeWire.NET/Spa/SpaPodReader.cs @@ -46,16 +46,23 @@ public SpaPodReader(ReadOnlySpan buffer) /// public bool EnterObject(out uint objectType, out uint objectId, out uint bodySize) { - objectType = 0; objectId = 0; bodySize = 0; - if (!TryReadHeader(out uint size, out SpaType type)) return false; - if (type != SpaType.Object) return false; + objectType = 0; + objectId = 0; + bodySize = 0; + if (!TryReadHeader(out uint size, out SpaType type)) + return false; + if (type != SpaType.Object) + return false; // An object body always carries at least its type and id. Without this check `size - 8` // underflows for a malformed pod and reports a body of nearly 4GB. - if (size < 8) return false; + if (size < 8) + return false; - if (!TryReadU32(out objectType)) return false; - if (!TryReadU32(out objectId)) return false; + if (!TryReadU32(out objectType)) + return false; + if (!TryReadU32(out objectId)) + return false; bodySize = size - 8; // size includes the object-type + object-id 8 bytes already consumed // Property iteration stops at the object's own end, not the buffer's. An object nested in a @@ -90,22 +97,27 @@ public bool TryReadProperty(out SpaKey key, out SpaPodPropFlags flags, out SpaPo // spa_pod_prop header = [uint32 key][uint32 flags][value pod...] int end = _objectEnd ?? _buf.Length; - if (_pos + 8 > end) return false; - if (!TryReadU32(out uint rawKey)) return false; + if (_pos + 8 > end) + return false; + if (!TryReadU32(out uint rawKey)) + return false; key = SpaKey.FromRaw(rawKey); - if (!TryReadU32(out uint rawFlags)) return false; + if (!TryReadU32(out uint rawFlags)) + return false; flags = (SpaPodPropFlags)rawFlags; // The value pod sits at the current offset. Peek its size. - if (_pos + 8 > end) return false; + if (_pos + 8 > end) + return false; // Checked against what is left before any arithmetic. Casting first and checking after is // not enough: a size of uint.MaxValue casts to -1 and passes the bounds test, and one near // int.MaxValue overflows the addition to a negative length that then throws out of Slice - // from a parser whose whole contract is that malformed input returns false. uint vSize = MemoryMarshal.Read(_buf.Slice(_pos, 4)); - if (vSize > (uint)(end - _pos - 8)) return false; + if (vSize > (uint)(end - _pos - 8)) + return false; - int valueLen = 8 + (int)vSize; // pod header + body + int valueLen = 8 + (int)vSize; // pod header + body int valueLenAligned = (valueLen + 7) & ~7; value = new SpaPodReader(_buf.Slice(_pos, valueLen)); @@ -119,7 +131,8 @@ public int ReadInt() { ReadHeaderOrThrow(SpaType.Int, expectedSize: 4); int v = MemoryMarshal.Read(_buf.Slice(_pos, 4)); - _pos += 4; AlignTo8(); + _pos += 4; + AlignTo8(); return v; } @@ -135,7 +148,8 @@ public float ReadFloat() { ReadHeaderOrThrow(SpaType.Float, expectedSize: 4); float v = MemoryMarshal.Read(_buf.Slice(_pos, 4)); - _pos += 4; AlignTo8(); + _pos += 4; + AlignTo8(); return v; } @@ -151,7 +165,8 @@ public bool ReadBool() { ReadHeaderOrThrow(SpaType.Bool, expectedSize: 4); int v = MemoryMarshal.Read(_buf.Slice(_pos, 4)); - _pos += 4; AlignTo8(); + _pos += 4; + AlignTo8(); return v != 0; } @@ -159,23 +174,28 @@ public SpaIdValue ReadId() { ReadHeaderOrThrow(SpaType.Id, expectedSize: 4); uint v = MemoryMarshal.Read(_buf.Slice(_pos, 4)); - _pos += 4; AlignTo8(); + _pos += 4; + AlignTo8(); return SpaIdValue.FromRaw(v); } public (uint Width, uint Height) ReadRectangle() { ReadHeaderOrThrow(SpaType.Rectangle, expectedSize: 8); - uint w = MemoryMarshal.Read(_buf.Slice(_pos, 4)); _pos += 4; - uint h = MemoryMarshal.Read(_buf.Slice(_pos, 4)); _pos += 4; + uint w = MemoryMarshal.Read(_buf.Slice(_pos, 4)); + _pos += 4; + uint h = MemoryMarshal.Read(_buf.Slice(_pos, 4)); + _pos += 4; return (w, h); } public (uint Numerator, uint Denominator) ReadFraction() { ReadHeaderOrThrow(SpaType.Fraction, expectedSize: 8); - uint n = MemoryMarshal.Read(_buf.Slice(_pos, 4)); _pos += 4; - uint d = MemoryMarshal.Read(_buf.Slice(_pos, 4)); _pos += 4; + uint n = MemoryMarshal.Read(_buf.Slice(_pos, 4)); + _pos += 4; + uint d = MemoryMarshal.Read(_buf.Slice(_pos, 4)); + _pos += 4; return (n, d); } @@ -190,7 +210,8 @@ public bool TryReadBytes(out ReadOnlySpan bytes) // Choice (None for a single match). if (_synthesizedType is { } synthesized) { - if (synthesized != SpaType.Bytes) return false; + if (synthesized != SpaType.Bytes) + return false; bytes = _buf[_pos..]; _pos = _buf.Length; return true; @@ -222,26 +243,62 @@ public bool TryReadModifier(out long first, out int count) first = 0; count = 0; int savedPos = _pos; - if (!TryReadHeader(out uint size, out SpaType type)) { _pos = savedPos; return false; } + if (!TryReadHeader(out uint size, out SpaType type)) + { + _pos = savedPos; + return false; + } if (type == SpaType.Long) { - if (size < 8 || _pos + 8 > _buf.Length) { _pos = savedPos; return false; } + if (size < 8 || _pos + 8 > _buf.Length) + { + _pos = savedPos; + return false; + } first = MemoryMarshal.Read(_buf.Slice(_pos, 8)); _pos += 8; count = 1; return true; } - if (type != SpaType.Choice) { _pos = savedPos; return false; } + if (type != SpaType.Choice) + { + _pos = savedPos; + return false; + } // spa_pod_choice_body: [choiceType][flags][childSize][childType], then the values. - if (_pos + 16 > _buf.Length) { _pos = savedPos; return false; } - if (!TryReadU32(out uint choiceType)) { _pos = savedPos; return false; } - if (!TryReadU32(out _)) { _pos = savedPos; return false; } // flags - if (!TryReadU32(out uint childSize)) { _pos = savedPos; return false; } - if (!TryReadU32(out uint childType)) { _pos = savedPos; return false; } - if ((SpaType)childType != SpaType.Long || childSize != 8) { _pos = savedPos; return false; } + if (_pos + 16 > _buf.Length) + { + _pos = savedPos; + return false; + } + if (!TryReadU32(out uint choiceType)) + { + _pos = savedPos; + return false; + } + if (!TryReadU32(out _)) + { + _pos = savedPos; + return false; + } // flags + if (!TryReadU32(out uint childSize)) + { + _pos = savedPos; + return false; + } + if (!TryReadU32(out uint childType)) + { + _pos = savedPos; + return false; + } + if ((SpaType)childType != SpaType.Long || childSize != 8) + { + _pos = savedPos; + return false; + } // Which kind of choice it is decides what the values mean. Enum is { default, alt... } and // None is a single value; Range is { default, min, max } and Step adds a stride, and reading @@ -256,7 +313,11 @@ public bool TryReadModifier(out long first, out int count) // The choice body length (size) covers the 16-byte header + N child values. Compared // unsigned before the cast, like every other bound in this file: size is the producer's // word, and casting first is what turns a large one into a negative length. - if (size < 16 + 8u) { _pos = savedPos; return false; } + if (size < 16 + 8u) + { + _pos = savedPos; + return false; + } uint valuesLen = size - 16; if (valuesLen % 8 != 0 || valuesLen > (uint)(_buf.Length - _pos)) { @@ -293,11 +354,13 @@ public bool TryUnwrapChoice(out SpaPodReader first) // Every exit below restores the position. The caller falls back to a plain typed read on // this same reader when a choice is declined, so leaving the position moved does not fail, // it silently reads the wrong bytes as the value. - if (_pos + 16 > _buf.Length - || !TryReadU32(out _) // choiceType - || !TryReadU32(out _) // flags + if ( + _pos + 16 > _buf.Length + || !TryReadU32(out _) // choiceType + || !TryReadU32(out _) // flags || !TryReadU32(out uint childSize) - || !TryReadU32(out uint childType)) + || !TryReadU32(out uint childType) + ) { _pos = savedPos; return false; @@ -331,15 +394,18 @@ public bool TryUnwrapChoice(out SpaPodReader first) private bool TryReadHeader(out uint size, out SpaType type) { - size = 0; type = 0; - if (_pos + 8 > _buf.Length) return false; + size = 0; + type = 0; + if (_pos + 8 > _buf.Length) + return false; uint declared = MemoryMarshal.Read(_buf.Slice(_pos, 4)); uint declaredType = MemoryMarshal.Read(_buf.Slice(_pos + 4, 4)); // The size field is attacker- or bug-controlled, so a pod claiming more body than the // buffer holds is rejected here rather than handed on as a length someone slices with. - if (declared > (uint)(_buf.Length - _pos - 8)) return false; + if (declared > (uint)(_buf.Length - _pos - 8)) + return false; size = declared; type = (SpaType)declaredType; @@ -355,7 +421,8 @@ private void ReadHeaderOrThrow(SpaType expectedType, uint expectedSize) { if (synthesized != expectedType) throw new InvalidOperationException( - $"SPA pod type mismatch: expected {expectedType}, got synthesized {synthesized}"); + $"SPA pod type mismatch: expected {expectedType}, got synthesized {synthesized}" + ); // The body came from a choice's childSize, which the producer chose. A short one - // an Id choice declaring one byte per child - would otherwise reach the Slice in the @@ -364,7 +431,8 @@ private void ReadHeaderOrThrow(SpaType expectedType, uint expectedSize) if ((uint)(_buf.Length - _pos) < expectedSize) throw new InvalidOperationException( $"SPA pod size mismatch: expected {expectedSize}, " - + $"synthesized body holds {_buf.Length - _pos}"); + + $"synthesized body holds {_buf.Length - _pos}" + ); return; } @@ -372,16 +440,22 @@ private void ReadHeaderOrThrow(SpaType expectedType, uint expectedSize) throw new InvalidOperationException("Truncated SPA pod."); if (type != expectedType) throw new InvalidOperationException( - $"SPA pod type mismatch: expected {expectedType}, got {type}"); + $"SPA pod type mismatch: expected {expectedType}, got {type}" + ); if (size != expectedSize) throw new InvalidOperationException( - $"SPA pod size mismatch: expected {expectedSize}, got {size}"); + $"SPA pod size mismatch: expected {expectedSize}, got {size}" + ); } [MethodImpl(MethodImplOptions.AggressiveInlining)] private bool TryReadU32(out uint value) { - if (_pos + 4 > _buf.Length) { value = 0; return false; } + if (_pos + 4 > _buf.Length) + { + value = 0; + return false; + } value = MemoryMarshal.Read(_buf.Slice(_pos, 4)); _pos += 4; return true; @@ -391,6 +465,7 @@ private bool TryReadU32(out uint value) private void AlignTo8() { int rem = _pos & 7; - if (rem != 0) _pos += 8 - rem; + if (rem != 0) + _pos += 8 - rem; } } diff --git a/src/PipeWire.NET/Spa/SpaValue.cs b/src/PipeWire.NET/Spa/SpaValue.cs index 6b45dbe..69de777 100644 --- a/src/PipeWire.NET/Spa/SpaValue.cs +++ b/src/PipeWire.NET/Spa/SpaValue.cs @@ -135,7 +135,8 @@ private static string Validated(string value) { throw new ArgumentException( "a SPA string is NUL-terminated on the wire, so it cannot contain a NUL.", - nameof(value)); + nameof(value) + ); } return value; @@ -195,7 +196,8 @@ public sealed record SpaBitmap(ImmutableArray Bits) : SpaValue public sealed record SpaArray(SpaType ChildType, ImmutableArray Items) : SpaValue { /// The items. - public ImmutableArray Items { get; init; } = Coherent(ChildType, Items, nameof(Items)); + public ImmutableArray Items { get; init; } = + Coherent(ChildType, Items, nameof(Items)); /// public override SpaType Type => SpaType.Array; @@ -207,16 +209,23 @@ public sealed record SpaArray(SpaType ChildType, ImmutableArray Items) /// the item is truncated or padded to fit and the failure is a wrong value rather than an error. /// internal static ImmutableArray Coherent( - SpaType childType, ImmutableArray items, string parameterName) + SpaType childType, + ImmutableArray items, + string parameterName + ) { - if (items.IsDefaultOrEmpty) return items; + if (items.IsDefaultOrEmpty) + return items; foreach (SpaValue item in items) { - if (item.Type == childType) continue; + if (item.Type == childType) + continue; throw new ArgumentException( - $"every child must be {childType}; found a {item.Type}.", parameterName); + $"every child must be {childType}; found a {item.Type}.", + parameterName + ); } return items; @@ -224,7 +233,8 @@ internal static ImmutableArray Coherent( /// public bool Equals(SpaArray? other) => - other is not null && ChildType == other.ChildType + other is not null + && ChildType == other.ChildType && SpaValueEquality.SequenceEqual(Items, other.Items); /// @@ -260,7 +270,11 @@ public sealed record SpaPodProperty(SpaKey Key, SpaPodPropFlags Flags, SpaValue /// What the object describes, such as . /// Which parameter it answers. /// The properties, in wire order. -public sealed record SpaObject(SpaType ObjectType, SpaParamType ObjectId, ImmutableArray Properties) : SpaValue +public sealed record SpaObject( + SpaType ObjectType, + SpaParamType ObjectId, + ImmutableArray Properties +) : SpaValue { /// public override SpaType Type => SpaType.Object; @@ -288,7 +302,9 @@ public sealed record SpaObject(SpaType ObjectType, SpaParamType ObjectId, Immuta /// public bool Equals(SpaObject? other) => - other is not null && ObjectType == other.ObjectType && ObjectId == other.ObjectId + other is not null + && ObjectType == other.ObjectType + && ObjectId == other.ObjectId && SpaValueEquality.SequenceEqual(Properties, other.Properties); /// @@ -304,20 +320,25 @@ public override int GetHashCode() => /// the two after it are the minimum and maximum. /// /// An alternative is not of . -public sealed record SpaChoice(SpaChoiceType Kind, SpaType ChildType, ImmutableArray Alternatives) : SpaValue +public sealed record SpaChoice( + SpaChoiceType Kind, + SpaType ChildType, + ImmutableArray Alternatives +) : SpaValue { /// The alternatives. public ImmutableArray Alternatives { get; init; } = Arity(Kind, SpaArray.Coherent(ChildType, Alternatives, nameof(Alternatives))); /// How many values the kind means, or 0 for the kinds that are lists. - internal static int RequiredCount(SpaChoiceType kind) => kind switch - { - SpaChoiceType.None => 1, - SpaChoiceType.Range => 3, - SpaChoiceType.Step => 4, - _ => 0, - }; + internal static int RequiredCount(SpaChoiceType kind) => + kind switch + { + SpaChoiceType.None => 1, + SpaChoiceType.Range => 3, + SpaChoiceType.Step => 4, + _ => 0, + }; /// Whether a count is one the kind can mean. The parser's form of the check. /// @@ -333,15 +354,20 @@ internal static bool CountFitsKind(SpaChoiceType kind, int count) } private static ImmutableArray Arity( - SpaChoiceType kind, ImmutableArray alternatives) + SpaChoiceType kind, + ImmutableArray alternatives + ) { - if (alternatives.IsDefaultOrEmpty) return alternatives; - if (CountFitsKind(kind, alternatives.Length)) return alternatives; + if (alternatives.IsDefaultOrEmpty) + return alternatives; + if (CountFitsKind(kind, alternatives.Length)) + return alternatives; throw new ArgumentException( $"a {kind} choice carries exactly {RequiredCount(kind)} values; this one has " - + $"{alternatives.Length}.", - nameof(alternatives)); + + $"{alternatives.Length}.", + nameof(alternatives) + ); } /// @@ -352,7 +378,9 @@ private static ImmutableArray Arity( /// public bool Equals(SpaChoice? other) => - other is not null && Kind == other.Kind && ChildType == other.ChildType + other is not null + && Kind == other.Kind + && ChildType == other.ChildType && SpaValueEquality.SequenceEqual(Alternatives, other.Alternatives); /// @@ -376,7 +404,8 @@ public sealed record SpaSequence(uint Unit, ImmutableArray Controls) /// public bool Equals(SpaSequence? other) => - other is not null && Unit == other.Unit + other is not null + && Unit == other.Unit && SpaValueEquality.SequenceEqual(Controls, other.Controls); /// @@ -434,7 +463,8 @@ public sealed record SpaUnknown(SpaType UnknownType, ImmutableArray Body) /// public bool Equals(SpaUnknown? other) => - other is not null && UnknownType == other.UnknownType + other is not null + && UnknownType == other.UnknownType && SpaValueEquality.SequenceEqual(Body, other.Body); /// diff --git a/src/PipeWire.NET/Spa/SpaValueEquality.cs b/src/PipeWire.NET/Spa/SpaValueEquality.cs index 6209cf6..4cf38e2 100644 --- a/src/PipeWire.NET/Spa/SpaValueEquality.cs +++ b/src/PipeWire.NET/Spa/SpaValueEquality.cs @@ -17,10 +17,12 @@ internal static class SpaValueEquality /// Compares two arrays element by element, treating default and empty as equal. internal static bool SequenceEqual(ImmutableArray left, ImmutableArray? right) { - if (right is not { } other) return false; + if (right is not { } other) + return false; if (left.IsDefaultOrEmpty || other.IsDefaultOrEmpty) return left.IsDefaultOrEmpty && other.IsDefaultOrEmpty; - if (left.Length != other.Length) return false; + if (left.Length != other.Length) + return false; for (int i = 0; i < left.Length; i++) { @@ -39,7 +41,8 @@ internal static bool SequenceEqual(ImmutableArray left, ImmutableArray? /// internal static int Combine(ImmutableArray values) { - if (values.IsDefaultOrEmpty) return 0; + if (values.IsDefaultOrEmpty) + return 0; var hash = new HashCode(); hash.Add(values.Length); diff --git a/tests/PipeWire.NET.Tests/AudioRoundTripTests.cs b/tests/PipeWire.NET.Tests/AudioRoundTripTests.cs index 9bb6da6..6dea6a7 100644 --- a/tests/PipeWire.NET.Tests/AudioRoundTripTests.cs +++ b/tests/PipeWire.NET.Tests/AudioRoundTripTests.cs @@ -30,7 +30,9 @@ private static void RequireLinux() } private static async Task<(PipeWireContext Context, PipeWireRegistry Registry)> ConnectAsync( - string name, CancellationToken ct) + string name, + CancellationToken ct + ) { var context = new PipeWireContext(name, ConsoleTestLoggerFactory.Instance); await context.StartAsync(ct); @@ -40,7 +42,10 @@ private static void RequireLinux() } private static async Task WaitForAsync( - PipeWireRegistry registry, Func until, CancellationToken ct) + PipeWireRegistry registry, + Func until, + CancellationToken ct + ) { await foreach (PipeWireGraphSnapshot graph in registry.WatchAsync(ct)) if (until(graph)) @@ -60,7 +65,10 @@ public async Task AProducerReturningAPartialFrame_PublishesOnlyWholeFrames() RequireLinux(); using var cts = new CancellationTokenSource(Budget); - await using var ctx = new PipeWireContext("pwnet-ragged", ConsoleTestLoggerFactory.Instance); + await using var ctx = new PipeWireContext( + "pwnet-ragged", + ConsoleTestLoggerFactory.Instance + ); await ctx.StartAsync(cts.Token); await using var reg = new PipeWireRegistry(ctx); await reg.WaitForInitialEnumerationAsync(cts.Token); @@ -76,10 +84,12 @@ public async Task AProducerReturningAPartialFrame_PublishesOnlyWholeFrames() await using var output = new PipeWireAudioOutput(ctx, nodeName, Rate, Channels, Format); output.FillSamples += (_, samples, _, _, _) => { - for (int i = 0; i < samples.Length; i++) samples[i] = (byte)(i % 3 == 0 ? 0x01 : 0x00); + for (int i = 0; i < samples.Length; i++) + samples[i] = (byte)(i % 3 == 0 ? 0x01 : 0x00); int ragged = Math.Min(samples.Length, (frameBytes * 8) + (frameBytes / 2)); - if (ragged % frameBytes != 0) Interlocked.Increment(ref askedRagged); + if (ragged % frameBytes != 0) + Interlocked.Increment(ref askedRagged); return ragged; }; @@ -91,7 +101,10 @@ public async Task AProducerReturningAPartialFrame_PublishesOnlyWholeFrames() var received = 0; var ragged = 0; - await using var capture = new PipeWireAudioCapture(ctx, $"pwnet-ragged-sink-{Environment.ProcessId}"); + await using var capture = new PipeWireAudioCapture( + ctx, + $"pwnet-ragged-sink-{Environment.ProcessId}" + ); capture.FrameReady += (_, frame) => { Interlocked.Increment(ref received); @@ -105,10 +118,15 @@ public async Task AProducerReturningAPartialFrame_PublishesOnlyWholeFrames() await WaitForCountAsync(() => Volatile.Read(ref received), 5, cts.Token); - Assert.IsTrue(Volatile.Read(ref askedRagged) > 0, - "the producer never actually returned a partial frame, so nothing was exercised"); - Assert.AreEqual(0, Volatile.Read(ref ragged), - $"{ragged} buffers reached the consumer without being a whole number of frames"); + Assert.IsTrue( + Volatile.Read(ref askedRagged) > 0, + "the producer never actually returned a partial frame, so nothing was exercised" + ); + Assert.AreEqual( + 0, + Volatile.Read(ref ragged), + $"{ragged} buffers reached the consumer without being a whole number of frames" + ); } [TestMethod] @@ -117,11 +135,17 @@ public async Task AProducerReturningAPartialFrame_PublishesOnlyWholeFrames() [DataRow(48000, 1, AudioSampleFormat.F32Le)] [DataRow(48000, 2, AudioSampleFormat.S16Le)] public async Task PublishedAudio_ArrivesAtAConsumerWithTheSameShape( - int rate, int channels, AudioSampleFormat format) + int rate, + int channels, + AudioSampleFormat format + ) { RequireLinux(); using var cts = new CancellationTokenSource(Budget); - (PipeWireContext ctx, PipeWireRegistry reg) = await ConnectAsync($"pwnet-art-{rate}-{channels}", cts.Token); + (PipeWireContext ctx, PipeWireRegistry reg) = await ConnectAsync( + $"pwnet-art-{rate}-{channels}", + cts.Token + ); await using (ctx) await using (reg) @@ -135,7 +159,8 @@ public async Task PublishedAudio_ArrivesAtAConsumerWithTheSameShape( // A recognisable pattern rather than silence, so a consumer reading zeroes because // nothing was routed is distinguishable from real audio. Deliberately tiny: if this // ever reaches a real device it should be inaudible, not a burst of noise. - for (int i = 0; i < samples.Length; i++) samples[i] = (byte)(i % 3 == 0 ? 0x01 : 0x00); + for (int i = 0; i < samples.Length; i++) + samples[i] = (byte)(i % 3 == 0 ? 0x01 : 0x00); Interlocked.Increment(ref filled); return samples.Length; }; @@ -150,10 +175,14 @@ public async Task PublishedAudio_ArrivesAtAConsumerWithTheSameShape( var received = 0; var ragged = 0; var allZero = 0; - int seenRate = 0, seenChannels = 0; + int seenRate = 0, + seenChannels = 0; AudioSampleFormat? seenFormat = null; - await using var capture = new PipeWireAudioCapture(ctx, $"pwnet-art-sink-{rate}-{channels}"); + await using var capture = new PipeWireAudioCapture( + ctx, + $"pwnet-art-sink-{rate}-{channels}" + ); capture.FrameReady += (_, frame) => { Interlocked.Increment(ref received); @@ -166,8 +195,16 @@ public async Task PublishedAudio_ArrivesAtAConsumerWithTheSameShape( Interlocked.Increment(ref ragged); bool nonZero = false; - foreach (byte b in frame.Samples) { if (b != 0) { nonZero = true; break; } } - if (!nonZero && frame.Samples.Length > 0) Interlocked.Increment(ref allZero); + foreach (byte b in frame.Samples) + { + if (b != 0) + { + nonZero = true; + break; + } + } + if (!nonZero && frame.Samples.Length > 0) + Interlocked.Increment(ref allZero); }; capture.Connect((await output.WaitForNodeIdAsync(cts.Token)), rate, channels, format); @@ -176,11 +213,17 @@ public async Task PublishedAudio_ArrivesAtAConsumerWithTheSameShape( Assert.IsTrue(filled > 0, "the producer was never asked for samples"); Assert.AreEqual(rate, seenRate, "the consumer negotiated a different rate"); - Assert.AreEqual(channels, seenChannels, "the consumer negotiated a different channel count"); + Assert.AreEqual( + channels, + seenChannels, + "the consumer negotiated a different channel count" + ); Assert.AreEqual(format, seenFormat); Assert.AreEqual(0, ragged, $"{ragged} buffers were not a whole number of frames"); - Assert.IsTrue(allZero < received, - "every buffer was silence, so nothing was actually routed from the producer"); + Assert.IsTrue( + allZero < received, + "every buffer was silence, so nothing was actually routed from the producer" + ); } } @@ -189,20 +232,29 @@ public async Task APublishedNode_IsARoutableGraphNodeLikeAnyOther() { RequireLinux(); using var cts = new CancellationTokenSource(Budget); - (PipeWireContext ctx, PipeWireRegistry reg) = await ConnectAsync("pwnet-art-routable", cts.Token); + (PipeWireContext ctx, PipeWireRegistry reg) = await ConnectAsync( + "pwnet-art-routable", + cts.Token + ); await using (ctx) await using (reg) { await using var output = new PipeWireAudioOutput(ctx, "pwnet_art_routable"); - output.FillSamples += (_, s, _, _, _) => { s.Clear(); return s.Length; }; + output.FillSamples += (_, s, _, _, _) => + { + s.Clear(); + return s.Length; + }; output.Connect(autoConnect: false); PipeWireGraphSnapshot graph = await WaitForAsync( reg, - g => g.Nodes.Any(n => n.NodeName == "pwnet_art_routable") - && g.GetPortsForNode(output.NodeId ?? 0).Length > 0, - cts.Token); + g => + g.Nodes.Any(n => n.NodeName == "pwnet_art_routable") + && g.GetPortsForNode(output.NodeId ?? 0).Length > 0, + cts.Token + ); uint id = (await output.WaitForNodeIdAsync(cts.Token)); PipeWireNode node = graph.GetNode(id)!; @@ -217,14 +269,22 @@ public async Task APublishedNode_IsARoutableGraphNodeLikeAnyOther() // And it must be linkable to a node we create separately. PipeWireNode sink = await reg.CreateVirtualSink("Sink") - .WithName("pwnet_art_sink").ExecuteAsync(cts.Token); + .WithName("pwnet_art_sink") + .ExecuteAsync(cts.Token); PipeWireGraphSnapshot ready = await WaitForAsync( - reg, g => g.GetPortsForNode(sink.NodeId).Length == 4, cts.Token); - - PipeWirePort from = ready.GetPortsForNode(id, PipeWirePortDirection.Out) - .OrderBy(p => p.PortId).First(); - PipeWirePort to = ready.GetPortsForNode(sink.NodeId, PipeWirePortDirection.In) - .OrderBy(p => p.PortId).First(); + reg, + g => g.GetPortsForNode(sink.NodeId).Length == 4, + cts.Token + ); + + PipeWirePort from = ready + .GetPortsForNode(id, PipeWirePortDirection.Out) + .OrderBy(p => p.PortId) + .First(); + PipeWirePort to = ready + .GetPortsForNode(sink.NodeId, PipeWirePortDirection.In) + .OrderBy(p => p.PortId) + .First(); PipeWireLink link = await reg.CreateLink(from, to).ExecuteAsync(cts.Token); Assert.IsNotNull(reg.Current.GetLink(link.LinkId)); @@ -236,17 +296,28 @@ public async Task AProducerThatWritesNothing_IsTreatedAsSilenceNotAsAFailure() { RequireLinux(); using var cts = new CancellationTokenSource(Budget); - (PipeWireContext ctx, PipeWireRegistry reg) = await ConnectAsync("pwnet-art-silent", cts.Token); + (PipeWireContext ctx, PipeWireRegistry reg) = await ConnectAsync( + "pwnet-art-silent", + cts.Token + ); await using (ctx) await using (reg) { var asked = 0; await using var output = new PipeWireAudioOutput(ctx, "pwnet_art_silent"); - output.FillSamples += (_, _, _, _, _) => { Interlocked.Increment(ref asked); return 0; }; + output.FillSamples += (_, _, _, _, _) => + { + Interlocked.Increment(ref asked); + return 0; + }; output.Connect(autoConnect: false); - await WaitForAsync(reg, g => g.Nodes.Any(n => n.NodeName == "pwnet_art_silent"), cts.Token); + await WaitForAsync( + reg, + g => g.Nodes.Any(n => n.NodeName == "pwnet_art_silent"), + cts.Token + ); var received = 0; await using var capture = new PipeWireAudioCapture(ctx, "pwnet-art-silent-sink"); @@ -266,7 +337,10 @@ public async Task AProducerWhoseCallbackThrows_DoesNotKillTheStream() { RequireLinux(); using var cts = new CancellationTokenSource(Budget); - (PipeWireContext ctx, PipeWireRegistry reg) = await ConnectAsync("pwnet-art-throw", cts.Token); + (PipeWireContext ctx, PipeWireRegistry reg) = await ConnectAsync( + "pwnet-art-throw", + cts.Token + ); await using (ctx) await using (reg) @@ -281,7 +355,11 @@ public async Task AProducerWhoseCallbackThrows_DoesNotKillTheStream() }; output.Connect(autoConnect: false); - await WaitForAsync(reg, g => g.Nodes.Any(n => n.NodeName == "pwnet_art_throw"), cts.Token); + await WaitForAsync( + reg, + g => g.Nodes.Any(n => n.NodeName == "pwnet_art_throw"), + cts.Token + ); // An unrouted output is never driven, so nothing would ask it for samples and the test // would pass without exercising anything. A consumer pulling from it is what makes the @@ -291,23 +369,28 @@ public async Task AProducerWhoseCallbackThrows_DoesNotKillTheStream() await WaitForCountAsync(() => Volatile.Read(ref asked), 5, cts.Token); - Assert.IsTrue(asked >= 5, - $"the stream stopped after {asked} throwing fills; it must survive a bad producer"); + Assert.IsTrue( + asked >= 5, + $"the stream stopped after {asked} throwing fills; it must survive a bad producer" + ); // Surviving is half of it. A swallowed exception nothing can read leaves a handler that // throws every cycle looking exactly like one that published silence, which is the // hardest kind of bug to find in somebody else's application. Assert.IsNotNull( output.LastProcessError, - "the fill handler threw every cycle and the stream reported no error at all"); + "the fill handler threw every cycle and the stream reported no error at all" + ); Assert.IsInstanceOfType( output.LastProcessError, - "the recorded error is not the one the handler threw"); + "the recorded error is not the one the handler threw" + ); Assert.IsTrue( output.ProcessErrorCount >= 5, - $"{asked} fills threw but only {output.ProcessErrorCount} were counted"); + $"{asked} fills threw but only {output.ProcessErrorCount} were counted" + ); } } @@ -316,17 +399,26 @@ public async Task ConnectingTwice_IsRefused() { RequireLinux(); using var cts = new CancellationTokenSource(Budget); - (PipeWireContext ctx, PipeWireRegistry reg) = await ConnectAsync("pwnet-art-twice", cts.Token); + (PipeWireContext ctx, PipeWireRegistry reg) = await ConnectAsync( + "pwnet-art-twice", + cts.Token + ); await using (ctx) await using (reg) { await using var output = new PipeWireAudioOutput(ctx, "pwnet_art_twice"); - output.FillSamples += (_, s, _, _, _) => { s.Clear(); return s.Length; }; + output.FillSamples += (_, s, _, _, _) => + { + s.Clear(); + return s.Length; + }; output.Connect(autoConnect: false); - Assert.ThrowsExactly(() => output.Connect(), - "a second Connect would leak the first stream"); + Assert.ThrowsExactly( + () => output.Connect(), + "a second Connect would leak the first stream" + ); await using var capture = new PipeWireAudioCapture(ctx, "pwnet-art-twice-sink"); capture.Connect(); @@ -342,18 +434,25 @@ public async Task ConnectingTwice_IsRefused() public async Task NonsenseStreamGeometry_IsRefusedAtConstruction(int rate, int channels) { RequireLinux(); - await using var ctx = new PipeWireContext("pwnet-art-bad", ConsoleTestLoggerFactory.Instance); + await using var ctx = new PipeWireContext( + "pwnet-art-bad", + ConsoleTestLoggerFactory.Instance + ); // Caught before any native call, so a bad value never reaches the daemon. - Assert.ThrowsExactly( - () => new PipeWireAudioOutput(ctx, "pwnet_art_bad", rate, channels)); + Assert.ThrowsExactly(() => + new PipeWireAudioOutput(ctx, "pwnet_art_bad", rate, channels) + ); } [TestMethod] public async Task AStreamRequiresAContextAndAName() { RequireLinux(); - await using var ctx = new PipeWireContext("pwnet-art-null", ConsoleTestLoggerFactory.Instance); + await using var ctx = new PipeWireContext( + "pwnet-art-null", + ConsoleTestLoggerFactory.Instance + ); Assert.ThrowsExactly(() => new PipeWireAudioOutput(null!, "n")); Assert.ThrowsExactly(() => new PipeWireAudioOutput(ctx, "")); @@ -392,14 +491,19 @@ public async Task TwoStreamsOnOneContext_SeeTheSameGraphClock() output.FillSamples += (_, _, _, _, _) => 0; output.Connect(autoConnect: false); - await WaitForAsync(reg, g => g.Nodes.Any(n => n.NodeName == "pwnet_clock_out"), cts.Token); + await WaitForAsync( + reg, + g => g.Nodes.Any(n => n.NodeName == "pwnet_clock_out"), + cts.Token + ); await using var capture = new PipeWireAudioCapture(ctx, "pwnet-clock-in"); capture.FrameReady += (_, _) => { }; capture.Connect((await output.WaitForNodeIdAsync(cts.Token))); // The area arrives with the first cycle, not with the connect. - PipeWireGraphClock? producer = null, consumer = null; + PipeWireGraphClock? producer = null, + consumer = null; for (int i = 0; i < 100 && (producer is null || consumer is null); i++) { await Task.Delay(50, cts.Token); @@ -411,9 +515,15 @@ public async Task TwoStreamsOnOneContext_SeeTheSameGraphClock() Assert.IsNotNull(consumer, "the consumer never received SPA_IO_Position"); Assert.IsTrue(producer!.Value.RateDen > 0, "a clock with no rate says nothing"); - Assert.AreEqual(producer!.Value.RateDen, consumer!.Value.RateDen, - "two streams on one graph must be on one clock rate"); - Assert.IsTrue(producer!.Value.Duration > 0, "the quantum must be a real number of frames"); + Assert.AreEqual( + producer!.Value.RateDen, + consumer!.Value.RateDen, + "two streams on one graph must be on one clock rate" + ); + Assert.IsTrue( + producer!.Value.Duration > 0, + "the quantum must be a real number of frames" + ); } } @@ -431,16 +541,27 @@ public async Task AStreamThatCannotGoOn_CanSaySoAndDisposeEitherWay() { RequireLinux(); using var cts = new CancellationTokenSource(Budget); - (PipeWireContext ctx, PipeWireRegistry reg) = await ConnectAsync("pwnet-art-seterror", cts.Token); + (PipeWireContext ctx, PipeWireRegistry reg) = await ConnectAsync( + "pwnet-art-seterror", + cts.Token + ); await using (ctx) await using (reg) { var output = new PipeWireAudioOutput(ctx, "pwnet_art_seterror"); - output.FillSamples += (_, samples, _, _, _) => { samples.Clear(); return samples.Length; }; + output.FillSamples += (_, samples, _, _, _) => + { + samples.Clear(); + return samples.Length; + }; output.Connect(autoConnect: false); - await WaitForAsync(reg, g => g.Nodes.Any(n => n.NodeName == "pwnet_art_seterror"), cts.Token); + await WaitForAsync( + reg, + g => g.Nodes.Any(n => n.NodeName == "pwnet_art_seterror"), + cts.Token + ); // Accepted on a live stream, and it does not throw its way back out. output.SetError(-5, "a test said so"); diff --git a/tests/PipeWire.NET.Tests/AudioTimingTests.cs b/tests/PipeWire.NET.Tests/AudioTimingTests.cs index b8f0934..61aaccd 100644 --- a/tests/PipeWire.NET.Tests/AudioTimingTests.cs +++ b/tests/PipeWire.NET.Tests/AudioTimingTests.cs @@ -27,7 +27,10 @@ public sealed class AudioTimingTests : PipeWireTestBase private const AudioSampleFormat Format = AudioSampleFormat.F32Le; private static async Task WaitForAsync( - PipeWireRegistry registry, Func until, CancellationToken ct) + PipeWireRegistry registry, + Func until, + CancellationToken ct + ) { await foreach (PipeWireGraphSnapshot graph in registry.WatchAsync(ct)) if (until(graph)) @@ -58,7 +61,10 @@ public async Task AudioFrames_CarryAdvancingQueuedTimesAndNoHeaderTimestamp() RequireLinux(); using var cts = new CancellationTokenSource(Budget); - await using var ctx = new PipeWireContext("pwnet-audiots", ConsoleTestLoggerFactory.Instance); + await using var ctx = new PipeWireContext( + "pwnet-audiots", + ConsoleTestLoggerFactory.Instance + ); await ctx.StartAsync(cts.Token); await using var reg = new PipeWireRegistry(ctx); await reg.WaitForInitialEnumerationAsync(cts.Token); @@ -78,13 +84,21 @@ public async Task AudioFrames_CarryAdvancingQueuedTimesAndNoHeaderTimestamp() var missing = 0; var headers = 0; - await using var capture = new PipeWireAudioCapture(ctx, $"pwnet-audiots-sink-{Environment.ProcessId}"); + await using var capture = new PipeWireAudioCapture( + ctx, + $"pwnet-audiots-sink-{Environment.ProcessId}" + ); capture.FrameReady += (_, frame) => { - if (frame.PresentationTimestampNs is not null) Interlocked.Increment(ref headers); + if (frame.PresentationTimestampNs is not null) + Interlocked.Increment(ref headers); if (frame.QueuedTimeNs is { } ts && ts > 0) { - lock (stamps) { if (stamps.Count < 32) stamps.Add(ts); } + lock (stamps) + { + if (stamps.Count < 32) + stamps.Add(ts); + } } else { @@ -97,27 +111,42 @@ public async Task AudioFrames_CarryAdvancingQueuedTimesAndNoHeaderTimestamp() for (int i = 0; i < 60; i++) { - lock (stamps) { if (stamps.Count >= 8) break; } + lock (stamps) + { + if (stamps.Count >= 8) + break; + } await Task.Delay(50, cts.Token); } long[] seen; - lock (stamps) seen = [.. stamps]; + lock (stamps) + seen = [.. stamps]; - Assert.IsTrue(seen.Length >= 8, - $"only {seen.Length} audio frames carried a timestamp ({missing} carried none)"); + Assert.IsTrue( + seen.Length >= 8, + $"only {seen.Length} audio frames carried a timestamp ({missing} carried none)" + ); // Monotonic and actually moving: a constant value would satisfy "has a timestamp" while // being just as useless for alignment. for (int i = 1; i < seen.Length; i++) - Assert.IsTrue(seen[i] > seen[i - 1], $"timestamp went backwards or stalled at index {i}"); + Assert.IsTrue( + seen[i] > seen[i - 1], + $"timestamp went backwards or stalled at index {i}" + ); - Assert.IsTrue(seen[^1] - seen[0] > 1_000_000, - "timestamps advanced by less than a millisecond across eight frames"); - - Assert.AreEqual(0, Volatile.Read(ref headers), - "an audio frame arrived with a header timestamp; audioconvert copies spa_meta_header now, " + - "and the PresentationTimestampNs docs that say audio consumers see null are wrong"); + Assert.IsTrue( + seen[^1] - seen[0] > 1_000_000, + "timestamps advanced by less than a millisecond across eight frames" + ); + + Assert.AreEqual( + 0, + Volatile.Read(ref headers), + "an audio frame arrived with a header timestamp; audioconvert copies spa_meta_header now, " + + "and the PresentationTimestampNs docs that say audio consumers see null are wrong" + ); } /// @@ -135,7 +164,10 @@ public async Task AnAudioOutput_ReportsANonZeroQueueDepth() RequireLinux(); using var cts = new CancellationTokenSource(Budget); - await using var ctx = new PipeWireContext("pwnet-audioq", ConsoleTestLoggerFactory.Instance); + await using var ctx = new PipeWireContext( + "pwnet-audioq", + ConsoleTestLoggerFactory.Instance + ); await ctx.StartAsync(cts.Token); await using var reg = new PipeWireRegistry(ctx); await reg.WaitForInitialEnumerationAsync(cts.Token); @@ -156,7 +188,10 @@ public async Task AnAudioOutput_ReportsANonZeroQueueDepth() await WaitForAsync(reg, g => g.Nodes.Any(n => n.NodeName == nodeName), cts.Token); - await using var capture = new PipeWireAudioCapture(ctx, $"pwnet-audioq-sink-{Environment.ProcessId}"); + await using var capture = new PipeWireAudioCapture( + ctx, + $"pwnet-audioq-sink-{Environment.ProcessId}" + ); capture.Connect((await output.WaitForNodeIdAsync(cts.Token))); await capture.WaitForStreamingAsync(cts.Token); await Task.Delay(500, cts.Token); @@ -166,7 +201,8 @@ public async Task AnAudioOutput_ReportsANonZeroQueueDepth() Assert.IsTrue( queue.Value.Queued > 0 || queue.Value.QueuedBuffers > 0, - "the output queued audio but reported an empty queue, so the size was never set"); + "the output queued audio but reported an empty queue, so the size was never set" + ); Assert.IsTrue(shortestAsk > 0 && shortestAsk < int.MaxValue, "the fill callback never ran"); Assert.IsNotNull(output.GraphClock, "a running output should see the graph clock"); diff --git a/tests/PipeWire.NET.Tests/AvSyncTests.cs b/tests/PipeWire.NET.Tests/AvSyncTests.cs index 740b4f8..2778359 100644 --- a/tests/PipeWire.NET.Tests/AvSyncTests.cs +++ b/tests/PipeWire.NET.Tests/AvSyncTests.cs @@ -37,7 +37,9 @@ namespace PipeWire.NET.Tests; [SupportedOSPlatform("linux")] public sealed class AvSyncTests { - private const int Width = 160, Height = 120, FrameRate = 30; + private const int Width = 160, + Height = 120, + FrameRate = 30; private const int Rate = 48000; private const int Cap = 128; @@ -69,24 +71,43 @@ private static long NowNs() => /// crosses the daemon: no audio converter or mixer copies spa_meta_header.pts, so an audio /// consumer is handed the graph's cycle time whatever the producer stamped. /// - private static async Task CaptureBothLegsAsync(string name, long? videoOffsetNs, CancellationToken ct) + private static async Task CaptureBothLegsAsync( + string name, + long? videoOffsetNs, + CancellationToken ct + ) { await using var ctx = new PipeWireContext(name, ConsoleTestLoggerFactory.Instance); await ctx.StartAsync(ct); - await using var video = new PipeWireVideoOutput(ctx, $"{name}-v", Width, Height, PixelFormat.Bgra, FrameRate); + await using var video = new PipeWireVideoOutput( + ctx, + $"{name}-v", + Width, + Height, + PixelFormat.Bgra, + FrameRate + ); video.FillFrame += (sender, pixels, _, _, _, _) => { pixels.Fill(0x40); - if (videoOffsetNs is { } offset) sender.NextPresentationTimestampNs = NowNs() + offset; + if (videoOffsetNs is { } offset) + sender.NextPresentationTimestampNs = NowNs() + offset; return true; }; - await using var audio = new PipeWireAudioOutput(ctx, $"{name}-a", Rate, 2, AudioSampleFormat.F32Le); + await using var audio = new PipeWireAudioOutput( + ctx, + $"{name}-a", + Rate, + 2, + AudioSampleFormat.F32Le + ); audio.FillSamples += (sender, samples, _, _, _) => { MemoryMarshal.Cast(samples).Fill(0.25f); - if (videoOffsetNs is not null) sender.NextPresentationTimestampNs = NowNs(); + if (videoOffsetNs is not null) + sender.NextPresentationTimestampNs = NowNs(); return samples.Length; }; @@ -131,10 +152,14 @@ private static async Task CaptureBothLegsAsync(string name, long? video DateTime deadline = DateTime.UtcNow + TimeSpan.FromSeconds(15); while (DateTime.UtcNow < deadline) { - bool vReady, aReady; - lock (v) vReady = v.Count >= 24 && v[^1].Time > v[0].Time; - lock (a) aReady = a.Count >= 24 && a[^1].Time > a[0].Time; - if (vReady && aReady) break; + bool vReady, + aReady; + lock (v) + vReady = v.Count >= 24 && v[^1].Time > v[0].Time; + lock (a) + aReady = a.Count >= 24 && a[^1].Time > a[0].Time; + if (vReady && aReady) + break; await Task.Delay(100, ct); } @@ -145,17 +170,27 @@ Leg Snapshot(List<(long Time, long Rx, long Delay, long Clock)> xs) lock (xs) { return new Leg( - [.. xs.Select(x => x.Time)], [.. xs.Select(x => x.Rx)], - [.. xs.Select(x => x.Delay)], [.. xs.Select(x => x.Clock)]); + [.. xs.Select(x => x.Time)], + [.. xs.Select(x => x.Rx)], + [.. xs.Select(x => x.Delay)], + [.. xs.Select(x => x.Clock)] + ); } } - Leg videoLeg = Snapshot(v), audioLeg = Snapshot(a); + Leg videoLeg = Snapshot(v), + audioLeg = Snapshot(a); // Buffers without their leg's time are not collected at all, so a producer that stopped // stamping shows up here as a leg that never filled rather than as a wrong number later. - Assert.IsTrue(videoLeg.Time.Length >= 24, $"only {videoLeg.Time.Length} video frames carried a presentation timestamp"); - Assert.IsTrue(audioLeg.Time.Length >= 24, $"only {audioLeg.Time.Length} audio buffers carried a queued time"); + Assert.IsTrue( + videoLeg.Time.Length >= 24, + $"only {videoLeg.Time.Length} video frames carried a presentation timestamp" + ); + Assert.IsTrue( + audioLeg.Time.Length >= 24, + $"only {audioLeg.Time.Length} audio buffers carried a queued time" + ); return new Capture(videoLeg, audioLeg, drove); } @@ -182,7 +217,7 @@ private static string Describe(string name, Leg leg) => leg.Time.Length == 0 ? $"{name}: nothing" : $"{name}: n={leg.Time.Length} pts {leg.Time[0]}..{leg.Time[^1]} " - + $"(span {(leg.Time[^1] - leg.Time[0]) / 1e6:F1}ms) lead {LeadNs(leg) / 1e6:F1}ms"; + + $"(span {(leg.Time[^1] - leg.Time[0]) / 1e6:F1}ms) lead {LeadNs(leg) / 1e6:F1}ms"; /// /// Audio and video published together, stamped by the outputs themselves, arrive on one timeline. @@ -200,8 +235,10 @@ public async Task AudioAndVideoPublishedTogether_ArriveOnOneTimeline() Capture c = await CaptureBothLegsAsync("pwnet-avsync", videoOffsetNs: null, cts.Token); - long videoFrom = c.Video.Time.Min(), videoTo = c.Video.Time.Max(); - long audioFrom = c.Audio.Time.Min(), audioTo = c.Audio.Time.Max(); + long videoFrom = c.Video.Time.Min(), + videoTo = c.Video.Time.Max(); + long audioFrom = c.Audio.Time.Min(), + audioTo = c.Audio.Time.Max(); Assert.IsTrue(videoTo > videoFrom, "the video timestamps never advanced"); Assert.IsTrue(audioTo > audioFrom, "the audio timestamps never advanced"); @@ -209,14 +246,16 @@ public async Task AudioAndVideoPublishedTogether_ArriveOnOneTimeline() long overlap = Math.Min(videoTo, audioTo) - Math.Max(videoFrom, audioFrom); Assert.IsTrue( overlap > 0, - $"the legs' timestamp windows do not meet (video {videoFrom}..{videoTo}, audio {audioFrom}..{audioTo})"); + $"the legs' timestamp windows do not meet (video {videoFrom}..{videoTo}, audio {audioFrom}..{audioTo})" + ); // Both were stamped from one clock at production, so what separates them is latency the // legs do not share - a quantum or a frame, not hundreds of milliseconds. long skew = LeadNs(c.Audio) - LeadNs(c.Video); Assert.IsTrue( Math.Abs(skew) < 100_000_000, - $"audio and video stamped by the same process sit {skew / 1e6:F1}ms apart"); + $"audio and video stamped by the same process sit {skew / 1e6:F1}ms apart" + ); // The graph clock the video capture reports must advance as well when this library drove // the graph: a driving stream has to publish the clock itself, and before that was done @@ -224,8 +263,10 @@ public async Task AudioAndVideoPublishedTogether_ArriveOnOneTimeline() if (c.VideoDrove) { long[] clock = [.. c.Video.Clock.Where(x => x >= 0)]; - Assert.IsTrue(clock.Length > 1 && clock.Max() > clock.Min(), - "this library drove the video graph, but the graph clock its consumer saw never advanced"); + Assert.IsTrue( + clock.Length > 1 && clock.Max() > clock.Min(), + "this library drove the video graph, but the graph clock its consumer saw never advanced" + ); } } @@ -262,7 +303,11 @@ public async Task AnOffsetPublishedIntoTheVideo_IsRecoveredByTheConsumer() foreach (long injected in new[] { 250_000_000L, -100_000_000L }) { - Capture c = await CaptureBothLegsAsync($"pwnet-avoff{(injected > 0 ? "p" : "n")}", injected, cts.Token); + Capture c = await CaptureBothLegsAsync( + $"pwnet-avoff{(injected > 0 ? "p" : "n")}", + injected, + cts.Token + ); long recovered = LeadNs(c.Video) - LeadNs(c.Audio) - baseSkew; Assert.AreEqual( @@ -270,8 +315,9 @@ public async Task AnOffsetPublishedIntoTheVideo_IsRecoveredByTheConsumer() recovered / 1e6, 15.0, $"a {injected / 1e6:F0}ms offset published into the video came back as {recovered / 1e6:F1}ms; " - + $"baseline [{Describe("audio", baseline.Audio)}; {Describe("video", baseline.Video)}], " - + $"offset run [{Describe("audio", c.Audio)}; {Describe("video", c.Video)}]"); + + $"baseline [{Describe("audio", baseline.Audio)}; {Describe("video", baseline.Video)}], " + + $"offset run [{Describe("audio", c.Audio)}; {Describe("video", c.Video)}]" + ); } } @@ -303,10 +349,14 @@ public async Task ADesyncedStream_IsDrivenBackIntoSync() for (var i = 1; i < c.Audio.Time.Length; i++) { double d = (c.Audio.Time[i] - c.Audio.Time[i - 1]) / 1e9 * Rate; - if (d is > 0 and < 1e6) periods.Add(d); + if (d is > 0 and < 1e6) + periods.Add(d); } - Assert.IsTrue(periods.Count > 5, "the audio leg produced no usable cadence to drive against"); + Assert.IsTrue( + periods.Count > 5, + "the audio leg produced no usable cadence to drive against" + ); double period = periods.Sum() / periods.Count; var dll = new PipeWireRateController(); @@ -320,7 +370,10 @@ public async Task ADesyncedStream_IsDrivenBackIntoSync() { double correction = dll.Update(offset); - Assert.IsTrue(double.IsFinite(correction), $"the controller produced a non-finite correction at cycle {i}"); + Assert.IsTrue( + double.IsFinite(correction), + $"the controller produced a non-finite correction at cycle {i}" + ); // Consumed at the corrected rate against a nominal arrival, which is what applying the // correction to the stream would do. @@ -330,9 +383,15 @@ public async Task ADesyncedStream_IsDrivenBackIntoSync() Assert.IsTrue( Math.Abs(offset) < Math.Abs(startingOffset), $"the correction made the desync worse: {startingOffset / Rate * 1000:F1}ms became " - + $"{offset / Rate * 1000:F1}ms - the sign is inverted"); - - Assert.AreEqual(0.0, offset / Rate, 0.005, $"a 250ms desync did not close; {offset / Rate * 1000:F1}ms remains"); + + $"{offset / Rate * 1000:F1}ms - the sign is inverted" + ); + + Assert.AreEqual( + 0.0, + offset / Rate, + 0.005, + $"a 250ms desync did not close; {offset / Rate * 1000:F1}ms remains" + ); } /// @@ -361,7 +420,10 @@ public async Task ApplyingTheReportedDelay_KeepsPresentationOrder() Assert.IsTrue(leg.Delay[i] >= 0, $"{name}: a negative delay at buffer {i}"); // A delay larger than a second is not a latency, it is a misread field. - Assert.IsTrue(leg.Delay[i] < 1_000_000_000, $"{name}: a delay of {leg.Delay[i] / 1e6:F1}ms is not a plausible latency"); + Assert.IsTrue( + leg.Delay[i] < 1_000_000_000, + $"{name}: a delay of {leg.Delay[i] / 1e6:F1}ms is not a plausible latency" + ); presentation[i] = leg.Time[i] + leg.Delay[i]; } @@ -370,7 +432,8 @@ public async Task ApplyingTheReportedDelay_KeepsPresentationOrder() { Assert.IsTrue( presentation[i] >= presentation[i - 1], - $"{name}: applying the reported delay reordered buffers {i - 1} and {i}"); + $"{name}: applying the reported delay reordered buffers {i - 1} and {i}" + ); } } } @@ -414,10 +477,18 @@ public async Task OneGStreamerPipeline_VideoCarriesItsRunningTimeAndAudioTheGrap await using var ctx = new PipeWireContext(name, ConsoleTestLoggerFactory.Instance); await ctx.StartAsync(cts.Token); - (GstTestSource source, uint videoNode, uint audioNode) = await GstTestSource.StartOnePipelineAsync( - ctx, - ($"videotestsrc is-live=true ! video/x-raw,format=BGRx,width={Width},height={Height},framerate={FrameRate}/1", $"{name}-v"), - ($"audiotestsrc is-live=true ! audio/x-raw,format=F32LE,rate={Rate},channels=2", $"{name}-a")); + (GstTestSource source, uint videoNode, uint audioNode) = + await GstTestSource.StartOnePipelineAsync( + ctx, + ( + $"videotestsrc is-live=true ! video/x-raw,format=BGRx,width={Width},height={Height},framerate={FrameRate}/1", + $"{name}-v" + ), + ( + $"audiotestsrc is-live=true ! audio/x-raw,format=F32LE,rate={Rate},channels=2", + $"{name}-a" + ) + ); await using (source) { var v = new List<(long Time, long Rx)>(); @@ -427,7 +498,11 @@ public async Task OneGStreamerPipeline_VideoCarriesItsRunningTimeAndAudioTheGrap vCap.FrameReady += (_, f) => { long rx = NowNs(); - lock (v) { if (v.Count < Cap && f.PresentationTimestampNs is { } pts) v.Add((pts, rx)); } + lock (v) + { + if (v.Count < Cap && f.PresentationTimestampNs is { } pts) + v.Add((pts, rx)); + } }; vCap.Connect(videoNode, stackalloc[] { PixelFormat.Bgrx }); @@ -435,43 +510,65 @@ public async Task OneGStreamerPipeline_VideoCarriesItsRunningTimeAndAudioTheGrap aCap.FrameReady += (_, f) => { long rx = NowNs(); - lock (a) { if (a.Count < Cap && f.QueuedTimeNs is { } pts) a.Add((pts, rx)); } + lock (a) + { + if (a.Count < Cap && f.QueuedTimeNs is { } pts) + a.Add((pts, rx)); + } }; aCap.Connect(audioNode, sampleRate: Rate, channels: 2, format: AudioSampleFormat.F32Le); DateTime deadline = DateTime.UtcNow + TimeSpan.FromSeconds(20); while (DateTime.UtcNow < deadline) { - bool vReady, aReady; - lock (v) vReady = v.Count >= 24 && v[^1].Time > v[0].Time; - lock (a) aReady = a.Count >= 24 && a[^1].Time > a[0].Time; - if (vReady && aReady) break; + bool vReady, + aReady; + lock (v) + vReady = v.Count >= 24 && v[^1].Time > v[0].Time; + lock (a) + aReady = a.Count >= 24 && a[^1].Time > a[0].Time; + if (vReady && aReady) + break; await Task.Delay(100, cts.Token); } Leg Snapshot(List<(long Time, long Rx)> xs) { - lock (xs) return new Leg([.. xs.Select(x => x.Time)], [.. xs.Select(x => x.Rx)], [], []); + lock (xs) + return new Leg([.. xs.Select(x => x.Time)], [.. xs.Select(x => x.Rx)], [], []); } - Leg video = Snapshot(v), audio = Snapshot(a); + Leg video = Snapshot(v), + audio = Snapshot(a); string seen = $"[{Describe("audio", audio)}; {Describe("video", video)}]"; - Assert.IsTrue(video.Time.Length >= 24, $"the pipeline's video leg delivered only {video.Time.Length} stamped frames {seen}"); - Assert.IsTrue(audio.Time.Length >= 24, $"the pipeline's audio leg delivered only {audio.Time.Length} stamped buffers {seen}"); + Assert.IsTrue( + video.Time.Length >= 24, + $"the pipeline's video leg delivered only {video.Time.Length} stamped frames {seen}" + ); + Assert.IsTrue( + audio.Time.Length >= 24, + $"the pipeline's audio leg delivered only {audio.Time.Length} stamped buffers {seen}" + ); // Video: GStreamer's running time, carried through intact. It starts when the pipeline // started, so it is small, and it advances with the wall clock. long videoSpan = video.Time[^1] - video.Time[0]; long videoWall = video.ReceivedNs[^1] - video.ReceivedNs[0]; - Assert.IsTrue(video.Time[0] < 60_000_000_000L, - $"the video times are not GStreamer's running time - a pipeline seconds old reports {video.Time[0] / 1e9:F1}s {seen}"); - Assert.IsTrue(Math.Abs(videoSpan - videoWall) < 100_000_000, - $"the video times advanced {videoSpan / 1e6:F0}ms over {videoWall / 1e6:F0}ms of arrivals {seen}"); + Assert.IsTrue( + video.Time[0] < 60_000_000_000L, + $"the video times are not GStreamer's running time - a pipeline seconds old reports {video.Time[0] / 1e9:F1}s {seen}" + ); + Assert.IsTrue( + Math.Abs(videoSpan - videoWall) < 100_000_000, + $"the video times advanced {videoSpan / 1e6:F0}ms over {videoWall / 1e6:F0}ms of arrivals {seen}" + ); // Audio: the graph's time, so within a cycle or two of when this process got the buffer. - Assert.IsTrue(Math.Abs(LeadNs(audio)) < 100_000_000, - $"the audio times are not the graph's cycle time: they sit {LeadNs(audio) / 1e6:F1}ms from arrival {seen}"); + Assert.IsTrue( + Math.Abs(LeadNs(audio)) < 100_000_000, + $"the audio times are not the graph's cycle time: they sit {LeadNs(audio) / 1e6:F1}ms from arrival {seen}" + ); } } } diff --git a/tests/PipeWire.NET.Tests/BorrowedFrameGpuTests.cs b/tests/PipeWire.NET.Tests/BorrowedFrameGpuTests.cs index a049a1b..37bb7b5 100644 --- a/tests/PipeWire.NET.Tests/BorrowedFrameGpuTests.cs +++ b/tests/PipeWire.NET.Tests/BorrowedFrameGpuTests.cs @@ -75,18 +75,29 @@ public async Task ABorrowedFrame_OverARealDmaBuf_CarriesALiveImportTriple() try { - await using var ctx = new PipeWireContext("pwnet-borrow-gpu", ConsoleTestLoggerFactory.Instance); + await using var ctx = new PipeWireContext( + "pwnet-borrow-gpu", + ConsoleTestLoggerFactory.Instance + ); await ctx.StartAsync(cts.Token); long modifier = (long)GbmAllocator.LinearModifier; await using var output = new PipeWireVideoOutput( - ctx, "pwnet-borrow-gpu-src", Width, Height, PixelFormat.Bgra, 30); + ctx, + "pwnet-borrow-gpu-src", + Width, + Height, + PixelFormat.Bgra, + 30 + ); output.AllocateDmaBuf += (_, index, _, _, _, _, planes) => { - if (index >= PoolCap) return 0; - while (buffers.Count <= index) buffers.Add(gbm.CreateBgra(Width, Height)); + if (index >= PoolCap) + return 0; + while (buffers.Count <= index) + buffers.Add(gbm.CreateBgra(Width, Height)); GbmAllocator.Buffer b = buffers[index]; planes[0] = new VideoPlane(b.Fd, b.Offset, b.Stride, b.Size); return 1; @@ -99,7 +110,8 @@ public async Task ABorrowedFrame_OverARealDmaBuf_CarriesALiveImportTriple() for (var i = 0; i < 60 && nodeId is null; i++) { nodeId = output.NodeId; - if (nodeId is null) await Task.Delay(50, cts.Token); + if (nodeId is null) + await Task.Delay(50, cts.Token); } Assert.IsNotNull(nodeId, "the dmabuf producer was never assigned a node id"); @@ -112,7 +124,8 @@ public async Task ABorrowedFrame_OverARealDmaBuf_CarriesALiveImportTriple() var sawDmaBuf = 0; capture.FrameReady += (_, frame) => { - if (frame.BufferType == PipeWireBufferType.DmaBuf) Interlocked.Increment(ref sawDmaBuf); + if (frame.BufferType == PipeWireBufferType.DmaBuf) + Interlocked.Increment(ref sawDmaBuf); }; capture.Connect(nodeId!.Value, [PixelFormat.Bgra], modifiers: [modifier]); @@ -122,42 +135,54 @@ public async Task ABorrowedFrame_OverARealDmaBuf_CarriesALiveImportTriple() for (var i = 0; i < 120 && !got; i++) { got = capture.TryGetBorrowedFrame(out borrowed) && borrowed.IsFdBacked; - if (!got) await Task.Delay(50, cts.Token); + if (!got) + await Task.Delay(50, cts.Token); } Assert.IsTrue( Volatile.Read(ref sawDmaBuf) > 0, - "no DMA-BUF frame ever arrived, so the borrowed path was never given a GPU buffer"); + "no DMA-BUF frame ever arrived, so the borrowed path was never given a GPU buffer" + ); Assert.IsTrue(got, "borrowed retention never produced an fd-backed frame"); Assert.AreEqual(Width, borrowed.Width); Assert.AreEqual(Height, borrowed.Height); - Assert.IsTrue(borrowed.PlaneCount > 0, "an fd-backed frame with no planes cannot be imported"); + Assert.IsTrue( + borrowed.PlaneCount > 0, + "an fd-backed frame with no planes cannot be imported" + ); Assert.AreEqual( DrmFormat.FromPixelFormat(PixelFormat.Bgra), borrowed.DrmFourcc, - "the fourcc does not describe the format that was negotiated"); + "the fourcc does not describe the format that was negotiated" + ); Assert.AreEqual( (ulong)modifier, borrowed.Modifier, - "the modifier does not match the one the producer and consumer agreed on"); + "the modifier does not match the one the producer and consumer agreed on" + ); BorrowedVideoPlane plane = borrowed[0]; Assert.IsTrue(plane.Fd >= 0, "the plane carries no descriptor"); - Assert.IsTrue(plane.Stride > 0, "the plane carries no stride, so an import cannot lay it out"); + Assert.IsTrue( + plane.Stride > 0, + "the plane carries no stride, so an import cannot lay it out" + ); // The liveness check. A stale number survives every assertion above. using SafeFileHandle duplicate = FdInterop.DuplicateWithCloseOnExec((int)plane.Fd); Assert.IsFalse( duplicate.IsInvalid, - "the borrowed descriptor could not be duplicated, so it is not a live dmabuf"); + "the borrowed descriptor could not be duplicated, so it is not a live dmabuf" + ); } finally { - foreach (GbmAllocator.Buffer b in buffers) b.Dispose(); + foreach (GbmAllocator.Buffer b in buffers) + b.Dispose(); } } @@ -183,18 +208,29 @@ public async Task ABorrowedGpuFrame_IsHandedOutOnlyOnce() try { - await using var ctx = new PipeWireContext("pwnet-borrow-once", ConsoleTestLoggerFactory.Instance); + await using var ctx = new PipeWireContext( + "pwnet-borrow-once", + ConsoleTestLoggerFactory.Instance + ); await ctx.StartAsync(cts.Token); long modifier = (long)GbmAllocator.LinearModifier; await using var output = new PipeWireVideoOutput( - ctx, "pwnet-borrow-once-src", Width, Height, PixelFormat.Bgra, 30); + ctx, + "pwnet-borrow-once-src", + Width, + Height, + PixelFormat.Bgra, + 30 + ); output.AllocateDmaBuf += (_, index, _, _, _, _, planes) => { - if (index >= PoolCap) return 0; - while (buffers.Count <= index) buffers.Add(gbm.CreateBgra(Width, Height)); + if (index >= PoolCap) + return 0; + while (buffers.Count <= index) + buffers.Add(gbm.CreateBgra(Width, Height)); GbmAllocator.Buffer b = buffers[index]; planes[0] = new VideoPlane(b.Fd, b.Offset, b.Stride, b.Size); return 1; @@ -207,7 +243,8 @@ public async Task ABorrowedGpuFrame_IsHandedOutOnlyOnce() for (var i = 0; i < 60 && nodeId is null; i++) { nodeId = output.NodeId; - if (nodeId is null) await Task.Delay(50, cts.Token); + if (nodeId is null) + await Task.Delay(50, cts.Token); } Assert.IsNotNull(nodeId); @@ -223,7 +260,8 @@ public async Task ABorrowedGpuFrame_IsHandedOutOnlyOnce() for (var i = 0; i < 120 && !got; i++) { got = capture.TryGetBorrowedFrame(out BorrowedVideoFrame first) && first.IsFdBacked; - if (!got) await Task.Delay(50, cts.Token); + if (!got) + await Task.Delay(50, cts.Token); } Assert.IsTrue(got, "borrowed retention never produced an fd-backed frame"); @@ -231,11 +269,13 @@ public async Task ABorrowedGpuFrame_IsHandedOutOnlyOnce() // Taken immediately after, before the producer's next cycle can refill the slot. Assert.IsFalse( capture.TryGetBorrowedFrame(out _), - "the same borrowed GPU frame was handed out twice"); + "the same borrowed GPU frame was handed out twice" + ); } finally { - foreach (GbmAllocator.Buffer b in buffers) b.Dispose(); + foreach (GbmAllocator.Buffer b in buffers) + b.Dispose(); } } } diff --git a/tests/PipeWire.NET.Tests/BorrowedVideoFrameTests.cs b/tests/PipeWire.NET.Tests/BorrowedVideoFrameTests.cs index 10ebbc3..7b47adf 100644 --- a/tests/PipeWire.NET.Tests/BorrowedVideoFrameTests.cs +++ b/tests/PipeWire.NET.Tests/BorrowedVideoFrameTests.cs @@ -21,11 +21,22 @@ private static BorrowedVideoFrame Nv12(long fd0 = 7, long fd1 = 8) ]; return new BorrowedVideoFrame( - planes, 1920, 1920, 1080, PixelFormat.Nv12, - DrmFormat.FromPixelFormat(PixelFormat.Nv12), DrmFormatModifier.Linear, - sequenceNumber: 3, presentationTimestampNs: 111, queuedTimeNs: 555, graphTimeNs: 222, - streamPositionNs: 333, delayNs: 44, crop: null, - transform: SpaMetaVideotransformValue.None); + planes, + 1920, + 1920, + 1080, + PixelFormat.Nv12, + DrmFormat.FromPixelFormat(PixelFormat.Nv12), + DrmFormatModifier.Linear, + sequenceNumber: 3, + presentationTimestampNs: 111, + queuedTimeNs: 555, + graphTimeNs: 222, + streamPositionNs: 333, + delayNs: 44, + crop: null, + transform: SpaMetaVideotransformValue.None + ); } [TestMethod] @@ -131,13 +142,30 @@ public void MorePlanesThanTheInlineStorageAreTruncated() { ReadOnlySpan many = [ - new(1, 0, 16, 16), new(2, 0, 16, 16), new(3, 0, 16, 16), - new(4, 0, 16, 16), new(5, 0, 16, 16), + new(1, 0, 16, 16), + new(2, 0, 16, 16), + new(3, 0, 16, 16), + new(4, 0, 16, 16), + new(5, 0, 16, 16), ]; BorrowedVideoFrame frame = new( - many, 16, 4, 4, PixelFormat.Bgra, 0, DrmFormatModifier.Invalid, - 0, null, null, null, null, 0, null, SpaMetaVideotransformValue.None); + many, + 16, + 4, + 4, + PixelFormat.Bgra, + 0, + DrmFormatModifier.Invalid, + 0, + null, + null, + null, + null, + 0, + null, + SpaMetaVideotransformValue.None + ); Assert.AreEqual(4, frame.PlaneCount); Assert.AreEqual(4, frame[3].Fd); diff --git a/tests/PipeWire.NET.Tests/BuilderAndExceptionContractTests.cs b/tests/PipeWire.NET.Tests/BuilderAndExceptionContractTests.cs index a11e564..a68adbe 100644 --- a/tests/PipeWire.NET.Tests/BuilderAndExceptionContractTests.cs +++ b/tests/PipeWire.NET.Tests/BuilderAndExceptionContractTests.cs @@ -36,14 +36,16 @@ public sealed class BuilderAndExceptionContractTests [TestMethod] public void EveryStreamPropertyBuilder_SetsTheKeyItNames() { - IReadOnlyDictionary props = - new StreamProperties(StreamMediaType.Video, StreamCategory.Capture) - .WithRole("Production") - .WithTargetObject("some-node") - .WithNodeName("my-name") - .WithNodeDescription("my description") - .With("custom.key", "custom-value") - .Values; + IReadOnlyDictionary props = new StreamProperties( + StreamMediaType.Video, + StreamCategory.Capture + ) + .WithRole("Production") + .WithTargetObject("some-node") + .WithNodeName("my-name") + .WithNodeDescription("my description") + .With("custom.key", "custom-value") + .Values; Assert.AreEqual("Production", props[PipeWireKeys.PW_KEY_MEDIA_ROLE]); Assert.AreEqual("some-node", props[PipeWireKeys.PW_KEY_TARGET_OBJECT]); @@ -60,8 +62,10 @@ public void EveryStreamPropertyBuilder_SetsTheKeyItNames() [TestMethod] public void TheMediaTypeAndCategory_ReflectTheConstructorsArguments() { - IReadOnlyDictionary audio = - new StreamProperties(StreamMediaType.Audio, StreamCategory.Playback).Values; + IReadOnlyDictionary audio = new StreamProperties( + StreamMediaType.Audio, + StreamCategory.Playback + ).Values; Assert.AreEqual("Audio", audio[PipeWireKeys.PW_KEY_MEDIA_TYPE]); Assert.AreEqual("Playback", audio[PipeWireKeys.PW_KEY_MEDIA_CATEGORY]); @@ -76,11 +80,13 @@ public void TheMediaTypeAndCategory_ReflectTheConstructorsArguments() [TestMethod] public void SettingAPropertyTwice_KeepsTheLastValue() { - IReadOnlyDictionary props = - new StreamProperties(StreamMediaType.Audio, StreamCategory.Capture) - .WithNodeName("first") - .WithNodeName("second") - .Values; + IReadOnlyDictionary props = new StreamProperties( + StreamMediaType.Audio, + StreamCategory.Capture + ) + .WithNodeName("first") + .WithNodeName("second") + .Values; Assert.AreEqual("second", props[PipeWireKeys.PW_KEY_NODE_NAME]); } @@ -101,13 +107,15 @@ public void EveryPipeWireException_CarriesItsResultObjectAndOperation() const int eacces = -13; const uint objectId = 42; - foreach (PipeWireException ex in new PipeWireException[] - { - new PipeWireConnectFailedException("connect", eacces, objectId, "denied"), - new PipeWireConnectionClosedException("closed", eacces, objectId, "denied"), - new PipeWireInteropException("interop", eacces, objectId, "denied"), - new PipeWireRequestRefusedException("refused", eacces, objectId, "denied"), - }) + foreach ( + PipeWireException ex in new PipeWireException[] + { + new PipeWireConnectFailedException("connect", eacces, objectId, "denied"), + new PipeWireConnectionClosedException("closed", eacces, objectId, "denied"), + new PipeWireInteropException("interop", eacces, objectId, "denied"), + new PipeWireRequestRefusedException("refused", eacces, objectId, "denied"), + } + ) { string which = ex.GetType().Name; @@ -127,9 +135,9 @@ public void EveryPipeWireException_CarriesItsResultObjectAndOperation() [TestMethod] public void TheErrnoClassifiers_SeparateTheCasesACallerActsOnDifferently() { - const int eacces = -13; // permission denied - const int enoent = -2; // no such object - const int epipe = -32; // the connection went away + const int eacces = -13; // permission denied + const int enoent = -2; // no such object + const int epipe = -32; // the connection went away var refused = new PipeWireRequestRefusedException("op", eacces, null, null); Assert.IsTrue(refused.IsPermissionDenied); diff --git a/tests/PipeWire.NET.Tests/ChaosSoakTests.cs b/tests/PipeWire.NET.Tests/ChaosSoakTests.cs index 91d86ab..79d6d78 100644 --- a/tests/PipeWire.NET.Tests/ChaosSoakTests.cs +++ b/tests/PipeWire.NET.Tests/ChaosSoakTests.cs @@ -55,7 +55,9 @@ private static void RequireLinux() } private static async Task<(PipeWireContext Context, PipeWireRegistry Registry)> ConnectAsync( - string name, CancellationToken cancellationToken) + string name, + CancellationToken cancellationToken + ) { var context = new PipeWireContext(name, ConsoleTestLoggerFactory.Instance); await context.StartAsync(cancellationToken); @@ -64,7 +66,8 @@ private static void RequireLinux() return (context, registry); } - private static string Unique(string p) => $"{p}_{Environment.ProcessId}_{Random.Shared.Next():x}"; + private static string Unique(string p) => + $"{p}_{Environment.ProcessId}_{Random.Shared.Next():x}"; /// Descriptors that could represent something this library failed to release. /// @@ -83,9 +86,7 @@ private static void RequireLinux() /// /// private static int LeakableDescriptors(Dictionary targets) => - targets - .Where(kv => kv.Key is "unix-socket" or "eventfd") - .Sum(kv => kv.Value); + targets.Where(kv => kv.Key is "unix-socket" or "eventfd").Sum(kv => kv.Value); /// What each open descriptor points at, counted by target. /// @@ -101,9 +102,19 @@ private static Dictionary DescriptorTargets() foreach (string entry in Directory.GetFiles("/proc/self/fd")) { string target; - try { target = File.ResolveLinkTarget(entry, returnFinalTarget: false)?.Name ?? "(unresolved)"; } - catch (IOException) { continue; } - catch (UnauthorizedAccessException) { continue; } + try + { + target = + File.ResolveLinkTarget(entry, returnFinalTarget: false)?.Name ?? "(unresolved)"; + } + catch (IOException) + { + continue; + } + catch (UnauthorizedAccessException) + { + continue; + } // A PipeWire connection is a unix socket, so those are separated from the rest. The // runtime opens sockets of its own for name resolution and the like, and one of those @@ -117,7 +128,8 @@ private static Dictionary DescriptorTargets() // Anonymous inodes carry a number that differs every time; the kind is the useful // part. int colon = target.IndexOf(':', StringComparison.Ordinal); - if (colon > 0) target = target[..colon]; + if (colon > 0) + target = target[..colon]; } targets[target] = targets.GetValueOrDefault(target) + 1; @@ -141,7 +153,8 @@ private static bool IsUnixSocket(string target) { foreach (string field in line.Split(' ', StringSplitOptions.RemoveEmptyEntries)) { - if (field == inode) return true; + if (field == inode) + return true; } } } @@ -167,11 +180,21 @@ private static HashSet SocketInodes() foreach (string entry in Directory.GetFiles("/proc/self/fd")) { string target; - try { target = File.ResolveLinkTarget(entry, returnFinalTarget: false)?.Name ?? ""; } - catch (IOException) { continue; } - catch (UnauthorizedAccessException) { continue; } + try + { + target = File.ResolveLinkTarget(entry, returnFinalTarget: false)?.Name ?? ""; + } + catch (IOException) + { + continue; + } + catch (UnauthorizedAccessException) + { + continue; + } - if (target.StartsWith("socket:", StringComparison.Ordinal)) inodes.Add(target); + if (target.StartsWith("socket:", StringComparison.Ordinal)) + inodes.Add(target); } return inodes; @@ -180,26 +203,32 @@ private static HashSet SocketInodes() private static string DescribeSockets(HashSet before, HashSet after) { string[] added = [.. after.Except(before)]; - if (added.Length == 0) return "no new sockets"; + if (added.Length == 0) + return "no new sockets"; var described = new List(); - string[] unix = File.Exists("/proc/net/unix") - ? File.ReadAllLines("/proc/net/unix") - : []; + string[] unix = File.Exists("/proc/net/unix") ? File.ReadAllLines("/proc/net/unix") : []; foreach (string socket in added) { string inode = socket["socket:[".Length..].TrimEnd(']'); - string? line = Array.Find(unix, l => l.Split(' ', StringSplitOptions.RemoveEmptyEntries) - .Any(f => f == inode)); - - described.Add(line is null ? $"{socket} (not a unix socket)" : $"{socket} -> {line.Trim()}"); + string? line = Array.Find( + unix, + l => l.Split(' ', StringSplitOptions.RemoveEmptyEntries).Any(f => f == inode) + ); + + described.Add( + line is null ? $"{socket} (not a unix socket)" : $"{socket} -> {line.Trim()}" + ); } return string.Join("; ", described); } - private static string DescribeGrowth(Dictionary before, Dictionary after) + private static string DescribeGrowth( + Dictionary before, + Dictionary after + ) { IEnumerable grown = after .Where(kv => kv.Value > before.GetValueOrDefault(kv.Key)) @@ -223,13 +252,24 @@ private static int LoopThreads() foreach (string task in Directory.GetDirectories("/proc/self/task")) { string name; - try { name = File.ReadAllText(Path.Combine(task, "comm")).Trim(); } - catch (IOException) { continue; } // exited between the listing and the read - catch (UnauthorizedAccessException) { continue; } + try + { + name = File.ReadAllText(Path.Combine(task, "comm")).Trim(); + } + catch (IOException) + { + continue; + } // exited between the listing and the read + catch (UnauthorizedAccessException) + { + continue; + } - if (name.StartsWith("pw-", StringComparison.Ordinal) + if ( + name.StartsWith("pw-", StringComparison.Ordinal) || name.Contains("pipewire", StringComparison.OrdinalIgnoreCase) - || name.StartsWith("pwnet-", StringComparison.Ordinal)) + || name.StartsWith("pwnet-", StringComparison.Ordinal) + ) { count++; } @@ -239,7 +279,14 @@ private static int LoopThreads() } private readonly record struct Census( - int Fds, int Threads, long Heap, int Nodes, int Links, int Clients, Dictionary FdTargets) + int Fds, + int Threads, + long Heap, + int Nodes, + int Links, + int Clients, + Dictionary FdTargets + ) { public override string ToString() => $"leakable-fds={Fds} loop-threads={Threads} heap={Heap / 1024}KB nodes={Nodes} links={Links} clients={Clients}"; @@ -284,11 +331,14 @@ private static async Task TakeCensusAsync(CancellationToken ct) LoopThreads(), GC.GetTotalMemory(forceFullCollection: true), ourNodes.Count, - dump.OfKind("Link").Count(e => - (e.Prop("link.output.node") is { } o && ourNodes.Contains(o)) - || (e.Prop("link.input.node") is { } i && ourNodes.Contains(i))), + dump.OfKind("Link") + .Count(e => + (e.Prop("link.output.node") is { } o && ourNodes.Contains(o)) + || (e.Prop("link.input.node") is { } i && ourNodes.Contains(i)) + ), dump.OfKind("Client").Count(e => IsOurs(e, "application.name", SoakClientPrefix)), - targets); + targets + ); } /// Takes a census once this process's own descriptor count has stopped moving. @@ -308,7 +358,8 @@ private static async Task SettledCensusAsync(CancellationToken ct) await Task.Delay(TimeSpan.FromMilliseconds(250), ct); Census next = await TakeCensusAsync(ct); - if (next.Fds == census.Fds && next.Threads == census.Threads) return next; + if (next.Fds == census.Fds && next.Threads == census.Threads) + return next; census = next; } @@ -329,7 +380,10 @@ public async Task SeveralActorsChangingOneGraph_LeaveNothingBehindOnEitherSide() // and reaped whatever a client of ours costs it. Counting from a cold session would charge // the soak for the first connection's permanent structures. { - (PipeWireContext warmCtx, PipeWireRegistry warmReg) = await ConnectAsync("pwnet-soak-warm", cts.Token); + (PipeWireContext warmCtx, PipeWireRegistry warmReg) = await ConnectAsync( + "pwnet-soak-warm", + cts.Token + ); await using (warmCtx) await using (warmReg) { @@ -338,8 +392,10 @@ public async Task SeveralActorsChangingOneGraph_LeaveNothingBehindOnEitherSide() // drop some creates mid-run, which the soak then (correctly) reports as faults. await SessionGates.RequireAudioRouteAsync(warmReg, cts.Token).ConfigureAwait(false); - PipeWireNode warm = await warmReg.CreateVirtualSink("Warm") - .WithName(Unique("pwnet_soak_warm")).ExecuteAsync(cts.Token); + PipeWireNode warm = await warmReg + .CreateVirtualSink("Warm") + .WithName(Unique("pwnet_soak_warm")) + .ExecuteAsync(cts.Token); await warmReg.DestroyGlobalAsync(warm.NodeId, cts.Token); } } @@ -362,15 +418,29 @@ public async Task SeveralActorsChangingOneGraph_LeaveNothingBehindOnEitherSide() using var stop = CancellationTokenSource.CreateLinkedTokenSource(cts.Token); stop.CancelAfter(TimeSpan.FromSeconds(45)); - Task maker = Task.Run(() => MakeAndBreakAsync(a, created, faults, stop.Token), CancellationToken.None); - Task inspector = Task.Run(() => InspectAsync(b, faults, stop.Token), CancellationToken.None); - Task linker = Task.Run(() => LinkAndUnlinkAsync(a, faults, stop.Token), CancellationToken.None); - Task external = Task.Run(() => ExternalToolsAsync(b, faults, stop.Token), CancellationToken.None); + Task maker = Task.Run( + () => MakeAndBreakAsync(a, created, faults, stop.Token), + CancellationToken.None + ); + Task inspector = Task.Run( + () => InspectAsync(b, faults, stop.Token), + CancellationToken.None + ); + Task linker = Task.Run( + () => LinkAndUnlinkAsync(a, faults, stop.Token), + CancellationToken.None + ); + Task external = Task.Run( + () => ExternalToolsAsync(b, faults, stop.Token), + CancellationToken.None + ); await Task.WhenAll(maker, inspector, linker, external); - Assert.IsTrue(faults.IsEmpty, - $"{faults.Count} actor faults, first: {(faults.TryPeek(out string? f) ? f : string.Empty)}"); + Assert.IsTrue( + faults.IsEmpty, + $"{faults.Count} actor faults, first: {(faults.TryPeek(out string? f) ? f : string.Empty)}" + ); // Everything this test made, gone, so the census compares like with like. One barrier // for the batch - and only for what is still alive: the maker destroys as it goes, so @@ -392,7 +462,10 @@ public async Task SeveralActorsChangingOneGraph_LeaveNothingBehindOnEitherSide() await a.DestroyGlobalsAsync(live, cts.Token); await a.WaitForInitialEnumerationAsync(cts.Token); - Assert.IsTrue(a.Current.Nodes.Length > 0, "the graph is empty, so the session did not survive"); + Assert.IsTrue( + a.Current.Nodes.Length > 0, + "the graph is empty, so the session did not survive" + ); await b.WaitForInitialEnumerationAsync(cts.Token); } @@ -409,24 +482,35 @@ public async Task SeveralActorsChangingOneGraph_LeaveNothingBehindOnEitherSide() // Our side. Descriptors and threads must come back; the managed heap is allowed to grow, // because the allocator keeps what it has taken and a fixed ceiling would be a flake. - Assert.IsTrue(after.Fds <= before.Fds, + Assert.IsTrue( + after.Fds <= before.Fds, $"sockets, pipes and event descriptors grew {before.Fds} -> {after.Fds} and stayed up: " - + DescribeGrowth(before.FdTargets, after.FdTargets) - + ". New sockets: " + DescribeSockets(socketsBefore, SocketInodes())); - Assert.IsTrue(after.Threads <= before.Threads, - $"PipeWire loop threads grew {before.Threads} -> {after.Threads}, so a loop was left running"); + + DescribeGrowth(before.FdTargets, after.FdTargets) + + ". New sockets: " + + DescribeSockets(socketsBefore, SocketInodes()) + ); + Assert.IsTrue( + after.Threads <= before.Threads, + $"PipeWire loop threads grew {before.Threads} -> {after.Threads}, so a loop was left running" + ); // The daemon's side, which nothing inside this process can see. Both contexts are gone, so // every object and client we accounted for should have gone with them. Counted by name, so // this is about what the soak left behind rather than about what the rest of the suite // happened to be doing at the same moment. - Assert.IsTrue(after.Clients <= before.Clients, + Assert.IsTrue( + after.Clients <= before.Clients, $"the daemon still holds {SoakClientPrefix} clients from this run: " - + $"{before.Clients} -> {after.Clients}"); - Assert.IsTrue(after.Nodes <= before.Nodes, - $"the daemon still holds {SoakPrefix} nodes from this run: {before.Nodes} -> {after.Nodes}"); - Assert.IsTrue(after.Links <= before.Links, - $"the daemon still holds {SoakPrefix} links from this run: {before.Links} -> {after.Links}"); + + $"{before.Clients} -> {after.Clients}" + ); + Assert.IsTrue( + after.Nodes <= before.Nodes, + $"the daemon still holds {SoakPrefix} nodes from this run: {before.Nodes} -> {after.Nodes}" + ); + Assert.IsTrue( + after.Links <= before.Links, + $"the daemon still holds {SoakPrefix} links from this run: {before.Links} -> {after.Links}" + ); } /// Waits until the daemon's own object counts stop moving. @@ -442,10 +526,14 @@ private static async Task SettleAsync(CancellationToken ct) for (int attempt = 0; attempt < 40; attempt++) { PwDump dump = await PwDump.CaptureAsync(ct); - (int, int, int) now = (dump.OfKind("Node").Count(), dump.OfKind("Link").Count(), - dump.OfKind("Client").Count()); + (int, int, int) now = ( + dump.OfKind("Node").Count(), + dump.OfKind("Link").Count(), + dump.OfKind("Client").Count() + ); - if (now == previous) return; + if (now == previous) + return; previous = now; await Task.Delay(TimeSpan.FromMilliseconds(250), ct); @@ -453,7 +541,11 @@ private static async Task SettleAsync(CancellationToken ct) } private static async Task MakeAndBreakAsync( - PipeWireRegistry registry, ConcurrentBag created, ConcurrentQueue faults, CancellationToken ct) + PipeWireRegistry registry, + ConcurrentBag created, + ConcurrentQueue faults, + CancellationToken ct + ) { try { @@ -464,9 +556,13 @@ private static async Task MakeAndBreakAsync( // No media.class: these are made and unmade continuously, and presenting each one // to the session manager as a routable sink is what fills its event queue with // activations it can never finish. - PipeWireNode node = await registry.CreateVirtualSink("Soak") - .WithName(Unique("pwnet_soak")).WithMediaClass("").ExecuteAsync(ct); - if (node.ObjectSerial is { } serial) created.Add(serial); + PipeWireNode node = await registry + .CreateVirtualSink("Soak") + .WithName(Unique("pwnet_soak")) + .WithMediaClass("") + .ExecuteAsync(ct); + if (node.ObjectSerial is { } serial) + created.Add(serial); // ENOENT anywhere in here is the object having gone already, and under a soak // against a live session manager that is legitimate: WirePlumber destroys nodes @@ -497,16 +593,27 @@ private static async Task MakeAndBreakAsync( // ENOENT is the object having gone already (see above); the destroy below keeps // its own tolerance rather than sharing the block's, because a create that never // finished binding must still be withdrawn. - try { await registry.DestroyGlobalAsync(node.NodeId, ct); } + try + { + await registry.DestroyGlobalAsync(node.NodeId, ct); + } catch (PipeWireException e) when (e.IsObjectGone) { } } } - catch (OperationCanceledException) { /* the soak's own clock */ } - catch (Exception ex) { faults.Enqueue($"maker: {ex.GetType().Name}: {ex.Message}"); } + catch (OperationCanceledException) + { /* the soak's own clock */ + } + catch (Exception ex) + { + faults.Enqueue($"maker: {ex.GetType().Name}: {ex.Message}"); + } } private static async Task InspectAsync( - PipeWireRegistry registry, ConcurrentQueue faults, CancellationToken ct) + PipeWireRegistry registry, + ConcurrentQueue faults, + CancellationToken ct + ) { try { @@ -518,7 +625,8 @@ private static async Task InspectAsync( foreach (PipeWireNode node in graph.Nodes.Take(6)) { - if (ct.IsCancellationRequested) break; + if (ct.IsCancellationRequested) + break; try { @@ -526,19 +634,29 @@ private static async Task InspectAsync( await control.ReadyAsync(ct); _ = await control.GetVolumeAsync(ct); } - catch (ArgumentException) { /* it went away between the snapshot and the bind */ } - catch (PipeWireException) { /* or the daemon refused, which is its right */ } + catch (ArgumentException) + { /* it went away between the snapshot and the bind */ + } + catch (PipeWireException) + { /* or the daemon refused, which is its right */ + } } await registry.WaitForInitialEnumerationAsync(ct); } } catch (OperationCanceledException) { } - catch (Exception ex) { faults.Enqueue($"inspector: {ex.GetType().Name}: {ex.Message}"); } + catch (Exception ex) + { + faults.Enqueue($"inspector: {ex.GetType().Name}: {ex.Message}"); + } } private static async Task LinkAndUnlinkAsync( - PipeWireRegistry registry, ConcurrentQueue faults, CancellationToken ct) + PipeWireRegistry registry, + ConcurrentQueue faults, + CancellationToken ct + ) { try { @@ -552,19 +670,26 @@ private static async Task LinkAndUnlinkAsync( // from the whole graph mostly cannot (on a desktop the first pair was MIDI into // audio), so each attempt was a refusal the daemon logs as an error: hundreds per // run, burying any real one, and reaching into the machine's own devices. - HashSet ours = graph.Nodes - .Where(n => n.NodeName?.StartsWith(SoakPrefix, StringComparison.Ordinal) == true) + HashSet ours = graph + .Nodes.Where(n => + n.NodeName?.StartsWith(SoakPrefix, StringComparison.Ordinal) == true + ) .Select(n => n.NodeId) .ToHashSet(); PipeWirePort? output = graph.Ports.FirstOrDefault(p => - p.PortDirection == PipeWirePortDirection.Out && ours.Contains(p.NodeId) && p.DspFormat is not null); + p.PortDirection == PipeWirePortDirection.Out + && ours.Contains(p.NodeId) + && p.DspFormat is not null + ); PipeWirePort? input = output is null ? null : graph.Ports.FirstOrDefault(p => - p.PortDirection == PipeWirePortDirection.In && ours.Contains(p.NodeId) + p.PortDirection == PipeWirePortDirection.In + && ours.Contains(p.NodeId) && p.NodeId != output.NodeId - && string.Equals(p.DspFormat, output.DspFormat, StringComparison.Ordinal)); + && string.Equals(p.DspFormat, output.DspFormat, StringComparison.Ordinal) + ); if (output is null || input is null) { @@ -577,7 +702,9 @@ private static async Task LinkAndUnlinkAsync( PipeWireLink link = await registry.CreateLinkAsync(output, input, ct); await registry.DestroyGlobalAsync(link.LinkId, ct); } - catch (ArgumentException) { /* one of the ports just left with its node */ } + catch (ArgumentException) + { /* one of the ports just left with its node */ + } catch (PipeWireException) { // One end destroyed between the snapshot and the link: the maker is unmaking @@ -586,11 +713,17 @@ private static async Task LinkAndUnlinkAsync( } } catch (OperationCanceledException) { } - catch (Exception ex) { faults.Enqueue($"linker: {ex.GetType().Name}: {ex.Message}"); } + catch (Exception ex) + { + faults.Enqueue($"linker: {ex.GetType().Name}: {ex.Message}"); + } } private static async Task ExternalToolsAsync( - PipeWireRegistry registry, ConcurrentQueue faults, CancellationToken ct) + PipeWireRegistry registry, + ConcurrentQueue faults, + CancellationToken ct + ) { try { @@ -600,7 +733,12 @@ private static async Task ExternalToolsAsync( { // Processes that are not us, changing the same graph. This is what makes the soak // more than four threads sharing one library. - await using (PwTools.Loopback loop = await PwTools.StartLoopbackAsync(Unique("pwnet_soak_lb"), ct)) + await using ( + PwTools.Loopback loop = await PwTools.StartLoopbackAsync( + Unique("pwnet_soak_lb"), + ct + ) + ) { await registry.WaitForInitialEnumerationAsync(ct); } @@ -612,7 +750,12 @@ private static async Task ExternalToolsAsync( } } catch (OperationCanceledException) { } - catch (AssertInconclusiveException) { /* a tool is missing; the other actors carry the soak */ } - catch (Exception ex) { faults.Enqueue($"external: {ex.GetType().Name}: {ex.Message}"); } + catch (AssertInconclusiveException) + { /* a tool is missing; the other actors carry the soak */ + } + catch (Exception ex) + { + faults.Enqueue($"external: {ex.GetType().Name}: {ex.Message}"); + } } } diff --git a/tests/PipeWire.NET.Tests/CliTool.cs b/tests/PipeWire.NET.Tests/CliTool.cs index 836cf76..c3c9aa0 100644 --- a/tests/PipeWire.NET.Tests/CliTool.cs +++ b/tests/PipeWire.NET.Tests/CliTool.cs @@ -33,19 +33,23 @@ private CliTool(string name, string path) if (Environment.GetEnvironmentVariable(envKey) is { Length: > 0 } overridden) return File.Exists(overridden) ? new CliTool(name, overridden) : null; - foreach (string dir in (Environment.GetEnvironmentVariable("PATH") ?? string.Empty) - .Split(System.IO.Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries)) + foreach ( + string dir in (Environment.GetEnvironmentVariable("PATH") ?? string.Empty).Split( + System.IO.Path.PathSeparator, + StringSplitOptions.RemoveEmptyEntries + ) + ) { string candidate = System.IO.Path.Combine(dir, name); - if (File.Exists(candidate)) return new CliTool(name, candidate); + if (File.Exists(candidate)) + return new CliTool(name, candidate); } return null; } /// Resolves a tool, or skips the calling test when it is not installed. - public static CliTool Require(string name) => - Find(name) ?? SkipBecauseMissing(name); + public static CliTool Require(string name) => Find(name) ?? SkipBecauseMissing(name); private static CliTool SkipBecauseMissing(string name) { @@ -57,7 +61,8 @@ private static CliTool SkipBecauseMissing(string name) { Assert.Fail( $"{name} is not installed, and PWNET_REQUIRE_TOOLS is set. Either install it in the " - + "image or drop the variable, but do not let this category stop running unnoticed."); + + "image or drop the variable, but do not let this category stop running unnoticed." + ); } Assert.Inconclusive($"{name} is not installed."); @@ -67,7 +72,10 @@ private static CliTool SkipBecauseMissing(string name) /// Runs the tool to completion and captures both streams. /// It did not exit within . public async Task<(int ExitCode, string Stdout, string Stderr)> RunAsync( - string[] args, CancellationToken cancellationToken, TimeSpan? timeout = null) + string[] args, + CancellationToken cancellationToken, + TimeSpan? timeout = null + ) { var psi = new ProcessStartInfo(Path) { @@ -75,10 +83,11 @@ private static CliTool SkipBecauseMissing(string name) RedirectStandardError = true, UseShellExecute = false, }; - foreach (string a in args) psi.ArgumentList.Add(a); + foreach (string a in args) + psi.ArgumentList.Add(a); - using Process proc = Process.Start(psi) - ?? throw new InvalidOperationException($"failed to start {Path}"); + using Process proc = + Process.Start(psi) ?? throw new InvalidOperationException($"failed to start {Path}"); Task stdout = proc.StandardOutput.ReadToEndAsync(cancellationToken); Task stderr = proc.StandardError.ReadToEndAsync(cancellationToken); @@ -93,9 +102,16 @@ private static CliTool SkipBecauseMissing(string name) { // Killed on any cancellation, not only the timeout: a child left running outlives the // test and interferes with every one after it. - try { proc.Kill(entireProcessTree: true); } catch (InvalidOperationException) { /* already gone */ } - - if (cancellationToken.IsCancellationRequested) throw; + try + { + proc.Kill(entireProcessTree: true); + } + catch (InvalidOperationException) + { /* already gone */ + } + + if (cancellationToken.IsCancellationRequested) + throw; throw new TimeoutException($"{Name} did not exit"); } @@ -111,7 +127,8 @@ public Process Start(params string[] args) RedirectStandardError = true, UseShellExecute = false, }; - foreach (string a in args) psi.ArgumentList.Add(a); + foreach (string a in args) + psi.ArgumentList.Add(a); return Process.Start(psi) ?? throw new InvalidOperationException($"failed to start {Path}"); } diff --git a/tests/PipeWire.NET.Tests/ControlContractTests.cs b/tests/PipeWire.NET.Tests/ControlContractTests.cs index 0caf524..2839200 100644 --- a/tests/PipeWire.NET.Tests/ControlContractTests.cs +++ b/tests/PipeWire.NET.Tests/ControlContractTests.cs @@ -45,8 +45,13 @@ public void AChannelVolumePod_IsAnArrayOfFloatsNotAStructOfThem() { // The array's child type is what says how the daemon reads the values that follow. Declared // as anything else, the bytes are the same length and mean something different. - var pod = Props(new SpaPodProperty(SpaProp.ChannelVolumes, 0, - new SpaArray(SpaType.Float, [new SpaFloat(0.25f), new SpaFloat(0.75f)]))); + var pod = Props( + new SpaPodProperty( + SpaProp.ChannelVolumes, + 0, + new SpaArray(SpaType.Float, [new SpaFloat(0.25f), new SpaFloat(0.75f)]) + ) + ); Assert.IsTrue(SpaPod.TryParse(SpaPod.ToBytes(pod), out SpaValue? read)); var array = (SpaArray)((SpaObject)read!)[SpaProp.ChannelVolumes]!; @@ -64,15 +69,23 @@ public void ARoutePod_CarriesItsVolumeNestedInsideRatherThanBesideIt() // it does not have. var props = Props( new SpaPodProperty(SpaProp.Mute, 0, new SpaBool(false)), - new SpaPodProperty(SpaProp.ChannelVolumes, 0, new SpaArray(SpaType.Float, [new SpaFloat(0.4f)]))); - - var route = new SpaObject(SpaType.ObjectParamRoute, SpaParamType.Route, - [ - new SpaPodProperty(SpaParamRoute.Index, 0, new SpaInt(2)), - new SpaPodProperty(SpaParamRoute.Device, 0, new SpaInt(1)), - new SpaPodProperty(SpaParamRoute.Props, 0, props), - new SpaPodProperty(SpaParamRoute.Save, 0, new SpaBool(true)), - ]); + new SpaPodProperty( + SpaProp.ChannelVolumes, + 0, + new SpaArray(SpaType.Float, [new SpaFloat(0.4f)]) + ) + ); + + var route = new SpaObject( + SpaType.ObjectParamRoute, + SpaParamType.Route, + [ + new SpaPodProperty(SpaParamRoute.Index, 0, new SpaInt(2)), + new SpaPodProperty(SpaParamRoute.Device, 0, new SpaInt(1)), + new SpaPodProperty(SpaParamRoute.Props, 0, props), + new SpaPodProperty(SpaParamRoute.Save, 0, new SpaBool(true)), + ] + ); Assert.IsTrue(SpaPod.TryParse(SpaPod.ToBytes(route), out SpaValue? read)); var parsed = (SpaObject)read!; @@ -81,8 +94,11 @@ public void ARoutePod_CarriesItsVolumeNestedInsideRatherThanBesideIt() Assert.AreEqual(new SpaInt(2), parsed[SpaParamRoute.Index]); var nested = (SpaObject)parsed[SpaParamRoute.Props]!; - Assert.AreEqual(SpaType.ObjectProps, nested.ObjectType, - "the nested mixer must still be a Props object"); + Assert.AreEqual( + SpaType.ObjectProps, + nested.ObjectType, + "the nested mixer must still be a Props object" + ); Assert.AreEqual(new SpaBool(false), nested[SpaProp.Mute]); } @@ -90,8 +106,11 @@ public void ARoutePod_CarriesItsVolumeNestedInsideRatherThanBesideIt() public void AProfilePod_CarriesOnlyTheIndex() { // Switching profile is one field. Sending more risks the daemon matching on something else. - var pod = new SpaObject(SpaType.ObjectParamProfile, SpaParamType.Profile, - [new SpaPodProperty(SpaParamProfile.Index, 0, new SpaInt(3))]); + var pod = new SpaObject( + SpaType.ObjectParamProfile, + SpaParamType.Profile, + [new SpaPodProperty(SpaParamProfile.Index, 0, new SpaInt(3))] + ); Assert.IsTrue(SpaPod.TryParse(SpaPod.ToBytes(pod), out SpaValue? read)); var parsed = (SpaObject)read!; @@ -108,7 +127,11 @@ public void ParameterInfo_DistinguishesReadableFromWritable() // A node advertises Props as both and Format as write-only. Collapsing the two into // "supported" would make enumerating Format look legal, and it is an error. var readable = new PipeWireParameterInfo(SpaParamType.Props, CanRead: true, CanWrite: true); - var writeOnly = new PipeWireParameterInfo(SpaParamType.Format, CanRead: false, CanWrite: true); + var writeOnly = new PipeWireParameterInfo( + SpaParamType.Format, + CanRead: false, + CanWrite: true + ); Assert.IsTrue(readable.CanRead && readable.CanWrite); Assert.IsFalse(writeOnly.CanRead); @@ -140,14 +163,22 @@ public void ParameterInfoFlags_MatchWhatSpaDefines() [TestMethod] public void APermissionEntry_IsAbsoluteAndCarriesTheObjectItIsAbout() { - var confine = new PipeWireObjectPermission(PipeWireClientProxy.AnyObject, PipeWirePermissions.None); - var grant = new PipeWireObjectPermission(42, PipeWirePermissions.Read | PipeWirePermissions.Execute); + var confine = new PipeWireObjectPermission( + PipeWireClientProxy.AnyObject, + PipeWirePermissions.None + ); + var grant = new PipeWireObjectPermission( + 42, + PipeWirePermissions.Read | PipeWirePermissions.Execute + ); Assert.AreEqual(uint.MaxValue, confine.ObjectId, "the catch-all id is the wildcard"); Assert.AreEqual(PipeWirePermissions.None, confine.Permissions); Assert.IsTrue(grant.Permissions.HasFlag(PipeWirePermissions.Read)); - Assert.IsFalse(grant.Permissions.HasFlag(PipeWirePermissions.Write), - "a permission set is exactly what was asked for, not a superset"); + Assert.IsFalse( + grant.Permissions.HasFlag(PipeWirePermissions.Write), + "a permission set is exactly what was asked for, not a superset" + ); } [TestMethod] @@ -177,7 +208,12 @@ public void PermissionBits_MatchPipeWiresOctalDefinitions() [TestMethod] public void AMetadataEntryReportsRemovalAsANullValue_AndKeepsItsSubject() { - var removal = new PipeWireMetadataEntry(PipeWireMetadataProxy.SubjectCore, "default.audio.sink", null, null); + var removal = new PipeWireMetadataEntry( + PipeWireMetadataProxy.SubjectCore, + "default.audio.sink", + null, + null + ); Assert.IsNull(removal.Value, "a removal is a null value, not an empty string"); Assert.IsNull(removal.NameValue); @@ -189,15 +225,39 @@ public void AJsonValueYieldsItsName_AndAnythingElseYieldsNothing() { // The daemon writes { "name": "..." } rather than a bare string, and writes whatever it likes // into other keys. Parsing must not throw on either. - Assert.AreEqual("alsa_output.x", - new PipeWireMetadataEntry(0, "k", "Spa:String:JSON", """{ "name": "alsa_output.x" }""").NameValue); - - foreach (string? junk in (string?[]) - [null, "", " ", "not json", "{", "}", "[]", "[1,2]", "\"bare\"", "{\"other\":1}", - "{\"name\":42}", "{\"name\":null}", "{\"name\":{}}"]) + Assert.AreEqual( + "alsa_output.x", + new PipeWireMetadataEntry( + 0, + "k", + "Spa:String:JSON", + """{ "name": "alsa_output.x" }""" + ).NameValue + ); + + foreach ( + string? junk in (string?[]) + [ + null, + "", + " ", + "not json", + "{", + "}", + "[]", + "[1,2]", + "\"bare\"", + "{\"other\":1}", + "{\"name\":42}", + "{\"name\":null}", + "{\"name\":{}}", + ] + ) { - Assert.IsNull(new PipeWireMetadataEntry(0, "k", null, junk).NameValue, - $"'{junk ?? "null"}' must not yield a name"); + Assert.IsNull( + new PipeWireMetadataEntry(0, "k", null, junk).NameValue, + $"'{junk ?? "null"}' must not yield a name" + ); } } @@ -208,11 +268,15 @@ public void ANameWithQuotesAndBackslashes_SurvivesTheJsonItIsWrappedIn() // delimit strings has to survive being written into one. foreach (string awkward in (string[])["a\"b", @"a\b", "a\"b\\c", "\\", "\"\"\""]) { - string json = $$"""{ "name": "{{awkward.Replace("\\", "\\\\", StringComparison.Ordinal) + string json = + $$"""{ "name": "{{awkward.Replace("\\", "\\\\", StringComparison.Ordinal) .Replace("\"", "\\\"", StringComparison.Ordinal)}}" }"""; - Assert.AreEqual(awkward, new PipeWireMetadataEntry(0, "k", null, json).NameValue, - $"'{awkward}' did not survive"); + Assert.AreEqual( + awkward, + new PipeWireMetadataEntry(0, "k", null, json).NameValue, + $"'{awkward}' did not survive" + ); } } @@ -247,8 +311,19 @@ public void TheTypedCollections_AreBuiltOnceAndReturnTheSameArray() { // Filtered on first read and kept, like the id indexes. Rebuilding per call would make a UI // that reads them each frame allocate for nothing. - var device = new PipeWireDevice(10, PipeWirePermissions.None, 3, - "card", null, null, "alsa", null, null, null, null); + var device = new PipeWireDevice( + 10, + PipeWirePermissions.None, + 3, + "card", + null, + null, + "alsa", + null, + null, + null, + null + ); var graph = new PipeWireGraphSnapshot(1, [], [], [], [device]); ImmutableArray first = graph.Devices; @@ -257,8 +332,9 @@ public void TheTypedCollections_AreBuiltOnceAndReturnTheSameArray() Assert.AreEqual(1, first.Length); Assert.IsTrue( System.Runtime.InteropServices.ImmutableCollectionsMarshal.AsArray(first) - == System.Runtime.InteropServices.ImmutableCollectionsMarshal.AsArray(second), - "the filtered collection must be built once and kept"); + == System.Runtime.InteropServices.ImmutableCollectionsMarshal.AsArray(second), + "the filtered collection must be built once and kept" + ); } [TestMethod] @@ -268,8 +344,31 @@ public void ObjectKindsAreDistinct_SoDispatchingOnThemCannotCollide() // dispatching without a type test. IPipeWireObject[] objects = [ - new PipeWireDevice(1, PipeWirePermissions.None, 3, null, null, null, null, null, null, null, null), - new PipeWireClient(2, PipeWirePermissions.None, 3, null, null, null, null, null, null, null), + new PipeWireDevice( + 1, + PipeWirePermissions.None, + 3, + null, + null, + null, + null, + null, + null, + null, + null + ), + new PipeWireClient( + 2, + PipeWirePermissions.None, + 3, + null, + null, + null, + null, + null, + null, + null + ), new PipeWireFactory(3, PipeWirePermissions.None, 3, null, null, null, null), new PipeWireModule(4, PipeWirePermissions.None, 3, null, null, null, null), new PipeWireMetadata(5, PipeWirePermissions.None, 3, null), @@ -290,21 +389,39 @@ public void ManyThreadsReadingATypedCollectionAtOnce_AllSeeTheWholeThing() { // A racing first touch must build the collection once and publish it whole: two threads // arriving together must not each build or see a half-filled array. - var devices = Enumerable.Range(0, 50).Select(i => - new PipeWireDevice((uint)i, PipeWirePermissions.None, 3, - $"dev{i}", null, null, "alsa", null, null, null, null)); + var devices = Enumerable + .Range(0, 50) + .Select(i => new PipeWireDevice( + (uint)i, + PipeWirePermissions.None, + 3, + $"dev{i}", + null, + null, + "alsa", + null, + null, + null, + null + )); var snapshot = new PipeWireGraphSnapshot(1, [], [], [], devices); var faults = new System.Collections.Concurrent.ConcurrentQueue(); - Parallel.For(0, 64, _ => - { - ImmutableArray read = snapshot.Devices; - if (read.Length != 50) faults.Enqueue($"saw {read.Length} devices"); - - ImmutableArray clients = snapshot.Clients; - if (clients.Length != 0) faults.Enqueue($"saw {clients.Length} clients"); - }); + Parallel.For( + 0, + 64, + _ => + { + ImmutableArray read = snapshot.Devices; + if (read.Length != 50) + faults.Enqueue($"saw {read.Length} devices"); + + ImmutableArray clients = snapshot.Clients; + if (clients.Length != 0) + faults.Enqueue($"saw {clients.Length} clients"); + } + ); Assert.IsTrue(faults.IsEmpty, string.Join("; ", faults)); } diff --git a/tests/PipeWire.NET.Tests/ControlSurfaceTests.cs b/tests/PipeWire.NET.Tests/ControlSurfaceTests.cs index 9da54de..c0a3a33 100644 --- a/tests/PipeWire.NET.Tests/ControlSurfaceTests.cs +++ b/tests/PipeWire.NET.Tests/ControlSurfaceTests.cs @@ -35,7 +35,9 @@ private static void RequireLinux() } private static async Task<(PipeWireContext Context, PipeWireRegistry Registry)> ConnectAsync( - string name, CancellationToken cancellationToken) + string name, + CancellationToken cancellationToken + ) { var context = new PipeWireContext(name, ConsoleTestLoggerFactory.Instance); await context.StartAsync(cancellationToken); @@ -49,11 +51,15 @@ public async Task ANodesSupportedFormats_CanBeEnumerated() { RequireLinux(); using var cts = new CancellationTokenSource(Budget); - (PipeWireContext ctx, PipeWireRegistry registry) = await ConnectAsync("pwnet-formats", cts.Token); + (PipeWireContext ctx, PipeWireRegistry registry) = await ConnectAsync( + "pwnet-formats", + cts.Token + ); await using (ctx) await using (registry) { - PipeWireNode node = await registry.CreateVirtualSink("Formats") + PipeWireNode node = await registry + .CreateVirtualSink("Formats") .WithName($"pwnet_formats_{Environment.ProcessId}_{Random.Shared.Next():x}") .ExecuteAsync(cts.Token); @@ -76,7 +82,10 @@ public async Task TheDefaultAudioSource_CanBeSetToWhatItAlreadyIs() { RequireLinux(); using var cts = new CancellationTokenSource(Budget); - (PipeWireContext ctx, PipeWireRegistry registry) = await ConnectAsync("pwnet-defsource", cts.Token); + (PipeWireContext ctx, PipeWireRegistry registry) = await ConnectAsync( + "pwnet-defsource", + cts.Token + ); await using (ctx) await using (registry) { @@ -97,8 +106,11 @@ public async Task TheDefaultAudioSource_CanBeSetToWhatItAlreadyIs() // cache rather than waiting for an event. await store.SetDefaultAudioSourceAsync(current!, cts.Token); - Assert.AreEqual(current, store.DefaultAudioSource?.NameValue, - "rewriting the default source must leave it where it was"); + Assert.AreEqual( + current, + store.DefaultAudioSource?.NameValue, + "rewriting the default source must leave it where it was" + ); } } } @@ -108,7 +120,10 @@ public async Task AStoreAndAClientControl_RefuseWorkAfterDisposal() { RequireLinux(); using var cts = new CancellationTokenSource(Budget); - (PipeWireContext ctx, PipeWireRegistry registry) = await ConnectAsync("pwnet-disposed", cts.Token); + (PipeWireContext ctx, PipeWireRegistry registry) = await ConnectAsync( + "pwnet-disposed", + cts.Token + ); await using (ctx) await using (registry) { @@ -120,10 +135,12 @@ public async Task AStoreAndAClientControl_RefuseWorkAfterDisposal() // ClearAsync empties a store the whole session shares, so it is checked only to the // point of its guard - calling it for real would take the session's defaults with it. - await Assert.ThrowsExactlyAsync( - async () => await store.ClearAsync(cts.Token)); - await Assert.ThrowsExactlyAsync( - async () => await store.SetAsync("k", "v", cancellationToken: cts.Token)); + await Assert.ThrowsExactlyAsync(async () => + await store.ClearAsync(cts.Token) + ); + await Assert.ThrowsExactlyAsync(async () => + await store.SetAsync("k", "v", cancellationToken: cts.Token) + ); PipeWireClient? self = registry.Current.Clients.FirstOrDefault(); if (self is null) @@ -132,9 +149,12 @@ await Assert.ThrowsExactlyAsync( PipeWireClientProxy client = registry.BindClient(self!.Id); await client.DisposeAsync(); - await Assert.ThrowsExactlyAsync( - async () => await client.UpdatePropertiesAsync( - new Dictionary { ["k"] = "v" }, cts.Token)); + await Assert.ThrowsExactlyAsync(async () => + await client.UpdatePropertiesAsync( + new Dictionary { ["k"] = "v" }, + cts.Token + ) + ); } } @@ -143,7 +163,10 @@ public async Task UpdatingPermissionsWithNothingToApply_IsRejectedBeforeItReache { RequireLinux(); using var cts = new CancellationTokenSource(Budget); - (PipeWireContext ctx, PipeWireRegistry registry) = await ConnectAsync("pwnet-perms", cts.Token); + (PipeWireContext ctx, PipeWireRegistry registry) = await ConnectAsync( + "pwnet-perms", + cts.Token + ); await using (ctx) await using (registry) { @@ -155,8 +178,12 @@ public async Task UpdatingPermissionsWithNothingToApply_IsRejectedBeforeItReache // Reducing a real client's permissions can cut off the connection that would restore // them, so only the argument guard is driven here. - await Assert.ThrowsExactlyAsync( - async () => await client.UpdatePermissionsAsync(ReadOnlyMemory.Empty, cts.Token)); + await Assert.ThrowsExactlyAsync(async () => + await client.UpdatePermissionsAsync( + ReadOnlyMemory.Empty, + cts.Token + ) + ); } } @@ -197,11 +224,15 @@ public async Task ASubscribedVolumeChange_RaisesAnEventWhileConcurrentReadsAgree RequireLinux(); SessionGates.RequireDaemonAtLeast(1, 6, 8); using var cts = new CancellationTokenSource(Budget); - (PipeWireContext ctx, PipeWireRegistry registry) = await ConnectAsync("pwnet-subscribe", cts.Token); + (PipeWireContext ctx, PipeWireRegistry registry) = await ConnectAsync( + "pwnet-subscribe", + cts.Token + ); await using (ctx) await using (registry) { - PipeWireNode node = await registry.CreateVirtualSink("Subscribe") + PipeWireNode node = await registry + .CreateVirtualSink("Subscribe") .WithName($"pwnet_sub_{Environment.ProcessId}_{Random.Shared.Next():x}") .ExecuteAsync(cts.Token); @@ -210,7 +241,9 @@ public async Task ASubscribedVolumeChange_RaisesAnEventWhileConcurrentReadsAgree control.SubscribeParameters(SpaParamType.Props); CollectionAssert.AreEqual( - new[] { SpaParamType.Props }, control.SubscribedParameters.ToArray()); + new[] { SpaParamType.Props }, + control.SubscribedParameters.ToArray() + ); // Completed by the event that carries the value written below, not by whichever Props // object arrives first. The subscription and the eight enumerations below share one @@ -218,12 +251,15 @@ public async Task ASubscribedVolumeChange_RaisesAnEventWhileConcurrentReadsAgree // write - and report it as the change. Waiting for the written value still fails if no // event ever carries it, which is the property under test. var changed = new TaskCompletionSource( - TaskCreationOptions.RunContinuationsAsynchronously); + TaskCreationOptions.RunContinuationsAsynchronously + ); control.ParameterChanged += (_, value) => { - if (value.ObjectType == SpaType.ObjectProps + if ( + value.ObjectType == SpaType.ObjectProps && value[SpaProp.Volume] is SpaFloat v - && Math.Abs(v.Value - 0.5f) < 0.0001f) + && Math.Abs(v.Value - 0.5f) < 0.0001f + ) { changed.TrySetResult(value); } @@ -231,10 +267,10 @@ public async Task ASubscribedVolumeChange_RaisesAnEventWhileConcurrentReadsAgree // No writer is active, so every concurrent read must file under its own key and all // must describe the same state. - Task>[] reads = - Enumerable.Range(0, 8) - .Select(_ => control.EnumerateParametersAsync(SpaParamType.Props, cts.Token)) - .ToArray(); + Task>[] reads = Enumerable + .Range(0, 8) + .Select(_ => control.EnumerateParametersAsync(SpaParamType.Props, cts.Token)) + .ToArray(); ImmutableArray[] results = await Task.WhenAll(reads); foreach (ImmutableArray result in results) CollectionAssert.AreEqual(results[0].ToArray(), result.ToArray()); @@ -258,11 +294,15 @@ public async Task NodeParameterGuards_RefuseBadInputBeforeTheDaemon() // of them are checked against a node that is otherwise fully usable. RequireLinux(); using var cts = new CancellationTokenSource(Budget); - (PipeWireContext ctx, PipeWireRegistry registry) = await ConnectAsync("pwnet-nodeguards", cts.Token); + (PipeWireContext ctx, PipeWireRegistry registry) = await ConnectAsync( + "pwnet-nodeguards", + cts.Token + ); await using (ctx) await using (registry) { - PipeWireNode node = await registry.CreateVirtualSink("Guards") + PipeWireNode node = await registry + .CreateVirtualSink("Guards") .WithName($"pwnet_guards_{Environment.ProcessId}_{Random.Shared.Next():x}") .ExecuteAsync(cts.Token); @@ -270,19 +310,32 @@ public async Task NodeParameterGuards_RefuseBadInputBeforeTheDaemon() await control.ReadyAsync(cts.Token); Assert.ThrowsExactly(() => control.SetVolumeAsync(-1f)); - Assert.ThrowsExactly(() => control.SetVolumeAsync(float.NaN)); - - await Assert.ThrowsExactlyAsync( - async () => await control.SetPortConfigAsync(null!, cts.Token)); - await Assert.ThrowsExactlyAsync( - async () => await control.SetProcessLatencyAsync(null!, cts.Token)); - await Assert.ThrowsExactlyAsync( - async () => await control.SetTagAsync(null!, cts.Token)); + Assert.ThrowsExactly(() => + control.SetVolumeAsync(float.NaN) + ); + + await Assert.ThrowsExactlyAsync(async () => + await control.SetPortConfigAsync(null!, cts.Token) + ); + await Assert.ThrowsExactlyAsync(async () => + await control.SetProcessLatencyAsync(null!, cts.Token) + ); + await Assert.ThrowsExactlyAsync(async () => + await control.SetTagAsync(null!, cts.Token) + ); // Both channel-volume overloads reach the daemon: unchecked writes verbatim, checked // writes against the map and the current volumes. - await control.SetChannelVolumesAsync(new float[] { 0.5f, 0.5f }, matchChannelMap: false, cts.Token); - await control.SetChannelVolumesAsync(new float[] { 0.5f, 0.5f }, matchChannelMap: true, cts.Token); + await control.SetChannelVolumesAsync( + new float[] { 0.5f, 0.5f }, + matchChannelMap: false, + cts.Token + ); + await control.SetChannelVolumesAsync( + new float[] { 0.5f, 0.5f }, + matchChannelMap: true, + cts.Token + ); ImmutableArray volumes = await control.GetChannelVolumesAsync(cts.Token); CollectionAssert.AreEqual(new[] { 0.5f, 0.5f }, volumes.ToArray()); @@ -320,7 +373,10 @@ public async Task DefaultEndpointGuards_RefuseAnEmptyNameBeforeTheDaemon() // empty case is refused here rather than written. RequireLinux(); using var cts = new CancellationTokenSource(Budget); - (PipeWireContext ctx, PipeWireRegistry registry) = await ConnectAsync("pwnet-defguards", cts.Token); + (PipeWireContext ctx, PipeWireRegistry registry) = await ConnectAsync( + "pwnet-defguards", + cts.Token + ); await using (ctx) await using (registry) { @@ -330,10 +386,12 @@ public async Task DefaultEndpointGuards_RefuseAnEmptyNameBeforeTheDaemon() await using (store) { - await Assert.ThrowsExactlyAsync( - async () => await store!.SetDefaultAudioSinkAsync("", cts.Token)); - await Assert.ThrowsExactlyAsync( - async () => await store!.SetDefaultAudioSourceAsync("", cts.Token)); + await Assert.ThrowsExactlyAsync(async () => + await store!.SetDefaultAudioSinkAsync("", cts.Token) + ); + await Assert.ThrowsExactlyAsync(async () => + await store!.SetDefaultAudioSourceAsync("", cts.Token) + ); } } } @@ -369,38 +427,52 @@ public async Task RetaggingAConnection_ReachesTheDaemonAndComesBack() PipeWireClient? own = null; for (var i = 0; i < 100 && own is null; i++) { - own = reg.Current.Clients.FirstOrDefault( - c => c.Properties.TryGetValue(PipeWireKeys.PW_KEY_APP_NAME, out string? v) && v == Before); + own = reg.Current.Clients.FirstOrDefault(c => + c.Properties.TryGetValue(PipeWireKeys.PW_KEY_APP_NAME, out string? v) + && v == Before + ); - if (own is null) await Task.Delay(50, cts.Token); + if (own is null) + await Task.Delay(50, cts.Token); } - Assert.IsNotNull(own, $"no client in the graph is this process (application.name '{Before}')"); + Assert.IsNotNull( + own, + $"no client in the graph is this process (application.name '{Before}')" + ); await using PipeWireClientProxy client = reg.BindClient(own!.Id); await client.ReadyAsync(cts.Token); Assert.AreEqual( Before, - client.Properties.TryGetValue(PipeWireKeys.PW_KEY_APP_NAME, out string? initial) ? initial : null, - "the bound client did not report the name it connected under"); + client.Properties.TryGetValue(PipeWireKeys.PW_KEY_APP_NAME, out string? initial) + ? initial + : null, + "the bound client did not report the name it connected under" + ); Assert.IsTrue( - ctx.UpdateProperties(new Dictionary - { - [PipeWireKeys.PW_KEY_APP_NAME] = After, - }) > 0, - "the new application.name changed nothing, so it was never stored"); + ctx.UpdateProperties( + new Dictionary { [PipeWireKeys.PW_KEY_APP_NAME] = After } + ) > 0, + "the new application.name changed nothing, so it was never stored" + ); for (var i = 0; i < 100; i++) { - if (client.Properties.TryGetValue(PipeWireKeys.PW_KEY_APP_NAME, out string? now) && now == After) + if ( + client.Properties.TryGetValue(PipeWireKeys.PW_KEY_APP_NAME, out string? now) + && now == After + ) return; await Task.Delay(50, cts.Token); } - Assert.Fail("the daemon never sent the retagged application.name back on the client's info event"); + Assert.Fail( + "the daemon never sent the retagged application.name back on the client's info event" + ); } } @@ -418,7 +490,10 @@ public async Task AClientsPermissions_CanBeReadBack() { RequireLinux(); using var cts = new CancellationTokenSource(Budget); - (PipeWireContext ctx, PipeWireRegistry reg) = await ConnectAsync("pwnet-perm-read", cts.Token); + (PipeWireContext ctx, PipeWireRegistry reg) = await ConnectAsync( + "pwnet-perm-read", + cts.Token + ); await using (ctx) await using (reg) @@ -426,11 +501,13 @@ public async Task AClientsPermissions_CanBeReadBack() PipeWireClient? own = null; for (var i = 0; i < 100 && own is null; i++) { - own = reg.Current.Clients.FirstOrDefault( - c => c.Properties.TryGetValue(PipeWireKeys.PW_KEY_APP_NAME, out string? v) - && v == "pwnet-perm-read"); + own = reg.Current.Clients.FirstOrDefault(c => + c.Properties.TryGetValue(PipeWireKeys.PW_KEY_APP_NAME, out string? v) + && v == "pwnet-perm-read" + ); - if (own is null) await Task.Delay(50, cts.Token); + if (own is null) + await Task.Delay(50, cts.Token); } Assert.IsNotNull(own, "no client in the graph is this process"); @@ -438,16 +515,19 @@ public async Task AClientsPermissions_CanBeReadBack() await using PipeWireClientProxy client = reg.BindClient(own!.Id); await client.ReadyAsync(cts.Token); - ImmutableArray permissions = - await client.GetPermissionsAsync(cancellationToken: cts.Token); + ImmutableArray permissions = await client.GetPermissionsAsync( + cancellationToken: cts.Token + ); Assert.IsFalse( permissions.IsDefaultOrEmpty, - "the daemon answered with no permission entries at all"); + "the daemon answered with no permission entries at all" + ); // Reading twice in a row has to work: the waiter is per-call and must be cleared. - ImmutableArray again = - await client.GetPermissionsAsync(cancellationToken: cts.Token); + ImmutableArray again = await client.GetPermissionsAsync( + cancellationToken: cts.Token + ); Assert.AreEqual(permissions.Length, again.Length, "a second read answered differently"); } @@ -467,18 +547,25 @@ public async Task AProxyWhoseObjectIsDestroyed_IsToldAboutIt() { RequireLinux(); using var cts = new CancellationTokenSource(Budget); - (PipeWireContext ctx, PipeWireRegistry reg) = await ConnectAsync("pwnet-removed", cts.Token); + (PipeWireContext ctx, PipeWireRegistry reg) = await ConnectAsync( + "pwnet-removed", + cts.Token + ); await using (ctx) await using (reg) { PipeWireNode node = await reg.CreateVirtualSinkAsync( - "pwnet removed probe", cancellationToken: cts.Token); + "pwnet removed probe", + cancellationToken: cts.Token + ); await using PipeWireNodeProxy proxy = reg.BindNode(node.NodeId); await proxy.ReadyAsync(cts.Token); - var removed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var removed = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously + ); proxy.Removed += () => removed.TrySetResult(); Assert.IsFalse(proxy.IsRemoved, "a live proxy reported its object as removed"); @@ -516,7 +603,11 @@ public async Task AHealthyConnection_ReportsNoFaultAndAcceptsALostHandler() ctx.ConnectionLost += OnLost; Assert.IsNull(ctx.ConnectionFault, "a healthy connection reported a fault"); - Assert.AreEqual(0, Volatile.Read(ref seen), "ConnectionLost fired on a healthy connection"); + Assert.AreEqual( + 0, + Volatile.Read(ref seen), + "ConnectionLost fired on a healthy connection" + ); ctx.ConnectionLost -= OnLost; } diff --git a/tests/PipeWire.NET.Tests/ConversionBoundaryTests.cs b/tests/PipeWire.NET.Tests/ConversionBoundaryTests.cs index 766318e..98fa556 100644 --- a/tests/PipeWire.NET.Tests/ConversionBoundaryTests.cs +++ b/tests/PipeWire.NET.Tests/ConversionBoundaryTests.cs @@ -3,8 +3,8 @@ using Microsoft.VisualStudio.TestTools.UnitTesting; using PipeWire.NET.Graph; using PipeWire.NET.Interop; -using PipeWire.NET.Spa; using PipeWire.NET.Media; +using PipeWire.NET.Spa; namespace PipeWire.NET.Tests; @@ -29,7 +29,10 @@ public sealed class ConversionBoundaryTests : PipeWireTestBase [DataRow("Video/Sink", PipeWireMediaKind.Video, PipeWireMediaFlow.Sink)] [DataRow("Midi/Bridge", PipeWireMediaKind.Midi, PipeWireMediaFlow.Duplex)] public void MediaClass_ParsesKindAndFlowStructurally( - string raw, PipeWireMediaKind kind, PipeWireMediaFlow flow) + string raw, + PipeWireMediaKind kind, + PipeWireMediaFlow flow + ) { Assert.AreEqual(kind, PipeWireMediaClass.ParseKind(raw)); Assert.AreEqual(flow, PipeWireMediaClass.ParseFlow(raw)); @@ -41,7 +44,10 @@ public void MediaClass_ParsesKindAndFlowStructurally( [DataRow("Stream/Input/Audio", PipeWireMediaKind.Audio, PipeWireMediaFlow.Sink)] [DataRow("Stream/Input/Video", PipeWireMediaKind.Video, PipeWireMediaFlow.Sink)] public void StreamClasses_NameTheirMediumLastAndInvertDirection( - string raw, PipeWireMediaKind kind, PipeWireMediaFlow flow) + string raw, + PipeWireMediaKind kind, + PipeWireMediaFlow flow + ) { // "Output" is the application's direction; from the graph it is a source to read from. Assert.AreEqual(kind, PipeWireMediaClass.ParseKind(raw)); @@ -53,7 +59,7 @@ public void StreamClasses_NameTheirMediumLastAndInvertDirection( [DataRow("")] [DataRow("/")] [DataRow("///")] - [DataRow("audio/source")] // media.class is case-sensitive + [DataRow("audio/source")] // media.class is case-sensitive [DataRow("Telephony/Gateway")] public void UnrecognisedOrMalformedClasses_ReportUnknownRatherThanGuessing(string? raw) { @@ -80,7 +86,10 @@ public void StructuralParsing_HandlesClassesNothingEnumerates() // whatever it actually states. Assert.AreEqual(PipeWireMediaKind.Video, PipeWireMediaClass.ParseKind("Video/Duplex")); Assert.AreEqual(PipeWireMediaFlow.Duplex, PipeWireMediaClass.ParseFlow("Video/Duplex")); - Assert.AreEqual(PipeWireMediaKind.Audio, PipeWireMediaClass.ParseKind("Audio/Source/Virtual/Something")); + Assert.AreEqual( + PipeWireMediaKind.Audio, + PipeWireMediaClass.ParseKind("Audio/Source/Virtual/Something") + ); } [TestMethod] @@ -114,9 +123,13 @@ public void EveryUsableSampleFormat_HasANonZeroWidth() // FrameCount divides by this, so a zero would be a divide-by-zero on a real capture. foreach (AudioSampleFormat fmt in Enum.GetValues()) { - if (fmt is AudioSampleFormat.Unknown) continue; + if (fmt is AudioSampleFormat.Unknown) + continue; - Assert.IsTrue(fmt.BytesPerSample() > 0, $"{fmt} reports a width of {fmt.BytesPerSample()}"); + Assert.IsTrue( + fmt.BytesPerSample() > 0, + $"{fmt} reports a width of {fmt.BytesPerSample()}" + ); } } @@ -133,12 +146,15 @@ public void AnUnknownFormat_HasNoWidthAndStillDoesNotDivideByZero() } [TestMethod] - [DataRow(AudioSampleFormat.S16Le, 2, 480)] // 1920 bytes / (2ch * 2B) - [DataRow(AudioSampleFormat.F32Le, 2, 240)] // 1920 / (2 * 4) - [DataRow(AudioSampleFormat.U8, 1, 1920)] // 1920 / (1 * 1) - [DataRow(AudioSampleFormat.S24Le, 2, 320)] // 1920 / (2 * 3) + [DataRow(AudioSampleFormat.S16Le, 2, 480)] // 1920 bytes / (2ch * 2B) + [DataRow(AudioSampleFormat.F32Le, 2, 240)] // 1920 / (2 * 4) + [DataRow(AudioSampleFormat.U8, 1, 1920)] // 1920 / (1 * 1) + [DataRow(AudioSampleFormat.S24Le, 2, 320)] // 1920 / (2 * 3) public void FrameCount_DividesBytesByChannelsAndWidth( - AudioSampleFormat fmt, int channels, int expected) + AudioSampleFormat fmt, + int channels, + int expected + ) { var frame = new AudioFrame(new byte[1920], 48000, channels, fmt, 0, 0, 0, 0, 0); Assert.AreEqual(expected, frame.FrameCount); @@ -172,7 +188,10 @@ public void StreamProperties_BuildOnlyWhatWasSet() [TestMethod] public void StreamProperties_WithersAreChainableAndAllLand() { - StreamProperties props = new StreamProperties(StreamMediaType.Audio, StreamCategory.Playback) + StreamProperties props = new StreamProperties( + StreamMediaType.Audio, + StreamCategory.Playback + ) .WithRole("Music") .WithNodeName("my_node") .WithNodeDescription("My Node") @@ -209,8 +228,10 @@ public void StreamProperties_AcceptMultiByteAndLongValues() { // These become C strings, so the byte length is what matters, not the char count. string emoji = string.Concat(Enumerable.Repeat("\U0001F3B5", 200)); - StreamProperties props = new StreamProperties(StreamMediaType.Audio, StreamCategory.Playback) - .WithNodeDescription(emoji); + StreamProperties props = new StreamProperties( + StreamMediaType.Audio, + StreamCategory.Playback + ).WithNodeDescription(emoji); Assert.AreEqual(emoji, props.Values["node.description"]); } @@ -245,7 +266,7 @@ public void SpaDictBuilder_BuildAlwaysReportsUnsortedFlags() Span items = stackalloc spa_dict_item[2]; var b = new SpaDictBuilder(scratch, items); b.Add("z"u8, "1"u8); - b.Add("a"u8, "2"u8); // deliberately out of order + b.Add("a"u8, "2"u8); // deliberately out of order Assert.AreEqual(0u, b.Build().flags); } @@ -361,9 +382,17 @@ public void AClonedVideoFrame_KeepsItsBytesAndMetadata() { byte[] pixels = [1, 2, 3, 4, 5, 6, 7, 8]; var frame = new VideoFrame( - pixels, stride: 4, width: 2, height: 2, format: PixelFormat.Bgra, - sequenceNumber: 9, presentationTimestampNs: 100, graphTimeNs: 200, - streamPositionNs: 300, delayNs: 5); + pixels, + stride: 4, + width: 2, + height: 2, + format: PixelFormat.Bgra, + sequenceNumber: 9, + presentationTimestampNs: 100, + graphTimeNs: 200, + streamPositionNs: 300, + delayNs: 5 + ); OwnedVideoFrame owned = frame.Clone(); @@ -390,8 +419,15 @@ public void CloningAnFdBackedFrame_RefusesRatherThanKeepingNothing() // buffer. A byte copy would keep an empty frame that reads as valid, so Clone refuses // and points at the descriptor duplication that actually keeps something. var frame = new VideoFrame( - [], stride: 0, width: 2, height: 2, format: PixelFormat.Bgra, - sequenceNumber: 9, bufferType: PipeWireBufferType.DmaBuf, fd: 42); + [], + stride: 0, + width: 2, + height: 2, + format: PixelFormat.Bgra, + sequenceNumber: 9, + bufferType: PipeWireBufferType.DmaBuf, + fd: 42 + ); // A ref struct cannot cross a lambda boundary, so the throw is asserted by hand. try @@ -399,9 +435,7 @@ public void CloningAnFdBackedFrame_RefusesRatherThanKeepingNothing() frame.Clone(); Assert.Fail("cloning an fd-backed frame must refuse"); } - catch (InvalidOperationException) - { - } + catch (InvalidOperationException) { } } [TestMethod] @@ -409,8 +443,15 @@ public void AClonedAudioChunk_KeepsItsSamplesAndMetadata() { byte[] samples = [10, 20, 30, 40]; var chunk = new AudioFrame( - samples, sampleRate: 48000, channels: 2, format: AudioSampleFormat.F32Le, - sequenceNumber: 4, presentationTimestampNs: -1, graphTimeNs: 700, delayNs: 2); + samples, + sampleRate: 48000, + channels: 2, + format: AudioSampleFormat.F32Le, + sequenceNumber: 4, + presentationTimestampNs: -1, + graphTimeNs: 700, + delayNs: 2 + ); OwnedAudioFrame owned = chunk.Clone(); @@ -431,25 +472,76 @@ public void AClonedAudioChunk_KeepsItsSamplesAndMetadata() public void OwnedFrames_CompareByValue() { var first = new OwnedVideoFrame( - ImmutableArray.Create(1, 2), 2, 1, 1, PixelFormat.Rgba, 0, - default, null, null, null, null, 0); + ImmutableArray.Create(1, 2), + 2, + 1, + 1, + PixelFormat.Rgba, + 0, + default, + null, + null, + null, + null, + 0 + ); var same = new OwnedVideoFrame( - ImmutableArray.Create(1, 2), 2, 1, 1, PixelFormat.Rgba, 0, - default, null, null, null, null, 0); + ImmutableArray.Create(1, 2), + 2, + 1, + 1, + PixelFormat.Rgba, + 0, + default, + null, + null, + null, + null, + 0 + ); var different = new OwnedVideoFrame( - ImmutableArray.Create(1, 3), 2, 1, 1, PixelFormat.Rgba, 0, - default, null, null, null, null, 0); + ImmutableArray.Create(1, 3), + 2, + 1, + 1, + PixelFormat.Rgba, + 0, + default, + null, + null, + null, + null, + 0 + ); Assert.AreEqual(first, same); Assert.AreEqual(first.GetHashCode(), same.GetHashCode()); Assert.AreNotEqual(first, different); var audio = new OwnedAudioFrame( - ImmutableArray.Create(1), 48000, 1, AudioSampleFormat.S16Le, 0, - null, null, null, null, 0); + ImmutableArray.Create(1), + 48000, + 1, + AudioSampleFormat.S16Le, + 0, + null, + null, + null, + null, + 0 + ); var audioSame = new OwnedAudioFrame( - ImmutableArray.Create(1), 48000, 1, AudioSampleFormat.S16Le, 0, - null, null, null, null, 0); + ImmutableArray.Create(1), + 48000, + 1, + AudioSampleFormat.S16Le, + 0, + null, + null, + null, + null, + 0 + ); Assert.AreEqual(audio, audioSame); Assert.AreEqual(audio.GetHashCode(), audioSame.GetHashCode()); } @@ -459,8 +551,13 @@ public void DuplicatingAHostMemoryFrame_ReportsNoDescriptor() { // No libc call on this path: a frame with no fd answers -1 without duplicating. var frame = new VideoFrame( - [1, 2, 3, 4], stride: 4, width: 1, height: 1, format: PixelFormat.Bgra, - sequenceNumber: 0); + [1, 2, 3, 4], + stride: 4, + width: 1, + height: 1, + format: PixelFormat.Bgra, + sequenceNumber: 0 + ); Assert.IsFalse(frame.IsFdBacked); using SafeDescriptorHandle none = frame.DuplicateFd(); Assert.IsTrue(none.IsInvalid); @@ -472,16 +569,28 @@ public void ReadFloatArray_ReturnsEmptyWhenThePropertyIsAbsent() // A node with no channel volumes has no array to read, which is not an error. Assert.AreEqual(0, PipeWireNodeProxy.ReadFloatArray(null, SpaProp.ChannelVolumes).Length); - var props = new SpaObject(SpaType.ObjectProps, SpaParamType.Props, - [new SpaPodProperty(SpaProp.Volume, 0, new SpaFloat(0.5f))]); + var props = new SpaObject( + SpaType.ObjectProps, + SpaParamType.Props, + [new SpaPodProperty(SpaProp.Volume, 0, new SpaFloat(0.5f))] + ); Assert.AreEqual(0, PipeWireNodeProxy.ReadFloatArray(props, SpaProp.ChannelVolumes).Length); - var withVolumes = new SpaObject(SpaType.ObjectProps, SpaParamType.Props, - [new SpaPodProperty(SpaProp.ChannelVolumes, 0, - new SpaArray(SpaType.Float, [new SpaFloat(0.5f), new SpaFloat(0.25f)]))]); + var withVolumes = new SpaObject( + SpaType.ObjectProps, + SpaParamType.Props, + [ + new SpaPodProperty( + SpaProp.ChannelVolumes, + 0, + new SpaArray(SpaType.Float, [new SpaFloat(0.5f), new SpaFloat(0.25f)]) + ), + ] + ); CollectionAssert.AreEqual( new[] { 0.5f, 0.25f }, - PipeWireNodeProxy.ReadFloatArray(withVolumes, SpaProp.ChannelVolumes).ToArray()); + PipeWireNodeProxy.ReadFloatArray(withVolumes, SpaProp.ChannelVolumes).ToArray() + ); } [TestMethod] @@ -490,8 +599,14 @@ public void SyncBuffersParam_DeclaresSyncBlocksAndMandatoryMetaType() // Explicit sync rides two extra data blocks plus a mandatory metaType: a peer that cannot // carry timeline metadata must refuse rather than silently accept unordered buffers. Span buf = stackalloc byte[256]; - int len = SpaFormatPod.WriteVideoBuffersParam(buf, size: 1024, stride: 256, - dataTypes: 1 << (int)SpaDataType.DmaBuf, blocks: 1, syncDataBlocks: 2); + int len = SpaFormatPod.WriteVideoBuffersParam( + buf, + size: 1024, + stride: 256, + dataTypes: 1 << (int)SpaDataType.DmaBuf, + blocks: 1, + syncDataBlocks: 2 + ); Assert.IsTrue(SpaPod.TryParse(buf[..len], out SpaValue? value)); var o = (SpaObject)value!; @@ -499,8 +614,11 @@ public void SyncBuffersParam_DeclaresSyncBlocksAndMandatoryMetaType() SpaPodProperty? metaType = o.Find((uint)SpaParamBuffers.MetaType); Assert.IsNotNull(metaType, "the sync buffers param must carry a metaType"); - Assert.AreNotEqual(SpaPodPropFlags.None, metaType.Flags & SpaPodPropFlags.Mandatory, - "the metaType must be mandatory, not advisory"); + Assert.AreNotEqual( + SpaPodPropFlags.None, + metaType.Flags & SpaPodPropFlags.Mandatory, + "the metaType must be mandatory, not advisory" + ); Assert.AreEqual(1 << (int)SpaMetaType.SyncTimeline, ((SpaInt)metaType.Value).Value); } diff --git a/tests/PipeWire.NET.Tests/CoreSyncContractTests.cs b/tests/PipeWire.NET.Tests/CoreSyncContractTests.cs index afb9f8e..212e994 100644 --- a/tests/PipeWire.NET.Tests/CoreSyncContractTests.cs +++ b/tests/PipeWire.NET.Tests/CoreSyncContractTests.cs @@ -32,7 +32,9 @@ private static void RequireLinux() } private static async Task<(PipeWireContext Context, PipeWireRegistry Registry)> ConnectAsync( - string name, CancellationToken cancellationToken) + string name, + CancellationToken cancellationToken + ) { var context = new PipeWireContext(name, ConsoleTestLoggerFactory.Instance); await context.StartAsync(cancellationToken); @@ -49,15 +51,23 @@ public async Task ARequestTheDaemonRefusesSynchronously_ReportsTheRefusal() // Reported through the round-trip or not at all. RequireLinux(); using var cts = new CancellationTokenSource(Budget); - (PipeWireContext ctx, PipeWireRegistry registry) = await ConnectAsync("pwnet-sync-refuse", cts.Token); + (PipeWireContext ctx, PipeWireRegistry registry) = await ConnectAsync( + "pwnet-sync-refuse", + cts.Token + ); await using (ctx) await using (registry) { - PipeWireException refused = await Assert.ThrowsExactlyAsync( - async () => await registry.DestroyGlobalAsync(NoSuchGlobal, cts.Token)); - - Assert.IsTrue(refused.Result < 0, $"a refusal must carry the daemon's code, got {refused.Result}"); + PipeWireException refused = + await Assert.ThrowsExactlyAsync(async () => + await registry.DestroyGlobalAsync(NoSuchGlobal, cts.Token) + ); + + Assert.IsTrue( + refused.Result < 0, + $"a refusal must carry the daemon's code, got {refused.Result}" + ); } } @@ -69,7 +79,10 @@ public async Task ABarrierRunningBesideARefusedRequest_DoesNotAdoptItsError() // the connection happens to be doing. RequireLinux(); using var cts = new CancellationTokenSource(Budget); - (PipeWireContext ctx, PipeWireRegistry registry) = await ConnectAsync("pwnet-sync-barrier", cts.Token); + (PipeWireContext ctx, PipeWireRegistry registry) = await ConnectAsync( + "pwnet-sync-barrier", + cts.Token + ); await using (ctx) await using (registry) @@ -79,10 +92,19 @@ public async Task ABarrierRunningBesideARefusedRequest_DoesNotAdoptItsError() Task refused = Task.Run( async () => { - try { await registry.DestroyGlobalAsync(NoSuchGlobal + (uint)attempt, cts.Token); } - catch (PipeWireException) { /* the point of the neighbour */ } + try + { + await registry.DestroyGlobalAsync( + NoSuchGlobal + (uint)attempt, + cts.Token + ); + } + catch (PipeWireException) + { /* the point of the neighbour */ + } }, - cts.Token); + cts.Token + ); await registry.WaitForInitialEnumerationAsync(cts.Token); await refused; @@ -95,7 +117,10 @@ public async Task ManyRoundTripsAtOnce_EachCompletesOnItsOwnReply() { RequireLinux(); using var cts = new CancellationTokenSource(Budget); - (PipeWireContext ctx, PipeWireRegistry registry) = await ConnectAsync("pwnet-sync-many", cts.Token); + (PipeWireContext ctx, PipeWireRegistry registry) = await ConnectAsync( + "pwnet-sync-many", + cts.Token + ); await using (ctx) await using (registry) { @@ -106,8 +131,9 @@ public async Task ManyRoundTripsAtOnce_EachCompletesOnItsOwnReply() { Task[] syncs = [ - .. Enumerable.Range(0, 30).Select(_ => - registry.WaitForInitialEnumerationAsync(cts.Token)), + .. Enumerable + .Range(0, 30) + .Select(_ => registry.WaitForInitialEnumerationAsync(cts.Token)), ]; await Task.WhenAll(syncs).WaitAsync(TimeSpan.FromSeconds(15), cts.Token); @@ -125,14 +151,18 @@ public async Task ARoundTripCancelledAtEveryPointInItsLife_NeverCompletesTwice() { RequireLinux(); using var cts = new CancellationTokenSource(Budget); - (PipeWireContext ctx, PipeWireRegistry registry) = await ConnectAsync("pwnet-sync-cancel", cts.Token); + (PipeWireContext ctx, PipeWireRegistry registry) = await ConnectAsync( + "pwnet-sync-cancel", + cts.Token + ); await using (ctx) await using (registry) { // Sweeping the cancellation delay walks the token across the whole exchange, including // the moment the reply is being dispatched - the one interleaving where a waiter could // be completed and cancelled at once. - int cancelled = 0, completed = 0; + int cancelled = 0, + completed = 0; for (int micros = 0; micros < 3000; micros += 61) { @@ -152,7 +182,10 @@ public async Task ARoundTripCancelledAtEveryPointInItsLife_NeverCompletesTwice() // Both outcomes must occur across that sweep, or the test is not covering the race it // claims to; and the connection must still work afterwards. - Assert.IsTrue(completed > 0, "no round-trip completed; the sweep never reached the reply"); + Assert.IsTrue( + completed > 0, + "no round-trip completed; the sweep never reached the reply" + ); Assert.IsTrue(cancelled > 0, "no round-trip was cancelled; the sweep started too late"); await registry.WaitForInitialEnumerationAsync(cts.Token); @@ -170,21 +203,28 @@ public async Task DisposingTheContextWithARoundTripOutstanding_ReleasesItRatherT // reply that can never arrive, forever. for (int round = 0; round < 5; round++) { - var ctx = new PipeWireContext($"pwnet-sync-dispose-{round}", ConsoleTestLoggerFactory.Instance); + var ctx = new PipeWireContext( + $"pwnet-sync-dispose-{round}", + ConsoleTestLoggerFactory.Instance + ); await ctx.StartAsync(cts.Token); var registry = new PipeWireRegistry(ctx); - Task waiting = Task.Run(async () => - { - try + Task waiting = Task.Run( + async () => { - // Deliberately uncancellable from the caller's side. - await registry.WaitForInitialEnumerationAsync(CancellationToken.None); - } - catch (ObjectDisposedException) { } - catch (Exception e) when (e is InvalidOperationException or PipeWireException) { } - catch (OperationCanceledException) { } - }, cts.Token); + try + { + // Deliberately uncancellable from the caller's side. + await registry.WaitForInitialEnumerationAsync(CancellationToken.None); + } + catch (ObjectDisposedException) { } + catch (Exception e) when (e is InvalidOperationException or PipeWireException) + { } + catch (OperationCanceledException) { } + }, + cts.Token + ); await ctx.DisposeAsync(); @@ -198,11 +238,15 @@ public async Task AFailedRequestFaultsItsOwnWaiter_AndLeavesOthersAlone() { RequireLinux(); using var cts = new CancellationTokenSource(Budget); - (PipeWireContext ctx, PipeWireRegistry registry) = await ConnectAsync("pwnet-sync-error", cts.Token); + (PipeWireContext ctx, PipeWireRegistry registry) = await ConnectAsync( + "pwnet-sync-error", + cts.Token + ); await using (ctx) await using (registry) { - PipeWireNode node = await registry.CreateVirtualSink("SyncErr") + PipeWireNode node = await registry + .CreateVirtualSink("SyncErr") .WithName($"pwnet_syncerr_{Environment.ProcessId}_{Random.Shared.Next():x}") .ExecuteAsync(cts.Token); @@ -214,13 +258,16 @@ public async Task AFailedRequestFaultsItsOwnWaiter_AndLeavesOthersAlone() // A parameter a node does not have. The daemon refuses the request and reports it // out of band on the error stream, carrying the request's own sequence number - // which is the only thing tying the failure to the caller waiting on it. - await Assert.ThrowsExactlyAsync( - async () => await control.EnumerateParametersAsync(SpaParamType.EnumProfile, cts.Token)); + await Assert.ThrowsExactlyAsync(async () => + await control.EnumerateParametersAsync(SpaParamType.EnumProfile, cts.Token) + ); // An error must fault only the request it belongs to. A read of a parameter the // node does have, straight afterwards, has to succeed. - Assert.IsNotNull(await control.GetVolumeAsync(cts.Token), - $"iteration {i}: an error on one request poisoned the next"); + Assert.IsNotNull( + await control.GetVolumeAsync(cts.Token), + $"iteration {i}: an error on one request poisoned the next" + ); } await registry.DestroyGlobalAsync(node.NodeId, cts.Token); @@ -232,19 +279,25 @@ public async Task RemovingSomethingThatCannotBeRemoved_SaysSoRatherThanReporting { RequireLinux(); using var cts = new CancellationTokenSource(Budget); - (PipeWireContext ctx, PipeWireRegistry registry) = await ConnectAsync("pwnet-rm-contract", cts.Token); + (PipeWireContext ctx, PipeWireRegistry registry) = await ConnectAsync( + "pwnet-rm-contract", + cts.Token + ); await using (ctx) await using (registry) { // The core. The daemon accepts the request and does nothing at all - no error, no // removal - so a caller that trusted the return value would believe it had worked. - await Assert.ThrowsExactlyAsync( - async () => await registry.DestroyGlobalAsync(0, cts.Token)); + await Assert.ThrowsExactlyAsync(async () => + await registry.DestroyGlobalAsync(0, cts.Token) + ); // An id the daemon has never issued. This one it does refuse, out of band on the error // stream, and that refusal has to reach the caller rather than being lost. - PipeWireException refused = await Assert.ThrowsExactlyAsync( - async () => await registry.DestroyGlobalAsync(NoSuchGlobal, cts.Token)); + PipeWireException refused = + await Assert.ThrowsExactlyAsync(async () => + await registry.DestroyGlobalAsync(NoSuchGlobal, cts.Token) + ); // The point of the type: a caller can tell what failed and why without parsing text. Assert.IsTrue(refused.Result < 0, "a refusal must carry the daemon's result code"); @@ -261,7 +314,10 @@ public async Task RoundTripsFromSeveralThreadsOnOneContext_DoNotCrossTheirComple { RequireLinux(); using var cts = new CancellationTokenSource(Budget); - (PipeWireContext ctx, PipeWireRegistry registry) = await ConnectAsync("pwnet-sync-threads", cts.Token); + (PipeWireContext ctx, PipeWireRegistry registry) = await ConnectAsync( + "pwnet-sync-threads", + cts.Token + ); await using (ctx) await using (registry) { @@ -271,14 +327,28 @@ public async Task RoundTripsFromSeveralThreadsOnOneContext_DoNotCrossTheirComple Task[] workers = [ - .. Enumerable.Range(0, 8).Select(w => Task.Run(async () => - { - for (int i = 0; i < 12; i++) - { - try { await registry.WaitForInitialEnumerationAsync(cts.Token); } - catch (Exception ex) { faults.Enqueue($"worker {w}: {ex.GetType().Name}"); return; } - } - }, cts.Token)), + .. Enumerable + .Range(0, 8) + .Select(w => + Task.Run( + async () => + { + for (int i = 0; i < 12; i++) + { + try + { + await registry.WaitForInitialEnumerationAsync(cts.Token); + } + catch (Exception ex) + { + faults.Enqueue($"worker {w}: {ex.GetType().Name}"); + return; + } + } + }, + cts.Token + ) + ), ]; await Task.WhenAll(workers).WaitAsync(TimeSpan.FromSeconds(20), cts.Token); @@ -295,19 +365,24 @@ public async Task CancellingAWriteDoesNotRecallIt_AndLeavesTheStoreAgreeingWithT // not happen is the two disagreeing afterwards, or the connection being left unusable. RequireLinux(); using var cts = new CancellationTokenSource(Budget); - (PipeWireContext ctx, PipeWireRegistry registry) = await ConnectAsync("pwnet-cancel-write", cts.Token); + (PipeWireContext ctx, PipeWireRegistry registry) = await ConnectAsync( + "pwnet-cancel-write", + cts.Token + ); await using (ctx) await using (registry) { PipeWireMetadataProxy? store = registry.BindMetadata("default"); - if (store is null) Assert.Inconclusive("no session manager, so no default store."); + if (store is null) + Assert.Inconclusive("no session manager, so no default store."); await using (store) { await store!.ReadyAsync(cts.Token); string key = $"pwnet.cancel.{Environment.ProcessId}.{Random.Shared.Next():x}"; - int cancelled = 0, completed = 0; + int cancelled = 0, + completed = 0; try { @@ -317,10 +392,16 @@ public async Task CancellingAWriteDoesNotRecallIt_AndLeavesTheStoreAgreeingWithT // window where the native call has been made but the reply has not arrived // is actually hit rather than assumed. using var race = new CancellationTokenSource(); - Task write = store.SetAsync(key, $"v{round}", cancellationToken: race.Token); + Task write = store.SetAsync( + key, + $"v{round}", + cancellationToken: race.Token + ); - if (round % 3 == 0) race.Cancel(); - else if (round % 3 == 1) await Task.Yield(); + if (round % 3 == 0) + race.Cancel(); + else if (round % 3 == 1) + await Task.Yield(); race.CancelAfter(TimeSpan.FromMilliseconds(round % 7)); @@ -341,16 +422,22 @@ public async Task CancellingAWriteDoesNotRecallIt_AndLeavesTheStoreAgreeingWithT string? settled = store.Get(key); await store.SetAsync(key, "final", cancellationToken: cts.Token); - Assert.AreEqual("final", store.Get(key), + Assert.AreEqual( + "final", + store.Get(key), $"the store stopped tracking the key after {cancelled} cancelled and " - + $"{completed} completed writes (it had settled on '{settled ?? "(null)"}')"); + + $"{completed} completed writes (it had settled on '{settled ?? "(null)"}')" + ); } finally { await store.SetAsync(key, null, cancellationToken: CancellationToken.None); } - Assert.IsTrue(cancelled > 0, "no write was actually cancelled, so nothing was exercised"); + Assert.IsTrue( + cancelled > 0, + "no write was actually cancelled, so nothing was exercised" + ); // And the connection still works, which a half-torn-down request would not leave it. await registry.WaitForInitialEnumerationAsync(cts.Token); @@ -364,11 +451,15 @@ public async Task ABindingDisposedTwiceAndThenFinalized_DestroysItsProxyExactlyO { RequireLinux(); using var cts = new CancellationTokenSource(Budget); - (PipeWireContext ctx, PipeWireRegistry registry) = await ConnectAsync("pwnet-sync-once", cts.Token); + (PipeWireContext ctx, PipeWireRegistry registry) = await ConnectAsync( + "pwnet-sync-once", + cts.Token + ); await using (ctx) await using (registry) { - PipeWireNode node = await registry.CreateVirtualSink("DestroyOnce") + PipeWireNode node = await registry + .CreateVirtualSink("DestroyOnce") .WithName($"pwnet_destroyonce_{Environment.ProcessId}_{Random.Shared.Next():x}") .ExecuteAsync(cts.Token); diff --git a/tests/PipeWire.NET.Tests/CoreSyncOrderingTests.cs b/tests/PipeWire.NET.Tests/CoreSyncOrderingTests.cs index 2ac11b2..cebc519 100644 --- a/tests/PipeWire.NET.Tests/CoreSyncOrderingTests.cs +++ b/tests/PipeWire.NET.Tests/CoreSyncOrderingTests.cs @@ -30,7 +30,9 @@ private static void RequireLinux() } private static async Task<(PipeWireContext Context, PipeWireRegistry Registry)> ConnectAsync( - string name, CancellationToken cancellationToken) + string name, + CancellationToken cancellationToken + ) { var context = new PipeWireContext(name, ConsoleTestLoggerFactory.Instance); await context.StartAsync(cancellationToken); @@ -47,14 +49,18 @@ public async Task AfterARoundTrip_EveryObjectCreatedBeforeItIsInTheGraph() { RequireLinux(); using var cts = new CancellationTokenSource(Budget); - (PipeWireContext ctx, PipeWireRegistry registry) = await ConnectAsync("pwnet-order-create", cts.Token); + (PipeWireContext ctx, PipeWireRegistry registry) = await ConnectAsync( + "pwnet-order-create", + cts.Token + ); await using (ctx) await using (registry) { var created = new List(); for (int i = 0; i < 12; i++) { - PipeWireNode node = await registry.CreateVirtualSink("Ordering") + PipeWireNode node = await registry + .CreateVirtualSink("Ordering") .WithName(Unique("pwnet_order")) .ExecuteAsync(cts.Token); @@ -67,7 +73,10 @@ public async Task AfterARoundTrip_EveryObjectCreatedBeforeItIsInTheGraph() PipeWireGraphSnapshot graph = registry.Current; foreach (uint id in created) - Assert.IsNotNull(graph.GetNode(id), $"node {id} was created before the barrier but is absent after it"); + Assert.IsNotNull( + graph.GetNode(id), + $"node {id} was created before the barrier but is absent after it" + ); foreach (uint id in created) await registry.DestroyGlobalAsync(id, cts.Token); @@ -76,7 +85,10 @@ public async Task AfterARoundTrip_EveryObjectCreatedBeforeItIsInTheGraph() PipeWireGraphSnapshot after = registry.Current; foreach (uint id in created) - Assert.IsNull(after.GetNode(id), $"node {id} was removed before the barrier but is present after it"); + Assert.IsNull( + after.GetNode(id), + $"node {id} was removed before the barrier but is present after it" + ); } } @@ -85,19 +97,34 @@ public async Task AfterARoundTrip_ALinkAndItsPortsAreAllVisibleTogether() { RequireLinux(); using var cts = new CancellationTokenSource(Budget); - (PipeWireContext ctx, PipeWireRegistry registry) = await ConnectAsync("pwnet-order-link", cts.Token); + (PipeWireContext ctx, PipeWireRegistry registry) = await ConnectAsync( + "pwnet-order-link", + cts.Token + ); await using (ctx) await using (registry) { - PipeWireNode source = await registry.CreateVirtualSink("OrderSrc") - .WithName(Unique("pwnet_order_src")).ExecuteAsync(cts.Token); - PipeWireNode sink = await registry.CreateVirtualSink("OrderSink") - .WithName(Unique("pwnet_order_sink")).ExecuteAsync(cts.Token); + PipeWireNode source = await registry + .CreateVirtualSink("OrderSrc") + .WithName(Unique("pwnet_order_src")) + .ExecuteAsync(cts.Token); + PipeWireNode sink = await registry + .CreateVirtualSink("OrderSink") + .WithName(Unique("pwnet_order_sink")) + .ExecuteAsync(cts.Token); ImmutableArray outputs = await PortsAsync( - registry, source.NodeId, PipeWirePortDirection.Out, cts.Token); + registry, + source.NodeId, + PipeWirePortDirection.Out, + cts.Token + ); ImmutableArray inputs = await PortsAsync( - registry, sink.NodeId, PipeWirePortDirection.In, cts.Token); + registry, + sink.NodeId, + PipeWirePortDirection.In, + cts.Token + ); PipeWireLink link = await registry.CreateLinkAsync(outputs[0], inputs[0], cts.Token); await registry.WaitForInitialEnumerationAsync(cts.Token); @@ -108,8 +135,10 @@ public async Task AfterARoundTrip_ALinkAndItsPortsAreAllVisibleTogether() Assert.IsNotNull(graph.GetLink(link.LinkId)); Assert.IsNotNull(graph.GetNode(link.OutputNodeId)); Assert.IsNotNull(graph.GetNode(link.InputNodeId)); - Assert.IsTrue(graph.GetLinksForNode(source.NodeId).Any(l => l.LinkId == link.LinkId), - "the link is not reachable from the node it starts at"); + Assert.IsTrue( + graph.GetLinksForNode(source.NodeId).Any(l => l.LinkId == link.LinkId), + "the link is not reachable from the node it starts at" + ); await registry.DestroyGlobalAsync(link.LinkId, cts.Token); await registry.DestroyGlobalAsync(source.NodeId, cts.Token); @@ -127,8 +156,14 @@ public async Task AMetadataWriteReachesASecondClient_ButNotBecauseOfABarrier() // arrives, so that is what is waited for; the timeout is the assertion. RequireLinux(); using var cts = new CancellationTokenSource(Budget); - (PipeWireContext a, PipeWireRegistry ra) = await ConnectAsync("pwnet-order-meta-a", cts.Token); - (PipeWireContext b, PipeWireRegistry rb) = await ConnectAsync("pwnet-order-meta-b", cts.Token); + (PipeWireContext a, PipeWireRegistry ra) = await ConnectAsync( + "pwnet-order-meta-a", + cts.Token + ); + (PipeWireContext b, PipeWireRegistry rb) = await ConnectAsync( + "pwnet-order-meta-b", + cts.Token + ); await using (a) await using (ra) await using (b) @@ -151,11 +186,19 @@ public async Task AMetadataWriteReachesASecondClient_ButNotBecauseOfABarrier() reader, key, () => writer.SetAsync(key, "v", cancellationToken: cts.Token), - cts.Token); + cts.Token + ); - Assert.AreEqual("v", seen, "the reader was told about the write, with the wrong value"); - Assert.AreEqual("v", reader.Get(key), - "the change was raised but the store it came from does not hold it"); + Assert.AreEqual( + "v", + seen, + "the reader was told about the write, with the wrong value" + ); + Assert.AreEqual( + "v", + reader.Get(key), + "the change was raised but the store it came from does not hold it" + ); } finally { @@ -173,24 +216,35 @@ public async Task ARoundTrip_OrdersOnlyItsOwnConnection() // before it is therefore in our own registry by then, with no polling. RequireLinux(); using var cts = new CancellationTokenSource(Budget); - (PipeWireContext ctx, PipeWireRegistry registry) = await ConnectAsync("pwnet-order-own", cts.Token); + (PipeWireContext ctx, PipeWireRegistry registry) = await ConnectAsync( + "pwnet-order-own", + cts.Token + ); await using (ctx) await using (registry) { - PipeWireNode node = await registry.CreateVirtualSink("Barrier") - .WithName(Unique("pwnet_barrier")).ExecuteAsync(cts.Token); + PipeWireNode node = await registry + .CreateVirtualSink("Barrier") + .WithName(Unique("pwnet_barrier")) + .ExecuteAsync(cts.Token); await CoreSync.RoundTripAsync(ctx, cts.Token); - Assert.IsNotNull(registry.Current.GetNode(node.NodeId), - "a global created before the barrier is not in the graph after it"); + Assert.IsNotNull( + registry.Current.GetNode(node.NodeId), + "a global created before the barrier is not in the graph after it" + ); await registry.DestroyGlobalAsync(node.NodeId, cts.Token); } } private static async Task> PortsAsync( - PipeWireRegistry registry, uint nodeId, PipeWirePortDirection direction, CancellationToken cancellationToken) + PipeWireRegistry registry, + uint nodeId, + PipeWirePortDirection direction, + CancellationToken cancellationToken + ) { while (true) { @@ -201,10 +255,13 @@ private static async Task> PortsAsync( // excluding monitors leaves the output side with nothing and waits for ever. ImmutableArray ports = [ - .. registry.Current.GetPortsForNode(nodeId).Where(p => p.PortDirection == direction), + .. registry + .Current.GetPortsForNode(nodeId) + .Where(p => p.PortDirection == direction), ]; - if (ports.Length >= 1) return ports; + if (ports.Length >= 1) + return ports; } } } diff --git a/tests/PipeWire.NET.Tests/CreationHostileTests.cs b/tests/PipeWire.NET.Tests/CreationHostileTests.cs index fb76dd8..9b83398 100644 --- a/tests/PipeWire.NET.Tests/CreationHostileTests.cs +++ b/tests/PipeWire.NET.Tests/CreationHostileTests.cs @@ -27,7 +27,9 @@ private static void RequireLinux() } private static async Task<(PipeWireContext Context, PipeWireRegistry Registry)> ConnectAsync( - string name, CancellationToken cancellationToken) + string name, + CancellationToken cancellationToken + ) { var context = new PipeWireContext(name, ConsoleTestLoggerFactory.Instance); await context.StartAsync(cancellationToken); @@ -36,31 +38,42 @@ private static void RequireLinux() return (context, registry); } - private static string Unique(string p) => $"{p}_{Environment.ProcessId}_{Random.Shared.Next():x}"; + private static string Unique(string p) => + $"{p}_{Environment.ProcessId}_{Random.Shared.Next():x}"; [TestMethod] public async Task ACreationTheDaemonRefuses_FaultsTheCallerRatherThanHanging() { RequireLinux(); using var cts = new CancellationTokenSource(Budget); - (PipeWireContext ctx, PipeWireRegistry registry) = await ConnectAsync("pwnet-create-refused", cts.Token); + (PipeWireContext ctx, PipeWireRegistry registry) = await ConnectAsync( + "pwnet-create-refused", + cts.Token + ); await using (ctx) await using (registry) { // A link between two ports that cannot be linked: the daemon reports the failure on the // error stream after the proxy is bound, which is the window where a waiter has an id // and no object. It has to fault rather than wait for a global that is not coming. - PipeWireNode node = await registry.CreateVirtualSink("Refuse") - .WithName(Unique("pwnet_refuse")).ExecuteAsync(cts.Token); + PipeWireNode node = await registry + .CreateVirtualSink("Refuse") + .WithName(Unique("pwnet_refuse")) + .ExecuteAsync(cts.Token); PipeWirePort[] ports = await PortsAsync(registry, node.NodeId, cts.Token); - PipeWirePort? input = Array.Find(ports, p => p.PortDirection == PipeWirePortDirection.In); - if (input is null) Assert.Inconclusive("the node published no input port."); + PipeWirePort? input = Array.Find( + ports, + p => p.PortDirection == PipeWirePortDirection.In + ); + if (input is null) + Assert.Inconclusive("the node published no input port."); // Input to input. Ports face the wrong way, so the library refuses before the daemon // is asked at all, which is the better of the two outcomes. - await Assert.ThrowsExactlyAsync( - async () => await registry.CreateLinkAsync(input!, input!, cts.Token)); + await Assert.ThrowsExactlyAsync(async () => + await registry.CreateLinkAsync(input!, input!, cts.Token) + ); // The connection is unharmed by the refusal. await registry.WaitForInitialEnumerationAsync(cts.Token); @@ -77,8 +90,14 @@ public async Task AnObjectRemovedTheInstantItAppears_LeavesNoWaiterBehind() RequireLinux(); SessionGates.RequireDaemonAtLeast(1, 6, 8); using var cts = new CancellationTokenSource(Budget); - (PipeWireContext ctx, PipeWireRegistry registry) = await ConnectAsync("pwnet-create-vanish", cts.Token); - (PipeWireContext killerCtx, PipeWireRegistry killer) = await ConnectAsync("pwnet-create-killer", cts.Token); + (PipeWireContext ctx, PipeWireRegistry registry) = await ConnectAsync( + "pwnet-create-vanish", + cts.Token + ); + (PipeWireContext killerCtx, PipeWireRegistry killer) = await ConnectAsync( + "pwnet-create-killer", + cts.Token + ); await using (ctx) await using (registry) await using (killerCtx) @@ -98,17 +117,29 @@ void OnAdded(PipeWireNode n) { for (int round = 0; round < 10; round++) { - PipeWireNode node = await registry.CreateVirtualSink("Vanish") - .WithName(Unique("pwnet_vanish")).ExecuteAsync(cts.Token); + PipeWireNode node = await registry + .CreateVirtualSink("Vanish") + .WithName(Unique("pwnet_vanish")) + .ExecuteAsync(cts.Token); while (doomed.TryDequeue(out uint id)) { - try { await killer.DestroyGlobalAsync(id, cts.Token); } - catch (PipeWireException) { /* already gone; that is the race working */ } + try + { + await killer.DestroyGlobalAsync(id, cts.Token); + } + catch (PipeWireException) + { /* already gone; that is the race working */ + } } - try { await registry.DestroyGlobalAsync(node.NodeId, cts.Token); } - catch (PipeWireException) { /* the killer got there first */ } + try + { + await registry.DestroyGlobalAsync(node.NodeId, cts.Token); + } + catch (PipeWireException) + { /* the killer got there first */ + } } // Both connections still work, and neither is holding a waiter that never completed. @@ -133,8 +164,14 @@ public async Task ObjectsDestroyedWhileBeingCreated_LeaveNoProxyBehind() RequireLinux(); SessionGates.RequireDaemonAtLeast(1, 6, 8); using var cts = new CancellationTokenSource(Budget); - (PipeWireContext ctx, PipeWireRegistry registry) = await ConnectAsync("pwnet-proxy-race", cts.Token); - (PipeWireContext killerCtx, PipeWireRegistry killer) = await ConnectAsync("pwnet-proxy-killer", cts.Token); + (PipeWireContext ctx, PipeWireRegistry registry) = await ConnectAsync( + "pwnet-proxy-race", + cts.Token + ); + (PipeWireContext killerCtx, PipeWireRegistry killer) = await ConnectAsync( + "pwnet-proxy-killer", + cts.Token + ); await using (ctx) await using (registry) await using (killerCtx) @@ -158,31 +195,51 @@ void OnAdded(PipeWireNode n) { // The killer races the creation from the other connection. Whichever wins, the // proxy must not be left filed for an object that no longer exists. - Task creating = registry.CreateVirtualSink("ProxyRace") - .WithName(Unique("pwnet_proxyrace")).ExecuteAsync(cts.Token); + Task creating = registry + .CreateVirtualSink("ProxyRace") + .WithName(Unique("pwnet_proxyrace")) + .ExecuteAsync(cts.Token); while (doomed.TryDequeue(out uint id)) { - try { await killer.DestroyGlobalAsync(id, cts.Token); } - catch (PipeWireException) { /* already gone; that is the race working */ } + try + { + await killer.DestroyGlobalAsync(id, cts.Token); + } + catch (PipeWireException) + { /* already gone; that is the race working */ + } } - try { mine.Add((await creating).NodeId); } - catch (PipeWireException) { /* the killer got it before it was published */ } + try + { + mine.Add((await creating).NodeId); + } + catch (PipeWireException) + { /* the killer got it before it was published */ + } } foreach (uint id in mine) { - try { await registry.DestroyGlobalAsync(id, cts.Token); } - catch (PipeWireException) { /* the killer got there first */ } + try + { + await registry.DestroyGlobalAsync(id, cts.Token); + } + catch (PipeWireException) + { /* the killer got there first */ + } } // Every object this test made is gone, so every proxy it filed should be too. for (int attempt = 0; attempt < 100 && registry.OwnedProxyCount > before; attempt++) await Task.Delay(TimeSpan.FromMilliseconds(50), cts.Token); - Assert.AreEqual(before, registry.OwnedProxyCount, - "proxies are still filed for objects that no longer exist"); + Assert.AreEqual( + before, + registry.OwnedProxyCount, + "proxies are still filed for objects that no longer exist" + ); } finally { @@ -196,14 +253,19 @@ public async Task AnIdReusedByANewObject_DoesNotChangeAnOlderSnapshot() { RequireLinux(); using var cts = new CancellationTokenSource(Budget); - (PipeWireContext ctx, PipeWireRegistry registry) = await ConnectAsync("pwnet-id-reuse", cts.Token); + (PipeWireContext ctx, PipeWireRegistry registry) = await ConnectAsync( + "pwnet-id-reuse", + cts.Token + ); await using (ctx) await using (registry) { // PipeWire reuses ids. A snapshot is immutable, so the object it recorded under an id // must stay that object even after the daemon has handed the id to something else. - PipeWireNode first = await registry.CreateVirtualSink("Reuse") - .WithName(Unique("pwnet_reuse_first")).ExecuteAsync(cts.Token); + PipeWireNode first = await registry + .CreateVirtualSink("Reuse") + .WithName(Unique("pwnet_reuse_first")) + .ExecuteAsync(cts.Token); await registry.WaitForInitialEnumerationAsync(cts.Token); PipeWireGraphSnapshot held = registry.Current; @@ -217,34 +279,45 @@ public async Task AnIdReusedByANewObject_DoesNotChangeAnOlderSnapshot() var created = new List(); for (int i = 0; i < 30 && !reused; i++) { - PipeWireNode next = await registry.CreateVirtualSink("Reuse") - .WithName(Unique("pwnet_reuse_next")).ExecuteAsync(cts.Token); + PipeWireNode next = await registry + .CreateVirtualSink("Reuse") + .WithName(Unique("pwnet_reuse_next")) + .ExecuteAsync(cts.Token); created.Add(next.NodeId); // The id coming back is not on its own proof of reuse - it is proof only when the // serial differs, since that is what says this is a different object rather than // the same one still around. - reused = next.NodeId == first.NodeId - && next.ObjectSerial != first.ObjectSerial; + reused = next.NodeId == first.NodeId && next.ObjectSerial != first.ObjectSerial; } foreach (uint id in created) { - try { await registry.DestroyGlobalAsync(id, cts.Token); } - catch (PipeWireException) { /* already gone */ } + try + { + await registry.DestroyGlobalAsync(id, cts.Token); + } + catch (PipeWireException) + { /* already gone */ + } } if (!reused) Assert.Inconclusive("the daemon did not reuse an id within the churn budget."); - Assert.AreEqual(firstName, held.GetNode(first.NodeId)?.NodeName, - "a held snapshot changed when the daemon reused the id it recorded"); + Assert.AreEqual( + firstName, + held.GetNode(first.NodeId)?.NodeName, + "a held snapshot changed when the daemon reused the id it recorded" + ); // And the snapshot's own answer to "is this still that object". The id resolves in // both, so only the serial can tell them apart, which is the whole reason it is read. - Assert.IsFalse(held.GetNode(first.NodeId)!.IsStillIn(registry.Current), - "the object the snapshot recorded must not read as still present once its id was reused"); + Assert.IsFalse( + held.GetNode(first.NodeId)!.IsStillIn(registry.Current), + "the object the snapshot recorded must not read as still present once its id was reused" + ); } } @@ -253,7 +326,10 @@ public async Task BindingAnIdOfTheWrongKind_IsRefusedBeforeItReachesTheDaemon() { RequireLinux(); using var cts = new CancellationTokenSource(Budget); - (PipeWireContext ctx, PipeWireRegistry registry) = await ConnectAsync("pwnet-kind-mismatch", cts.Token); + (PipeWireContext ctx, PipeWireRegistry registry) = await ConnectAsync( + "pwnet-kind-mismatch", + cts.Token + ); await using (ctx) await using (registry) { @@ -289,7 +365,10 @@ public async Task BindingAnIdOfTheWrongKind_IsRefusedBeforeItReachesTheDaemon() } private static async Task PortsAsync( - PipeWireRegistry registry, uint nodeId, CancellationToken cancellationToken) + PipeWireRegistry registry, + uint nodeId, + CancellationToken cancellationToken + ) { while (true) { @@ -297,7 +376,8 @@ private static async Task PortsAsync( await registry.WaitForInitialEnumerationAsync(cancellationToken); PipeWirePort[] ports = [.. registry.Current.GetPortsForNode(nodeId)]; - if (ports.Length > 0) return ports; + if (ports.Length > 0) + return ports; } } } diff --git a/tests/PipeWire.NET.Tests/CrossProcessOrderingTests.cs b/tests/PipeWire.NET.Tests/CrossProcessOrderingTests.cs index f92f576..1f2cd98 100644 --- a/tests/PipeWire.NET.Tests/CrossProcessOrderingTests.cs +++ b/tests/PipeWire.NET.Tests/CrossProcessOrderingTests.cs @@ -31,7 +31,9 @@ private static void RequireLinux() } private static async Task<(PipeWireContext Context, PipeWireRegistry Registry)> ConnectAsync( - string name, CancellationToken cancellationToken) + string name, + CancellationToken cancellationToken + ) { var context = new PipeWireContext(name, ConsoleTestLoggerFactory.Instance); await context.StartAsync(cancellationToken); @@ -40,7 +42,8 @@ private static void RequireLinux() return (context, registry); } - private static string Unique(string p) => $"{p}_{Environment.ProcessId}_{Random.Shared.Next():x}"; + private static string Unique(string p) => + $"{p}_{Environment.ProcessId}_{Random.Shared.Next():x}"; [TestMethod] public async Task AWriterSeesItsOwnWriteWithoutWaiting_EvenThroughTheSessionManager() @@ -49,20 +52,30 @@ public async Task AWriterSeesItsOwnWriteWithoutWaiting_EvenThroughTheSessionMana // whatever the session manager is doing, because the store applied it on the way out. RequireLinux(); using var cts = new CancellationTokenSource(Budget); - (PipeWireContext ctx, PipeWireRegistry registry) = await ConnectAsync("pwnet-xproc-self", cts.Token); + (PipeWireContext ctx, PipeWireRegistry registry) = await ConnectAsync( + "pwnet-xproc-self", + cts.Token + ); await using (ctx) await using (registry) { PipeWireMetadataProxy? store = registry.BindMetadata("default"); - if (store is null) Assert.Inconclusive("no session manager, so no default store."); + if (store is null) + Assert.Inconclusive("no session manager, so no default store."); await using (store) { await store!.ReadyAsync(cts.Token); string key = Unique("pwnet.xproc.self"); - try { await store.SetAsync(key, "v", cancellationToken: cts.Token); } - catch (PipeWireException e) { Assert.Inconclusive($"cannot write metadata here: {e.Message}"); } + try + { + await store.SetAsync(key, "v", cancellationToken: cts.Token); + } + catch (PipeWireException e) + { + Assert.Inconclusive($"cannot write metadata here: {e.Message}"); + } Assert.AreEqual("v", store.Get(key), "a client cannot read back its own write"); @@ -106,20 +119,32 @@ public async Task ASecondClientLearnsOfTheWrite_ByEventAndNotByBarrier() key, async () => { - try { await writer.SetAsync(key, "v", cancellationToken: cts.Token); } - catch (PipeWireException e) { Assert.Inconclusive($"cannot write metadata here: {e.Message}"); } + try + { + await writer.SetAsync(key, "v", cancellationToken: cts.Token); + } + catch (PipeWireException e) + { + Assert.Inconclusive($"cannot write metadata here: {e.Message}"); + } await reader.ReadyAsync(cts.Token); // Whether this is already "v" is a race, so it is not the assertion. The // assertion is that waiting works, which is the contract callers get. Console.WriteLine( - $"reader held '{reader.Get(key) ?? "(null)"}' the moment its barrier completed"); + $"reader held '{reader.Get(key) ?? "(null)"}' the moment its barrier completed" + ); }, - cts.Token); + cts.Token + ); Assert.AreEqual("v", seen, "the second client was told, with the wrong value"); - Assert.AreEqual("v", reader.Get(key), "the event fired but the store does not hold it"); + Assert.AreEqual( + "v", + reader.Get(key), + "the event fired but the store does not hold it" + ); } finally { @@ -137,12 +162,17 @@ public async Task AStoreThisProcessServes_IsOrderedByTheBarrier() // that follows it can complete. If this raced too, the diagnosis above would be wrong. RequireLinux(); using var cts = new CancellationTokenSource(Budget); - (PipeWireContext ctx, PipeWireRegistry registry) = await ConnectAsync("pwnet-xproc-own", cts.Token); + (PipeWireContext ctx, PipeWireRegistry registry) = await ConnectAsync( + "pwnet-xproc-own", + cts.Token + ); await using (ctx) await using (registry) { - await using PipeWireMetadataProvider provider = - PipeWireMetadataProvider.Create(ctx, Unique("pwnet-xproc-store")); + await using PipeWireMetadataProvider provider = PipeWireMetadataProvider.Create( + ctx, + Unique("pwnet-xproc-store") + ); // Export is a request, not a transaction: settle before treating the store as served. await provider.ReadyAsync(cts.Token); @@ -156,8 +186,11 @@ public async Task AStoreThisProcessServes_IsOrderedByTheBarrier() await CoreSync.RoundTripAsync(ctx, cts.Token); - Assert.AreEqual(value, provider.Get(key), - "a store served in this process lost a write across its own barrier"); + Assert.AreEqual( + value, + provider.Get(key), + "a store served in this process lost a write across its own barrier" + ); } provider.Clear(); diff --git a/tests/PipeWire.NET.Tests/DescriptorAndExceptionTests.cs b/tests/PipeWire.NET.Tests/DescriptorAndExceptionTests.cs index 62231f4..b76ba92 100644 --- a/tests/PipeWire.NET.Tests/DescriptorAndExceptionTests.cs +++ b/tests/PipeWire.NET.Tests/DescriptorAndExceptionTests.cs @@ -41,8 +41,11 @@ public void ADuplicatedPlaneDescriptor_CarriesCloseOnExec() int flags = Fcntl(copy.Descriptor, FGetFd); Assert.IsTrue(flags >= 0, "F_GETFD failed on the duplicate"); - Assert.AreEqual(FdCloexec, flags & FdCloexec, - "the duplicate would be inherited across exec"); + Assert.AreEqual( + FdCloexec, + flags & FdCloexec, + "the duplicate would be inherited across exec" + ); } finally { @@ -59,7 +62,12 @@ public void ADescriptorHandle_ClosesWhatItOwnsAndRefusesToHandOutAClosedOne() try { using SafeFileHandle file = File.OpenHandle(path); - SafeDescriptorHandle owned = new VideoPlane(file.DangerousGetHandle(), 0, 4, 1).DuplicateFd(); + SafeDescriptorHandle owned = new VideoPlane( + file.DangerousGetHandle(), + 0, + 4, + 1 + ).DuplicateFd(); int raw = owned.Descriptor; Assert.IsTrue(Fcntl(raw, FGetFd) >= 0, "the duplicate is not open"); @@ -95,14 +103,21 @@ public void EveryExceptionInTheTaxonomy_IsCaughtAsThePipeWireBase() ]; foreach (PipeWireException e in all) - Assert.IsInstanceOfType(e, $"{e.GetType().Name} is outside the hierarchy"); + Assert.IsInstanceOfType( + e, + $"{e.GetType().Name} is outside the hierarchy" + ); Assert.IsInstanceOfType(all[0]); Assert.IsInstanceOfType(all[1]); - Assert.IsNotInstanceOfType(all[2], - "a refusal is not a connection failure"); - Assert.IsNotInstanceOfType(all[3], - "a local call is not a connection failure"); + Assert.IsNotInstanceOfType( + all[2], + "a refusal is not a connection failure" + ); + Assert.IsNotInstanceOfType( + all[3], + "a local call is not a connection failure" + ); } [TestMethod] @@ -139,14 +154,17 @@ public void EveryExceptionKind_OffersTheStandardConstructors() { var inner = new InvalidOperationException("inner"); - foreach (Type type in (Type[])[ - typeof(PipeWireException), - typeof(PipeWireConnectionException), - typeof(PipeWireConnectFailedException), - typeof(PipeWireConnectionClosedException), - typeof(PipeWireRequestRefusedException), - typeof(PipeWireInteropException), - ]) + foreach ( + Type type in (Type[]) + [ + typeof(PipeWireException), + typeof(PipeWireConnectionException), + typeof(PipeWireConnectFailedException), + typeof(PipeWireConnectionClosedException), + typeof(PipeWireRequestRefusedException), + typeof(PipeWireInteropException), + ] + ) { var bare = (PipeWireException)Activator.CreateInstance(type)!; Assert.AreEqual("unknown", bare.Operation, type.Name); @@ -165,18 +183,26 @@ public void EveryExceptionKind_OffersTheStandardConstructors() public async Task BorrowAsync_HoldsTheHandleUntilTheWorkCompletes() { RequireLinux(); - using var listening = new Socket(AddressFamily.Unix, SocketType.Stream, ProtocolType.Unspecified); + using var listening = new Socket( + AddressFamily.Unix, + SocketType.Stream, + ProtocolType.Unspecified + ); SafeHandle handle = listening.SafeHandle; var released = new TaskCompletionSource(); bool openDuringAwait = false; - Task borrow = FdInterop.BorrowAsync(handle, handle, async (first, second) => - { - Assert.AreEqual(first, second); - await released.Task.ConfigureAwait(false); - openDuringAwait = Fcntl(first, FGetFd) >= 0; - }); + Task borrow = FdInterop.BorrowAsync( + handle, + handle, + async (first, second) => + { + Assert.AreEqual(first, second); + await released.Task.ConfigureAwait(false); + openDuringAwait = Fcntl(first, FGetFd) >= 0; + } + ); released.SetResult(); await borrow; @@ -188,7 +214,11 @@ public async Task BorrowAsync_HoldsTheHandleUntilTheWorkCompletes() public void Borrow_ReturnsWhatTheWorkProducedAndReleasesAfterwards() { RequireLinux(); - using var socket = new Socket(AddressFamily.Unix, SocketType.Stream, ProtocolType.Unspecified); + using var socket = new Socket( + AddressFamily.Unix, + SocketType.Stream, + ProtocolType.Unspecified + ); int seen = FdInterop.Borrow(socket.SafeHandle, fd => fd); diff --git a/tests/PipeWire.NET.Tests/DeviceAndMetadataWriteTests.cs b/tests/PipeWire.NET.Tests/DeviceAndMetadataWriteTests.cs index 5e87365..811efdf 100644 --- a/tests/PipeWire.NET.Tests/DeviceAndMetadataWriteTests.cs +++ b/tests/PipeWire.NET.Tests/DeviceAndMetadataWriteTests.cs @@ -29,7 +29,9 @@ private static void RequireLinux() } private static async Task<(PipeWireContext Context, PipeWireRegistry Registry)> ConnectAsync( - string name, CancellationToken cancellationToken) + string name, + CancellationToken cancellationToken + ) { var context = new PipeWireContext(name, ConsoleTestLoggerFactory.Instance); await context.StartAsync(cancellationToken); @@ -43,15 +45,20 @@ public async Task APropertyANodeDoesNotSupport_IsReportedAbsentRatherThanGuessed { RequireLinux(); using var cts = new CancellationTokenSource(Budget); - (PipeWireContext ctx, PipeWireRegistry registry) = await ConnectAsync("pwnet-latency", cts.Token); + (PipeWireContext ctx, PipeWireRegistry registry) = await ConnectAsync( + "pwnet-latency", + cts.Token + ); await using (ctx) await using (registry) { // A unique name: a session manager restores properties by node.name, so reusing one // would read back whatever a previous run left rather than what this one wrote. string name = $"pwnet_latency_{Environment.ProcessId}_{Random.Shared.Next():x}"; - PipeWireNode node = await registry.CreateVirtualSink("Latency") - .WithName(name).ExecuteAsync(cts.Token); + PipeWireNode node = await registry + .CreateVirtualSink("Latency") + .WithName(name) + .ExecuteAsync(cts.Token); await using PipeWireNodeProxy control = registry.BindNode(node.NodeId); await control.ReadyAsync(cts.Token); @@ -63,16 +70,18 @@ public async Task APropertyANodeDoesNotSupport_IsReportedAbsentRatherThanGuessed await control.EnumeratePropertyInfoAsync(cts.Token); bool advertised = info.Select(o => o[SpaPropInfo.Id]) - .OfType() - .Any(id => id.Value == (uint)SpaProp.LatencyOffsetNsec); + .OfType() + .Any(id => id.Value == (uint)SpaProp.LatencyOffsetNsec); Assert.IsFalse(advertised, "this node kind does not advertise a latency offset"); Assert.IsNull(await control.GetLatencyOffsetAsync(cts.Token)); // Writing it is not an error - the daemon accepts and ignores it - and it stays absent. await control.SetLatencyOffsetAsync(2_000_000, cts.Token); - Assert.IsNull(await control.GetLatencyOffsetAsync(cts.Token), - "an unsupported property must not appear to have been set"); + Assert.IsNull( + await control.GetLatencyOffsetAsync(cts.Token), + "an unsupported property must not appear to have been set" + ); await registry.DestroyGlobalAsync(node.NodeId, cts.Token); } @@ -86,23 +95,35 @@ public async Task APropertyANodeDoesNotSupport_IsReportedAbsentRatherThanGuessed /// routes - so every route test skipped without ever exercising the code it was written for. /// private static async Task BindCardWithRoutesAsync( - PipeWireRegistry registry, CancellationToken cancellationToken) + PipeWireRegistry registry, + CancellationToken cancellationToken + ) { - foreach (PipeWireDevice card in registry.Current.Devices - .Where(d => string.Equals(d.Api, "alsa", StringComparison.Ordinal))) + foreach ( + PipeWireDevice card in registry.Current.Devices.Where(d => + string.Equals(d.Api, "alsa", StringComparison.Ordinal) + ) + ) { // The snapshot is a moment, and on a session whose ALSA devices are being destroyed and // recreated the card can be gone before this binds it. BindDevice reports that as an // ArgumentException naming the id, which is indistinguishable here from being handed // nonsense, so the only thing to do is try the next card. PipeWireDeviceProxy control; - try { control = registry.BindDevice(card.Id); } - catch (ArgumentException) { continue; } + try + { + control = registry.BindDevice(card.Id); + } + catch (ArgumentException) + { + continue; + } try { await control.ReadyAsync(cancellationToken); - if (!(await control.GetActiveRoutesAsync(cancellationToken)).IsEmpty) return control; + if (!(await control.GetActiveRoutesAsync(cancellationToken)).IsEmpty) + return control; } catch (PipeWireException) { @@ -120,7 +141,10 @@ public async Task ReapplyingTheActiveRoute_IsAcceptedAndChangesNothing() { RequireLinux(); using var cts = new CancellationTokenSource(Budget); - (PipeWireContext ctx, PipeWireRegistry registry) = await ConnectAsync("pwnet-setroute", cts.Token); + (PipeWireContext ctx, PipeWireRegistry registry) = await ConnectAsync( + "pwnet-setroute", + cts.Token + ); await using (ctx) await using (registry) { @@ -132,7 +156,10 @@ public async Task ReapplyingTheActiveRoute_IsAcceptedAndChangesNothing() ImmutableArray active = await control.GetActiveRoutesAsync(cts.Token); SpaObject route = active[0]; - if (route[SpaParamRoute.Index] is not SpaInt index || route[SpaParamRoute.Device] is not SpaInt device) + if ( + route[SpaParamRoute.Index] is not SpaInt index + || route[SpaParamRoute.Device] is not SpaInt device + ) { Assert.Inconclusive("the active route does not report an index and device port."); return; @@ -149,7 +176,11 @@ public async Task ReapplyingTheActiveRoute_IsAcceptedAndChangesNothing() ImmutableArray after; try { - await control.SetRouteAsync(index.Value, device.Value, cancellationToken: cts.Token); + await control.SetRouteAsync( + index.Value, + device.Value, + cancellationToken: cts.Token + ); after = await control.GetActiveRoutesAsync(cts.Token); } catch (PipeWireException e) when (e.IsObjectGone) @@ -159,8 +190,11 @@ public async Task ReapplyingTheActiveRoute_IsAcceptedAndChangesNothing() } Assert.IsFalse(after.IsEmpty, "the card lost its active route"); - Assert.AreEqual(index.Value, ((SpaInt)after[0][SpaParamRoute.Index]!).Value, - "re-selecting the active route must leave it selected"); + Assert.AreEqual( + index.Value, + ((SpaInt)after[0][SpaParamRoute.Index]!).Value, + "re-selecting the active route must leave it selected" + ); } } @@ -169,15 +203,18 @@ public async Task ADevicesRoutes_CarryTheirOwnVolumeAndSurviveBeingReadBack() { RequireLinux(); using var cts = new CancellationTokenSource(Budget); - (PipeWireContext ctx, PipeWireRegistry registry) = await ConnectAsync("pwnet-routes", cts.Token); + (PipeWireContext ctx, PipeWireRegistry registry) = await ConnectAsync( + "pwnet-routes", + cts.Token + ); await using (ctx) await using (registry) { // Lowest id, not "whichever came first". Enumeration order is not stable across // processes, so an unordered pick makes two runs exercise different cards, and a // machine with more than one ALSA card then passes or fails on which it landed on. - PipeWireDevice? card = registry.Current.Devices - .Where(d => string.Equals(d.Api, "alsa", StringComparison.Ordinal)) + PipeWireDevice? card = registry + .Current.Devices.Where(d => string.Equals(d.Api, "alsa", StringComparison.Ordinal)) .OrderBy(d => d.Id) .FirstOrDefault(); @@ -195,8 +232,14 @@ public async Task ADevicesRoutes_CarryTheirOwnVolumeAndSurviveBeingReadBack() // needed to select it, so their absence would make the route API unusable. foreach (SpaObject route in routes) { - Assert.IsInstanceOfType(route[SpaParamRoute.Index], "a route must carry its index"); - Assert.IsInstanceOfType(route[SpaParamRoute.Direction], "a route must say which way it faces"); + Assert.IsInstanceOfType( + route[SpaParamRoute.Index], + "a route must carry its index" + ); + Assert.IsInstanceOfType( + route[SpaParamRoute.Direction], + "a route must say which way it faces" + ); Assert.IsNotNull(route[SpaParamRoute.Name], "a route must be nameable in a UI"); } @@ -210,7 +253,8 @@ public async Task ADevicesRoutes_CarryTheirOwnVolumeAndSurviveBeingReadBack() { Assert.IsTrue( props[SpaProp.ChannelVolumes] is SpaArray or null, - "a route's volume is per channel when it has one at all"); + "a route's volume is per channel when it has one at all" + ); } } } @@ -221,15 +265,18 @@ public async Task ADevicesProfiles_AreEnumerableAndTheCurrentOneIsAmongThem() { RequireLinux(); using var cts = new CancellationTokenSource(Budget); - (PipeWireContext ctx, PipeWireRegistry registry) = await ConnectAsync("pwnet-profiles", cts.Token); + (PipeWireContext ctx, PipeWireRegistry registry) = await ConnectAsync( + "pwnet-profiles", + cts.Token + ); await using (ctx) await using (registry) { // Lowest id, not "whichever came first". Enumeration order is not stable across // processes, so an unordered pick makes two runs exercise different cards, and a // machine with more than one ALSA card then passes or fails on which it landed on. - PipeWireDevice? card = registry.Current.Devices - .Where(d => string.Equals(d.Api, "alsa", StringComparison.Ordinal)) + PipeWireDevice? card = registry + .Current.Devices.Where(d => string.Equals(d.Api, "alsa", StringComparison.Ordinal)) .OrderBy(d => d.Id) .FirstOrDefault(); @@ -248,8 +295,11 @@ public async Task ADevicesProfiles_AreEnumerableAndTheCurrentOneIsAmongThem() // would match nothing in the list it shows. int currentIndex = ((SpaInt)current![SpaParamProfile.Index]!).Value; int[] offered = [.. profiles.Select(p => ((SpaInt)p[SpaParamProfile.Index]!).Value)]; - CollectionAssert.Contains(offered, currentIndex, - "the active profile must be one of the enumerated ones"); + CollectionAssert.Contains( + offered, + currentIndex, + "the active profile must be one of the enumerated ones" + ); } } @@ -258,7 +308,10 @@ public async Task AMetadataEntry_CanBeWrittenReadBackAndRemoved() { RequireLinux(); using var cts = new CancellationTokenSource(Budget); - (PipeWireContext ctx, PipeWireRegistry registry) = await ConnectAsync("pwnet-metawrite", cts.Token); + (PipeWireContext ctx, PipeWireRegistry registry) = await ConnectAsync( + "pwnet-metawrite", + cts.Token + ); await using (ctx) await using (registry) { @@ -273,22 +326,33 @@ public async Task AMetadataEntry_CanBeWrittenReadBackAndRemoved() // A key of our own, so nothing the session relies on is touched. string key = $"pwnet.test.{Environment.ProcessId}"; var changes = new List(); - var removalSeen = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var removalSeen = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously + ); store.EntryChanged += (_, entry) => { - if (entry.Key != key) return; - lock (changes) changes.Add(entry); - if (entry.Value is null) removalSeen.TrySetResult(); + if (entry.Key != key) + return; + lock (changes) + changes.Add(entry); + if (entry.Value is null) + removalSeen.TrySetResult(); }; try { - await store.SetAsync(key, "hello", subject: PipeWireMetadataProxy.SubjectCore, - cancellationToken: cts.Token); + await store.SetAsync( + key, + "hello", + subject: PipeWireMetadataProxy.SubjectCore, + cancellationToken: cts.Token + ); } catch (PipeWireException e) { - Assert.Inconclusive($"this client may not write metadata on this daemon: {e.Message}"); + Assert.Inconclusive( + $"this client may not write metadata on this daemon: {e.Message}" + ); } Assert.AreEqual("hello", store.Get(key)); @@ -308,8 +372,10 @@ await store.SetAsync(key, "hello", subject: PipeWireMetadataProxy.SubjectCore, { Assert.IsTrue(changes.Count > 0, "the change must be reported at all"); Assert.IsNull(changes[^1].Value, "a removal is reported as a null value"); - Assert.IsTrue(changes.Any(c => c.Value == "hello"), - "the value that was written must have been reported too"); + Assert.IsTrue( + changes.Any(c => c.Value == "hello"), + "the value that was written must have been reported too" + ); } } } @@ -320,7 +386,10 @@ public async Task TheDefaultSinkCanBeSetToWhatItAlreadyIs_WithoutDisturbingTheSe { RequireLinux(); using var cts = new CancellationTokenSource(Budget); - (PipeWireContext ctx, PipeWireRegistry registry) = await ConnectAsync("pwnet-defsink", cts.Token); + (PipeWireContext ctx, PipeWireRegistry registry) = await ConnectAsync( + "pwnet-defsink", + cts.Token + ); await using (ctx) await using (registry) { @@ -340,7 +409,8 @@ public async Task TheDefaultSinkCanBeSetToWhatItAlreadyIs_WithoutDisturbingTheSe for (int i = 0; i < 50 && currentName is null; i++) { currentName = store.DefaultAudioSink?.NameValue; - if (currentName is null) await Task.Delay(100, cts.Token); + if (currentName is null) + await Task.Delay(100, cts.Token); } if (currentName is null) @@ -354,12 +424,17 @@ public async Task TheDefaultSinkCanBeSetToWhatItAlreadyIs_WithoutDisturbingTheSe } catch (PipeWireException e) { - Assert.Inconclusive($"this client may not write metadata on this daemon: {e.Message}"); + Assert.Inconclusive( + $"this client may not write metadata on this daemon: {e.Message}" + ); } await store.ReadyAsync(cts.Token); - Assert.AreEqual(currentName, store.DefaultAudioSink?.NameValue, - "the default sink must still name the same node"); + Assert.AreEqual( + currentName, + store.DefaultAudioSink?.NameValue, + "the default sink must still name the same node" + ); } } } @@ -369,7 +444,10 @@ public async Task ANodeNameWithCharactersThatWouldBreakJson_IsEscapedRatherThanC { RequireLinux(); using var cts = new CancellationTokenSource(Budget); - (PipeWireContext ctx, PipeWireRegistry registry) = await ConnectAsync("pwnet-jsonesc", cts.Token); + (PipeWireContext ctx, PipeWireRegistry registry) = await ConnectAsync( + "pwnet-jsonesc", + cts.Token + ); await using (ctx) await using (registry) { @@ -388,19 +466,35 @@ public async Task ANodeNameWithCharactersThatWouldBreakJson_IsEscapedRatherThanC try { - await store.SetAsync(key, $$"""{ "name": "{{awkward.Replace("\\", "\\\\").Replace("\"", "\\\"")}}" }""", - "Spa:String:JSON", PipeWireMetadataProxy.SubjectCore, cts.Token); + await store.SetAsync( + key, + $$"""{ "name": "{{awkward.Replace("\\", "\\\\").Replace("\"", "\\\"")}}" }""", + "Spa:String:JSON", + PipeWireMetadataProxy.SubjectCore, + cts.Token + ); } catch (PipeWireException e) { - Assert.Inconclusive($"this client may not write metadata on this daemon: {e.Message}"); + Assert.Inconclusive( + $"this client may not write metadata on this daemon: {e.Message}" + ); } string? raw = store.Get(key); Assert.IsNotNull(raw); - var entry = new PipeWireMetadataEntry(PipeWireMetadataProxy.SubjectCore, key, null, raw); - Assert.AreEqual(awkward, entry.NameValue, "the escaping did not survive the round trip"); + var entry = new PipeWireMetadataEntry( + PipeWireMetadataProxy.SubjectCore, + key, + null, + raw + ); + Assert.AreEqual( + awkward, + entry.NameValue, + "the escaping did not survive the round trip" + ); await store.SetAsync(key, null, cancellationToken: cts.Token); } @@ -412,13 +506,19 @@ public void AMetadataValueThatIsNotJson_ReadsAsNullRatherThanThrowing() { // The daemon writes what it likes into a metadata value; an accessor that parsed it as JSON // and threw would take down whatever was enumerating the store. - foreach (string? value in (string?[])[null, "", "not json", "{", "[]", "{\"other\":1}", "\"bare\""]) + foreach ( + string? value in (string?[]) + [null, "", "not json", "{", "[]", "{\"other\":1}", "\"bare\""] + ) { var entry = new PipeWireMetadataEntry(0, "k", null, value); Assert.IsNull(entry.NameValue, $"'{value}' must not yield a name"); } - Assert.AreEqual("x", new PipeWireMetadataEntry(0, "k", null, """{ "name": "x" }""").NameValue); + Assert.AreEqual( + "x", + new PipeWireMetadataEntry(0, "k", null, """{ "name": "x" }""").NameValue + ); } [TestMethod] @@ -426,15 +526,18 @@ public async Task SwitchingACardProfileAndPuttingItBack_ReplacesItsNodesBothTime { RequireLinux(); using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(60)); - (PipeWireContext ctx, PipeWireRegistry registry) = await ConnectAsync("pwnet-profileswitch", cts.Token); + (PipeWireContext ctx, PipeWireRegistry registry) = await ConnectAsync( + "pwnet-profileswitch", + cts.Token + ); await using (ctx) await using (registry) { // Lowest id, not "whichever came first". Enumeration order is not stable across // processes, so an unordered pick makes two runs exercise different cards, and a // machine with more than one ALSA card then passes or fails on which it landed on. - PipeWireDevice? card = registry.Current.Devices - .Where(d => string.Equals(d.Api, "alsa", StringComparison.Ordinal)) + PipeWireDevice? card = registry + .Current.Devices.Where(d => string.Equals(d.Api, "alsa", StringComparison.Ordinal)) .OrderBy(d => d.Id) .FirstOrDefault(); if (card is null) @@ -459,8 +562,10 @@ public async Task SwitchingACardProfileAndPuttingItBack_ReplacesItsNodesBothTime int[] candidates = [ .. profiles - .Where(p => p[SpaParamProfile.Available] is not SpaId a - || a.Value != (uint)SpaParamAvailability.No) + .Where(p => + p[SpaParamProfile.Available] is not SpaId a + || a.Value != (uint)SpaParamAvailability.No + ) .Select(p => (p[SpaParamProfile.Index] as SpaInt)?.Value ?? -1) .Where(i => i >= 0 && i != originalIndex), ]; @@ -482,9 +587,13 @@ .. profiles // test is actually asking. The test's own warning that "any node id held across it is // stale" applies to the test as much as to a caller. List CardNodes() => - [.. registry.Current.Nodes - .Where(n => n.DeviceId == card.Id && n.ObjectSerial is not null) - .Select(n => n.ObjectSerial!.Value)]; + [ + .. registry + .Current.Nodes.Where(n => + n.DeviceId == card.Id && n.ObjectSerial is not null + ) + .Select(n => n.ObjectSerial!.Value), + ]; // Names, for the restore half. Serials answer "are these the same node instances", // which is the right question on the way out and the wrong one on the way back: a @@ -493,10 +602,12 @@ [.. registry.Current.Nodes // with the same names under new serials. Comparing serials there reports every // restored node as a leftover, and no amount of waiting clears it. List CardNodeNames() => - [.. registry.Current.Nodes - .Where(n => n.DeviceId == card.Id && n.NodeName is not null) - .Select(n => n.NodeName!) - .OrderBy(n => n, StringComparer.Ordinal)]; + [ + .. registry + .Current.Nodes.Where(n => n.DeviceId == card.Id && n.NodeName is not null) + .Select(n => n.NodeName!) + .OrderBy(n => n, StringComparer.Ordinal), + ]; List before = CardNodes(); List beforeNames = CardNodeNames(); @@ -509,8 +620,11 @@ [.. registry.Current.Nodes await registry.WaitForInitialEnumerationAsync(cts.Token); SpaObject? now = await control.GetProfileAsync(cts.Token); - Assert.AreEqual(candidates[0], ((SpaInt)now![SpaParamProfile.Index]!).Value, - "the card did not switch profile"); + Assert.AreEqual( + candidates[0], + ((SpaInt)now![SpaParamProfile.Index]!).Value, + "the card did not switch profile" + ); // A profile that offers different endpoints exchanges the card's nodes rather than // adding to them, so an id held across the switch is stale. @@ -530,7 +644,11 @@ [.. registry.Current.Nodes for (int attempt = 0; attempt < 100; attempt++) { List seen = CardNodes(); - if (seen.Count > 0 && !seen.Intersect(before).Any() && seen.SequenceEqual(settled)) + if ( + seen.Count > 0 + && !seen.Intersect(before).Any() + && seen.SequenceEqual(settled) + ) { if (++stableSamples >= 5) break; @@ -544,9 +662,11 @@ [.. registry.Current.Nodes await Task.Delay(100, cts.Token); } - Assert.IsFalse(before.Intersect(CardNodes()).Any(), + Assert.IsFalse( + before.Intersect(CardNodes()).Any(), "the old profile's nodes are still on the card after the switch: " - + string.Join(", ", before.Intersect(CardNodes()))); + + string.Join(", ", before.Intersect(CardNodes())) + ); // The device is still coherent afterwards: it still enumerates, and its routes are // the new profile's rather than a mixture. @@ -556,7 +676,8 @@ [.. registry.Current.Nodes catch (PipeWireException ex) { cardGone = await CardGoneAsync(registry, control.Id, cts.Token); - if (!cardGone) throw; + if (!cardGone) + throw; Assert.Inconclusive($"the card left the graph mid-test: {ex.Message}"); } finally @@ -569,13 +690,21 @@ [.. registry.Current.Nodes { try { - await control.SetProfileAsync(originalIndex, cancellationToken: CancellationToken.None); + await control.SetProfileAsync( + originalIndex, + cancellationToken: CancellationToken.None + ); await registry.WaitForInitialEnumerationAsync(CancellationToken.None); } catch (PipeWireException) { - cardGone = await CardGoneAsync(registry, control.Id, CancellationToken.None); - if (!cardGone) throw; + cardGone = await CardGoneAsync( + registry, + control.Id, + CancellationToken.None + ); + if (!cardGone) + throw; } } } @@ -583,8 +712,11 @@ [.. registry.Current.Nodes if (!cardGone) { SpaObject? restored = await control.GetProfileAsync(cts.Token); - Assert.AreEqual(originalIndex, ((SpaInt)restored![SpaParamProfile.Index]!).Value, - "the original profile must have been restored"); + Assert.AreEqual( + originalIndex, + ((SpaInt)restored![SpaParamProfile.Index]!).Value, + "the original profile must have been restored" + ); // Teardown is not synchronous with the profile write, so the set is given time to // settle before anything is claimed about it. @@ -592,7 +724,8 @@ [.. registry.Current.Nodes for (int attempt = 0; attempt < 100; attempt++) { stale = [.. CardNodeNames().Except(beforeNames)]; - if (stale.Count == 0) break; + if (stale.Count == 0) + break; await Task.Delay(100, cts.Token); } @@ -600,9 +733,12 @@ [.. registry.Current.Nodes // nodes stay on the card is not a restore: they remain as candidate sinks and // sources for everything that runs afterwards, which is how they went unnoticed // while this test asserted the profile index alone. - Assert.AreEqual(0, stale.Count, + Assert.AreEqual( + 0, + stale.Count, "nodes the other profile added outlived the switch back: " - + string.Join(", ", stale)); + + string.Join(", ", stale) + ); } } } @@ -612,7 +748,10 @@ public async Task SettingARouteVolumeAndRestoringIt_ChangesTheHardwareMixer() { RequireLinux(); using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(40)); - (PipeWireContext ctx, PipeWireRegistry registry) = await ConnectAsync("pwnet-routevol", cts.Token); + (PipeWireContext ctx, PipeWireRegistry registry) = await ConnectAsync( + "pwnet-routevol", + cts.Token + ); await using (ctx) await using (registry) { @@ -626,7 +765,8 @@ public async Task SettingARouteVolumeAndRestoringIt_ChangesTheHardwareMixer() SpaObject? route = active.FirstOrDefault(r => r[SpaParamRoute.Props] is SpaObject props && props[SpaProp.ChannelVolumes] is SpaArray volumes - && !volumes.Items.IsDefaultOrEmpty); + && !volumes.Items.IsDefaultOrEmpty + ); if (route is null) Assert.Inconclusive("no active route on this card carries a volume."); @@ -659,19 +799,29 @@ r[SpaParamRoute.Props] is SpaObject props { float[] test = [.. restore.Select(_ => 0.37f)]; await control.SetRouteVolumeAsync( - index, device, test, originalMute, save: false, cts.Token); + index, + device, + test, + originalMute, + save: false, + cts.Token + ); ImmutableArray after = await control.GetActiveRoutesAsync(cts.Token); changed = after.FirstOrDefault(r => - r[SpaParamRoute.Index] is SpaInt i && i.Value == index); + r[SpaParamRoute.Index] is SpaInt i && i.Value == index + ); - if (changed?[SpaParamRoute.Props] is SpaObject routeProps + if ( + changed?[SpaParamRoute.Props] is SpaObject routeProps && routeProps[SpaProp.ChannelVolumes] is SpaArray volumes && !volumes.Items.IsDefaultOrEmpty - && volumes.Items[0] is SpaFloat value) + && volumes.Items[0] is SpaFloat value + ) { first = value.Value; - if (Math.Abs(first - 0.37f) <= 0.02f) break; + if (Math.Abs(first - 0.37f) <= 0.02f) + break; } await Task.Delay(100, cts.Token); @@ -683,7 +833,8 @@ await control.SetRouteVolumeAsync( catch (PipeWireException ex) { cardGone = await CardGoneAsync(registry, control.Id, cts.Token); - if (!cardGone) throw; + if (!cardGone) + throw; Assert.Inconclusive($"the card left the graph mid-test: {ex.Message}"); } finally @@ -695,12 +846,23 @@ await control.SetRouteVolumeAsync( try { await control.SetRouteVolumeAsync( - index, device, restore, originalMute, save: false, CancellationToken.None); + index, + device, + restore, + originalMute, + save: false, + CancellationToken.None + ); } catch (PipeWireException) { - cardGone = await CardGoneAsync(registry, control.Id, CancellationToken.None); - if (!cardGone) throw; + cardGone = await CardGoneAsync( + registry, + control.Id, + CancellationToken.None + ); + if (!cardGone) + throw; } } } @@ -709,7 +871,10 @@ await control.SetRouteVolumeAsync( /// Whether a card is still in the graph, after giving the registry a beat to notice. private static async Task CardGoneAsync( - PipeWireRegistry registry, uint cardId, CancellationToken cancellationToken) + PipeWireRegistry registry, + uint cardId, + CancellationToken cancellationToken + ) { await registry.WaitForInitialEnumerationAsync(cancellationToken); return registry.Current.Devices.All(d => d.Id != cardId); diff --git a/tests/PipeWire.NET.Tests/DeviceIdNegotiationEndToEndTests.cs b/tests/PipeWire.NET.Tests/DeviceIdNegotiationEndToEndTests.cs index 7d752b5..5b65d63 100644 --- a/tests/PipeWire.NET.Tests/DeviceIdNegotiationEndToEndTests.cs +++ b/tests/PipeWire.NET.Tests/DeviceIdNegotiationEndToEndTests.cs @@ -28,24 +28,32 @@ namespace PipeWire.NET.Tests; [SupportedOSPlatform("linux")] public sealed class DeviceIdNegotiationEndToEndTests : PipeWireTestBase { - private const int Width = 320, Height = 240, PoolCap = 16; + private const int Width = 320, + Height = 240, + PoolCap = 16; private static readonly long Linear = (long)GbmAllocator.LinearModifier; /// What one run saw. private sealed record Outcome( - int Frames, int DmaBufFrames, int SyncFrames, - DrmDevice? OutputDevice, DrmDevice? CaptureDevice, - ImmutableArray HandedToAllocator); + int Frames, + int DmaBufFrames, + int SyncFrames, + DrmDevice? OutputDevice, + DrmDevice? CaptureDevice, + ImmutableArray HandedToAllocator + ); private static DrmDevice Phantom(uint minor) => DrmDevice.FromNumbers(226, minor); /// The render node the buffers are allocated on, and an allocator for it. private static (DrmDevice Device, GbmAllocator Gbm) RealDevice() { - if (!OperatingSystem.IsLinux()) Assert.Inconclusive("PipeWire is a Linux daemon."); + if (!OperatingSystem.IsLinux()) + Assert.Inconclusive("PipeWire is a Linux daemon."); ImmutableArray nodes = DrmDevice.EnumerateRenderNodes(); - if (nodes.IsEmpty) Assert.Inconclusive("No GPU render node - skipping device-ID negotiation."); + if (nodes.IsEmpty) + Assert.Inconclusive("No GPU render node - skipping device-ID negotiation."); try { @@ -53,7 +61,9 @@ private static (DrmDevice Device, GbmAllocator Gbm) RealDevice() } catch (Exception ex) { - Assert.Inconclusive($"libgbm unavailable ({ex.Message}) - skipping device-ID negotiation."); + Assert.Inconclusive( + $"libgbm unavailable ({ex.Message}) - skipping device-ID negotiation." + ); throw; } } @@ -69,18 +79,24 @@ private static async Task StreamAsync( Action connectCapture, bool explicitSync = false, int wantFrames = 10, - TimeSpan? budget = null) + TimeSpan? budget = null + ) { var buffers = new List(); var handed = new List(); - int frames = 0, dmaBufFrames = 0, syncFrames = 0; + int frames = 0, + dmaBufFrames = 0, + syncFrames = 0; long stamped = 0; int Back(int index, DrmDevice? device, Span planes) { - lock (handed) handed.Add(device); - if (index >= PoolCap) return 0; - while (buffers.Count <= index) buffers.Add(gbm.CreateBgra(Width, Height)); + lock (handed) + handed.Add(device); + if (index >= PoolCap) + return 0; + while (buffers.Count <= index) + buffers.Add(gbm.CreateBgra(Width, Height)); GbmAllocator.Buffer b = buffers[index]; planes[0] = new VideoPlane(b.Fd, b.Offset, b.Stride, b.Size); return 1; @@ -91,10 +107,27 @@ int Back(int index, DrmDevice? device, Span planes) await using var ctx = new PipeWireContext(name, ConsoleTestLoggerFactory.Instance); await ctx.StartAsync(); - await using var output = new PipeWireVideoOutput(ctx, $"{name}-src", Width, Height, PixelFormat.Bgra, 30); + await using var output = new PipeWireVideoOutput( + ctx, + $"{name}-src", + Width, + Height, + PixelFormat.Bgra, + 30 + ); if (explicitSync) { - output.AllocateDmaBufSync += (_, index, _, _, _, device, planes, out acquireFd, out releaseFd) => + output.AllocateDmaBufSync += ( + _, + index, + _, + _, + _, + device, + planes, + out acquireFd, + out releaseFd + ) => { acquireFd = -1; releaseFd = -1; @@ -109,7 +142,8 @@ int Back(int index, DrmDevice? device, Span planes) } else { - output.AllocateDmaBuf += (_, index, _, _, _, device, planes) => Back(index, device, planes); + output.AllocateDmaBuf += (_, index, _, _, _, device, planes) => + Back(index, device, planes); output.FillDmaBuf += (_, _) => true; } @@ -122,8 +156,10 @@ int Back(int index, DrmDevice? device, Span planes) capture.FrameReady += (_, frame) => { Interlocked.Increment(ref frames); - if (frame.BufferType == PipeWireBufferType.DmaBuf) Interlocked.Increment(ref dmaBufFrames); - if (frame.SyncTimeline is not null) Interlocked.Increment(ref syncFrames); + if (frame.BufferType == PipeWireBufferType.DmaBuf) + Interlocked.Increment(ref dmaBufFrames); + if (frame.SyncTimeline is not null) + Interlocked.Increment(ref syncFrames); }; connectCapture(capture, nodeId); @@ -132,15 +168,22 @@ int Back(int index, DrmDevice? device, Span planes) await Task.Delay(100); ImmutableArray allocated; - lock (handed) allocated = [.. handed]; + lock (handed) + allocated = [.. handed]; return new Outcome( - Volatile.Read(ref frames), Volatile.Read(ref dmaBufFrames), Volatile.Read(ref syncFrames), - output.NegotiatedDevice, capture.NegotiatedDevice, allocated); + Volatile.Read(ref frames), + Volatile.Read(ref dmaBufFrames), + Volatile.Read(ref syncFrames), + output.NegotiatedDevice, + capture.NegotiatedDevice, + allocated + ); } finally { - foreach (GbmAllocator.Buffer b in buffers) b.Dispose(); + foreach (GbmAllocator.Buffer b in buffers) + b.Dispose(); } } @@ -156,19 +199,42 @@ public async Task BothEndsNegotiating_SettleOnTheDeviceTheyShare_AndTheAllocator // Each end prefers a device the other does not have. The producer lists its phantom first, // so a negotiation that ignored the devices would settle on it; the consumer's phantom is // not in the producer's available-devices list and is filtered out before it is offered. - Outcome o = await StreamAsync("pwnet-devid-both", gbm, - output => output.ConnectDmaBuf([new DmaBufDeviceOffer(Phantom(250), [Linear]), new DmaBufDeviceOffer(real, [Linear])]), - (capture, node) => capture.Connect(node, [PixelFormat.Bgra], - deviceOffers: [new DmaBufDeviceOffer(Phantom(251), [Linear]), new DmaBufDeviceOffer(real, [Linear])])); + Outcome o = await StreamAsync( + "pwnet-devid-both", + gbm, + output => + output.ConnectDmaBuf([ + new DmaBufDeviceOffer(Phantom(250), [Linear]), + new DmaBufDeviceOffer(real, [Linear]), + ]), + (capture, node) => + capture.Connect( + node, + [PixelFormat.Bgra], + deviceOffers: + [ + new DmaBufDeviceOffer(Phantom(251), [Linear]), + new DmaBufDeviceOffer(real, [Linear]), + ] + ) + ); - Assert.IsTrue(o.DmaBufFrames >= 10, $"expected DMA-BUF frames, got {o.DmaBufFrames} of {o.Frames}"); + Assert.IsTrue( + o.DmaBufFrames >= 10, + $"expected DMA-BUF frames, got {o.DmaBufFrames} of {o.Frames}" + ); Assert.AreEqual(real, o.OutputDevice, "the producer's negotiated device"); Assert.AreEqual(real, o.CaptureDevice, "the consumer's negotiated device"); - Assert.AreEqual(real.RenderNodePath, o.OutputDevice!.Value.RenderNodePath, - "the negotiated device is described by the offer it matched, path included"); + Assert.AreEqual( + real.RenderNodePath, + o.OutputDevice!.Value.RenderNodePath, + "the negotiated device is described by the offer it matched, path included" + ); Assert.IsFalse(o.HandedToAllocator.IsEmpty, "the allocator was never called"); - Assert.IsTrue(o.HandedToAllocator.All(d => d == real), - $"every buffer must be allocated on the negotiated device; handed [{string.Join(", ", o.HandedToAllocator)}]"); + Assert.IsTrue( + o.HandedToAllocator.All(d => d == real), + $"every buffer must be allocated on the negotiated device; handed [{string.Join(", ", o.HandedToAllocator)}]" + ); } } @@ -183,15 +249,27 @@ public async Task AConsumerThatDoesNotNegotiate_StillStreams_WithTheDeviceUndefi { // Every consumer that predates the protocol: modifiers, no Capability. The producer falls // back to its first device's modifiers without a device, upstream's implicit device. - Outcome o = await StreamAsync("pwnet-devid-oldsink", gbm, + Outcome o = await StreamAsync( + "pwnet-devid-oldsink", + gbm, output => output.ConnectDmaBuf([new DmaBufDeviceOffer(real, [Linear])]), - (capture, node) => capture.Connect(node, [PixelFormat.Bgra], modifiers: [Linear])); + (capture, node) => capture.Connect(node, [PixelFormat.Bgra], modifiers: [Linear]) + ); - Assert.IsTrue(o.DmaBufFrames >= 10, $"expected DMA-BUF frames, got {o.DmaBufFrames} of {o.Frames}"); - Assert.IsNull(o.OutputDevice, "no device can have been negotiated with a peer that does not negotiate"); + Assert.IsTrue( + o.DmaBufFrames >= 10, + $"expected DMA-BUF frames, got {o.DmaBufFrames} of {o.Frames}" + ); + Assert.IsNull( + o.OutputDevice, + "no device can have been negotiated with a peer that does not negotiate" + ); Assert.IsNull(o.CaptureDevice); Assert.IsFalse(o.HandedToAllocator.IsEmpty, "the allocator was never called"); - Assert.IsTrue(o.HandedToAllocator.All(d => d is null), "the allocator is told the device is undefined"); + Assert.IsTrue( + o.HandedToAllocator.All(d => d is null), + "the allocator is told the device is undefined" + ); } } @@ -207,12 +285,22 @@ public async Task AProducerThatDoesNotNegotiate_StillStreams_WithTheDeviceUndefi // The consumer connects inactive and waits for the producer's capabilities; this producer // sends none, so what arrives is pw_stream's synthesised PeerCapability, and the consumer // must still activate and offer the first device's modifiers without a device. - Outcome o = await StreamAsync("pwnet-devid-oldsrc", gbm, + Outcome o = await StreamAsync( + "pwnet-devid-oldsrc", + gbm, output => output.ConnectDmaBuf([Linear]), - (capture, node) => capture.Connect(node, [PixelFormat.Bgra], - deviceOffers: [new DmaBufDeviceOffer(real, [Linear])])); + (capture, node) => + capture.Connect( + node, + [PixelFormat.Bgra], + deviceOffers: [new DmaBufDeviceOffer(real, [Linear])] + ) + ); - Assert.IsTrue(o.DmaBufFrames >= 10, $"expected DMA-BUF frames, got {o.DmaBufFrames} of {o.Frames}"); + Assert.IsTrue( + o.DmaBufFrames >= 10, + $"expected DMA-BUF frames, got {o.DmaBufFrames} of {o.Frames}" + ); Assert.IsNull(o.OutputDevice); Assert.IsNull(o.CaptureDevice); } @@ -225,22 +313,39 @@ public async Task AProducerThatDoesNotNegotiate_StillStreams_WithTheDeviceUndefi public async Task ExplicitSync_OverANegotiatedDevice_CarriesTimelinesAndTheDevice() { (DrmDevice real, GbmAllocator gbm) = RealDevice(); - if (!DrmSyncobj.IsAvailable) Assert.Inconclusive("No render node supports syncobj timelines."); + if (!DrmSyncobj.IsAvailable) + Assert.Inconclusive("No render node supports syncobj timelines."); using (gbm) { - Outcome o = await StreamAsync("pwnet-devid-sync", gbm, + Outcome o = await StreamAsync( + "pwnet-devid-sync", + gbm, output => output.ConnectDmaBufSync([new DmaBufDeviceOffer(real, [Linear])]), - (capture, node) => capture.Connect(node, [PixelFormat.Bgra], - deviceOffers: [new DmaBufDeviceOffer(real, [Linear])], requestExplicitSync: true), - explicitSync: true); + (capture, node) => + capture.Connect( + node, + [PixelFormat.Bgra], + deviceOffers: [new DmaBufDeviceOffer(real, [Linear])], + requestExplicitSync: true + ), + explicitSync: true + ); - Assert.IsTrue(o.DmaBufFrames >= 10, $"expected DMA-BUF frames, got {o.DmaBufFrames} of {o.Frames}"); - Assert.IsTrue(o.SyncFrames >= 10, $"expected frames carrying timelines, got {o.SyncFrames} of {o.Frames}"); + Assert.IsTrue( + o.DmaBufFrames >= 10, + $"expected DMA-BUF frames, got {o.DmaBufFrames} of {o.Frames}" + ); + Assert.IsTrue( + o.SyncFrames >= 10, + $"expected frames carrying timelines, got {o.SyncFrames} of {o.Frames}" + ); Assert.AreEqual(real, o.OutputDevice); Assert.AreEqual(real, o.CaptureDevice); - Assert.IsTrue(o.HandedToAllocator.All(d => d == real), - $"handed [{string.Join(", ", o.HandedToAllocator)}]"); + Assert.IsTrue( + o.HandedToAllocator.All(d => d == real), + $"handed [{string.Join(", ", o.HandedToAllocator)}]" + ); } } @@ -258,17 +363,27 @@ public async Task EndsWithNoDeviceInCommon_DoNotSettleOnOne() // Both negotiate and share nothing. The consumer's only remaining shape is host memory, // which a DMA-BUF-only producer cannot agree to, so nothing may stream - and above all no // device may be reported that neither end offered. - Outcome o = await StreamAsync("pwnet-devid-disjoint", gbm, + Outcome o = await StreamAsync( + "pwnet-devid-disjoint", + gbm, output => output.ConnectDmaBuf([new DmaBufDeviceOffer(real, [Linear])]), - (capture, node) => capture.Connect(node, [PixelFormat.Bgra], - deviceOffers: [new DmaBufDeviceOffer(Phantom(252), [Linear])]), + (capture, node) => + capture.Connect( + node, + [PixelFormat.Bgra], + deviceOffers: [new DmaBufDeviceOffer(Phantom(252), [Linear])] + ), wantFrames: 1, - budget: TimeSpan.FromSeconds(4)); + budget: TimeSpan.FromSeconds(4) + ); Assert.AreEqual(0, o.DmaBufFrames, "a DMA-BUF frame arrived with no device in common"); Assert.IsNull(o.OutputDevice); Assert.IsNull(o.CaptureDevice); - Assert.IsTrue(o.HandedToAllocator.IsEmpty, "nothing was negotiated, so nothing should be allocated"); + Assert.IsTrue( + o.HandedToAllocator.IsEmpty, + "nothing was negotiated, so nothing should be allocated" + ); } } } diff --git a/tests/PipeWire.NET.Tests/DeviceIdNegotiationTests.cs b/tests/PipeWire.NET.Tests/DeviceIdNegotiationTests.cs index ca4589d..aeef460 100644 --- a/tests/PipeWire.NET.Tests/DeviceIdNegotiationTests.cs +++ b/tests/PipeWire.NET.Tests/DeviceIdNegotiationTests.cs @@ -46,12 +46,15 @@ private static byte[] PeerCapability(params (uint PeerId, byte[]? Capability)[] props.Add(new SpaPodProperty(peerId, 0, value)); } - return SpaPod.ToBytes(new SpaObject(SpaType.ObjectPeerParam, SpaParamType.PeerCapability, props.ToImmutable())); + return SpaPod.ToBytes( + new SpaObject(SpaType.ObjectPeerParam, SpaParamType.PeerCapability, props.ToImmutable()) + ); } private static PeerCapabilities Parse(byte[] pod) { - fixed (byte* p = pod) return DeviceIdNegotiation.Parse((spa_pod*)p); + fixed (byte* p = pod) + return DeviceIdNegotiation.Parse((spa_pod*)p); } [TestMethod] @@ -77,9 +80,15 @@ public void ADrmDevice_IsItsNumber_AndNotItsPath() Assert.AreEqual(226u, CardA.Major); Assert.AreEqual(128u, CardA.Minor); Assert.AreEqual(CardA, new DrmDevice(CardA.Id, "/dev/dri/renderD128")); - Assert.AreEqual(CardA.GetHashCode(), new DrmDevice(CardA.Id, "/somewhere/else").GetHashCode()); + Assert.AreEqual( + CardA.GetHashCode(), + new DrmDevice(CardA.Id, "/somewhere/else").GetHashCode() + ); Assert.AreNotEqual(CardA, CardB); - Assert.AreEqual("226:128 (/dev/dri/renderD128)", new DrmDevice(CardA.Id, "/dev/dri/renderD128").ToString()); + Assert.AreEqual( + "226:128 (/dev/dri/renderD128)", + new DrmDevice(CardA.Id, "/dev/dri/renderD128").ToString() + ); } [TestMethod] @@ -88,7 +97,8 @@ public void TheRenderNodesOnThisMachine_ReadBackAsTheNodesTheyCameFrom() RequireLinux(); ImmutableArray nodes = DrmDevice.EnumerateRenderNodes(); - if (nodes.IsEmpty) Assert.Inconclusive("No render node on this machine."); + if (nodes.IsEmpty) + Assert.Inconclusive("No render node on this machine."); foreach (DrmDevice node in nodes) { @@ -121,7 +131,10 @@ public void AProducersCapability_ReadsBackAsNegotiatingWithItsDevices() Assert.AreEqual(new SpaString("pipewire.device-id-negotiation"), fields[1]); Assert.AreEqual(new SpaString("1"), fields[2]); Assert.AreEqual(new SpaString("pipewire.device-ids"), fields[3]); - Assert.AreEqual(new SpaString("""{"available-devices":["80e2000000000000","81e2000000000000"]}"""), fields[4]); + Assert.AreEqual( + new SpaString("""{"available-devices":["80e2000000000000","81e2000000000000"]}"""), + fields[4] + ); PeerCapabilities peer = Parse(PeerCapability((57, capability))); Assert.IsTrue(peer.NegotiatesDeviceIds); @@ -135,11 +148,16 @@ public void AConsumersCapability_NegotiatesAndNamesNoDevices_WhichAcceptsAny() { RequireLinux(); - PeerCapabilities peer = Parse(PeerCapability((57, DeviceIdNegotiation.CapabilityParam([])))); + PeerCapabilities peer = Parse( + PeerCapability((57, DeviceIdNegotiation.CapabilityParam([]))) + ); Assert.IsTrue(peer.NegotiatesDeviceIds); Assert.IsTrue(peer.AvailableDevices.IsEmpty); - Assert.IsTrue(peer.Accepts(CardA.Id), "a peer that names no devices works with any, as video-play-fixate reads it"); + Assert.IsTrue( + peer.Accepts(CardA.Id), + "a peer that names no devices works with any, as video-play-fixate reads it" + ); } [TestMethod] @@ -160,15 +178,25 @@ public void AMalformedDeviceList_IsReadAsNamingNone_NotAsAFailure() { RequireLinux(); - byte[] capability = SpaPod.ToBytes(new SpaObject(SpaType.ObjectParamDict, SpaParamType.Capability, - [ - new SpaPodProperty(SpaParamDict.Info, SpaPodPropFlags.HintDict, new SpaStruct( - [ - new SpaInt(2), - new SpaString("pipewire.device-id-negotiation"), new SpaString("1"), - new SpaString("pipewire.device-ids"), new SpaString("{\"available-devices\": [\"80e2\", 12, "), - ])), - ])); + byte[] capability = SpaPod.ToBytes( + new SpaObject( + SpaType.ObjectParamDict, + SpaParamType.Capability, + [ + new SpaPodProperty( + SpaParamDict.Info, + SpaPodPropFlags.HintDict, + new SpaStruct([ + new SpaInt(2), + new SpaString("pipewire.device-id-negotiation"), + new SpaString("1"), + new SpaString("pipewire.device-ids"), + new SpaString("{\"available-devices\": [\"80e2\", 12, "), + ]) + ), + ] + ) + ); PeerCapabilities peer = Parse(PeerCapability((3, capability))); @@ -182,24 +210,51 @@ public void AFormatForADevice_CarriesItsIdMandatory_AndReadsBack() RequireLinux(); byte[] pod = new byte[1024]; - int len = SpaFormatPod.WriteVideoFormat(pod, [PixelFormat.Bgra], 640, 480, 30, fixedSize: true, - modifiers: [0, 0x0100000000000001], deviceId: CardB.Id); + int len = SpaFormatPod.WriteVideoFormat( + pod, + [PixelFormat.Bgra], + 640, + 480, + 30, + fixedSize: true, + modifiers: [0, 0x0100000000000001], + deviceId: CardB.Id + ); Assert.IsTrue(SpaPod.TryParse(pod.AsSpan(0, len), out SpaValue? value)); - SpaPodProperty device = ((SpaObject)value!).Properties.Single(p => p.Key == (SpaKey)SpaFormat.VideoDeviceId); + SpaPodProperty device = ((SpaObject)value!).Properties.Single(p => + p.Key == (SpaKey)SpaFormat.VideoDeviceId + ); Assert.AreEqual(SpaPodPropFlags.Mandatory, device.Flags); - CollectionAssert.AreEqual(BitConverter.GetBytes(CardB.Id), ((SpaBytes)device.Value).Value.ToArray()); + CollectionAssert.AreEqual( + BitConverter.GetBytes(CardB.Id), + ((SpaBytes)device.Value).Value.ToArray() + ); SpaFormatPod.VideoFormatInfo info; - fixed (byte* p = pod) info = SpaFormatPod.ParseVideoFormat((spa_pod*)p, default); + fixed (byte* p = pod) + info = SpaFormatPod.ParseVideoFormat((spa_pod*)p, default); Assert.AreEqual(CardB.Id, info.DeviceId); - Assert.IsTrue(info.ModifierNeedsFixation, "the negotiation offer leaves the modifier to fixate"); + Assert.IsTrue( + info.ModifierNeedsFixation, + "the negotiation offer leaves the modifier to fixate" + ); // The fixation pass keeps the device: a fixation without it matches none of a negotiating // peer's formats, which all name one. - len = SpaFormatPod.WriteVideoFormat(pod, [PixelFormat.Bgra], 640, 480, 30, fixedSize: true, - modifiers: [0], fixateModifier: true, deviceId: CardB.Id); - fixed (byte* p = pod) info = SpaFormatPod.ParseVideoFormat((spa_pod*)p, default); + len = SpaFormatPod.WriteVideoFormat( + pod, + [PixelFormat.Bgra], + 640, + 480, + 30, + fixedSize: true, + modifiers: [0], + fixateModifier: true, + deviceId: CardB.Id + ); + fixed (byte* p = pod) + info = SpaFormatPod.ParseVideoFormat((spa_pod*)p, default); Assert.AreEqual(CardB.Id, info.DeviceId); Assert.IsFalse(info.ModifierNeedsFixation); Assert.AreEqual(0UL, info.Modifier); @@ -214,17 +269,42 @@ public void ANegotiatedFormat_YieldsItsDevice_FromInsideTheChoiceTheFilterWrapsI // Choice, and a single match stays a Choice(None) holding one value (filter.h 246-257). A // reader that only takes a bare Bytes pod reports the device as undefined - which is how the // first live run of the end-to-end tests streamed on the right device and reported none. - byte[] pod = SpaPod.ToBytes(new SpaObject(SpaType.ObjectFormat, SpaParamType.Format, - [ - new SpaPodProperty(SpaFormat.MediaType, 0, new SpaId((uint)SpaMediaType.Video)), - new SpaPodProperty(SpaFormat.MediaSubtype, 0, new SpaId((uint)SpaMediaSubtype.Raw)), - new SpaPodProperty(SpaFormat.VideoDeviceId, SpaPodPropFlags.Mandatory, - new SpaChoice(SpaChoiceType.None, SpaType.Bytes, [new SpaBytes([.. BitConverter.GetBytes(CardB.Id)])])), - new SpaPodProperty(SpaFormat.VideoFormat, 0, new SpaChoice(SpaChoiceType.None, SpaType.Id, [new SpaId((uint)SpaVideoFormat.Bgra)])), - ])); + byte[] pod = SpaPod.ToBytes( + new SpaObject( + SpaType.ObjectFormat, + SpaParamType.Format, + [ + new SpaPodProperty(SpaFormat.MediaType, 0, new SpaId((uint)SpaMediaType.Video)), + new SpaPodProperty( + SpaFormat.MediaSubtype, + 0, + new SpaId((uint)SpaMediaSubtype.Raw) + ), + new SpaPodProperty( + SpaFormat.VideoDeviceId, + SpaPodPropFlags.Mandatory, + new SpaChoice( + SpaChoiceType.None, + SpaType.Bytes, + [new SpaBytes([.. BitConverter.GetBytes(CardB.Id)])] + ) + ), + new SpaPodProperty( + SpaFormat.VideoFormat, + 0, + new SpaChoice( + SpaChoiceType.None, + SpaType.Id, + [new SpaId((uint)SpaVideoFormat.Bgra)] + ) + ), + ] + ) + ); SpaFormatPod.VideoFormatInfo info; - fixed (byte* p = pod) info = SpaFormatPod.ParseVideoFormat((spa_pod*)p, default); + fixed (byte* p = pod) + info = SpaFormatPod.ParseVideoFormat((spa_pod*)p, default); Assert.AreEqual(CardB.Id, info.DeviceId); Assert.AreEqual(PixelFormat.Bgra, info.Format); @@ -236,13 +316,31 @@ public void AFormatWithoutADevice_ReadsBackAsDeviceUndefined_EvenAfterOneThatHad RequireLinux(); byte[] pod = new byte[1024]; - SpaFormatPod.WriteVideoFormat(pod, [PixelFormat.Bgra], 640, 480, 30, fixedSize: true, modifiers: [0]); - - var previous = new SpaFormatPod.VideoFormatInfo(PixelFormat.Bgra, 640, 480, VideoColorInfo.Unknown, DeviceId: CardA.Id); + SpaFormatPod.WriteVideoFormat( + pod, + [PixelFormat.Bgra], + 640, + 480, + 30, + fixedSize: true, + modifiers: [0] + ); + + var previous = new SpaFormatPod.VideoFormatInfo( + PixelFormat.Bgra, + 640, + 480, + VideoColorInfo.Unknown, + DeviceId: CardA.Id + ); SpaFormatPod.VideoFormatInfo info; - fixed (byte* p = pod) info = SpaFormatPod.ParseVideoFormat((spa_pod*)p, previous); + fixed (byte* p = pod) + info = SpaFormatPod.ParseVideoFormat((spa_pod*)p, previous); - Assert.IsNull(info.DeviceId, "a device from the last negotiation must not survive into one that named none"); + Assert.IsNull( + info.DeviceId, + "a device from the last negotiation must not survive into one that named none" + ); } private static List ReadPods(byte[] pods, int count) @@ -259,7 +357,11 @@ public void AFormatWithoutADevice_ReadsBackAsDeviceUndefined_EvenAfterOneThatHad } } - Assert.AreEqual(pods.Length, at, "the pods must fill the buffer exactly, each 8-byte aligned"); + Assert.AreEqual( + pods.Length, + at, + "the pods must fill the buffer exactly, each 8-byte aligned" + ); return read; } @@ -278,8 +380,18 @@ public void ANegotiatingPeer_IsOfferedOneFormatPerDeviceItAccepts_InOrder() // The producer named A and B only: C is filtered out, as video-play-fixate's has_device_id does. var peer = new PeerCapabilities(true, [CardA.Id, CardB.Id]); - byte[] pods = DeviceIdNegotiation.WriteDeviceFormats(peer, offers, PixelFormat.Bgra, 640, 480, 30, - fixedSize: false, hostMemoryFallback: true, out int count, out int deviceFormats); + byte[] pods = DeviceIdNegotiation.WriteDeviceFormats( + peer, + offers, + PixelFormat.Bgra, + 640, + 480, + 30, + fixedSize: false, + hostMemoryFallback: true, + out int count, + out int deviceFormats + ); Assert.AreEqual(3, count); Assert.AreEqual(2, deviceFormats); @@ -288,9 +400,17 @@ public void ANegotiatingPeer_IsOfferedOneFormatPerDeviceItAccepts_InOrder() Assert.AreEqual(CardA.Id, read[0].DeviceId); Assert.AreEqual(0UL, read[0].Modifier); Assert.AreEqual(CardB.Id, read[1].DeviceId); - Assert.AreEqual(0x0100000000000001UL, read[1].Modifier, "each device keeps its own modifiers, first preferred"); + Assert.AreEqual( + 0x0100000000000001UL, + read[1].Modifier, + "each device keeps its own modifiers, first preferred" + ); Assert.IsNull(read[2].DeviceId); - Assert.AreEqual(DrmFormatModifier.Invalid, read[2].Modifier, "the fallback is host memory: no modifier at all"); + Assert.AreEqual( + DrmFormatModifier.Invalid, + read[2].Modifier, + "the fallback is host memory: no modifier at all" + ); } [TestMethod] @@ -300,13 +420,26 @@ public void APeerThatDoesNotNegotiate_IsOfferedTheFirstDevicesModifiersWithoutAD DmaBufDeviceOffer[] offers = [new(CardA, [0x0100000000000001]), new(CardB, [0])]; - byte[] pods = DeviceIdNegotiation.WriteDeviceFormats(default, offers, PixelFormat.Bgra, 640, 480, 30, - fixedSize: true, hostMemoryFallback: false, out int count, out int deviceFormats); + byte[] pods = DeviceIdNegotiation.WriteDeviceFormats( + default, + offers, + PixelFormat.Bgra, + 640, + 480, + 30, + fixedSize: true, + hostMemoryFallback: false, + out int count, + out int deviceFormats + ); Assert.AreEqual(1, count); Assert.AreEqual(0, deviceFormats); SpaFormatPod.VideoFormatInfo only = ReadPods(pods, count).Single(); - Assert.IsNull(only.DeviceId, "an old peer cannot match a mandatory property it has never heard of"); + Assert.IsNull( + only.DeviceId, + "an old peer cannot match a mandatory property it has never heard of" + ); Assert.AreEqual(0x0100000000000001UL, only.Modifier); } @@ -316,10 +449,18 @@ public void DeviceOffers_AreValidatedBeforeAnythingIsSent() RequireLinux(); Assert.ThrowsExactly(() => DeviceIdNegotiation.Validate([], "offers")); - Assert.ThrowsExactly(() => DeviceIdNegotiation.Validate([new(CardA, [])], "offers")); - Assert.ThrowsExactly(() => DeviceIdNegotiation.Validate([new(CardA, default)], "offers")); Assert.ThrowsExactly(() => - DeviceIdNegotiation.Validate([new(CardA, [0]), new(new DrmDevice(CardA.Id, "/dev/dri/renderD128"), [0])], "offers")); + DeviceIdNegotiation.Validate([new(CardA, [])], "offers") + ); + Assert.ThrowsExactly(() => + DeviceIdNegotiation.Validate([new(CardA, default)], "offers") + ); + Assert.ThrowsExactly(() => + DeviceIdNegotiation.Validate( + [new(CardA, [0]), new(new DrmDevice(CardA.Id, "/dev/dri/renderD128"), [0])], + "offers" + ) + ); DeviceIdNegotiation.Validate([new(CardA, [0]), new(CardB, [0])], "offers"); } @@ -333,9 +474,15 @@ public void TheNegotiatedDevice_IsDescribedByTheMatchingOffer() DmaBufDeviceOffer[] offers = [new(CardA, [0]), new(named, [0])]; Assert.IsNull(DeviceIdNegotiation.Resolve(null, offers)); - Assert.AreEqual("/dev/dri/renderD129", DeviceIdNegotiation.Resolve(CardB.Id, offers)!.Value.RenderNodePath); - - DrmDevice? unknown = DeviceIdNegotiation.Resolve(DrmDevice.FromNumbers(226, 140).Id, offers); + Assert.AreEqual( + "/dev/dri/renderD129", + DeviceIdNegotiation.Resolve(CardB.Id, offers)!.Value.RenderNodePath + ); + + DrmDevice? unknown = DeviceIdNegotiation.Resolve( + DrmDevice.FromNumbers(226, 140).Id, + offers + ); Assert.AreEqual(140u, unknown!.Value.Minor); Assert.IsNull(unknown.Value.RenderNodePath); } diff --git a/tests/PipeWire.NET.Tests/DeviceProviderTests.cs b/tests/PipeWire.NET.Tests/DeviceProviderTests.cs index 79e6b5a..b4faac2 100644 --- a/tests/PipeWire.NET.Tests/DeviceProviderTests.cs +++ b/tests/PipeWire.NET.Tests/DeviceProviderTests.cs @@ -31,26 +31,37 @@ private static void RequireLinux() Assert.Inconclusive("PipeWire is a Linux daemon."); } - private static string Unique() => $"pwnet_device_{Environment.ProcessId}_{Random.Shared.Next():x}"; + private static string Unique() => + $"pwnet_device_{Environment.ProcessId}_{Random.Shared.Next():x}"; /// Two profiles, which is the least that makes a device selectable. private static ImmutableArray Profiles() => - [ - new SpaObject(SpaType.ObjectParamProfile, SpaParamType.EnumProfile, [ - new SpaPodProperty((uint)SpaParamProfile.Index, 0, new SpaInt(0)), - new SpaPodProperty((uint)SpaParamProfile.Name, 0, new SpaString("off")), - new SpaPodProperty((uint)SpaParamProfile.Description, 0, new SpaString("Off")), - new SpaPodProperty((uint)SpaParamProfile.Priority, 0, new SpaInt(0)), - ]), - new SpaObject(SpaType.ObjectParamProfile, SpaParamType.EnumProfile, - [ - new SpaPodProperty((uint)SpaParamProfile.Index, 0, new SpaInt(1)), - new SpaPodProperty((uint)SpaParamProfile.Name, 0, new SpaString("stereo")), - new SpaPodProperty((uint)SpaParamProfile.Description, 0, new SpaString("Stereo")), - new SpaPodProperty((uint)SpaParamProfile.Priority, 0, new SpaInt(100)), - ]), - ]; + new SpaObject( + SpaType.ObjectParamProfile, + SpaParamType.EnumProfile, + [ + new SpaPodProperty((uint)SpaParamProfile.Index, 0, new SpaInt(0)), + new SpaPodProperty((uint)SpaParamProfile.Name, 0, new SpaString("off")), + new SpaPodProperty((uint)SpaParamProfile.Description, 0, new SpaString("Off")), + new SpaPodProperty((uint)SpaParamProfile.Priority, 0, new SpaInt(0)), + ] + ), + new SpaObject( + SpaType.ObjectParamProfile, + SpaParamType.EnumProfile, + [ + new SpaPodProperty((uint)SpaParamProfile.Index, 0, new SpaInt(1)), + new SpaPodProperty((uint)SpaParamProfile.Name, 0, new SpaString("stereo")), + new SpaPodProperty( + (uint)SpaParamProfile.Description, + 0, + new SpaString("Stereo") + ), + new SpaPodProperty((uint)SpaParamProfile.Priority, 0, new SpaInt(100)), + ] + ), + ]; /// Two routes, so the device has ports to select as well as profiles. /// @@ -61,28 +72,56 @@ private static ImmutableArray Profiles() => /// the event hook down with it. /// private static ImmutableArray Routes() => - [ - new SpaObject(SpaType.ObjectParamRoute, SpaParamType.EnumRoute, - [ - new SpaPodProperty((uint)SpaParamRoute.Index, 0, new SpaInt(0)), - new SpaPodProperty((uint)SpaParamRoute.Name, 0, new SpaString("speaker")), - new SpaPodProperty((uint)SpaParamRoute.Description, 0, new SpaString("Speaker")), - new SpaPodProperty((uint)SpaParamRoute.Priority, 0, new SpaInt(100)), - new SpaPodProperty((uint)SpaParamRoute.Direction, 0, new SpaId((uint)SpaDirection.Output)), - new SpaPodProperty((uint)SpaParamRoute.Available, 0, - new SpaId((uint)SpaParamAvailability.Yes)), - ]), - new SpaObject(SpaType.ObjectParamRoute, SpaParamType.EnumRoute, [ - new SpaPodProperty((uint)SpaParamRoute.Index, 0, new SpaInt(1)), - new SpaPodProperty((uint)SpaParamRoute.Name, 0, new SpaString("headphone")), - new SpaPodProperty((uint)SpaParamRoute.Description, 0, new SpaString("Headphones")), - new SpaPodProperty((uint)SpaParamRoute.Priority, 0, new SpaInt(50)), - new SpaPodProperty((uint)SpaParamRoute.Direction, 0, new SpaId((uint)SpaDirection.Output)), - new SpaPodProperty((uint)SpaParamRoute.Available, 0, - new SpaId((uint)SpaParamAvailability.Unknown)), - ]), - ]; + new SpaObject( + SpaType.ObjectParamRoute, + SpaParamType.EnumRoute, + [ + new SpaPodProperty((uint)SpaParamRoute.Index, 0, new SpaInt(0)), + new SpaPodProperty((uint)SpaParamRoute.Name, 0, new SpaString("speaker")), + new SpaPodProperty( + (uint)SpaParamRoute.Description, + 0, + new SpaString("Speaker") + ), + new SpaPodProperty((uint)SpaParamRoute.Priority, 0, new SpaInt(100)), + new SpaPodProperty( + (uint)SpaParamRoute.Direction, + 0, + new SpaId((uint)SpaDirection.Output) + ), + new SpaPodProperty( + (uint)SpaParamRoute.Available, + 0, + new SpaId((uint)SpaParamAvailability.Yes) + ), + ] + ), + new SpaObject( + SpaType.ObjectParamRoute, + SpaParamType.EnumRoute, + [ + new SpaPodProperty((uint)SpaParamRoute.Index, 0, new SpaInt(1)), + new SpaPodProperty((uint)SpaParamRoute.Name, 0, new SpaString("headphone")), + new SpaPodProperty( + (uint)SpaParamRoute.Description, + 0, + new SpaString("Headphones") + ), + new SpaPodProperty((uint)SpaParamRoute.Priority, 0, new SpaInt(50)), + new SpaPodProperty( + (uint)SpaParamRoute.Direction, + 0, + new SpaId((uint)SpaDirection.Output) + ), + new SpaPodProperty( + (uint)SpaParamRoute.Available, + 0, + new SpaId((uint)SpaParamAvailability.Unknown) + ), + ] + ), + ]; [TestMethod] public async Task ADeviceWeServe_AppearsInTheGraphAndLeavesTheSessionResponsive() @@ -90,19 +129,27 @@ public async Task ADeviceWeServe_AppearsInTheGraphAndLeavesTheSessionResponsive( RequireLinux(); using var cts = new CancellationTokenSource(Budget); - await using var ctx = new PipeWireContext("pwnet-device-host", ConsoleTestLoggerFactory.Instance); + await using var ctx = new PipeWireContext( + "pwnet-device-host", + ConsoleTestLoggerFactory.Instance + ); await ctx.StartAsync(cts.Token); await using var registry = new PipeWireRegistry(ctx); await registry.WaitForInitialEnumerationAsync(cts.Token); string name = Unique(); - using (PipeWireDeviceProvider provider = PipeWireDeviceProvider.Create( - ctx, name, "A device this test serves", - new Dictionary> - { - [SpaParamType.EnumProfile] = Profiles(), - })) + using ( + PipeWireDeviceProvider provider = PipeWireDeviceProvider.Create( + ctx, + name, + "A device this test serves", + new Dictionary> + { + [SpaParamType.EnumProfile] = Profiles(), + } + ) + ) { Assert.AreEqual(name, provider.Name); @@ -111,7 +158,8 @@ public async Task ADeviceWeServe_AppearsInTheGraphAndLeavesTheSessionResponsive( PipeWireGraphSnapshot graph = await WaitForAsync( registry, g => g.Devices.Any(d => d.DeviceName == name), - cts.Token); + cts.Token + ); PipeWireDevice? seen = graph.Devices.FirstOrDefault(d => d.DeviceName == name); Assert.IsNotNull(seen, $"the device '{name}' never appeared in the graph"); @@ -131,7 +179,10 @@ public async Task ADeviceWeServe_AnswersItsProfilesThroughTheOrdinaryClientPath( RequireLinux(); using var cts = new CancellationTokenSource(Budget); - await using var ctx = new PipeWireContext("pwnet-device-params", ConsoleTestLoggerFactory.Instance); + await using var ctx = new PipeWireContext( + "pwnet-device-params", + ConsoleTestLoggerFactory.Instance + ); await ctx.StartAsync(cts.Token); await using var registry = new PipeWireRegistry(ctx); await registry.WaitForInitialEnumerationAsync(cts.Token); @@ -139,21 +190,30 @@ public async Task ADeviceWeServe_AnswersItsProfilesThroughTheOrdinaryClientPath( string name = Unique(); using PipeWireDeviceProvider provider = PipeWireDeviceProvider.Create( - ctx, name, "A device with profiles", + ctx, + name, + "A device with profiles", new Dictionary> { [SpaParamType.EnumProfile] = Profiles(), - }); + } + ); PipeWireGraphSnapshot graph = await WaitForAsync( - registry, g => g.Devices.Any(d => d.DeviceName == name), cts.Token); + registry, + g => g.Devices.Any(d => d.DeviceName == name), + cts.Token + ); uint id = graph.Devices.First(d => d.DeviceName == name).Id; // Read it back from a second connection, which is what a device provider is for: the // consumer is another client, not the process serving it. Same-connection enumeration is a // different question and is covered separately. - await using var reader = new PipeWireContext("pwnet-device-reader", ConsoleTestLoggerFactory.Instance); + await using var reader = new PipeWireContext( + "pwnet-device-reader", + ConsoleTestLoggerFactory.Instance + ); await reader.StartAsync(cts.Token); await using var readerRegistry = new PipeWireRegistry(reader); await readerRegistry.WaitForInitialEnumerationAsync(cts.Token); @@ -161,8 +221,10 @@ public async Task ADeviceWeServe_AnswersItsProfilesThroughTheOrdinaryClientPath( await using PipeWireDeviceProxy control = readerRegistry.BindDevice(id); await control.ReadyAsync(cts.Token); - ImmutableArray profiles = - await control.EnumerateParametersAsync(SpaParamType.EnumProfile, cts.Token); + ImmutableArray profiles = await control.EnumerateParametersAsync( + SpaParamType.EnumProfile, + cts.Token + ); Assert.HasCount(2, profiles, "the device did not answer with the profiles it was given"); @@ -179,7 +241,10 @@ public async Task ADeviceWeServe_AnswersAFilteredEnumerationWithOnlyMatches() RequireLinux(); using var cts = new CancellationTokenSource(Budget); - await using var ctx = new PipeWireContext("pwnet-device-filter", ConsoleTestLoggerFactory.Instance); + await using var ctx = new PipeWireContext( + "pwnet-device-filter", + ConsoleTestLoggerFactory.Instance + ); await ctx.StartAsync(cts.Token); await using var registry = new PipeWireRegistry(ctx); await registry.WaitForInitialEnumerationAsync(cts.Token); @@ -187,18 +252,27 @@ public async Task ADeviceWeServe_AnswersAFilteredEnumerationWithOnlyMatches() string name = Unique(); using PipeWireDeviceProvider provider = PipeWireDeviceProvider.Create( - ctx, name, "A device with profiles", + ctx, + name, + "A device with profiles", new Dictionary> { [SpaParamType.EnumProfile] = Profiles(), - }); + } + ); PipeWireGraphSnapshot graph = await WaitForAsync( - registry, g => g.Devices.Any(d => d.DeviceName == name), cts.Token); + registry, + g => g.Devices.Any(d => d.DeviceName == name), + cts.Token + ); uint id = graph.Devices.First(d => d.DeviceName == name).Id; - await using var reader = new PipeWireContext("pwnet-device-filter-reader", ConsoleTestLoggerFactory.Instance); + await using var reader = new PipeWireContext( + "pwnet-device-filter-reader", + ConsoleTestLoggerFactory.Instance + ); await reader.StartAsync(cts.Token); await using var readerRegistry = new PipeWireRegistry(reader); await readerRegistry.WaitForInitialEnumerationAsync(cts.Token); @@ -207,36 +281,52 @@ public async Task ADeviceWeServe_AnswersAFilteredEnumerationWithOnlyMatches() await control.ReadyAsync(cts.Token); // The filter objects: one matching nothing, one matching the stereo profile. - var surround = new SpaObject(SpaType.ObjectParamProfile, SpaParamType.EnumProfile, - [ - new SpaPodProperty((uint)SpaParamProfile.Name, 0, new SpaString("surround")), - ]); - var stereo = new SpaObject(SpaType.ObjectParamProfile, SpaParamType.EnumProfile, - [ - new SpaPodProperty((uint)SpaParamProfile.Name, 0, new SpaString("stereo")), - ]); + var surround = new SpaObject( + SpaType.ObjectParamProfile, + SpaParamType.EnumProfile, + [new SpaPodProperty((uint)SpaParamProfile.Name, 0, new SpaString("surround"))] + ); + var stereo = new SpaObject( + SpaType.ObjectParamProfile, + SpaParamType.EnumProfile, + [new SpaPodProperty((uint)SpaParamProfile.Name, 0, new SpaString("stereo"))] + ); // Filtered first, on a fresh device: the daemon has cached nothing yet, so these run the // provider's own matcher rather than the daemon's cache projection. - ImmutableArray noneFirst = - await control.EnumerateParametersAsync(SpaParamType.EnumProfile, surround, cts.Token); + ImmutableArray noneFirst = await control.EnumerateParametersAsync( + SpaParamType.EnumProfile, + surround, + cts.Token + ); Assert.HasCount(0, noneFirst); - ImmutableArray matches = - await control.EnumerateParametersAsync(SpaParamType.EnumProfile, stereo, cts.Token); + ImmutableArray matches = await control.EnumerateParametersAsync( + SpaParamType.EnumProfile, + stereo, + cts.Token + ); Assert.HasCount(1, matches, "the filter did not narrow the enumeration to its match"); - Assert.AreEqual("stereo", ProfileName(matches[0]), - "the filtered enumeration did not return the stereo profile"); + Assert.AreEqual( + "stereo", + ProfileName(matches[0]), + "the filtered enumeration did not return the stereo profile" + ); // Unfiltered, both profiles arrive: the filter narrows, it does not change the set. This // full enumeration also populates the daemon's cache, so the repeat below is served from // there rather than by the provider again - same answer, different path. - ImmutableArray all = - await control.EnumerateParametersAsync(SpaParamType.EnumProfile, cts.Token); + ImmutableArray all = await control.EnumerateParametersAsync( + SpaParamType.EnumProfile, + cts.Token + ); Assert.HasCount(2, all); - ImmutableArray cached = - await control.EnumerateParametersAsync(SpaParamType.EnumProfile, stereo, cts.Token); + ImmutableArray cached = await control.EnumerateParametersAsync( + SpaParamType.EnumProfile, + stereo, + cts.Token + ); Assert.HasCount(1, cached); Assert.AreEqual("stereo", ProfileName(cached[0])); } @@ -265,7 +355,10 @@ public async Task DisposingADeviceWithARemoteListenerAttached_ThenDroppingTheLis RequireLinux(); using var cts = new CancellationTokenSource(Budget); - await using var ctx = new PipeWireContext("pwnet-device-teardown", ConsoleTestLoggerFactory.Instance); + await using var ctx = new PipeWireContext( + "pwnet-device-teardown", + ConsoleTestLoggerFactory.Instance + ); await ctx.StartAsync(cts.Token); await using var registry = new PipeWireRegistry(ctx); await registry.WaitForInitialEnumerationAsync(cts.Token); @@ -273,20 +366,29 @@ public async Task DisposingADeviceWithARemoteListenerAttached_ThenDroppingTheLis string name = Unique(); var provider = PipeWireDeviceProvider.Create( - ctx, name, "A device with a listener", + ctx, + name, + "A device with a listener", new Dictionary> { [SpaParamType.EnumProfile] = Profiles(), - }); + } + ); PipeWireGraphSnapshot graph = await WaitForAsync( - registry, g => g.Devices.Any(d => d.DeviceName == name), cts.Token); + registry, + g => g.Devices.Any(d => d.DeviceName == name), + cts.Token + ); uint id = graph.Devices.First(d => d.DeviceName == name).Id; // A second connection binds and subscribes, so a remote listener is attached while the // provider is disposed - the order the invariant is about. - var reader = new PipeWireContext("pwnet-device-teardown-reader", ConsoleTestLoggerFactory.Instance); + var reader = new PipeWireContext( + "pwnet-device-teardown-reader", + ConsoleTestLoggerFactory.Instance + ); await reader.StartAsync(cts.Token); var readerRegistry = new PipeWireRegistry(reader); await readerRegistry.WaitForInitialEnumerationAsync(cts.Token); @@ -315,15 +417,21 @@ public async Task ADeviceWithNoParameters_IsStillPublishedRatherThanRefused() RequireLinux(); using var cts = new CancellationTokenSource(Budget); - await using var ctx = new PipeWireContext("pwnet-device-empty", ConsoleTestLoggerFactory.Instance); + await using var ctx = new PipeWireContext( + "pwnet-device-empty", + ConsoleTestLoggerFactory.Instance + ); await ctx.StartAsync(cts.Token); await using var registry = new PipeWireRegistry(ctx); await registry.WaitForInitialEnumerationAsync(cts.Token); string name = Unique(); - using PipeWireDeviceProvider provider = - PipeWireDeviceProvider.Create(ctx, name, "An empty device"); + using PipeWireDeviceProvider provider = PipeWireDeviceProvider.Create( + ctx, + name, + "An empty device" + ); await WaitForAsync(registry, g => g.Devices.Any(d => d.DeviceName == name), cts.Token); } @@ -336,7 +444,10 @@ public async Task ManyDevicesServedAndWithdrawn_LeaveNothingBehind() RequireLinux(); using var cts = new CancellationTokenSource(Budget); - await using var ctx = new PipeWireContext("pwnet-device-churn", ConsoleTestLoggerFactory.Instance); + await using var ctx = new PipeWireContext( + "pwnet-device-churn", + ConsoleTestLoggerFactory.Instance + ); await ctx.StartAsync(cts.Token); await using var registry = new PipeWireRegistry(ctx); await registry.WaitForInitialEnumerationAsync(cts.Token); @@ -344,11 +455,14 @@ public async Task ManyDevicesServedAndWithdrawn_LeaveNothingBehind() for (int i = 0; i < 20; i++) { using PipeWireDeviceProvider provider = PipeWireDeviceProvider.Create( - ctx, $"{Unique()}_{i}", $"Churn {i}", + ctx, + $"{Unique()}_{i}", + $"Churn {i}", new Dictionary> { [SpaParamType.EnumProfile] = Profiles(), - }); + } + ); } await registry.WaitForInitialEnumerationAsync(cts.Token); @@ -363,7 +477,10 @@ public async Task SettingAParameter_UpdatesWhatClientsEnumerate() RequireLinux(); using var cts = new CancellationTokenSource(Budget); - await using var ctx = new PipeWireContext("pwnet-device-update", ConsoleTestLoggerFactory.Instance); + await using var ctx = new PipeWireContext( + "pwnet-device-update", + ConsoleTestLoggerFactory.Instance + ); await ctx.StartAsync(cts.Token); await using var registry = new PipeWireRegistry(ctx); await registry.WaitForInitialEnumerationAsync(cts.Token); @@ -371,17 +488,26 @@ public async Task SettingAParameter_UpdatesWhatClientsEnumerate() string name = Unique(); using PipeWireDeviceProvider provider = PipeWireDeviceProvider.Create( - ctx, name, "A device with profiles", + ctx, + name, + "A device with profiles", new Dictionary> { [SpaParamType.EnumProfile] = Profiles(), - }); + } + ); PipeWireGraphSnapshot graph = await WaitForAsync( - registry, g => g.Devices.Any(d => d.DeviceName == name), cts.Token); + registry, + g => g.Devices.Any(d => d.DeviceName == name), + cts.Token + ); uint id = graph.Devices.First(d => d.DeviceName == name).Id; - await using var reader = new PipeWireContext("pwnet-device-reread", ConsoleTestLoggerFactory.Instance); + await using var reader = new PipeWireContext( + "pwnet-device-reread", + ConsoleTestLoggerFactory.Instance + ); await reader.StartAsync(cts.Token); await using var readerRegistry = new PipeWireRegistry(reader); await readerRegistry.WaitForInitialEnumerationAsync(cts.Token); @@ -389,7 +515,10 @@ public async Task SettingAParameter_UpdatesWhatClientsEnumerate() await using PipeWireDeviceProxy control = readerRegistry.BindDevice(id); await control.ReadyAsync(cts.Token); - Assert.HasCount(2, await control.EnumerateParametersAsync(SpaParamType.EnumProfile, cts.Token)); + Assert.HasCount( + 2, + await control.EnumerateParametersAsync(SpaParamType.EnumProfile, cts.Token) + ); ImmutableArray three = [.. Profiles(), Profiles()[0]]; provider.SetParameter(SpaParamType.EnumProfile, three); @@ -403,12 +532,18 @@ public async Task SettingAParameter_UpdatesWhatClientsEnumerate() int answered = 0; for (int attempt = 0; attempt < 40 && answered != 3; attempt++) { - answered = (await control.EnumerateParametersAsync(SpaParamType.EnumProfile, cts.Token)).Length; - if (answered != 3) await Task.Delay(50, cts.Token); + answered = ( + await control.EnumerateParametersAsync(SpaParamType.EnumProfile, cts.Token) + ).Length; + if (answered != 3) + await Task.Delay(50, cts.Token); } - Assert.AreEqual(3, answered, - "the replacement set is not what the next enumeration answered with"); + Assert.AreEqual( + 3, + answered, + "the replacement set is not what the next enumeration answered with" + ); } [TestMethod] @@ -420,7 +555,10 @@ public async Task CreatingADeviceWithExtraProperties_KeepsItsName() RequireLinux(); using var cts = new CancellationTokenSource(Budget); - await using var ctx = new PipeWireContext("pwnet-device-props", ConsoleTestLoggerFactory.Instance); + await using var ctx = new PipeWireContext( + "pwnet-device-props", + ConsoleTestLoggerFactory.Instance + ); await ctx.StartAsync(cts.Token); await using var registry = new PipeWireRegistry(ctx); await registry.WaitForInitialEnumerationAsync(cts.Token); @@ -428,7 +566,9 @@ public async Task CreatingADeviceWithExtraProperties_KeepsItsName() string name = Unique(); using PipeWireDeviceProvider provider = PipeWireDeviceProvider.Create( - ctx, name, "A device with properties", + ctx, + name, + "A device with properties", new Dictionary> { [SpaParamType.EnumProfile] = Profiles(), @@ -438,12 +578,16 @@ public async Task CreatingADeviceWithExtraProperties_KeepsItsName() ["device.name"] = "ignored", ["device.description"] = "ignored", ["x-pwnet-test"] = "yes", - }); + } + ); Assert.AreEqual(name, provider.Name); PipeWireGraphSnapshot graph = await WaitForAsync( - registry, g => g.Devices.Any(d => d.DeviceName == name), cts.Token); + registry, + g => g.Devices.Any(d => d.DeviceName == name), + cts.Token + ); Assert.IsNotNull(graph.Devices.FirstOrDefault(d => d.DeviceName == name)); } @@ -456,7 +600,10 @@ public async Task DisposingADeviceWhileClientsEnumerate_LeavesTheSessionResponsi RequireLinux(); using var cts = new CancellationTokenSource(Budget); - await using var ctx = new PipeWireContext("pwnet-device-teardown", ConsoleTestLoggerFactory.Instance); + await using var ctx = new PipeWireContext( + "pwnet-device-teardown", + ConsoleTestLoggerFactory.Instance + ); await ctx.StartAsync(cts.Token); await using var registry = new PipeWireRegistry(ctx); await registry.WaitForInitialEnumerationAsync(cts.Token); @@ -464,17 +611,26 @@ public async Task DisposingADeviceWhileClientsEnumerate_LeavesTheSessionResponsi string name = Unique(); var provider = PipeWireDeviceProvider.Create( - ctx, name, "A device withdrawn mid-read", + ctx, + name, + "A device withdrawn mid-read", new Dictionary> { [SpaParamType.EnumProfile] = Profiles(), - }); + } + ); PipeWireGraphSnapshot graph = await WaitForAsync( - registry, g => g.Devices.Any(d => d.DeviceName == name), cts.Token); + registry, + g => g.Devices.Any(d => d.DeviceName == name), + cts.Token + ); uint id = graph.Devices.First(d => d.DeviceName == name).Id; - await using var reader = new PipeWireContext("pwnet-device-hammer", ConsoleTestLoggerFactory.Instance); + await using var reader = new PipeWireContext( + "pwnet-device-hammer", + ConsoleTestLoggerFactory.Instance + ); await reader.StartAsync(cts.Token); await using var readerRegistry = new PipeWireRegistry(reader); await readerRegistry.WaitForInitialEnumerationAsync(cts.Token); @@ -492,7 +648,9 @@ public async Task DisposingADeviceWhileClientsEnumerate_LeavesTheSessionResponsi await control.EnumerateParametersAsync(SpaParamType.EnumProfile, stop.Token); } catch (OperationCanceledException) { } - catch (PipeWireException) { /* the device is going or gone */ } + catch (PipeWireException) + { /* the device is going or gone */ + } catch (ObjectDisposedException) { } } }); @@ -502,7 +660,9 @@ public async Task DisposingADeviceWhileClientsEnumerate_LeavesTheSessionResponsi // Failures above are the device going away. A crash would have ended the host instead. await hammer; - Assert.ThrowsExactly(() => provider.SetParameter(SpaParamType.EnumProfile, [])); + Assert.ThrowsExactly(() => + provider.SetParameter(SpaParamType.EnumProfile, []) + ); await registry.WaitForInitialEnumerationAsync(cts.Token); Assert.IsTrue(registry.Current.Nodes.Length > 0, "the session stopped answering"); @@ -516,7 +676,10 @@ public async Task EnumeratingAParameterTheDeviceDoesNotList_IsRefused() RequireLinux(); using var cts = new CancellationTokenSource(Budget); - await using var ctx = new PipeWireContext("pwnet-device-gate", ConsoleTestLoggerFactory.Instance); + await using var ctx = new PipeWireContext( + "pwnet-device-gate", + ConsoleTestLoggerFactory.Instance + ); await ctx.StartAsync(cts.Token); await using var registry = new PipeWireRegistry(ctx); await registry.WaitForInitialEnumerationAsync(cts.Token); @@ -524,17 +687,26 @@ public async Task EnumeratingAParameterTheDeviceDoesNotList_IsRefused() string name = Unique(); using PipeWireDeviceProvider provider = PipeWireDeviceProvider.Create( - ctx, name, "A device with profiles only", + ctx, + name, + "A device with profiles only", new Dictionary> { [SpaParamType.EnumProfile] = Profiles(), - }); + } + ); PipeWireGraphSnapshot graph = await WaitForAsync( - registry, g => g.Devices.Any(d => d.DeviceName == name), cts.Token); + registry, + g => g.Devices.Any(d => d.DeviceName == name), + cts.Token + ); uint id = graph.Devices.First(d => d.DeviceName == name).Id; - await using var reader = new PipeWireContext("pwnet-device-gateread", ConsoleTestLoggerFactory.Instance); + await using var reader = new PipeWireContext( + "pwnet-device-gateread", + ConsoleTestLoggerFactory.Instance + ); await reader.StartAsync(cts.Token); await using var readerRegistry = new PipeWireRegistry(reader); await readerRegistry.WaitForInitialEnumerationAsync(cts.Token); @@ -542,8 +714,10 @@ public async Task EnumeratingAParameterTheDeviceDoesNotList_IsRefused() await using PipeWireDeviceProxy control = readerRegistry.BindDevice(id); await control.ReadyAsync(cts.Token); - PipeWireException refused = await Assert.ThrowsExactlyAsync( - () => control.EnumerateParametersAsync(SpaParamType.EnumRoute, cts.Token)); + PipeWireException refused = + await Assert.ThrowsExactlyAsync(() => + control.EnumerateParametersAsync(SpaParamType.EnumRoute, cts.Token) + ); Assert.AreEqual(-2, refused.Result); } @@ -555,7 +729,10 @@ public async Task WritingAProfile_ReachesTheHostAsAParameterWrite() RequireLinux(); using var cts = new CancellationTokenSource(Budget); - await using var ctx = new PipeWireContext("pwnet-device-write", ConsoleTestLoggerFactory.Instance); + await using var ctx = new PipeWireContext( + "pwnet-device-write", + ConsoleTestLoggerFactory.Instance + ); await ctx.StartAsync(cts.Token); await using var registry = new PipeWireRegistry(ctx); await registry.WaitForInitialEnumerationAsync(cts.Token); @@ -563,20 +740,32 @@ public async Task WritingAProfile_ReachesTheHostAsAParameterWrite() string name = Unique(); using PipeWireDeviceProvider provider = PipeWireDeviceProvider.Create( - ctx, name, "A device clients write to", + ctx, + name, + "A device clients write to", new Dictionary> { [SpaParamType.EnumProfile] = Profiles(), - }); + } + ); - var seen = new System.Collections.Concurrent.ConcurrentBag<(SpaParamType Id, SpaObject? Value)>(); + var seen = new System.Collections.Concurrent.ConcurrentBag<( + SpaParamType Id, + SpaObject? Value + )>(); provider.ParameterWritten += (p, id, value) => seen.Add((id, value)); PipeWireGraphSnapshot graph = await WaitForAsync( - registry, g => g.Devices.Any(d => d.DeviceName == name), cts.Token); + registry, + g => g.Devices.Any(d => d.DeviceName == name), + cts.Token + ); uint id = graph.Devices.First(d => d.DeviceName == name).Id; - await using var writer = new PipeWireContext("pwnet-device-writer", ConsoleTestLoggerFactory.Instance); + await using var writer = new PipeWireContext( + "pwnet-device-writer", + ConsoleTestLoggerFactory.Instance + ); await writer.StartAsync(cts.Token); await using var writerRegistry = new PipeWireRegistry(writer); await writerRegistry.WaitForInitialEnumerationAsync(cts.Token); @@ -590,8 +779,10 @@ public async Task WritingAProfile_ReachesTheHostAsAParameterWrite() { foreach ((SpaParamType got, SpaObject? value) in seen) { - if (got == SpaParamType.Profile - && (value?[(uint)SpaParamProfile.Index] as SpaInt)?.Value == 1) + if ( + got == SpaParamType.Profile + && (value?[(uint)SpaParamProfile.Index] as SpaInt)?.Value == 1 + ) { arrived = true; break; @@ -613,7 +804,10 @@ public async Task AThrowingParameterHandler_IsReportedAndContained() RequireLinux(); using var cts = new CancellationTokenSource(Budget); - await using var ctx = new PipeWireContext("pwnet-device-fault", ConsoleTestLoggerFactory.Instance); + await using var ctx = new PipeWireContext( + "pwnet-device-fault", + ConsoleTestLoggerFactory.Instance + ); await ctx.StartAsync(cts.Token); await using var registry = new PipeWireRegistry(ctx); await registry.WaitForInitialEnumerationAsync(cts.Token); @@ -621,17 +815,26 @@ public async Task AThrowingParameterHandler_IsReportedAndContained() string name = Unique(); using PipeWireDeviceProvider provider = PipeWireDeviceProvider.Create( - ctx, name, "A device with a faulting subscriber", + ctx, + name, + "A device with a faulting subscriber", new Dictionary> { [SpaParamType.EnumProfile] = Profiles(), - }); + } + ); PipeWireGraphSnapshot graph = await WaitForAsync( - registry, g => g.Devices.Any(d => d.DeviceName == name), cts.Token); + registry, + g => g.Devices.Any(d => d.DeviceName == name), + cts.Token + ); uint id = graph.Devices.First(d => d.DeviceName == name).Id; - await using var reader = new PipeWireContext("pwnet-device-faultread", ConsoleTestLoggerFactory.Instance); + await using var reader = new PipeWireContext( + "pwnet-device-faultread", + ConsoleTestLoggerFactory.Instance + ); await reader.StartAsync(cts.Token); await using var readerRegistry = new PipeWireRegistry(reader); await readerRegistry.WaitForInitialEnumerationAsync(cts.Token); @@ -645,8 +848,11 @@ public async Task AThrowingParameterHandler_IsReportedAndContained() // which throws inside the dispatch. provider.SetParameter(SpaParamType.EnumProfile, Profiles()); - Assert.HasCount(2, await control.EnumerateParametersAsync(SpaParamType.EnumProfile, cts.Token), - "the binding stopped answering after a faulting handler"); + Assert.HasCount( + 2, + await control.EnumerateParametersAsync(SpaParamType.EnumProfile, cts.Token), + "the binding stopped answering after a faulting handler" + ); } [TestMethod] @@ -655,7 +861,10 @@ public async Task RouteVolumeGuards_RefuseBadInputBeforeTheDaemon() RequireLinux(); using var cts = new CancellationTokenSource(Budget); - await using var ctx = new PipeWireContext("pwnet-device-vol", ConsoleTestLoggerFactory.Instance); + await using var ctx = new PipeWireContext( + "pwnet-device-vol", + ConsoleTestLoggerFactory.Instance + ); await ctx.StartAsync(cts.Token); await using var registry = new PipeWireRegistry(ctx); await registry.WaitForInitialEnumerationAsync(cts.Token); @@ -663,17 +872,26 @@ public async Task RouteVolumeGuards_RefuseBadInputBeforeTheDaemon() string name = Unique(); using PipeWireDeviceProvider provider = PipeWireDeviceProvider.Create( - ctx, name, "A device volumes are checked against", + ctx, + name, + "A device volumes are checked against", new Dictionary> { [SpaParamType.EnumProfile] = Profiles(), - }); + } + ); PipeWireGraphSnapshot graph = await WaitForAsync( - registry, g => g.Devices.Any(d => d.DeviceName == name), cts.Token); + registry, + g => g.Devices.Any(d => d.DeviceName == name), + cts.Token + ); uint id = graph.Devices.First(d => d.DeviceName == name).Id; - await using var reader = new PipeWireContext("pwnet-device-volread", ConsoleTestLoggerFactory.Instance); + await using var reader = new PipeWireContext( + "pwnet-device-volread", + ConsoleTestLoggerFactory.Instance + ); await reader.StartAsync(cts.Token); await using var readerRegistry = new PipeWireRegistry(reader); await readerRegistry.WaitForInitialEnumerationAsync(cts.Token); @@ -681,11 +899,18 @@ public async Task RouteVolumeGuards_RefuseBadInputBeforeTheDaemon() await using PipeWireDeviceProxy control = readerRegistry.BindDevice(id); await control.ReadyAsync(cts.Token); - await Assert.ThrowsExactlyAsync( - async () => await control.SetRouteVolumeAsync(0, 0, [], false, cancellationToken: cts.Token)); - await Assert.ThrowsExactlyAsync( - async () => await control.SetRouteVolumeAsync( - 0, 0, new float[] { -1f }, false, cancellationToken: cts.Token)); + await Assert.ThrowsExactlyAsync(async () => + await control.SetRouteVolumeAsync(0, 0, [], false, cancellationToken: cts.Token) + ); + await Assert.ThrowsExactlyAsync(async () => + await control.SetRouteVolumeAsync( + 0, + 0, + new float[] { -1f }, + false, + cancellationToken: cts.Token + ) + ); } [TestMethod] @@ -696,7 +921,10 @@ public async Task ADeviceWeServe_AnswersItsRoutesThroughTheOrdinaryClientPath() RequireLinux(); using var cts = new CancellationTokenSource(Budget); - await using var ctx = new PipeWireContext("pwnet-device-routes", ConsoleTestLoggerFactory.Instance); + await using var ctx = new PipeWireContext( + "pwnet-device-routes", + ConsoleTestLoggerFactory.Instance + ); await ctx.StartAsync(cts.Token); await using var registry = new PipeWireRegistry(ctx); await registry.WaitForInitialEnumerationAsync(cts.Token); @@ -704,21 +932,33 @@ public async Task ADeviceWeServe_AnswersItsRoutesThroughTheOrdinaryClientPath() string name = Unique(); using PipeWireDeviceProvider provider = PipeWireDeviceProvider.Create( - ctx, name, "A device with routes", + ctx, + name, + "A device with routes", new Dictionary> { [SpaParamType.EnumProfile] = Profiles(), [SpaParamType.EnumRoute] = Routes(), - }); + } + ); - var seen = new System.Collections.Concurrent.ConcurrentBag<(SpaParamType Id, SpaObject? Value)>(); + var seen = new System.Collections.Concurrent.ConcurrentBag<( + SpaParamType Id, + SpaObject? Value + )>(); provider.ParameterWritten += (p, id, value) => seen.Add((id, value)); PipeWireGraphSnapshot graph = await WaitForAsync( - registry, g => g.Devices.Any(d => d.DeviceName == name), cts.Token); + registry, + g => g.Devices.Any(d => d.DeviceName == name), + cts.Token + ); uint id = graph.Devices.First(d => d.DeviceName == name).Id; - await using var reader = new PipeWireContext("pwnet-device-routeread", ConsoleTestLoggerFactory.Instance); + await using var reader = new PipeWireContext( + "pwnet-device-routeread", + ConsoleTestLoggerFactory.Instance + ); await reader.StartAsync(cts.Token); await using var readerRegistry = new PipeWireRegistry(reader); await readerRegistry.WaitForInitialEnumerationAsync(cts.Token); @@ -726,8 +966,7 @@ public async Task ADeviceWeServe_AnswersItsRoutesThroughTheOrdinaryClientPath() await using PipeWireDeviceProxy control = readerRegistry.BindDevice(id); await control.ReadyAsync(cts.Token); - ImmutableArray routes = - await control.EnumerateRoutesAsync(cts.Token); + ImmutableArray routes = await control.EnumerateRoutesAsync(cts.Token); Assert.HasCount(2, routes, "the device did not answer with the routes it was given"); var names = routes @@ -737,8 +976,9 @@ public async Task ADeviceWeServe_AnswersItsRoutesThroughTheOrdinaryClientPath() // The active routes are a different parameter this device does not serve: asking for // them is refused rather than answered empty. - await Assert.ThrowsExactlyAsync( - () => control.GetActiveRoutesAsync(cts.Token)); + await Assert.ThrowsExactlyAsync(() => + control.GetActiveRoutesAsync(cts.Token) + ); await control.SetRouteAsync(1, 0, cancellationToken: cts.Token); @@ -747,8 +987,10 @@ await Assert.ThrowsExactlyAsync( { foreach ((SpaParamType got, SpaObject? value) in seen) { - if (got == SpaParamType.Route - && (value?[(uint)SpaParamRoute.Index] as SpaInt)?.Value == 1) + if ( + got == SpaParamType.Route + && (value?[(uint)SpaParamRoute.Index] as SpaInt)?.Value == 1 + ) { arrived = true; break; @@ -765,12 +1007,14 @@ await Assert.ThrowsExactlyAsync( private static async Task WaitForAsync( PipeWireRegistry registry, Func predicate, - CancellationToken cancellationToken) + CancellationToken cancellationToken + ) { for (int attempt = 0; attempt < 100; attempt++) { PipeWireGraphSnapshot graph = registry.Current; - if (predicate(graph)) return graph; + if (predicate(graph)) + return graph; await Task.Delay(100, cancellationToken).ConfigureAwait(false); } diff --git a/tests/PipeWire.NET.Tests/DmaBufLifetimeTests.cs b/tests/PipeWire.NET.Tests/DmaBufLifetimeTests.cs index 88aa8b5..592233e 100644 --- a/tests/PipeWire.NET.Tests/DmaBufLifetimeTests.cs +++ b/tests/PipeWire.NET.Tests/DmaBufLifetimeTests.cs @@ -46,18 +46,29 @@ private static GbmAllocator RequireGbm() } /// One producer and consumer pair, run to a frame count and then torn down. - private static async Task<(int Frames, int DmaBufFrames, IReadOnlyList Indexes)> RunSessionAsync( - GbmAllocator gbm, List buffers, string name) + private static async Task<( + int Frames, + int DmaBufFrames, + IReadOnlyList Indexes + )> RunSessionAsync(GbmAllocator gbm, List buffers, string name) { long modifier = (long)GbmAllocator.LinearModifier; var indexes = new ConcurrentQueue(); bool streaming = false; - int frames = 0, dmaBufFrames = 0; + int frames = 0, + dmaBufFrames = 0; await using var ctx = new PipeWireContext(name, ConsoleTestLoggerFactory.Instance); await ctx.StartAsync(); - await using var output = new PipeWireVideoOutput(ctx, $"{name}-src", Width, Height, PixelFormat.Bgra, 30); + await using var output = new PipeWireVideoOutput( + ctx, + $"{name}-src", + Width, + Height, + PixelFormat.Bgra, + 30 + ); output.AllocateDmaBuf += (_, index, _, _, _, _, planes) => { @@ -66,9 +77,11 @@ private static GbmAllocator RequireGbm() // The daemon chooses the index. An allocator that grew an array per index rather than // reusing one would be the leak this is looking for, so the cap is enforced here and // the assertions below check it was never approached from the other side. - if (index >= PoolCap) return 0; + if (index >= PoolCap) + return 0; - while (buffers.Count <= index) buffers.Add(gbm.CreateBgra(Width, Height)); + while (buffers.Count <= index) + buffers.Add(gbm.CreateBgra(Width, Height)); GbmAllocator.Buffer b = buffers[index]; planes[0] = new VideoPlane(b.Fd, b.Offset, b.Stride, b.Size); @@ -83,16 +96,19 @@ private static GbmAllocator RequireGbm() for (int i = 0; i < 50 && nodeId is null; i++) { nodeId = output.NodeId; - if (nodeId is null) await Task.Delay(50); + if (nodeId is null) + await Task.Delay(50); } - if (nodeId is null) Assert.Inconclusive("the producer was never given a node id."); + if (nodeId is null) + Assert.Inconclusive("the producer was never given a node id."); await using var capture = new PipeWireVideoCapture(ctx, $"{name}-sink"); capture.FrameReady += (_, frame) => { Interlocked.Increment(ref frames); - if (frame.BufferType == PipeWireBufferType.DmaBuf) Interlocked.Increment(ref dmaBufFrames); + if (frame.BufferType == PipeWireBufferType.DmaBuf) + Interlocked.Increment(ref dmaBufFrames); }; capture.Connect(nodeId!.Value, [PixelFormat.Bgra], modifiers: [modifier]); @@ -136,12 +152,15 @@ public async Task ADmaBufSessionTornDown_ReturnsEveryDescriptorItTook() // The buffers themselves are owned by this test and deliberately still alive, so what // is being measured is whether the streams gave back what they imported. - Assert.IsTrue(after <= before + 2, - $"three connect/teardown cycles left {after - before} descriptors behind"); + Assert.IsTrue( + after <= before + 2, + $"three connect/teardown cycles left {after - before} descriptors behind" + ); } finally { - foreach (GbmAllocator.Buffer b in buffers) b.Dispose(); + foreach (GbmAllocator.Buffer b in buffers) + b.Dispose(); gbm.Dispose(); } } @@ -154,31 +173,42 @@ public async Task ABufferPool_ReusesItsIndexesRatherThanGrowing() try { - (int frames, int dmaBufFrames, IReadOnlyList indexes) = - await RunSessionAsync(gbm, buffers, "pwnet-dmabuf-pool"); + (int frames, int dmaBufFrames, IReadOnlyList indexes) = await RunSessionAsync( + gbm, + buffers, + "pwnet-dmabuf-pool" + ); if (dmaBufFrames == 0) - Assert.Inconclusive($"no dmabuf frames arrived ({frames} total), so the pool never cycled."); + Assert.Inconclusive( + $"no dmabuf frames arrived ({frames} total), so the pool never cycled." + ); Assert.IsTrue(indexes.Count > 0, "no buffer was ever allocated"); // The daemon asks for a bounded set and reuses it. An index beyond the pool means it // kept asking for new ones, which is the shape a leak takes on this path. int highest = indexes.Max(); - Assert.IsTrue(highest < PoolCap, - $"the daemon asked for buffer index {highest}, beyond the pool of {PoolCap}"); + Assert.IsTrue( + highest < PoolCap, + $"the daemon asked for buffer index {highest}, beyond the pool of {PoolCap}" + ); // And far more frames than buffers, which is what proves the buffers are being cycled // rather than one being used per frame. - Assert.IsTrue(dmaBufFrames > indexes.Count, - $"{dmaBufFrames} frames from {indexes.Count} allocations, so nothing was reused"); + Assert.IsTrue( + dmaBufFrames > indexes.Count, + $"{dmaBufFrames} frames from {indexes.Count} allocations, so nothing was reused" + ); Console.Error.WriteLine( - $"pool: {indexes.Count} allocations, highest index {highest}, {dmaBufFrames} dmabuf frames"); + $"pool: {indexes.Count} allocations, highest index {highest}, {dmaBufFrames} dmabuf frames" + ); } finally { - foreach (GbmAllocator.Buffer b in buffers) b.Dispose(); + foreach (GbmAllocator.Buffer b in buffers) + b.Dispose(); gbm.Dispose(); } } @@ -197,8 +227,11 @@ public async Task ABufferRemovedWhileTheApplicationHoldsItsFd_LeavesThatFdUsable try { - (_, int dmaBufFrames, IReadOnlyList indexes) = - await RunSessionAsync(gbm, buffers, "pwnet-dmabuf-hold"); + (_, int dmaBufFrames, IReadOnlyList indexes) = await RunSessionAsync( + gbm, + buffers, + "pwnet-dmabuf-hold" + ); if (dmaBufFrames == 0 || indexes.Count == 0) Assert.Inconclusive("no dmabuf frames arrived, so no descriptor was ever shared."); @@ -208,30 +241,41 @@ public async Task ABufferRemovedWhileTheApplicationHoldsItsFd_LeavesThatFdUsable foreach (GbmAllocator.Buffer buffer in buffers) { int duplicate = dup((int)buffer.Fd); - if (duplicate >= 0) held.Add(duplicate); + if (duplicate >= 0) + held.Add(duplicate); } - Assert.IsTrue(held.Count > 0, "no descriptor could be duplicated, so nothing was tested"); + Assert.IsTrue( + held.Count > 0, + "no descriptor could be duplicated, so nothing was tested" + ); // Still usable after the stream that published them is gone: fstat succeeds on a live // descriptor and fails with EBADF on a closed one, which is exactly the distinction. foreach (int duplicate in held) { - Assert.AreEqual(0, FStatSucceeds(duplicate) ? 0 : 1, - $"descriptor {duplicate} was closed underneath a holder that still had it"); + Assert.AreEqual( + 0, + FStatSucceeds(duplicate) ? 0 : 1, + $"descriptor {duplicate} was closed underneath a holder that still had it" + ); } // And the originals too: teardown must not have closed what the allocator owns. foreach (GbmAllocator.Buffer buffer in buffers) { - Assert.IsTrue(FStatSucceeds((int)buffer.Fd), - "the stream closed a descriptor the allocator owns"); + Assert.IsTrue( + FStatSucceeds((int)buffer.Fd), + "the stream closed a descriptor the allocator owns" + ); } } finally { - foreach (int duplicate in held) close(duplicate); - foreach (GbmAllocator.Buffer b in buffers) b.Dispose(); + foreach (int duplicate in held) + close(duplicate); + foreach (GbmAllocator.Buffer b in buffers) + b.Dispose(); gbm.Dispose(); } } @@ -246,7 +290,8 @@ private static bool FStatSucceeds(int fd) Span statbuf = stackalloc byte[512]; unsafe { - fixed (byte* p = statbuf) return fstat(fd, p) == 0; + fixed (byte* p = statbuf) + return fstat(fd, p) == 0; } } @@ -272,25 +317,39 @@ public async Task BufferIndexesAfterAReconnect_StartAgainRatherThanContinuing() try { - (_, int firstDmaBuf, IReadOnlyList first) = - await RunSessionAsync(gbm, buffers, "pwnet-dmabuf-reconnect-a"); - - (_, int secondDmaBuf, IReadOnlyList second) = - await RunSessionAsync(gbm, buffers, "pwnet-dmabuf-reconnect-b"); + (_, int firstDmaBuf, IReadOnlyList first) = await RunSessionAsync( + gbm, + buffers, + "pwnet-dmabuf-reconnect-a" + ); + + (_, int secondDmaBuf, IReadOnlyList second) = await RunSessionAsync( + gbm, + buffers, + "pwnet-dmabuf-reconnect-b" + ); if (firstDmaBuf == 0 || secondDmaBuf == 0) - Assert.Inconclusive("a session produced no dmabuf frames, so there is nothing to compare."); + Assert.Inconclusive( + "a session produced no dmabuf frames, so there is nothing to compare." + ); Assert.IsTrue(first.Count > 0 && second.Count > 0, "a session allocated nothing"); - Assert.AreEqual(0, second.Min(), - "the second session's indexes did not start again from zero"); - Assert.IsTrue(second.Max() < PoolCap, - $"the second session asked for index {second.Max()}, beyond the pool"); + Assert.AreEqual( + 0, + second.Min(), + "the second session's indexes did not start again from zero" + ); + Assert.IsTrue( + second.Max() < PoolCap, + $"the second session asked for index {second.Max()}, beyond the pool" + ); } finally { - foreach (GbmAllocator.Buffer b in buffers) b.Dispose(); + foreach (GbmAllocator.Buffer b in buffers) + b.Dispose(); gbm.Dispose(); } } @@ -326,27 +385,42 @@ public async Task ARenegotiatingProducer_KeepsItsPoolBoundedAndBalanced() var gate = new object(); var live = new HashSet(); var violations = new List(); - int allocations = 0, releases = 0, highest = -1; + int allocations = 0, + releases = 0, + highest = -1; try { - await using var ctx = new PipeWireContext("pwnet-dmabuf-churn", ConsoleTestLoggerFactory.Instance); + await using var ctx = new PipeWireContext( + "pwnet-dmabuf-churn", + ConsoleTestLoggerFactory.Instance + ); await ctx.StartAsync(); - await using var output = new PipeWireVideoOutput(ctx, "pwnet-dmabuf-churn-src", Width, Height, PixelFormat.Bgra, 30); + await using var output = new PipeWireVideoOutput( + ctx, + "pwnet-dmabuf-churn-src", + Width, + Height, + PixelFormat.Bgra, + 30 + ); output.AllocateDmaBuf += (_, index, _, _, _, _, planes) => { - if (index >= PoolCap) return 0; + if (index >= PoolCap) + return 0; lock (gate) { - if (!live.Add(index)) violations.Add($"index {index} handed out while still live"); + if (!live.Add(index)) + violations.Add($"index {index} handed out while still live"); allocations++; highest = Math.Max(highest, index); } - while (buffers.Count <= index) buffers.Add(gbm.CreateBgra(Width, Height)); + while (buffers.Count <= index) + buffers.Add(gbm.CreateBgra(Width, Height)); GbmAllocator.Buffer b = buffers[index]; planes[0] = new VideoPlane(b.Fd, b.Offset, b.Stride, b.Size); @@ -357,7 +431,8 @@ public async Task ARenegotiatingProducer_KeepsItsPoolBoundedAndBalanced() { lock (gate) { - if (!live.Remove(index)) violations.Add($"index {index} released without being live"); + if (!live.Remove(index)) + violations.Add($"index {index} released without being live"); releases++; } }; @@ -371,11 +446,14 @@ public async Task ARenegotiatingProducer_KeepsItsPoolBoundedAndBalanced() { int dmaBufFrames = 0; - await using (var capture = new PipeWireVideoCapture(ctx, $"pwnet-dmabuf-churn-sink-{round}")) + await using ( + var capture = new PipeWireVideoCapture(ctx, $"pwnet-dmabuf-churn-sink-{round}") + ) { capture.FrameReady += (_, frame) => { - if (frame.BufferType == PipeWireBufferType.DmaBuf) Interlocked.Increment(ref dmaBufFrames); + if (frame.BufferType == PipeWireBufferType.DmaBuf) + Interlocked.Increment(ref dmaBufFrames); }; capture.Connect(nodeId, [PixelFormat.Bgra], modifiers: [modifier]); @@ -385,42 +463,59 @@ public async Task ARenegotiatingProducer_KeepsItsPoolBoundedAndBalanced() } if (round == 0 && Volatile.Read(ref dmaBufFrames) == 0) - Assert.Inconclusive("the first round produced no dmabuf frames, so there is no pool to churn."); + Assert.Inconclusive( + "the first round produced no dmabuf frames, so there is no pool to churn." + ); - Assert.IsTrue(Volatile.Read(ref dmaBufFrames) >= 10, - $"round {round}: only {dmaBufFrames} dmabuf frames after the producer was renegotiated"); + Assert.IsTrue( + Volatile.Read(ref dmaBufFrames) >= 10, + $"round {round}: only {dmaBufFrames} dmabuf frames after the producer was renegotiated" + ); // The consumer is gone, so its link is, and upstream clears the producer's format // and every buffer with it. Waiting for that is the proof the round renegotiated. for (int i = 0; i < 100; i++) { - lock (gate) { if (live.Count == 0) break; } + lock (gate) + { + if (live.Count == 0) + break; + } await Task.Delay(50); } lock (gate) { - Assert.AreEqual(0, live.Count, - $"round {round}: buffers {string.Join(",", live)} were never released after the consumer left"); + Assert.AreEqual( + 0, + live.Count, + $"round {round}: buffers {string.Join(",", live)} were never released after the consumer left" + ); } } lock (gate) { Console.Error.WriteLine( - $"churn: {Rounds} rounds, {allocations} allocations, {releases} releases, highest index {highest}"); + $"churn: {Rounds} rounds, {allocations} allocations, {releases} releases, highest index {highest}" + ); Assert.AreEqual(0, violations.Count, string.Join("; ", violations)); Assert.AreEqual(allocations, releases, "allocations and releases do not balance"); - Assert.IsTrue(allocations >= Rounds, - $"{allocations} allocations over {Rounds} rounds, so the pool was not rebuilt each round"); - Assert.IsTrue(highest < PoolCap, - $"index {highest} after {Rounds} renegotiations, so freed indexes were not reused"); + Assert.IsTrue( + allocations >= Rounds, + $"{allocations} allocations over {Rounds} rounds, so the pool was not rebuilt each round" + ); + Assert.IsTrue( + highest < PoolCap, + $"index {highest} after {Rounds} renegotiations, so freed indexes were not reused" + ); } } finally { - foreach (GbmAllocator.Buffer b in buffers) b.Dispose(); + foreach (GbmAllocator.Buffer b in buffers) + b.Dispose(); gbm.Dispose(); } } @@ -453,14 +548,23 @@ public async Task AnAllocatorThatMisbehaves_LeavesTheBufferUnbacked(string how, try { long modifier = (long)GbmAllocator.LinearModifier; - int calls = 0, framesSeen = 0; + int calls = 0, + framesSeen = 0; await using var ctx = new PipeWireContext( - $"pwnet-badalloc-{mode}", ConsoleTestLoggerFactory.Instance); + $"pwnet-badalloc-{mode}", + ConsoleTestLoggerFactory.Instance + ); await ctx.StartAsync(); await using var output = new PipeWireVideoOutput( - ctx, $"pwnet-badalloc-src-{mode}", Width, Height, PixelFormat.Bgra, 30); + ctx, + $"pwnet-badalloc-src-{mode}", + Width, + Height, + PixelFormat.Bgra, + 30 + ); output.AllocateDmaBuf += (_, index, _, _, _, _, planes) => { @@ -480,7 +584,8 @@ public async Task AnAllocatorThatMisbehaves_LeavesTheBufferUnbacked(string how, default: // One real plane, but claiming none were backed: the partial answer. - while (buffers.Count <= index) buffers.Add(gbm.CreateBgra(Width, Height)); + while (buffers.Count <= index) + buffers.Add(gbm.CreateBgra(Width, Height)); GbmAllocator.Buffer b = buffers[index]; planes[0] = new VideoPlane(b.Fd, b.Offset, b.Stride, b.Size); return 0; @@ -494,10 +599,12 @@ public async Task AnAllocatorThatMisbehaves_LeavesTheBufferUnbacked(string how, for (var i = 0; i < 50 && nodeId is null; i++) { nodeId = output.NodeId; - if (nodeId is null) await Task.Delay(50); + if (nodeId is null) + await Task.Delay(50); } - if (nodeId is null) Assert.Inconclusive("the producer was never given a node id."); + if (nodeId is null) + Assert.Inconclusive("the producer was never given a node id."); await using var capture = new PipeWireVideoCapture(ctx, $"pwnet-badalloc-sink-{mode}"); capture.FrameReady += (_, _) => Interlocked.Increment(ref framesSeen); @@ -506,12 +613,16 @@ public async Task AnAllocatorThatMisbehaves_LeavesTheBufferUnbacked(string how, await Task.Delay(TimeSpan.FromSeconds(3)); Assert.AreNotEqual( - 0, Volatile.Read(ref calls), - "the daemon never asked for a buffer, so nothing was actually exercised"); + 0, + Volatile.Read(ref calls), + "the daemon never asked for a buffer, so nothing was actually exercised" + ); Assert.AreEqual( - 0, Volatile.Read(ref framesSeen), - $"a producer whose allocator {how} published a frame anyway"); + 0, + Volatile.Read(ref framesSeen), + $"a producer whose allocator {how} published a frame anyway" + ); // The stream is still the library's to talk to: a refused pool is not a torn-down one. Assert.IsNotNull(output.NodeId, "the producer fell out of the graph"); @@ -522,18 +633,21 @@ public async Task AnAllocatorThatMisbehaves_LeavesTheBufferUnbacked(string how, { Assert.IsNotNull( output.LastProcessError, - "an allocator that threw was contained but never reported"); + "an allocator that threw was contained but never reported" + ); } else { Assert.IsNull( output.LastProcessError, - "an allocator that refused cleanly was recorded as a fault"); + "an allocator that refused cleanly was recorded as a fault" + ); } } finally { - foreach (GbmAllocator.Buffer b in buffers) b.Dispose(); + foreach (GbmAllocator.Buffer b in buffers) + b.Dispose(); } } } diff --git a/tests/PipeWire.NET.Tests/DmaBufRoundTripTests.cs b/tests/PipeWire.NET.Tests/DmaBufRoundTripTests.cs index eaf325a..7476725 100644 --- a/tests/PipeWire.NET.Tests/DmaBufRoundTripTests.cs +++ b/tests/PipeWire.NET.Tests/DmaBufRoundTripTests.cs @@ -28,9 +28,13 @@ public sealed class DmaBufRoundTripTests : PipeWireTestBase public async Task DmaBufProducer_ToConsumer_DeliversDmaBufFrames() { if (!File.Exists("/dev/dri/renderD128")) - Assert.Inconclusive("No GPU render node (/dev/dri/renderD128) - skipping dmabuf round-trip."); + Assert.Inconclusive( + "No GPU render node (/dev/dri/renderD128) - skipping dmabuf round-trip." + ); - const int width = 320, height = 240, poolCap = 8; + const int width = 320, + height = 240, + poolCap = 8; GbmAllocator gbm; try { @@ -50,14 +54,24 @@ public async Task DmaBufProducer_ToConsumer_DeliversDmaBufFrames() long modifier = (long)GbmAllocator.LinearModifier; bool streaming = false; - int framesConsumed = 0, dmaBufConsumed = 0; + int framesConsumed = 0, + dmaBufConsumed = 0; long firstFd = -1; - await using var output = new PipeWireVideoOutput(ctx, "stx-dmabuf-roundtrip", width, height, PixelFormat.Bgra, 30); + await using var output = new PipeWireVideoOutput( + ctx, + "stx-dmabuf-roundtrip", + width, + height, + PixelFormat.Bgra, + 30 + ); output.AllocateDmaBuf += (_, index, w, h, _, _, planes) => { - if (index >= poolCap) return 0; - while (buffers.Count <= index) buffers.Add(gbm.CreateBgra(width, height)); + if (index >= poolCap) + return 0; + while (buffers.Count <= index) + buffers.Add(gbm.CreateBgra(width, height)); GbmAllocator.Buffer b = buffers[index]; planes[0] = new VideoPlane(b.Fd, b.Offset, b.Stride, b.Size); return 1; @@ -70,7 +84,8 @@ public async Task DmaBufProducer_ToConsumer_DeliversDmaBufFrames() for (int i = 0; i < 50 && nodeId is null; i++) { nodeId = output.NodeId; - if (nodeId is null) await Task.Delay(50); + if (nodeId is null) + await Task.Delay(50); } Assert.IsNotNull(nodeId, "producer node should be assigned an id"); @@ -93,13 +108,19 @@ public async Task DmaBufProducer_ToConsumer_DeliversDmaBufFrames() await Task.Delay(TimeSpan.FromSeconds(4)); - Assert.IsTrue(dmaBufConsumed >= 10, - $"expected >=10 DMA-BUF frames through the dmabuf path, got {dmaBufConsumed} dmabuf of {framesConsumed} total"); - Assert.IsTrue(firstFd >= 0, "a delivered DMA-BUF frame must expose a valid fd for zero-copy import"); + Assert.IsTrue( + dmaBufConsumed >= 10, + $"expected >=10 DMA-BUF frames through the dmabuf path, got {dmaBufConsumed} dmabuf of {framesConsumed} total" + ); + Assert.IsTrue( + firstFd >= 0, + "a delivered DMA-BUF frame must expose a valid fd for zero-copy import" + ); } finally { - foreach (GbmAllocator.Buffer b in buffers) b.Dispose(); + foreach (GbmAllocator.Buffer b in buffers) + b.Dispose(); gbm.Dispose(); } } diff --git a/tests/PipeWire.NET.Tests/DocumentationExampleTests.cs b/tests/PipeWire.NET.Tests/DocumentationExampleTests.cs index b352697..34e4691 100644 --- a/tests/PipeWire.NET.Tests/DocumentationExampleTests.cs +++ b/tests/PipeWire.NET.Tests/DocumentationExampleTests.cs @@ -84,15 +84,26 @@ private static List Collect() var found = new List(); var files = new List { Path.Combine(root, "README.md") }; - files.AddRange(Directory.EnumerateFiles(Path.Combine(root, "docs"), "*.md").Order(StringComparer.Ordinal)); + files.AddRange( + Directory + .EnumerateFiles(Path.Combine(root, "docs"), "*.md") + .Order(StringComparer.Ordinal) + ); foreach (string path in files) { string text = File.ReadAllText(path).ReplaceLineEndings("\n"); - foreach (Match m in Regex.Matches(text, @"(?\s*\n)?```csharp\n(?.*?)```", RegexOptions.Singleline)) + foreach ( + Match m in Regex.Matches( + text, + @"(?\s*\n)?```csharp\n(?.*?)```", + RegexOptions.Singleline + ) + ) { - if (m.Groups["opt"].Success) continue; + if (m.Groups["opt"].Success) + continue; int line = text[..m.Index].Count(c => c == '\n') + 1; found.Add(new Example(Path.GetFileName(path), line, m.Groups["code"].Value)); @@ -123,37 +134,50 @@ public void EveryDocumentationLink_Resolves() string name = Path.GetFileName(path); string dir = Path.GetDirectoryName(path)!; - HashSet anchors = [.. text - .Split('\n') - .Where(line => line.StartsWith('#')) - .Select(line => Slug(line.TrimStart('#')))]; + HashSet anchors = + [ + .. text.Split('\n') + .Where(line => line.StartsWith('#')) + .Select(line => Slug(line.TrimStart('#'))), + ]; foreach (Match m in Regex.Matches(text, @"\[[^\]]*\]\((?[^)]+)\)")) { string target = m.Groups["target"].Value; - if (target.StartsWith("http", StringComparison.Ordinal)) continue; + if (target.StartsWith("http", StringComparison.Ordinal)) + continue; if (target.StartsWith('#')) { - if (!anchors.Contains(target[1..])) broken.Add($"{name}: {target}"); + if (!anchors.Contains(target[1..])) + broken.Add($"{name}: {target}"); continue; } - if (!File.Exists(Path.Combine(dir, target.Split('#')[0]))) broken.Add($"{name}: {target}"); + if (!File.Exists(Path.Combine(dir, target.Split('#')[0]))) + broken.Add($"{name}: {target}"); } } - Assert.IsTrue(broken.Count == 0, - $"the documentation links to things that do not exist: {string.Join(", ", broken)}"); + Assert.IsTrue( + broken.Count == 0, + $"the documentation links to things that do not exist: {string.Join(", ", broken)}" + ); } /// A heading as the anchor a link to it has to use. /// Lowercased, punctuation dropped, spaces to hyphens, which is what GitHub does. - private static string Slug(string heading) => string.Join( - '-', - new string([.. heading.Trim().ToLowerInvariant().Where(c => char.IsLetterOrDigit(c) || c is ' ' or '-')]) - .Split(' ', StringSplitOptions.RemoveEmptyEntries)); + private static string Slug(string heading) => + string.Join( + '-', + new string([ + .. heading + .Trim() + .ToLowerInvariant() + .Where(c => char.IsLetterOrDigit(c) || c is ' ' or '-'), + ]).Split(' ', StringSplitOptions.RemoveEmptyEntries) + ); [TestMethod] public void EveryExampleInTheDocumentation_Compiles() @@ -163,7 +187,8 @@ public void EveryExampleInTheDocumentation_Compiles() Assert.IsTrue( examples.Count > 5, $"only {examples.Count} examples were found, so the fences or the paths moved and this " - + "test is checking nothing"); + + "test is checking nothing" + ); string root = PublicSurfaceTests.RepoRoot(); @@ -184,8 +209,10 @@ public void EveryExampleInTheDocumentation_Compiles() foreach (string line in example.Code.TrimEnd().Split('\n')) { Match u = Regex.Match(line, @"^\s*using ([\w.]+);\s*$"); - if (u.Success) usings.Add(u.Groups[1].Value); - else lines.Add(line); + if (u.Success) + usings.Add(u.Groups[1].Value); + else + lines.Add(line); } bodies.Add([.. lines]); @@ -193,7 +220,8 @@ public void EveryExampleInTheDocumentation_Compiles() var code = new StringBuilder(Preamble); code.AppendLine(); - foreach (string ns in usings) code.AppendLine($"using {ns};"); + foreach (string ns in usings) + code.AppendLine($"using {ns};"); code.AppendLine(); code.Append(Harness); @@ -215,7 +243,11 @@ public void EveryExampleInTheDocumentation_Compiles() code.AppendLine("}"); - File.WriteAllText(Path.Combine(dir, "Examples.cs"), code.ToString(), new UTF8Encoding(false)); + File.WriteAllText( + Path.Combine(dir, "Examples.cs"), + code.ToString(), + new UTF8Encoding(false) + ); string tfm = new DirectoryInfo(AppContext.BaseDirectory).Name; @@ -223,22 +255,35 @@ public void EveryExampleInTheDocumentation_Compiles() foreach (string dll in new[] { "PipeWire.NET.dll", "PipeWire.NET.Media.dll" }) { string path = Path.Combine(AppContext.BaseDirectory, dll); - Assert.IsTrue(File.Exists(path), $"{dll} is not beside the tests, so nothing can be compiled against it"); + Assert.IsTrue( + File.Exists(path), + $"{dll} is not beside the tests, so nothing can be compiled against it" + ); paths.Add(path); } // A context takes an ILoggerFactory, so an example that builds one is CS0012 without this. // Only on the frameworks that carry it locally: net11 resolves it from the shared framework // and has no copy beside the tests. - string logging = Path.Combine(AppContext.BaseDirectory, "Microsoft.Extensions.Logging.Abstractions.dll"); - if (File.Exists(logging)) paths.Add(logging); - - string references = string.Join('\n', paths.Select( - p => $""" {p}""")); + string logging = Path.Combine( + AppContext.BaseDirectory, + "Microsoft.Extensions.Logging.Abstractions.dll" + ); + if (File.Exists(logging)) + paths.Add(logging); + + string references = string.Join( + '\n', + paths.Select(p => + $""" {p}""" + ) + ); // Analyzers and warnings-as-errors are the repository's rules for its own code. An example // is written to be read, so it is held to compiling, not to CA1303. - File.WriteAllText(Path.Combine(dir, "doc-examples.csproj"), $""" + File.WriteAllText( + Path.Combine(dir, "doc-examples.csproj"), + $""" {tfm} @@ -256,7 +301,9 @@ public void EveryExampleInTheDocumentation_Compiles() {references} - """, new UTF8Encoding(false)); + """, + new UTF8Encoding(false) + ); var psi = new ProcessStartInfo("dotnet") { @@ -271,9 +318,13 @@ public void EveryExampleInTheDocumentation_Compiles() Assert.IsNotNull(build, "could not start dotnet to compile the examples"); string output = build!.StandardOutput.ReadToEnd() + build.StandardError.ReadToEnd(); - Assert.IsTrue(build.WaitForExit(milliseconds: 300_000), "compiling the examples did not finish"); + Assert.IsTrue( + build.WaitForExit(milliseconds: 300_000), + "compiling the examples did not finish" + ); - if (build.ExitCode == 0) return; + if (build.ExitCode == 0) + return; // Point at the markdown, not at the generated file nobody will open: each example carries a // comment naming its file and line, and the compiler's line numbers are into the generated @@ -281,13 +332,19 @@ public void EveryExampleInTheDocumentation_Compiles() string[] generated = File.ReadAllLines(Path.Combine(dir, "Examples.cs")); var reported = new List(); - foreach (Match m in Regex.Matches(output, @"Examples\.cs\((?\d+),\d+\): (?error .*)")) + foreach ( + Match m in Regex.Matches(output, @"Examples\.cs\((?\d+),\d+\): (?error .*)") + ) { - var line = int.Parse(m.Groups["line"].Value, System.Globalization.CultureInfo.InvariantCulture); + var line = int.Parse( + m.Groups["line"].Value, + System.Globalization.CultureInfo.InvariantCulture + ); string origin = "unknown example"; for (int i = Math.Min(line, generated.Length) - 1; i >= 0; i--) { - if (!generated[i].TrimStart().StartsWith("// ", StringComparison.Ordinal)) continue; + if (!generated[i].TrimStart().StartsWith("// ", StringComparison.Ordinal)) + continue; origin = generated[i].Trim(' ', '/'); break; } @@ -297,6 +354,7 @@ public void EveryExampleInTheDocumentation_Compiles() Assert.Fail( "documentation examples do not compile against the shipped surface:\n" - + (reported.Count > 0 ? string.Join('\n', reported.Distinct()) : output)); + + (reported.Count > 0 ? string.Join('\n', reported.Distinct()) : output) + ); } } diff --git a/tests/PipeWire.NET.Tests/DrmFormatTests.cs b/tests/PipeWire.NET.Tests/DrmFormatTests.cs index b4a62dc..a22d144 100644 --- a/tests/PipeWire.NET.Tests/DrmFormatTests.cs +++ b/tests/PipeWire.NET.Tests/DrmFormatTests.cs @@ -19,7 +19,8 @@ private static string Tag(uint fourcc) { uint v = fourcc & ~NativeLibdrm.DRM_FORMAT_BIG_ENDIAN; StringBuilder sb = new(4); - for (int i = 0; i < 4; i++) sb.Append((char)((v >> (8 * i)) & 0xFF)); + for (int i = 0; i < 4; i++) + sb.Append((char)((v >> (8 * i)) & 0xFF)); return sb.ToString(); } @@ -39,14 +40,32 @@ public void FourccPacksItsCharactersLittleEndian() [TestMethod] public void ByteOrderIsReversedBetweenSpaAndDrmNames() { - Assert.AreEqual(NativeLibdrm.DRM_FORMAT_ABGR8888, DrmFormat.FromVideoFormat(SpaVideoFormat.Rgba)); - Assert.AreEqual(NativeLibdrm.DRM_FORMAT_ARGB8888, DrmFormat.FromVideoFormat(SpaVideoFormat.Bgra)); - Assert.AreEqual(NativeLibdrm.DRM_FORMAT_RGBA8888, DrmFormat.FromVideoFormat(SpaVideoFormat.Abgr)); - Assert.AreEqual(NativeLibdrm.DRM_FORMAT_BGRA8888, DrmFormat.FromVideoFormat(SpaVideoFormat.Argb)); + Assert.AreEqual( + NativeLibdrm.DRM_FORMAT_ABGR8888, + DrmFormat.FromVideoFormat(SpaVideoFormat.Rgba) + ); + Assert.AreEqual( + NativeLibdrm.DRM_FORMAT_ARGB8888, + DrmFormat.FromVideoFormat(SpaVideoFormat.Bgra) + ); + Assert.AreEqual( + NativeLibdrm.DRM_FORMAT_RGBA8888, + DrmFormat.FromVideoFormat(SpaVideoFormat.Abgr) + ); + Assert.AreEqual( + NativeLibdrm.DRM_FORMAT_BGRA8888, + DrmFormat.FromVideoFormat(SpaVideoFormat.Argb) + ); // Same reversal on the 24-bit pair, where it is easiest to get backwards. - Assert.AreEqual(NativeLibdrm.DRM_FORMAT_BGR888, DrmFormat.FromVideoFormat(SpaVideoFormat.Rgb)); - Assert.AreEqual(NativeLibdrm.DRM_FORMAT_RGB888, DrmFormat.FromVideoFormat(SpaVideoFormat.Bgr)); + Assert.AreEqual( + NativeLibdrm.DRM_FORMAT_BGR888, + DrmFormat.FromVideoFormat(SpaVideoFormat.Rgb) + ); + Assert.AreEqual( + NativeLibdrm.DRM_FORMAT_RGB888, + DrmFormat.FromVideoFormat(SpaVideoFormat.Bgr) + ); } [TestMethod] @@ -68,8 +87,8 @@ public void ByteOrderIsReversedBetweenSpaAndDrmNames() [DataRow(SpaVideoFormat.Rgbx, "XB24")] [DataRow(SpaVideoFormat.P010_10Le, "P010")] [DataRow(SpaVideoFormat.Gray8, "R8 ")] - public void MapsToTheFourccUpstreamNames(SpaVideoFormat format, string expected) - => Assert.AreEqual(expected, Tag(DrmFormat.FromVideoFormat(format))); + public void MapsToTheFourccUpstreamNames(SpaVideoFormat format, string expected) => + Assert.AreEqual(expected, Tag(DrmFormat.FromVideoFormat(format))); /// /// The big-endian grey pair share a fourcc and are told apart only by the high bit. Dropping it @@ -83,7 +102,10 @@ public void BigEndianGreyIsDistinguishedByTheHighBit() Assert.AreNotEqual(le, be, "the two grey layouts must not share a fourcc"); Assert.AreEqual(0u, le & NativeLibdrm.DRM_FORMAT_BIG_ENDIAN); - Assert.AreEqual(NativeLibdrm.DRM_FORMAT_BIG_ENDIAN, be & NativeLibdrm.DRM_FORMAT_BIG_ENDIAN); + Assert.AreEqual( + NativeLibdrm.DRM_FORMAT_BIG_ENDIAN, + be & NativeLibdrm.DRM_FORMAT_BIG_ENDIAN + ); Assert.AreEqual(le, be & ~NativeLibdrm.DRM_FORMAT_BIG_ENDIAN); } @@ -92,8 +114,8 @@ public void BigEndianGreyIsDistinguishedByTheHighBit() /// maps neither, and SPA's own Ayuv must not be attached to it. /// [TestMethod] - public void AyuvIsNotMappedBecauseItIsADifferentChannelOrder() - => Assert.AreEqual(DrmFormat.Invalid, DrmFormat.FromVideoFormat(SpaVideoFormat.Ayuv)); + public void AyuvIsNotMappedBecauseItIsADifferentChannelOrder() => + Assert.AreEqual(DrmFormat.Invalid, DrmFormat.FromVideoFormat(SpaVideoFormat.Ayuv)); /// /// Walks the whole SPA enum. Two properties hold across it: the mapping never throws for any @@ -115,7 +137,8 @@ public void TheMappingIsTotalAndFreeOfAliases() mapped++; Assert.IsFalse( seen.TryGetValue(fourcc, out SpaVideoFormat other), - $"{format} and {other} both map to {Tag(fourcc)}"); + $"{format} and {other} both map to {Tag(fourcc)}" + ); seen[fourcc] = format; } diff --git a/tests/PipeWire.NET.Tests/DrmSyncobjTests.cs b/tests/PipeWire.NET.Tests/DrmSyncobjTests.cs index 24cf24c..e5bd037 100644 --- a/tests/PipeWire.NET.Tests/DrmSyncobjTests.cs +++ b/tests/PipeWire.NET.Tests/DrmSyncobjTests.cs @@ -52,17 +52,32 @@ public void ATimeline_IsReachedOnlyOnceItsPointIsSignalled() SyncWait early = DrmSyncobj.Wait(handle, 5, TimeSpan.FromMilliseconds(200)); long elapsed = Environment.TickCount64 - start; - Assert.AreEqual(SyncWaitOutcome.TimedOut, early.Outcome, "an unsignalled point was not waited for"); + Assert.AreEqual( + SyncWaitOutcome.TimedOut, + early.Outcome, + "an unsignalled point was not waited for" + ); Assert.AreEqual(NativeLibc.ETIME, early.Errno); - Assert.IsTrue(elapsed >= 150, - $"the wait gave up after {elapsed}ms of a 200ms deadline - the deadline is not absolute CLOCK_MONOTONIC"); + Assert.IsTrue( + elapsed >= 150, + $"the wait gave up after {elapsed}ms of a 200ms deadline - the deadline is not absolute CLOCK_MONOTONIC" + ); Assert.IsTrue(DrmSyncobj.Signal(handle, 5), "the signal was refused"); - Assert.IsTrue(DrmSyncobj.Wait(handle, 5, TimeSpan.FromSeconds(1)).Reached, "the signalled point was not reached"); - Assert.IsTrue(DrmSyncobj.Wait(handle, 3, TimeSpan.FromSeconds(1)).Reached, "an earlier point was not reached"); - Assert.AreEqual(SyncWaitOutcome.TimedOut, DrmSyncobj.Wait(handle, 6, TimeSpan.FromMilliseconds(50)).Outcome, - "a later point read as reached"); + Assert.IsTrue( + DrmSyncobj.Wait(handle, 5, TimeSpan.FromSeconds(1)).Reached, + "the signalled point was not reached" + ); + Assert.IsTrue( + DrmSyncobj.Wait(handle, 3, TimeSpan.FromSeconds(1)).Reached, + "an earlier point was not reached" + ); + Assert.AreEqual( + SyncWaitOutcome.TimedOut, + DrmSyncobj.Wait(handle, 6, TimeSpan.FromMilliseconds(50)).Outcome, + "a later point read as reached" + ); } finally { @@ -85,16 +100,27 @@ public void AnImportedTimeline_IsTheSameTimeline() try { - Assert.AreNotEqual(0u, imported, $"the timeline's own descriptor did not import (errno {errno})"); - Assert.AreEqual(SyncTimelineKind.Syncobj, SyncTimeline.Classify(fd, out uint classified, out _)); + Assert.AreNotEqual( + 0u, + imported, + $"the timeline's own descriptor did not import (errno {errno})" + ); + Assert.AreEqual( + SyncTimelineKind.Syncobj, + SyncTimeline.Classify(fd, out uint classified, out _) + ); DrmSyncobj.Destroy(classified); Assert.IsTrue(DrmSyncobj.Signal(imported, 9)); - Assert.IsTrue(DrmSyncobj.Wait(created, 9, TimeSpan.FromSeconds(1)).Reached, - "a point signalled through the imported handle was not seen through the original"); - - Assert.IsTrue(SyncTimeline.Wait(fd, 9, TimeSpan.FromSeconds(1)).Reached, - "the descriptor-level wait did not see the point"); + Assert.IsTrue( + DrmSyncobj.Wait(created, 9, TimeSpan.FromSeconds(1)).Reached, + "a point signalled through the imported handle was not seen through the original" + ); + + Assert.IsTrue( + SyncTimeline.Wait(fd, 9, TimeSpan.FromSeconds(1)).Reached, + "the descriptor-level wait did not see the point" + ); } finally { @@ -117,8 +143,10 @@ public void AReleasePromisedOnASyncobj_SignalsItsPoint() Assert.IsTrue(release.IsPending, "no release was promised on a syncobj"); Assert.IsTrue(release.Signal(), "the release signal was refused"); - Assert.IsTrue(DrmSyncobj.Wait(handle, 7, TimeSpan.FromSeconds(1)).Reached, - "the release point was not signalled"); + Assert.IsTrue( + DrmSyncobj.Wait(handle, 7, TimeSpan.FromSeconds(1)).Reached, + "the release point was not signalled" + ); } finally { @@ -136,7 +164,11 @@ public void AnEventfd_DoesNotImportAsASyncobj() int fd = Descriptors.CreateEventfd(); try { - Assert.AreEqual(0u, DrmSyncobj.Import(fd, out int errno), "an eventfd imported as a syncobj"); + Assert.AreEqual( + 0u, + DrmSyncobj.Import(fd, out int errno), + "an eventfd imported as a syncobj" + ); Assert.AreNotEqual(0, errno, "the refused import carries no errno"); Assert.AreEqual(SyncTimelineKind.Eventfd, SyncTimeline.Classify(fd, out _, out _)); } diff --git a/tests/PipeWire.NET.Tests/ExplicitSyncTests.cs b/tests/PipeWire.NET.Tests/ExplicitSyncTests.cs index 369e3b7..ed42a04 100644 --- a/tests/PipeWire.NET.Tests/ExplicitSyncTests.cs +++ b/tests/PipeWire.NET.Tests/ExplicitSyncTests.cs @@ -26,9 +26,13 @@ public sealed class ExplicitSyncTests : PipeWireTestBase public async Task SyncProducer_ToSyncConsumer_CarriesPointsAndCompletesTheHandshake() { if (!File.Exists("/dev/dri/renderD128")) - Assert.Inconclusive("No GPU render node (/dev/dri/renderD128) - skipping explicit-sync round-trip."); + Assert.Inconclusive( + "No GPU render node (/dev/dri/renderD128) - skipping explicit-sync round-trip." + ); - const int width = 320, height = 240, poolCap = 8; + const int width = 320, + height = 240, + poolCap = 8; GbmAllocator gbm; try { @@ -36,7 +40,9 @@ public async Task SyncProducer_ToSyncConsumer_CarriesPointsAndCompletesTheHandsh } catch (Exception ex) { - Assert.Inconclusive($"libgbm unavailable ({ex.Message}) - skipping explicit-sync round-trip."); + Assert.Inconclusive( + $"libgbm unavailable ({ex.Message}) - skipping explicit-sync round-trip." + ); return; } @@ -48,18 +54,39 @@ public async Task SyncProducer_ToSyncConsumer_CarriesPointsAndCompletesTheHandsh long modifier = (long)GbmAllocator.LinearModifier; bool streaming = false; - int framesConsumed = 0, syncFrames = 0; - ulong maxAcquireSeen = 0, maxReleaseSeen = 0; + int framesConsumed = 0, + syncFrames = 0; + ulong maxAcquireSeen = 0, + maxReleaseSeen = 0; long stamped = 0; - await using var output = new PipeWireVideoOutput(ctx, "stx-sync-roundtrip", width, height, PixelFormat.Bgra, 30); - output.AllocateDmaBufSync += (_, index, w, h, _, _, planes, out acquireFd, out releaseFd) => + await using var output = new PipeWireVideoOutput( + ctx, + "stx-sync-roundtrip", + width, + height, + PixelFormat.Bgra, + 30 + ); + output.AllocateDmaBufSync += ( + _, + index, + w, + h, + _, + _, + planes, + out acquireFd, + out releaseFd + ) => { // Library-created syncobj timelines; the app only stamps points below. acquireFd = -1; releaseFd = -1; - if (index >= poolCap) return 0; - while (buffers.Count <= index) buffers.Add(gbm.CreateBgra(width, height)); + if (index >= poolCap) + return 0; + while (buffers.Count <= index) + buffers.Add(gbm.CreateBgra(width, height)); GbmAllocator.Buffer b = buffers[index]; planes[0] = new VideoPlane(b.Fd, b.Offset, b.Stride, b.Size); return 1; @@ -82,18 +109,26 @@ public async Task SyncProducer_ToSyncConsumer_CarriesPointsAndCompletesTheHandsh // Loop-thread serial: no synchronization needed inside the handler. The test // method below reads through Volatile after the observation window. framesConsumed++; - if (frame.SyncTimeline is not { } timeline) return; + if (frame.SyncTimeline is not { } timeline) + return; syncFrames++; - if (timeline.AcquirePoint == 0 || timeline.ReleasePoint == 0) return; - if (timeline.AcquirePoint > maxAcquireSeen) maxAcquireSeen = timeline.AcquirePoint; - if (timeline.ReleasePoint > maxReleaseSeen) maxReleaseSeen = timeline.ReleasePoint; + if (timeline.AcquirePoint == 0 || timeline.ReleasePoint == 0) + return; + if (timeline.AcquirePoint > maxAcquireSeen) + maxAcquireSeen = timeline.AcquirePoint; + if (timeline.ReleasePoint > maxReleaseSeen) + maxReleaseSeen = timeline.ReleasePoint; }; - capture.Connect(nodeId.Value, [PixelFormat.Bgra], modifiers: [modifier], - requestExplicitSync: true); + capture.Connect( + nodeId.Value, + [PixelFormat.Bgra], + modifiers: [modifier], + requestExplicitSync: true + ); // Not driven from here. This node is not the graph's driver, and pw_stream_trigger_process - // on a node that is not one reaches the real driver as RequestProcess, which an audio - // adapter refuses - once per call, logged as an error. The consumer drives the graph. + // on a node that is not one reaches the real driver as RequestProcess, which an audio + // adapter refuses - once per call, logged as an error. The consumer drives the graph. await Task.Delay(TimeSpan.FromSeconds(6)); @@ -102,16 +137,23 @@ public async Task SyncProducer_ToSyncConsumer_CarriesPointsAndCompletesTheHandsh ulong maxAcquire = Volatile.Read(ref maxAcquireSeen); ulong maxRelease = Volatile.Read(ref maxReleaseSeen); - Assert.IsTrue(synced >= 10, - $"expected >=10 frames carrying timeline points, got {synced} of {seen}"); - Assert.IsTrue(maxAcquire > 0 && maxRelease > 0, - "timeline points must arrive nonzero: the stamped sequence never reached the consumer"); - Assert.IsTrue(seen >= 10, - "flow must sustain: buffers recycle only when every release is signalled"); + Assert.IsTrue( + synced >= 10, + $"expected >=10 frames carrying timeline points, got {synced} of {seen}" + ); + Assert.IsTrue( + maxAcquire > 0 && maxRelease > 0, + "timeline points must arrive nonzero: the stamped sequence never reached the consumer" + ); + Assert.IsTrue( + seen >= 10, + "flow must sustain: buffers recycle only when every release is signalled" + ); } finally { - foreach (GbmAllocator.Buffer b in buffers) b.Dispose(); + foreach (GbmAllocator.Buffer b in buffers) + b.Dispose(); gbm.Dispose(); } } @@ -139,11 +181,15 @@ public async Task SyncProducer_ToSyncConsumer_CarriesPointsAndCompletesTheHandsh public async Task AHeldBorrowedFrame_KeepsItsBufferOutOfRotation_UntilItIsReleased() { if (!File.Exists("/dev/dri/renderD128")) - Assert.Inconclusive("No GPU render node (/dev/dri/renderD128) - explicit sync needs one."); + Assert.Inconclusive( + "No GPU render node (/dev/dri/renderD128) - explicit sync needs one." + ); // The whole pool the producer's Buffers param allows (2..16): a buffer the allocator declines // fails every buffer, not just itself (client-node.c do_port_use_buffers). - const int width = 320, height = 240, poolCap = 16; + const int width = 320, + height = 240, + poolCap = 16; GbmAllocator gbm; try { @@ -160,21 +206,46 @@ public async Task AHeldBorrowedFrame_KeepsItsBufferOutOfRotation_UntilItIsReleas { using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(40)); - await using var producerCtx = new PipeWireContext("pwnet-gate-producer", ConsoleTestLoggerFactory.Instance); + await using var producerCtx = new PipeWireContext( + "pwnet-gate-producer", + ConsoleTestLoggerFactory.Instance + ); await producerCtx.StartAsync(cts.Token); - await using var consumerCtx = new PipeWireContext("pwnet-gate-consumer", ConsoleTestLoggerFactory.Instance); + await using var consumerCtx = new PipeWireContext( + "pwnet-gate-consumer", + ConsoleTestLoggerFactory.Instance + ); await consumerCtx.StartAsync(cts.Token); long modifier = (long)GbmAllocator.LinearModifier; long stamped = 0; - await using var output = new PipeWireVideoOutput(producerCtx, "pwnet-gate", width, height, PixelFormat.Bgra, 30); - output.AllocateDmaBufSync += (_, index, _, _, _, _, planes, out acquireFd, out releaseFd) => + await using var output = new PipeWireVideoOutput( + producerCtx, + "pwnet-gate", + width, + height, + PixelFormat.Bgra, + 30 + ); + output.AllocateDmaBufSync += ( + _, + index, + _, + _, + _, + _, + planes, + out acquireFd, + out releaseFd + ) => { acquireFd = -1; releaseFd = -1; - if (index >= poolCap) return 0; - while (buffers.Count <= index) buffers.Add(gbm.CreateBgra(width, height)); + if (index >= poolCap) + return 0; + while (buffers.Count <= index) + buffers.Add(gbm.CreateBgra(width, height)); GbmAllocator.Buffer b = buffers[index]; planes[0] = new VideoPlane(b.Fd, b.Offset, b.Stride, b.Size); return 1; @@ -197,16 +268,26 @@ public async Task AHeldBorrowedFrame_KeepsItsBufferOutOfRotation_UntilItIsReleas }; capture.FrameReady += (_, frame) => { - if (frame.SyncTimeline is null || frame.Planes.IsEmpty) return; - lock (deliveries) deliveries.Add((frame.Planes[0].Fd, DateTime.UtcNow.Ticks)); + if (frame.SyncTimeline is null || frame.Planes.IsEmpty) + return; + lock (deliveries) + deliveries.Add((frame.Planes[0].Fd, DateTime.UtcNow.Ticks)); }; - capture.Connect(nodeId, [PixelFormat.Bgra], modifiers: [modifier], requestExplicitSync: true); + capture.Connect( + nodeId, + [PixelFormat.Bgra], + modifiers: [modifier], + requestExplicitSync: true + ); await capture.WaitForStreamingAsync(cts.Token); // Let the pool turn over so every buffer has been seen at least once. await Task.Delay(1000, cts.Token); - Assert.IsTrue(capture.TryGetBorrowedFrame(out BorrowedVideoFrame held), "no borrowed frame to hold"); + Assert.IsTrue( + capture.TryGetBorrowedFrame(out BorrowedVideoFrame held), + "no borrowed frame to hold" + ); long heldFd = held[0].Fd; long heldAt = DateTime.UtcNow.Ticks; @@ -218,21 +299,32 @@ public async Task AHeldBorrowedFrame_KeepsItsBufferOutOfRotation_UntilItIsReleas await Task.Delay(1500, cts.Token); (long Fd, long AtTicks)[] seen; - lock (deliveries) seen = [.. deliveries]; - - Assert.IsTrue(seen.Count(d => d.AtTicks < heldAt) >= poolCap, - "the pool never turned over before the hold, so there is nothing to compare against"); - - int reusedWhileHeld = seen.Count(d => d.Fd == heldFd && d.AtTicks > heldAt && d.AtTicks < releasedAt); - Assert.AreEqual(0, reusedWhileHeld, - "the producer wrote into a buffer the consumer was still holding - the release point did not gate reuse"); - - Assert.IsTrue(seen.Any(d => d.Fd == heldFd && d.AtTicks > releasedAt), - "the held buffer never came back after it was released - the release was not signalled"); + lock (deliveries) + seen = [.. deliveries]; + + Assert.IsTrue( + seen.Count(d => d.AtTicks < heldAt) >= poolCap, + "the pool never turned over before the hold, so there is nothing to compare against" + ); + + int reusedWhileHeld = seen.Count(d => + d.Fd == heldFd && d.AtTicks > heldAt && d.AtTicks < releasedAt + ); + Assert.AreEqual( + 0, + reusedWhileHeld, + "the producer wrote into a buffer the consumer was still holding - the release point did not gate reuse" + ); + + Assert.IsTrue( + seen.Any(d => d.Fd == heldFd && d.AtTicks > releasedAt), + "the held buffer never came back after it was released - the release was not signalled" + ); } finally { - foreach (GbmAllocator.Buffer b in buffers) b.Dispose(); + foreach (GbmAllocator.Buffer b in buffers) + b.Dispose(); gbm.Dispose(); } } @@ -260,13 +352,19 @@ public async Task AHeldBorrowedFrame_KeepsItsBufferOutOfRotation_UntilItIsReleas public async Task AnAppSuppliedAcquireTimeline_IsLeftForTheAppToSignal() { if (!File.Exists("/dev/dri/renderD128")) - Assert.Inconclusive("No GPU render node (/dev/dri/renderD128) - explicit sync needs one."); + Assert.Inconclusive( + "No GPU render node (/dev/dri/renderD128) - explicit sync needs one." + ); if (!DrmSyncobj.IsAvailable) - Assert.Inconclusive("No render node with timeline syncobjs, so the app has no timelines to supply."); + Assert.Inconclusive( + "No render node with timeline syncobjs, so the app has no timelines to supply." + ); // The whole pool the producer's Buffers param allows (2..16): a buffer the allocator declines // fails every buffer, not just itself (client-node.c do_port_use_buffers). - const int width = 320, height = 240, poolCap = 16; + const int width = 320, + height = 240, + poolCap = 16; GbmAllocator gbm; try { @@ -279,7 +377,9 @@ public async Task AnAppSuppliedAcquireTimeline_IsLeftForTheAppToSignal() } var buffers = new List(); - var timelines = new (uint AcquireHandle, int AcquireFd, uint ReleaseHandle, int ReleaseFd)[poolCap]; + var timelines = new (uint AcquireHandle, int AcquireFd, uint ReleaseHandle, int ReleaseFd)[ + poolCap + ]; var signalledAt = new System.Collections.Concurrent.ConcurrentDictionary(); var arrivals = new List<(ulong Point, long AtTicks)>(); @@ -287,22 +387,46 @@ public async Task AnAppSuppliedAcquireTimeline_IsLeftForTheAppToSignal() { using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(40)); - await using var producerCtx = new PipeWireContext("pwnet-appsync-producer", ConsoleTestLoggerFactory.Instance); + await using var producerCtx = new PipeWireContext( + "pwnet-appsync-producer", + ConsoleTestLoggerFactory.Instance + ); await producerCtx.StartAsync(cts.Token); - await using var consumerCtx = new PipeWireContext("pwnet-appsync-consumer", ConsoleTestLoggerFactory.Instance); + await using var consumerCtx = new PipeWireContext( + "pwnet-appsync-consumer", + ConsoleTestLoggerFactory.Instance + ); await consumerCtx.StartAsync(cts.Token); long modifier = (long)GbmAllocator.LinearModifier; long stamped = 0; - var output = new PipeWireVideoOutput(producerCtx, "pwnet-appsync", width, height, PixelFormat.Bgra, 30); + var output = new PipeWireVideoOutput( + producerCtx, + "pwnet-appsync", + width, + height, + PixelFormat.Bgra, + 30 + ); try { - output.AllocateDmaBufSync += (_, index, _, _, _, _, planes, out acquireFd, out releaseFd) => + output.AllocateDmaBufSync += ( + _, + index, + _, + _, + _, + _, + planes, + out acquireFd, + out releaseFd + ) => { acquireFd = -1; releaseFd = -1; - if (index >= poolCap) return 0; + if (index >= poolCap) + return 0; if (timelines[index].AcquireFd == 0) { @@ -314,7 +438,8 @@ public async Task AnAppSuppliedAcquireTimeline_IsLeftForTheAppToSignal() acquireFd = timelines[index].AcquireFd; releaseFd = timelines[index].ReleaseFd; - while (buffers.Count <= index) buffers.Add(gbm.CreateBgra(width, height)); + while (buffers.Count <= index) + buffers.Add(gbm.CreateBgra(width, height)); GbmAllocator.Buffer b = buffers[index]; planes[0] = new VideoPlane(b.Fd, b.Offset, b.Stride, b.Size); return 1; @@ -339,13 +464,23 @@ public async Task AnAppSuppliedAcquireTimeline_IsLeftForTheAppToSignal() output.ConnectDmaBufSync([modifier]); uint nodeId = await output.WaitForNodeIdAsync(cts.Token); - await using var capture = new PipeWireVideoCapture(consumerCtx, "pwnet-appsync-sink"); + await using var capture = new PipeWireVideoCapture( + consumerCtx, + "pwnet-appsync-sink" + ); capture.FrameReady += (_, frame) => { - if (frame.SyncTimeline is not { AcquirePoint: > 0 } timeline) return; - lock (arrivals) arrivals.Add((timeline.AcquirePoint, DateTime.UtcNow.Ticks)); + if (frame.SyncTimeline is not { AcquirePoint: > 0 } timeline) + return; + lock (arrivals) + arrivals.Add((timeline.AcquirePoint, DateTime.UtcNow.Ticks)); }; - capture.Connect(nodeId, [PixelFormat.Bgra], modifiers: [modifier], requestExplicitSync: true); + capture.Connect( + nodeId, + [PixelFormat.Bgra], + modifiers: [modifier], + requestExplicitSync: true + ); await capture.WaitForStreamingAsync(cts.Token); await Task.Delay(3000, cts.Token); @@ -356,31 +491,40 @@ public async Task AnAppSuppliedAcquireTimeline_IsLeftForTheAppToSignal() } (ulong Point, long AtTicks)[] seen; - lock (arrivals) seen = [.. arrivals]; + lock (arrivals) + seen = [.. arrivals]; - Assert.IsTrue(seen.Length >= 5, - $"only {seen.Length} frames arrived, so the app's timelines did not carry the stream"); + Assert.IsTrue( + seen.Length >= 5, + $"only {seen.Length} frames arrived, so the app's timelines did not carry the stream" + ); foreach ((ulong point, long at) in seen) { - Assert.IsTrue(signalledAt.TryGetValue(point, out long signalled), - $"frame {point} arrived although the app never signalled its acquire point"); - Assert.IsTrue(at >= signalled, - $"frame {point} arrived {(signalled - at) / TimeSpan.TicksPerMillisecond}ms before the app signalled it - the library declared it ready itself"); + Assert.IsTrue( + signalledAt.TryGetValue(point, out long signalled), + $"frame {point} arrived although the app never signalled its acquire point" + ); + Assert.IsTrue( + at >= signalled, + $"frame {point} arrived {(signalled - at) / TimeSpan.TicksPerMillisecond}ms before the app signalled it - the library declared it ready itself" + ); } } finally { foreach ((uint ah, int af, uint rh, int rf) in timelines) { - if (af == 0) continue; + if (af == 0) + continue; DrmSyncobj.Destroy(ah); DrmSyncobj.Destroy(rh); Descriptors.CloseDescriptor(af); Descriptors.CloseDescriptor(rf); } - foreach (GbmAllocator.Buffer b in buffers) b.Dispose(); + foreach (GbmAllocator.Buffer b in buffers) + b.Dispose(); gbm.Dispose(); } } diff --git a/tests/PipeWire.NET.Tests/ExportedFormatTests.cs b/tests/PipeWire.NET.Tests/ExportedFormatTests.cs index 76ac25d..43a7a10 100644 --- a/tests/PipeWire.NET.Tests/ExportedFormatTests.cs +++ b/tests/PipeWire.NET.Tests/ExportedFormatTests.cs @@ -18,7 +18,8 @@ namespace PipeWire.NET.Tests; [SupportedOSPlatform("linux")] public sealed class ExportedFormatTests { - private static SpaPodProperty Prop(SpaKey key, SpaValue value) => new(key, SpaPodPropFlags.None, value); + private static SpaPodProperty Prop(SpaKey key, SpaValue value) => + new(key, SpaPodPropFlags.None, value); /// The format audioadapter's configure_format sets on a mono F32 follower, as it logged it. private static byte[] AdapterFormat(params SpaPodProperty[] extra) @@ -30,7 +31,10 @@ private static byte[] AdapterFormat(params SpaPodProperty[] extra) Prop(SpaFormat.AudioFormat, new SpaId((uint)SpaAudioFormat.F32Le)), Prop(SpaFormat.AudioRate, new SpaInt(48000)), Prop(SpaFormat.AudioChannels, new SpaInt(1)), - Prop(SpaFormat.AudioPosition, new SpaArray(SpaType.Id, [new SpaId((uint)SpaAudioChannel.Mono)])), + Prop( + SpaFormat.AudioPosition, + new SpaArray(SpaType.Id, [new SpaId((uint)SpaAudioChannel.Mono)]) + ), }; props.AddRange(extra); return SpaPod.ToBytes(new SpaObject(SpaType.ObjectFormat, SpaParamType.Format, [.. props])); @@ -52,14 +56,46 @@ public void ValuesWrappedInANoneChoice_AreAccepted() { // The daemon sends settled formats with each value in a None choice, which spa_pod_parser // unwraps; a parser that did not would refuse every format the graph settles. - byte[] pod = SpaPod.ToBytes(new SpaObject(SpaType.ObjectFormat, SpaParamType.Format, - [ - Prop(SpaFormat.MediaType, new SpaChoice(SpaChoiceType.None, SpaType.Id, [new SpaId((uint)SpaMediaType.Audio)])), - Prop(SpaFormat.MediaSubtype, new SpaChoice(SpaChoiceType.None, SpaType.Id, [new SpaId((uint)SpaMediaSubtype.Raw)])), - Prop(SpaFormat.AudioFormat, new SpaChoice(SpaChoiceType.None, SpaType.Id, [new SpaId((uint)SpaAudioFormat.S16Le)])), - Prop(SpaFormat.AudioRate, new SpaChoice(SpaChoiceType.None, SpaType.Int, [new SpaInt(44100)])), - Prop(SpaFormat.AudioChannels, new SpaChoice(SpaChoiceType.None, SpaType.Int, [new SpaInt(2)])), - ])); + byte[] pod = SpaPod.ToBytes( + new SpaObject( + SpaType.ObjectFormat, + SpaParamType.Format, + [ + Prop( + SpaFormat.MediaType, + new SpaChoice( + SpaChoiceType.None, + SpaType.Id, + [new SpaId((uint)SpaMediaType.Audio)] + ) + ), + Prop( + SpaFormat.MediaSubtype, + new SpaChoice( + SpaChoiceType.None, + SpaType.Id, + [new SpaId((uint)SpaMediaSubtype.Raw)] + ) + ), + Prop( + SpaFormat.AudioFormat, + new SpaChoice( + SpaChoiceType.None, + SpaType.Id, + [new SpaId((uint)SpaAudioFormat.S16Le)] + ) + ), + Prop( + SpaFormat.AudioRate, + new SpaChoice(SpaChoiceType.None, SpaType.Int, [new SpaInt(44100)]) + ), + Prop( + SpaFormat.AudioChannels, + new SpaChoice(SpaChoiceType.None, SpaType.Int, [new SpaInt(2)]) + ), + ] + ) + ); PipeWireExportedFormat? settled = PipeWireExportedFormat.FromPod(pod); @@ -81,15 +117,44 @@ public void ValuesWrappedInANoneChoice_AreAccepted() [TestMethod] public void AFormatFixatedTheWayUpstreamDoesIt_IsAccepted() { - byte[] pod = SpaPod.ToBytes(new SpaObject(SpaType.ObjectFormat, SpaParamType.Format, - [ - Prop(SpaFormat.MediaType, new SpaId((uint)SpaMediaType.Audio)), - Prop(SpaFormat.MediaSubtype, new SpaId((uint)SpaMediaSubtype.Raw)), - Prop(SpaFormat.AudioFormat, new SpaChoice(SpaChoiceType.Enum, SpaType.Id, - [new SpaId((uint)SpaAudioFormat.F32Le), new SpaId((uint)SpaAudioFormat.F32Le), new SpaId((uint)SpaAudioFormat.S16Le)])), - Prop(SpaFormat.AudioRate, new SpaChoice(SpaChoiceType.Range, SpaType.Int, [new SpaInt(48000), new SpaInt(1), new SpaInt(384000)])), - Prop(SpaFormat.AudioChannels, new SpaChoice(SpaChoiceType.Range, SpaType.Int, [new SpaInt(1), new SpaInt(1), new SpaInt(64)])), - ])); + byte[] pod = SpaPod.ToBytes( + new SpaObject( + SpaType.ObjectFormat, + SpaParamType.Format, + [ + Prop(SpaFormat.MediaType, new SpaId((uint)SpaMediaType.Audio)), + Prop(SpaFormat.MediaSubtype, new SpaId((uint)SpaMediaSubtype.Raw)), + Prop( + SpaFormat.AudioFormat, + new SpaChoice( + SpaChoiceType.Enum, + SpaType.Id, + [ + new SpaId((uint)SpaAudioFormat.F32Le), + new SpaId((uint)SpaAudioFormat.F32Le), + new SpaId((uint)SpaAudioFormat.S16Le), + ] + ) + ), + Prop( + SpaFormat.AudioRate, + new SpaChoice( + SpaChoiceType.Range, + SpaType.Int, + [new SpaInt(48000), new SpaInt(1), new SpaInt(384000)] + ) + ), + Prop( + SpaFormat.AudioChannels, + new SpaChoice( + SpaChoiceType.Range, + SpaType.Int, + [new SpaInt(1), new SpaInt(1), new SpaInt(64)] + ) + ), + ] + ) + ); FixateInPlace(pod); @@ -127,15 +192,23 @@ private static void FixateInPlace(byte[] pod) [TestMethod] public void AFormatThisNodeNeverOffered_IsRefused() { - byte[] pod = SpaPod.ToBytes(new SpaObject(SpaType.ObjectFormat, SpaParamType.Format, - [ - Prop(SpaFormat.MediaType, new SpaId((uint)SpaMediaType.Audio)), - Prop(SpaFormat.MediaSubtype, new SpaId((uint)SpaMediaSubtype.Raw)), - Prop(SpaFormat.AudioFormat, new SpaId((uint)SpaAudioFormat.F32P)), - Prop(SpaFormat.AudioRate, new SpaInt(48000)), - Prop(SpaFormat.AudioChannels, new SpaInt(1)), - ])); - - Assert.IsNull(PipeWireExportedFormat.FromPod(pod), "a planar format this node never offered was accepted"); + byte[] pod = SpaPod.ToBytes( + new SpaObject( + SpaType.ObjectFormat, + SpaParamType.Format, + [ + Prop(SpaFormat.MediaType, new SpaId((uint)SpaMediaType.Audio)), + Prop(SpaFormat.MediaSubtype, new SpaId((uint)SpaMediaSubtype.Raw)), + Prop(SpaFormat.AudioFormat, new SpaId((uint)SpaAudioFormat.F32P)), + Prop(SpaFormat.AudioRate, new SpaInt(48000)), + Prop(SpaFormat.AudioChannels, new SpaInt(1)), + ] + ) + ); + + Assert.IsNull( + PipeWireExportedFormat.FromPod(pod), + "a planar format this node never offered was accepted" + ); } } diff --git a/tests/PipeWire.NET.Tests/ExportedNodeTests.cs b/tests/PipeWire.NET.Tests/ExportedNodeTests.cs index ddee086..51b70e7 100644 --- a/tests/PipeWire.NET.Tests/ExportedNodeTests.cs +++ b/tests/PipeWire.NET.Tests/ExportedNodeTests.cs @@ -58,7 +58,10 @@ public async Task AnExportedNode_AppearsInTheGraph() string name = $"pwnet-export-{Environment.ProcessId}"; - await using var ctx = new PipeWireContext("pwnet-export", ConsoleTestLoggerFactory.Instance); + await using var ctx = new PipeWireContext( + "pwnet-export", + ConsoleTestLoggerFactory.Instance + ); await ctx.StartAsync(cts.Token); await using var reg = new PipeWireRegistry(ctx); @@ -73,15 +76,18 @@ public async Task AnExportedNode_AppearsInTheGraph() { [PipeWireKeys.PW_KEY_MEDIA_CLASS] = "Audio/Source", [PipeWireKeys.PW_KEY_NODE_DESCRIPTION] = "exported by PipeWire.NET", - }); + } + ); PipeWireNode? seen = null; for (var i = 0; i < 100 && seen is null; i++) { - seen = reg.Current.Nodes.FirstOrDefault( - n => string.Equals(n.NodeName, name, StringComparison.Ordinal)); + seen = reg.Current.Nodes.FirstOrDefault(n => + string.Equals(n.NodeName, name, StringComparison.Ordinal) + ); - if (seen is null) await Task.Delay(50, cts.Token); + if (seen is null) + await Task.Delay(50, cts.Token); } Assert.IsNotNull(seen, "the exported node never appeared in the graph"); @@ -91,7 +97,8 @@ public async Task AnExportedNode_AppearsInTheGraph() Assert.AreEqual( "exported by PipeWire.NET", seen.Properties.GetValueOrDefault(PipeWireKeys.PW_KEY_NODE_DESCRIPTION), - "the exported node lost the properties it was published with"); + "the exported node lost the properties it was published with" + ); // A node with no ports is in the graph but cannot be linked to, which presents downstream // as "no target node available" when something tries to connect - a message that says @@ -100,17 +107,20 @@ public async Task AnExportedNode_AppearsInTheGraph() for (var i = 0; i < 60 && ports.Count == 0; i++) { ports = [.. reg.Current.Ports.Where(p => p.NodeId == seen.NodeId)]; - if (ports.Count == 0) await Task.Delay(50, cts.Token); + if (ports.Count == 0) + await Task.Delay(50, cts.Token); } Assert.IsTrue( ports.Count > 0, "the exported node has no ports in the graph, so nothing can link to it - the node's " - + "port_info emission did not register a port"); + + "port_info emission did not register a port" + ); Assert.IsTrue( ports.Any(p => p.PortDirection == PipeWirePortDirection.Out), - "the exported source has no output port for a consumer to read from"); + "the exported source has no output port for a consumer to read from" + ); } /// @@ -130,7 +140,10 @@ public async Task AnExportedSource_NegotiatesAndItsSamplesReachAConsumer() string name = $"pwnet-exportsrc-{Environment.ProcessId}"; - await using var ctx = new PipeWireContext("pwnet-exportsrc", ConsoleTestLoggerFactory.Instance); + await using var ctx = new PipeWireContext( + "pwnet-exportsrc", + ConsoleTestLoggerFactory.Instance + ); await ctx.StartAsync(cts.Token); await using var reg = new PipeWireRegistry(ctx); @@ -145,25 +158,26 @@ public async Task AnExportedSource_NegotiatesAndItsSamplesReachAConsumer() name, PipeWireExportedFormat.AudioF32(Rate, Channels), SpaDirection.Output, - new Dictionary - { - [PipeWireKeys.PW_KEY_MEDIA_CLASS] = "Audio/Source", - }); + new Dictionary { [PipeWireKeys.PW_KEY_MEDIA_CLASS] = "Audio/Source" } + ); node.ProcessCallback = (_, data) => { Span floats = MemoryMarshal.Cast(data); - for (var i = 0; i < floats.Length; i++) floats[i] = ++next; + for (var i = 0; i < floats.Length; i++) + floats[i] = ++next; return floats.Length * 4; }; PipeWireNode? exported = null; for (var i = 0; i < 100 && exported is null; i++) { - exported = reg.Current.Nodes.FirstOrDefault( - n => string.Equals(n.NodeName, name, StringComparison.Ordinal)); + exported = reg.Current.Nodes.FirstOrDefault(n => + string.Equals(n.NodeName, name, StringComparison.Ordinal) + ); - if (exported is null) await Task.Delay(50, cts.Token); + if (exported is null) + await Task.Delay(50, cts.Token); } Assert.IsNotNull(exported, "the exported node never appeared in the graph"); @@ -173,7 +187,12 @@ public async Task AnExportedSource_NegotiatesAndItsSamplesReachAConsumer() capture.FrameReady += (_, f) => { ReadOnlySpan floats = MemoryMarshal.Cast(f.Samples); - lock (received) { if (received.Count < 8000) foreach (float v in floats) received.Add(v); } + lock (received) + { + if (received.Count < 8000) + foreach (float v in floats) + received.Add(v); + } }; // Routed by the session manager, as upstream's export-source is: it exports with @@ -189,28 +208,43 @@ public async Task AnExportedSource_NegotiatesAndItsSamplesReachAConsumer() PipeWirePort? source = null; for (var i = 0; i < 100 && source is null; i++) { - source = reg.Current.Ports.FirstOrDefault( - p => p.NodeId == exported!.NodeId && p.PortDirection == PipeWirePortDirection.Out); + source = reg.Current.Ports.FirstOrDefault(p => + p.NodeId == exported!.NodeId && p.PortDirection == PipeWirePortDirection.Out + ); - if (source is null) await Task.Delay(50, cts.Token); + if (source is null) + await Task.Delay(50, cts.Token); } Assert.IsNotNull(source, "the exported node never registered an output port to route to"); - capture.Connect(exported!.NodeId, sampleRate: Rate, channels: Channels, format: AudioSampleFormat.F32Le); + capture.Connect( + exported!.NodeId, + sampleRate: Rate, + channels: Channels, + format: AudioSampleFormat.F32Le + ); await capture.WaitForStreamingAsync(cts.Token); for (var i = 0; i < 100; i++) { - lock (received) { if (received.Count > 2000) break; } + lock (received) + { + if (received.Count > 2000) + break; + } await Task.Delay(50, cts.Token); } float[] got; - lock (received) got = [.. received]; + lock (received) + got = [.. received]; // The graph negotiated: the node was told a format and given buffers. - Assert.IsNotNull(node.NegotiatedFormat, "the graph never settled a format on the exported node"); + Assert.IsNotNull( + node.NegotiatedFormat, + "the graph never settled a format on the exported node" + ); Assert.IsTrue(node.BufferCount > 0, "the graph never gave the exported node any buffers"); Assert.IsTrue(node.HasProcessed, "the exported node was never driven"); @@ -218,19 +252,23 @@ public async Task AnExportedSource_NegotiatesAndItsSamplesReachAConsumer() // And what arrived is the ramp this node wrote, in order - not silence, and not garbage. int start = 0; - while (start < got.Length && got[start] == 0f) start++; + while (start < got.Length && got[start] == 0f) + start++; Assert.IsTrue( got.Length - start > 500, - "the consumer received only silence, so the exported node's data never reached it"); + "the consumer received only silence, so the exported node's data never reached it" + ); var breaks = 0; for (int i = start + 1; i < got.Length; i++) - if (got[i] != got[i - 1] + 1f) breaks++; + if (got[i] != got[i - 1] + 1f) + breaks++; Assert.IsTrue( breaks <= 2, - $"the ramp broke {breaks} times, so the exported node's buffers were reordered or lost"); + $"the ramp broke {breaks} times, so the exported node's buffers were reordered or lost" + ); } /// @@ -257,7 +295,10 @@ public async Task AnExportedSource_IsRoutedToByTheSessionManager() string name = $"pwnet-exportauto-{Environment.ProcessId}"; - await using var ctx = new PipeWireContext("pwnet-exportauto", ConsoleTestLoggerFactory.Instance); + await using var ctx = new PipeWireContext( + "pwnet-exportauto", + ConsoleTestLoggerFactory.Instance + ); await ctx.StartAsync(cts.Token); await using var reg = new PipeWireRegistry(ctx); @@ -270,25 +311,26 @@ public async Task AnExportedSource_IsRoutedToByTheSessionManager() name, PipeWireExportedFormat.AudioF32(Rate, Channels), SpaDirection.Output, - new Dictionary - { - [PipeWireKeys.PW_KEY_MEDIA_CLASS] = "Audio/Source", - }); + new Dictionary { [PipeWireKeys.PW_KEY_MEDIA_CLASS] = "Audio/Source" } + ); node.ProcessCallback = (_, data) => { Span floats = MemoryMarshal.Cast(data); - for (var i = 0; i < floats.Length; i++) floats[i] = ++next; + for (var i = 0; i < floats.Length; i++) + floats[i] = ++next; return floats.Length * 4; }; PipeWireNode? exported = null; for (var i = 0; i < 100 && exported is null; i++) { - exported = reg.Current.Nodes.FirstOrDefault( - n => string.Equals(n.NodeName, name, StringComparison.Ordinal)); + exported = reg.Current.Nodes.FirstOrDefault(n => + string.Equals(n.NodeName, name, StringComparison.Ordinal) + ); - if (exported is null) await Task.Delay(50, cts.Token); + if (exported is null) + await Task.Delay(50, cts.Token); } Assert.IsNotNull(exported, "the exported node never appeared in the graph"); @@ -297,10 +339,12 @@ public async Task AnExportedSource_IsRoutedToByTheSessionManager() var hasPort = false; for (var i = 0; i < 100 && !hasPort; i++) { - hasPort = reg.Current.Ports.Any( - p => p.NodeId == exported!.NodeId && p.PortDirection == PipeWirePortDirection.Out); + hasPort = reg.Current.Ports.Any(p => + p.NodeId == exported!.NodeId && p.PortDirection == PipeWirePortDirection.Out + ); - if (!hasPort) await Task.Delay(50, cts.Token); + if (!hasPort) + await Task.Delay(50, cts.Token); } Assert.IsTrue(hasPort, "the exported node never exposed an output port to route to"); @@ -310,11 +354,20 @@ public async Task AnExportedSource_IsRoutedToByTheSessionManager() capture.FrameReady += (_, f) => { ReadOnlySpan floats = MemoryMarshal.Cast(f.Samples); - lock (received) { if (received.Count < 8000) foreach (float v in floats) received.Add(v); } + lock (received) + { + if (received.Count < 8000) + foreach (float v in floats) + received.Add(v); + } }; - capture.Connect(exported!.NodeId, sampleRate: Rate, channels: Channels, - format: AudioSampleFormat.F32Le); + capture.Connect( + exported!.NodeId, + sampleRate: Rate, + channels: Channels, + format: AudioSampleFormat.F32Le + ); await capture.WaitForStreamingAsync(cts.Token); uint captureNode = await capture.WaitForNodeIdAsync(cts.Token); @@ -326,15 +379,22 @@ public async Task AnExportedSource_IsRoutedToByTheSessionManager() bool linked = false; for (var i = 0; i < 100 && !linked; i++) { - linked = reg.Current.Links.Any(l => l.OutputNodeId == exported.NodeId && l.InputNodeId == captureNode); - if (!linked) await Task.Delay(50, cts.Token); + linked = reg.Current.Links.Any(l => + l.OutputNodeId == exported.NodeId && l.InputNodeId == captureNode + ); + if (!linked) + await Task.Delay(50, cts.Token); } Assert.IsTrue(linked, "the session manager never linked the consumer to the exported node"); for (var i = 0; i < 100; i++) { - lock (received) { if (received.Count > 2000) break; } + lock (received) + { + if (received.Count > 2000) + break; + } await Task.Delay(50, cts.Token); } @@ -358,7 +418,10 @@ public async Task ASpaFactorysNode_CanBeExported() string name = $"pwnet-exportspa-{Environment.ProcessId}"; - await using var ctx = new PipeWireContext("pwnet-exportspa", ConsoleTestLoggerFactory.Instance); + await using var ctx = new PipeWireContext( + "pwnet-exportspa", + ConsoleTestLoggerFactory.Instance + ); await ctx.StartAsync(cts.Token); await using var reg = new PipeWireRegistry(ctx); @@ -378,7 +441,8 @@ public async Task ASpaFactorysNode_CanBeExported() // Named outright, as upstream's export-spa does. A client's context.spa-libs map // does not cover audiotestsrc, so resolving it by name alone fails on a stock // install and the test skips itself claiming the plugin is not installed. - libraryName: "audiotestsrc/libspa-audiotestsrc"); + libraryName: "audiotestsrc/libspa-audiotestsrc" + ); } catch (InvalidOperationException ex) { @@ -392,10 +456,12 @@ public async Task ASpaFactorysNode_CanBeExported() PipeWireNode? seen = null; for (var i = 0; i < 100 && seen is null; i++) { - seen = reg.Current.Nodes.FirstOrDefault( - n => string.Equals(n.NodeName, name, StringComparison.Ordinal)); + seen = reg.Current.Nodes.FirstOrDefault(n => + string.Equals(n.NodeName, name, StringComparison.Ordinal) + ); - if (seen is null) await Task.Delay(50, cts.Token); + if (seen is null) + await Task.Delay(50, cts.Token); } Assert.IsNotNull(seen, "the exported SPA factory node never appeared in the graph"); @@ -420,7 +486,10 @@ public async Task AnExportedSink_IsHandedWhatAProducerSends() string sinkName = $"pwnet-exportsink-{Environment.ProcessId}"; - await using var ctx = new PipeWireContext("pwnet-exportsink", ConsoleTestLoggerFactory.Instance); + await using var ctx = new PipeWireContext( + "pwnet-exportsink", + ConsoleTestLoggerFactory.Instance + ); await ctx.StartAsync(cts.Token); await using var reg = new PipeWireRegistry(ctx); @@ -433,25 +502,30 @@ public async Task AnExportedSink_IsHandedWhatAProducerSends() sinkName, PipeWireExportedFormat.AudioF32(Rate, Channels), SpaDirection.Input, - new Dictionary - { - [PipeWireKeys.PW_KEY_MEDIA_CLASS] = "Audio/Sink", - }); + new Dictionary { [PipeWireKeys.PW_KEY_MEDIA_CLASS] = "Audio/Sink" } + ); sink.ProcessCallback = (_, data) => { ReadOnlySpan floats = MemoryMarshal.Cast(data); - lock (consumed) { if (consumed.Count < 8000) foreach (float v in floats) consumed.Add(v); } + lock (consumed) + { + if (consumed.Count < 8000) + foreach (float v in floats) + consumed.Add(v); + } return 0; }; PipeWireNode? exported = null; for (var i = 0; i < 100 && exported is null; i++) { - exported = reg.Current.Nodes.FirstOrDefault( - n => string.Equals(n.NodeName, sinkName, StringComparison.Ordinal)); + exported = reg.Current.Nodes.FirstOrDefault(n => + string.Equals(n.NodeName, sinkName, StringComparison.Ordinal) + ); - if (exported is null) await Task.Delay(50, cts.Token); + if (exported is null) + await Task.Delay(50, cts.Token); } Assert.IsNotNull(exported, "the exported sink never appeared in the graph"); @@ -462,10 +536,12 @@ public async Task AnExportedSink_IsHandedWhatAProducerSends() PipeWirePort? input = null; for (var i = 0; i < 100 && input is null; i++) { - input = reg.Current.Ports.FirstOrDefault( - p => p.NodeId == exported!.NodeId && p.PortDirection == PipeWirePortDirection.In); + input = reg.Current.Ports.FirstOrDefault(p => + p.NodeId == exported!.NodeId && p.PortDirection == PipeWirePortDirection.In + ); - if (input is null) await Task.Delay(50, cts.Token); + if (input is null) + await Task.Delay(50, cts.Token); } Assert.IsNotNull(input, "the exported sink never registered an input port to route to"); @@ -473,12 +549,18 @@ public async Task AnExportedSink_IsHandedWhatAProducerSends() // An ordinary producer, which knows nothing about how the sink is implemented. uint next = 0; await using var output = new PipeWireAudioOutput( - ctx, $"{sinkName}-src", Rate, Channels, AudioSampleFormat.F32Le); + ctx, + $"{sinkName}-src", + Rate, + Channels, + AudioSampleFormat.F32Le + ); output.FillSamples += (_, samples, _, _, _) => { Span floats = MemoryMarshal.Cast(samples); - for (var i = 0; i < floats.Length; i++) floats[i] = ++next; + for (var i = 0; i < floats.Length; i++) + floats[i] = ++next; return samples.Length; }; @@ -486,16 +568,24 @@ public async Task AnExportedSink_IsHandedWhatAProducerSends() for (var i = 0; i < 120; i++) { - lock (consumed) { if (consumed.Count > 1000) break; } + lock (consumed) + { + if (consumed.Count > 1000) + break; + } await Task.Delay(50, cts.Token); } float[] got; - lock (consumed) got = [.. consumed]; + lock (consumed) + got = [.. consumed]; // A failure, not a skip: the port is registered and the producer was routed to it, so a // sink that is never driven is this node failing to negotiate or to be scheduled. - Assert.IsNotNull(sink.NegotiatedFormat, "the graph never settled a format on the exported sink"); + Assert.IsNotNull( + sink.NegotiatedFormat, + "the graph never settled a format on the exported sink" + ); Assert.IsTrue(sink.BufferCount > 0, "the graph never gave the exported sink any buffers"); Assert.IsTrue(sink.HasProcessed, "the exported sink was linked but never driven"); Assert.IsNull(sink.LastCallbackError, $"a sink callback faulted: {sink.LastCallbackError}"); @@ -503,11 +593,13 @@ public async Task AnExportedSink_IsHandedWhatAProducerSends() Assert.IsTrue(got.Length > 500, $"the exported sink was handed only {got.Length} samples"); int start = 0; - while (start < got.Length && got[start] == 0f) start++; + while (start < got.Length && got[start] == 0f) + start++; Assert.IsTrue( got.Length - start > 200, - "the exported sink was handed only silence, so the producer's data never reached it"); + "the exported sink was handed only silence, so the producer's data never reached it" + ); } /// @@ -526,20 +618,29 @@ public async Task DisposingAnExportedNode_RemovesItFromTheGraph() string name = $"pwnet-exportgone-{Environment.ProcessId}"; - await using var ctx = new PipeWireContext("pwnet-exportgone", ConsoleTestLoggerFactory.Instance); + await using var ctx = new PipeWireContext( + "pwnet-exportgone", + ConsoleTestLoggerFactory.Instance + ); await ctx.StartAsync(cts.Token); await using var reg = new PipeWireRegistry(ctx); await reg.WaitForInitialEnumerationAsync(cts.Token); PipeWireNodeProvider node = PipeWireNodeProvider.Create( - ctx, name, PipeWireExportedFormat.AudioF32(Rate, Channels)); + ctx, + name, + PipeWireExportedFormat.AudioF32(Rate, Channels) + ); var appeared = false; for (var i = 0; i < 100 && !appeared; i++) { - appeared = reg.Current.Nodes.Any(n => string.Equals(n.NodeName, name, StringComparison.Ordinal)); - if (!appeared) await Task.Delay(50, cts.Token); + appeared = reg.Current.Nodes.Any(n => + string.Equals(n.NodeName, name, StringComparison.Ordinal) + ); + if (!appeared) + await Task.Delay(50, cts.Token); } Assert.IsTrue(appeared, "the exported node never appeared, so its removal proves nothing"); @@ -549,8 +650,11 @@ public async Task DisposingAnExportedNode_RemovesItFromTheGraph() var gone = false; for (var i = 0; i < 100 && !gone; i++) { - gone = !reg.Current.Nodes.Any(n => string.Equals(n.NodeName, name, StringComparison.Ordinal)); - if (!gone) await Task.Delay(50, cts.Token); + gone = !reg.Current.Nodes.Any(n => + string.Equals(n.NodeName, name, StringComparison.Ordinal) + ); + if (!gone) + await Task.Delay(50, cts.Token); } Assert.IsTrue(gone, "the exported node outlived its disposal, so the graph still holds it"); @@ -583,7 +687,10 @@ public async Task AProcessHandlerThatThrows_DropsTheCycleInsteadOfKillingTheProc string name = $"pwnet-exportthrow-{Environment.ProcessId}"; - await using var ctx = new PipeWireContext("pwnet-exportthrow", ConsoleTestLoggerFactory.Instance); + await using var ctx = new PipeWireContext( + "pwnet-exportthrow", + ConsoleTestLoggerFactory.Instance + ); await ctx.StartAsync(cts.Token); await using var reg = new PipeWireRegistry(ctx); @@ -596,10 +703,8 @@ public async Task AProcessHandlerThatThrows_DropsTheCycleInsteadOfKillingTheProc name, PipeWireExportedFormat.AudioF32(Rate, Channels), SpaDirection.Output, - new Dictionary - { - [PipeWireKeys.PW_KEY_MEDIA_CLASS] = "Audio/Source", - }); + new Dictionary { [PipeWireKeys.PW_KEY_MEDIA_CLASS] = "Audio/Source" } + ); node.ProcessCallback = (_, _) => { @@ -610,28 +715,42 @@ public async Task AProcessHandlerThatThrows_DropsTheCycleInsteadOfKillingTheProc PipeWireNode? exported = null; for (var i = 0; i < 100 && exported is null; i++) { - exported = reg.Current.Nodes.FirstOrDefault( - n => string.Equals(n.NodeName, name, StringComparison.Ordinal)); + exported = reg.Current.Nodes.FirstOrDefault(n => + string.Equals(n.NodeName, name, StringComparison.Ordinal) + ); - if (exported is null) await Task.Delay(50, cts.Token); + if (exported is null) + await Task.Delay(50, cts.Token); } - Assert.IsNotNull(exported, "the exported node never appeared, so nothing would have driven it"); + Assert.IsNotNull( + exported, + "the exported node never appeared, so nothing would have driven it" + ); var hasPort = false; for (var i = 0; i < 100 && !hasPort; i++) { - hasPort = reg.Current.Ports.Any( - p => p.NodeId == exported!.NodeId && p.PortDirection == PipeWirePortDirection.Out); + hasPort = reg.Current.Ports.Any(p => + p.NodeId == exported!.NodeId && p.PortDirection == PipeWirePortDirection.Out + ); - if (!hasPort) await Task.Delay(50, cts.Token); + if (!hasPort) + await Task.Delay(50, cts.Token); } - Assert.IsTrue(hasPort, "the exported node never exposed a port, so it would never be scheduled"); + Assert.IsTrue( + hasPort, + "the exported node never exposed a port, so it would never be scheduled" + ); await using var capture = new PipeWireAudioCapture(ctx, $"{name}-sink"); - capture.Connect(exported!.NodeId, sampleRate: Rate, channels: Channels, - format: AudioSampleFormat.F32Le); + capture.Connect( + exported!.NodeId, + sampleRate: Rate, + channels: Channels, + format: AudioSampleFormat.F32Le + ); await capture.WaitForStreamingAsync(cts.Token); await Task.Delay(700, cts.Token); @@ -640,16 +759,19 @@ public async Task AProcessHandlerThatThrows_DropsTheCycleInsteadOfKillingTheProc // throw above ended the test host here rather than failing this test. Assert.IsTrue( Volatile.Read(ref calls) > 0, - "the handler was never invoked, so the throw was never actually exercised"); + "the handler was never invoked, so the throw was never actually exercised" + ); Assert.IsInstanceOfType( node.LastProcessError, - "the exception a cycle threw should be recorded where a caller can diagnose it"); + "the exception a cycle threw should be recorded where a caller can diagnose it" + ); // And the graph keeps running: the node stays, and the context is still usable afterwards. Assert.IsTrue( reg.Current.Nodes.Any(n => string.Equals(n.NodeName, name, StringComparison.Ordinal)), - "the node was torn out of the graph by a fault that should only have dropped one cycle"); + "the node was torn out of the graph by a fault that should only have dropped one cycle" + ); } /// @@ -686,7 +808,10 @@ public async Task AnExportedSource_IsReadCorrectlyByGStreamer() string name = $"pwnet-exportgst-{Environment.ProcessId}"; string output = Path.Combine(Path.GetTempPath(), $"{name}.f32"); - await using var ctx = new PipeWireContext("pwnet-exportgst", ConsoleTestLoggerFactory.Instance); + await using var ctx = new PipeWireContext( + "pwnet-exportgst", + ConsoleTestLoggerFactory.Instance + ); await ctx.StartAsync(cts.Token); await using var reg = new PipeWireRegistry(ctx); @@ -699,7 +824,8 @@ public async Task AnExportedSource_IsReadCorrectlyByGStreamer() name, PipeWireExportedFormat.AudioF32(Rate, stereo), SpaDirection.Output, - new Dictionary { [PipeWireKeys.PW_KEY_MEDIA_CLASS] = "Audio/Source" }); + new Dictionary { [PipeWireKeys.PW_KEY_MEDIA_CLASS] = "Audio/Source" } + ); // Every cycle's io state, so a failure below says whether a quantum was lost at this end // (published over a buffer the consumer had not taken) or after it. @@ -712,9 +838,11 @@ public async Task AnExportedSource_IsReadCorrectlyByGStreamer() node.ProcessCallback = (_, data) => { Span floats = MemoryMarshal.Cast(data); - if (cycles < firstOfCycle.Length) firstOfCycle[cycles] = next + 1; + if (cycles < firstOfCycle.Length) + firstOfCycle[cycles] = next + 1; cycles++; - for (var i = 0; i < floats.Length; i++) floats[i] = ++next; + for (var i = 0; i < floats.Length; i++) + floats[i] = ++next; return floats.Length * 4; }; @@ -722,12 +850,20 @@ public async Task AnExportedSource_IsReadCorrectlyByGStreamer() for (var i = 0; i < 100 && port is null; i++) { port = reg.Current.Ports.FirstOrDefault(p => - reg.Current.Nodes.Any(n => n.NodeId == p.NodeId && string.Equals(n.NodeName, name, StringComparison.Ordinal)) - && p.PortDirection == PipeWirePortDirection.Out); - if (port is null) await Task.Delay(50, cts.Token); + reg.Current.Nodes.Any(n => + n.NodeId == p.NodeId + && string.Equals(n.NodeName, name, StringComparison.Ordinal) + ) + && p.PortDirection == PipeWirePortDirection.Out + ); + if (port is null) + await Task.Delay(50, cts.Token); } - Assert.IsNotNull(port, "the exported node never registered an output port for GStreamer to reach"); + Assert.IsNotNull( + port, + "the exported node never registered an output port for GStreamer to reach" + ); var psi = new ProcessStartInfo("/usr/bin/gst-launch-1.0") { @@ -745,17 +881,28 @@ public async Task AnExportedSource_IsReadCorrectlyByGStreamer() // min-buffers: pipewiresrc asks for one by default and negotiates two, so any moment // GStreamer holds both (filesink writing, the machine loaded) drops a whole cycle. Sixteen // is the headroom upstream's property exists to give a consumer that must not drop. - foreach (string arg in new[] - { - "-q", "pipewiresrc", $"target-object={name}", "num-buffers=40", "min-buffers=16", "!", - $"audio/x-raw,format=F32LE,channels={stereo},rate={Rate}", "!", - "filesink", $"location={output}", - }) + foreach ( + string arg in new[] + { + "-q", + "pipewiresrc", + $"target-object={name}", + "num-buffers=40", + "min-buffers=16", + "!", + $"audio/x-raw,format=F32LE,channels={stereo},rate={Rate}", + "!", + "filesink", + $"location={output}", + } + ) { psi.ArgumentList.Add(arg); } - using Process gst = Process.Start(psi) ?? throw new InvalidOperationException("gst-launch-1.0 did not start"); + using Process gst = + Process.Start(psi) + ?? throw new InvalidOperationException("gst-launch-1.0 did not start"); // Both streams drained: a child writing to a pipe nobody reads blocks once it fills, and a // blocked child looks exactly like a hung test. @@ -785,15 +932,23 @@ public async Task AnExportedSource_IsReadCorrectlyByGStreamer() Assert.IsTrue(got.Length > 1000, $"GStreamer wrote only {got.Length} samples"); int start = 0; - while (start < got.Length && got[start] == 0f) start++; - Assert.IsTrue(got.Length - start > 500, "GStreamer received only silence from the exported node"); + while (start < got.Length && got[start] == 0f) + start++; + Assert.IsTrue( + got.Length - start > 500, + "GStreamer received only silence from the exported node" + ); // Every break must be a whole number of cycles skipped, and reported by the consumer. // Our node records the value each published cycle started at; a lost cycle ends one run // on a cycle's last value and resumes on a later cycle's first, forward. Anything else // (a jump inside a cycle, backwards, a value never written) is data altered on the way. - var cycleStarts = new HashSet(firstOfCycle.Take(Math.Min(cycles, firstOfCycle.Length))); - int dropsReported = errors.Split((char)10).Count(l => l.Contains("out of buffers", StringComparison.Ordinal)); + var cycleStarts = new HashSet( + firstOfCycle.Take(Math.Min(cycles, firstOfCycle.Length)) + ); + int dropsReported = errors + .Split((char)10) + .Count(l => l.Contains("out of buffers", StringComparison.Ordinal)); var breaks = 0; var altered = 0; @@ -801,20 +956,32 @@ public async Task AnExportedSource_IsReadCorrectlyByGStreamer() var breakList = new List(); for (int i = start + 1; i < got.Length; i++) { - if (got[i] == got[i - 1] + 1f) continue; + if (got[i] == got[i - 1] + 1f) + continue; breaks++; - if (firstBreak < 0) firstBreak = i; - bool wholeCycles = got[i] > got[i - 1] && cycleStarts.Contains(got[i]) && cycleStarts.Contains(got[i - 1] + 1f); - if (!wholeCycles) altered++; + if (firstBreak < 0) + firstBreak = i; + bool wholeCycles = + got[i] > got[i - 1] + && cycleStarts.Contains(got[i]) + && cycleStarts.Contains(got[i - 1] + 1f); + if (!wholeCycles) + altered++; if (breakList.Count < 16) - breakList.Add($"{i - start}:{got[i] - got[i - 1] - 1f:+0;-0}{(wholeCycles ? "" : "!")}"); + breakList.Add( + $"{i - start}:{got[i] - got[i - 1] - 1f:+0;-0}{(wholeCycles ? "" : "!")}" + ); } // What the stream looked like where it first went wrong, so a failure says whether the // values were scaled (a volume), interpolated (a resampler), repeated or reordered. - string around = firstBreak < 0 - ? "" - : string.Join(", ", got[Math.Max(start, firstBreak - 4)..Math.Min(got.Length, firstBreak + 6)]); + string around = + firstBreak < 0 + ? "" + : string.Join( + ", ", + got[Math.Max(start, firstBreak - 4)..Math.Min(got.Length, firstBreak + 6)] + ); string gstLog = Path.Combine(Path.GetTempPath(), $"{name}.gst.log"); if (altered > 0 || (breaks > 0 && dropsReported == 0)) @@ -827,22 +994,39 @@ public async Task AnExportedSource_IsReadCorrectlyByGStreamer() for (int c = 0, written = 0; c < recorded; c++) { PipeWireNodeProvider.ProduceCycleRecord r = trace[c]; - string at = r.Result == 2 && written < firstOfCycle.Length ? $" @{firstOfCycle[written++]}" : ""; - cycleLines.Append(System.Globalization.CultureInfo.InvariantCulture, - $"{c}: s{r.EntryStatus}/b{(int)r.EntryBuffer} -> b{(int)r.Published} : {r.Result} free {r.FreeAfter}/{r.Pool}{at}") + string at = + r.Result == 2 && written < firstOfCycle.Length + ? $" @{firstOfCycle[written++]}" + : ""; + cycleLines + .Append( + System.Globalization.CultureInfo.InvariantCulture, + $"{c}: s{r.EntryStatus}/b{(int)r.EntryBuffer} -> b{(int)r.Published} : {r.Result} free {r.FreeAfter}/{r.Pool}{at}" + ) .Append((char)10); } - await File.WriteAllTextAsync(gstLog, errors + (char)10 + "--- exported node cycles ---" + (char)10 + cycleLines, cts.Token); + await File.WriteAllTextAsync( + gstLog, + errors + (char)10 + "--- exported node cycles ---" + (char)10 + cycleLines, + cts.Token + ); } - string detail = $"{breaks} break(s) in {got.Length - start} samples, first at {firstBreak - start}: [{around}]; " + string detail = + $"{breaks} break(s) in {got.Length - start} samples, first at {firstBreak - start}: [{around}]; " + $"breaks (index:jump, ! = not whole cycles): {string.Join(" ", breakList)}; " + $"the consumer reported {dropsReported} drop(s); logs: {gstLog}"; - Assert.AreEqual(0, altered, $"the exported node's data reached GStreamer altered: {detail}"); - Assert.IsTrue(breaks == 0 || dropsReported > 0, - $"whole cycles went missing without the consumer reporting a drop, so they were lost between this node and it: {detail}"); + Assert.AreEqual( + 0, + altered, + $"the exported node's data reached GStreamer altered: {detail}" + ); + Assert.IsTrue( + breaks == 0 || dropsReported > 0, + $"whole cycles went missing without the consumer reporting a drop, so they were lost between this node and it: {detail}" + ); } finally { @@ -878,7 +1062,10 @@ public async Task AnExportedSourceReconfiguredWhileProcessing_KeepsItsDataIntact string name = $"pwnet-exportchurn-{Environment.ProcessId}"; - await using var ctx = new PipeWireContext("pwnet-exportchurn", ConsoleTestLoggerFactory.Instance); + await using var ctx = new PipeWireContext( + "pwnet-exportchurn", + ConsoleTestLoggerFactory.Instance + ); await ctx.StartAsync(cts.Token); await using var reg = new PipeWireRegistry(ctx); @@ -890,12 +1077,14 @@ public async Task AnExportedSourceReconfiguredWhileProcessing_KeepsItsDataIntact name, PipeWireExportedFormat.AudioF32(Rate, Channels), SpaDirection.Output, - new Dictionary { [PipeWireKeys.PW_KEY_MEDIA_CLASS] = "Audio/Source" }); + new Dictionary { [PipeWireKeys.PW_KEY_MEDIA_CLASS] = "Audio/Source" } + ); node.ProcessCallback = (_, data) => { Span floats = MemoryMarshal.Cast(data); - for (var i = 0; i < floats.Length; i++) floats[i] = ++next; + for (var i = 0; i < floats.Length; i++) + floats[i] = ++next; return floats.Length * 4; }; @@ -903,14 +1092,18 @@ public async Task AnExportedSourceReconfiguredWhileProcessing_KeepsItsDataIntact PipeWirePort? source = null; for (var i = 0; i < 100 && source is null; i++) { - exported ??= reg.Current.Nodes.FirstOrDefault(n => string.Equals(n.NodeName, name, StringComparison.Ordinal)); + exported ??= reg.Current.Nodes.FirstOrDefault(n => + string.Equals(n.NodeName, name, StringComparison.Ordinal) + ); if (exported is not null) { - source = reg.Current.Ports.FirstOrDefault( - p => p.NodeId == exported.NodeId && p.PortDirection == PipeWirePortDirection.Out); + source = reg.Current.Ports.FirstOrDefault(p => + p.NodeId == exported.NodeId && p.PortDirection == PipeWirePortDirection.Out + ); } - if (source is null) await Task.Delay(50, cts.Token); + if (source is null) + await Task.Delay(50, cts.Token); } Assert.IsNotNull(source, "the exported node never registered an output port to route to"); @@ -920,11 +1113,25 @@ public async Task AnExportedSourceReconfiguredWhileProcessing_KeepsItsDataIntact var first = new List(); var second = new List(); - await using (PipeWireAudioCapture a = await ConnectRampConsumerAsync(ctx, $"{name}-a{round}", exported!.NodeId, first, cts.Token)) + await using ( + PipeWireAudioCapture a = await ConnectRampConsumerAsync( + ctx, + $"{name}-a{round}", + exported!.NodeId, + first, + cts.Token + ) + ) { if (round % 2 == 1) { - await using PipeWireAudioCapture b = await ConnectRampConsumerAsync(ctx, $"{name}-b{round}", exported.NodeId, second, cts.Token); + await using PipeWireAudioCapture b = await ConnectRampConsumerAsync( + ctx, + $"{name}-b{round}", + exported.NodeId, + second, + cts.Token + ); await WaitForSamplesAsync(second, cts.Token); AssertRamp(second, $"round {round}, joining consumer"); } @@ -935,21 +1142,42 @@ public async Task AnExportedSourceReconfiguredWhileProcessing_KeepsItsDataIntact AssertRamp(first, $"round {round}"); } - Assert.IsNull(node.LastCallbackError, $"a node callback faulted while reconfigured: {node.LastCallbackError}"); - Assert.IsNull(node.LastProcessError, $"the process handler faulted while reconfigured: {node.LastProcessError}"); + Assert.IsNull( + node.LastCallbackError, + $"a node callback faulted while reconfigured: {node.LastCallbackError}" + ); + Assert.IsNull( + node.LastProcessError, + $"the process handler faulted while reconfigured: {node.LastProcessError}" + ); } private static async Task ConnectRampConsumerAsync( - PipeWireContext ctx, string name, uint target, List received, CancellationToken ct) + PipeWireContext ctx, + string name, + uint target, + List received, + CancellationToken ct + ) { var capture = new PipeWireAudioCapture(ctx, name); capture.FrameReady += (_, f) => { ReadOnlySpan floats = MemoryMarshal.Cast(f.Samples); - lock (received) { if (received.Count < 8000) foreach (float v in floats) received.Add(v); } + lock (received) + { + if (received.Count < 8000) + foreach (float v in floats) + received.Add(v); + } }; - capture.Connect(target, sampleRate: Rate, channels: Channels, format: AudioSampleFormat.F32Le); + capture.Connect( + target, + sampleRate: Rate, + channels: Channels, + format: AudioSampleFormat.F32Le + ); await capture.WaitForStreamingAsync(ct); return capture; } @@ -958,7 +1186,11 @@ private static async Task WaitForSamplesAsync(List received, Cancellation { for (var i = 0; i < 100; i++) { - lock (received) { if (received.Count > 2000) return; } + lock (received) + { + if (received.Count > 2000) + return; + } await Task.Delay(50, ct); } } @@ -966,18 +1198,27 @@ private static async Task WaitForSamplesAsync(List received, Cancellation private static void AssertRamp(List received, string what) { float[] got; - lock (received) got = [.. received]; + lock (received) + got = [.. received]; int start = 0; - while (start < got.Length && got[start] == 0f) start++; + while (start < got.Length && got[start] == 0f) + start++; - Assert.IsTrue(got.Length - start > 500, $"{what}: only {got.Length - start} samples of the ramp arrived"); + Assert.IsTrue( + got.Length - start > 500, + $"{what}: only {got.Length - start} samples of the ramp arrived" + ); var breaks = 0; for (int i = start + 1; i < got.Length; i++) - if (got[i] != got[i - 1] + 1f) breaks++; + if (got[i] != got[i - 1] + 1f) + breaks++; - Assert.IsTrue(breaks <= 2, $"{what}: the ramp broke {breaks} times, so a buffer was reordered or recycled in use"); + Assert.IsTrue( + breaks <= 2, + $"{what}: the ramp broke {breaks} times, so a buffer was reordered or recycled in use" + ); } /// @@ -1017,10 +1258,8 @@ public async Task ANodeThatMissesACycle_CanReportTheXrunToTheGraph() name, PipeWireExportedFormat.AudioF32(Rate, Channels), SpaDirection.Output, - new Dictionary - { - [PipeWireKeys.PW_KEY_MEDIA_CLASS] = "Audio/Source", - }); + new Dictionary { [PipeWireKeys.PW_KEY_MEDIA_CLASS] = "Audio/Source" } + ); var reported = 0; var accepted = 0; @@ -1045,18 +1284,24 @@ public async Task ANodeThatMissesACycle_CanReportTheXrunToTheGraph() Assert.IsTrue( Volatile.Read(ref accepted) > 0, - "the graph installed no xrun callback, so a node here cannot report a missed cycle at all"); + "the graph installed no xrun callback, so a node here cannot report a missed cycle at all" + ); } private static async Task WaitForNodeIdAsync( - PipeWireRegistry reg, string nodeName, CancellationToken cancellationToken) + PipeWireRegistry reg, + string nodeName, + CancellationToken cancellationToken + ) { for (var i = 0; i < 100; i++) { - PipeWireNode? found = reg.Current.Nodes.FirstOrDefault( - n => string.Equals(n.NodeName, nodeName, StringComparison.Ordinal)); + PipeWireNode? found = reg.Current.Nodes.FirstOrDefault(n => + string.Equals(n.NodeName, nodeName, StringComparison.Ordinal) + ); - if (found is not null) return found.NodeId; + if (found is not null) + return found.NodeId; await Task.Delay(50, cancellationToken); } @@ -1085,7 +1330,10 @@ public async Task AnExportedNodeWithNoHandler_IsStillDrivenAndProducesSilence() string name = $"pwnet-nohandler-{Environment.ProcessId}"; - await using var ctx = new PipeWireContext("pwnet-nohandler", ConsoleTestLoggerFactory.Instance); + await using var ctx = new PipeWireContext( + "pwnet-nohandler", + ConsoleTestLoggerFactory.Instance + ); await ctx.StartAsync(cts.Token); await using var reg = new PipeWireRegistry(ctx); @@ -1096,20 +1344,20 @@ public async Task AnExportedNodeWithNoHandler_IsStillDrivenAndProducesSilence() name, PipeWireExportedFormat.AudioF32(Rate, Channels), SpaDirection.Output, - new Dictionary - { - [PipeWireKeys.PW_KEY_MEDIA_CLASS] = "Audio/Source", - }); + new Dictionary { [PipeWireKeys.PW_KEY_MEDIA_CLASS] = "Audio/Source" } + ); // Deliberately no ProcessCallback. PipeWireNode? exported = null; for (var i = 0; i < 100 && exported is null; i++) { - exported = reg.Current.Nodes.FirstOrDefault( - n => string.Equals(n.NodeName, name, StringComparison.Ordinal)); + exported = reg.Current.Nodes.FirstOrDefault(n => + string.Equals(n.NodeName, name, StringComparison.Ordinal) + ); - if (exported is null) await Task.Delay(50, cts.Token); + if (exported is null) + await Task.Delay(50, cts.Token); } Assert.IsNotNull(exported, "the exported node never appeared in the graph"); @@ -1117,10 +1365,12 @@ public async Task AnExportedNodeWithNoHandler_IsStillDrivenAndProducesSilence() PipeWirePort? source = null; for (var i = 0; i < 100 && source is null; i++) { - source = reg.Current.Ports.FirstOrDefault( - p => p.NodeId == exported!.NodeId && p.PortDirection == PipeWirePortDirection.Out); + source = reg.Current.Ports.FirstOrDefault(p => + p.NodeId == exported!.NodeId && p.PortDirection == PipeWirePortDirection.Out + ); - if (source is null) await Task.Delay(50, cts.Token); + if (source is null) + await Task.Delay(50, cts.Token); } Assert.IsNotNull(source, "the exported node never registered an output port to route to"); @@ -1135,33 +1385,45 @@ public async Task AnExportedNodeWithNoHandler_IsStillDrivenAndProducesSilence() ReadOnlySpan floats = MemoryMarshal.Cast(f.Samples); foreach (float v in floats) { - if (v != 0f) { Interlocked.Increment(ref nonZero); break; } + if (v != 0f) + { + Interlocked.Increment(ref nonZero); + break; + } } }; - capture.Connect(exported!.NodeId, sampleRate: Rate, channels: Channels, format: AudioSampleFormat.F32Le); + capture.Connect( + exported!.NodeId, + sampleRate: Rate, + channels: Channels, + format: AudioSampleFormat.F32Le + ); await capture.WaitForStreamingAsync(cts.Token); - for (var i = 0; i < 100 && Volatile.Read(ref frames) < 10; i++) await Task.Delay(50, cts.Token); + for (var i = 0; i < 100 && Volatile.Read(ref frames) < 10; i++) + await Task.Delay(50, cts.Token); - Assert.IsTrue( - node.HasBeenScheduled, - "a node with no handler was never driven at all"); + Assert.IsTrue(node.HasBeenScheduled, "a node with no handler was never driven at all"); Assert.IsFalse( node.HasProcessed, - "a node with no handler reported that a handler had produced something"); + "a node with no handler reported that a handler had produced something" + ); Assert.IsTrue(node.BufferCount > 0, "the graph never gave the node any buffers"); Assert.IsNull(node.LastProcessError, "an absent handler was recorded as a process fault"); Assert.AreEqual( - 0, Volatile.Read(ref nonZero), - "a node with no handler produced something other than silence"); + 0, + Volatile.Read(ref nonZero), + "a node with no handler produced something other than silence" + ); // Still there: an empty cycle is not a reason for the graph to drop the node. Assert.IsTrue( reg.Current.Nodes.Any(n => n.NodeId == exported.NodeId), - "the graph removed a node that was merely producing nothing"); + "the graph removed a node that was merely producing nothing" + ); } } diff --git a/tests/PipeWire.NET.Tests/FdOwnershipTests.cs b/tests/PipeWire.NET.Tests/FdOwnershipTests.cs index 2e36f01..871f554 100644 --- a/tests/PipeWire.NET.Tests/FdOwnershipTests.cs +++ b/tests/PipeWire.NET.Tests/FdOwnershipTests.cs @@ -4,7 +4,6 @@ using System.Runtime.Versioning; using System.Text.RegularExpressions; using Microsoft.Win32.SafeHandles; - using PipeWire.NET.Graph; using PipeWire.NET.Interop; @@ -56,8 +55,10 @@ public async Task StartAsync_BorrowOnly_LeavesTheCallerHandleUsableAndLeaksNoDup // caller's handle is untouched, which is what this pins. _ = await TryStartOverAsync(context, stream.SafeFileHandle); - Assert.IsFalse(stream.SafeFileHandle.IsClosed, - "the caller's handle is only borrowed and must never be closed by the library"); + Assert.IsFalse( + stream.SafeFileHandle.IsClosed, + "the caller's handle is only borrowed and must never be closed by the library" + ); await context.DisposeAsync(); @@ -65,8 +66,11 @@ public async Task StartAsync_BorrowOnly_LeavesTheCallerHandleUsableAndLeaksNoDup // connect, or the connection teardown the context disposal ran - none of it may // survive the context, and the caller's own descriptor is the only one left pointing // at the file. - Assert.AreEqual(1, FdsPointingTo(path), - "the library-owned duplicate must not outlive the connection"); + Assert.AreEqual( + 1, + FdsPointingTo(path), + "the library-owned duplicate must not outlive the connection" + ); stream.Seek(0, SeekOrigin.Begin); stream.WriteByte(0x2A); stream.Flush(); @@ -78,8 +82,7 @@ public async Task StartAsync_NullHandle_ThrowsArgumentNull_BeforeAnyDescriptorWo RequireLinux(); await using PipeWireContext context = new("fd-ownership-test"); - Assert.ThrowsExactly( - () => context.StartAsync((SafeHandle)null!)); + Assert.ThrowsExactly(() => context.StartAsync((SafeHandle)null!)); } [TestMethod] @@ -89,8 +92,7 @@ public async Task StartAsync_InvalidHandle_ThrowsArgument_BeforeAnyDescriptorWor await using PipeWireContext context = new("fd-ownership-test"); using SafeFileHandle invalid = new(new IntPtr(-1), ownsHandle: true); - Assert.ThrowsExactly( - () => context.StartAsync(invalid)); + Assert.ThrowsExactly(() => context.StartAsync(invalid)); } /// @@ -116,9 +118,16 @@ public async Task StartAsync_AfterAFailedAttempt_TheContextIsStartableAgain() // native code that reads PIPEWIRE_REMOTE at connect time; setenv goes through libc // directly. Disposing the scope restores the variable's previous state - the value it // held before, or its absence. - using (ScopedNativeEnv remote = ScopedNativeEnv.Override("PIPEWIRE_REMOTE", "fd-ownership-test-absent")) + using ( + ScopedNativeEnv remote = ScopedNativeEnv.Override( + "PIPEWIRE_REMOTE", + "fd-ownership-test-absent" + ) + ) { - await Assert.ThrowsExactlyAsync(() => context.StartAsync()); + await Assert.ThrowsExactlyAsync(() => + context.StartAsync() + ); } // The failed attempt fell back to Created rather than wedging the context: a start over @@ -246,13 +255,19 @@ public async Task StartAsync_OverAConnectedDaemonSocket_ConnectsAndLeavesTheSock using Socket socket = await ConnectDaemonSocketAsync(cts.Token); SafeHandle borrowed = socket.SafeHandle; - await using PipeWireContext context = new("fd-ownership-test", ConsoleTestLoggerFactory.Instance); + await using PipeWireContext context = new( + "fd-ownership-test", + ConsoleTestLoggerFactory.Instance + ); await context.StartAsync(borrowed, cts.Token); // The connection is live: the registry enumerated the graph over it. await using PipeWireRegistry registry = new(context); await registry.WaitForInitialEnumerationAsync(cts.Token); - Assert.IsTrue(registry.Current.Version >= 0, "an enumerated graph carries a snapshot version"); + Assert.IsTrue( + registry.Current.Version >= 0, + "an enumerated graph carries a snapshot version" + ); // Borrow-only: while the connection is up, the caller's socket still refers to a live // descriptor the caller may act on. @@ -282,9 +297,13 @@ public async Task StartAsync_OverARawDescriptor_ConnectsAndTakesOwnership() using Socket socket = await ConnectDaemonSocketAsync(cts.Token); using SafeFileHandle duplicate = FdInterop.DuplicateWithCloseOnExec( - (int)socket.SafeHandle.DangerousGetHandle()); + (int)socket.SafeHandle.DangerousGetHandle() + ); - await using PipeWireContext context = new("fd-ownership-test", ConsoleTestLoggerFactory.Instance); + await using PipeWireContext context = new( + "fd-ownership-test", + ConsoleTestLoggerFactory.Instance + ); await context.StartAsync((int)duplicate.DangerousGetHandle(), cts.Token); // The handed-over number is the duplicate, so the caller-side Socket still owns the @@ -312,8 +331,14 @@ private static void RequireLinux() } private static FileStream OpenTempFile(string path) => - new(path, FileMode.CreateNew, FileAccess.ReadWrite, FileShare.None, bufferSize: 1, - FileOptions.DeleteOnClose); + new( + path, + FileMode.CreateNew, + FileAccess.ReadWrite, + FileShare.None, + bufferSize: 1, + FileOptions.DeleteOnClose + ); /// A descriptor already connected to the daemon socket, no handshake attempted. /// @@ -322,8 +347,11 @@ private static FileStream OpenTempFile(string path) => /// private static async Task ConnectDaemonSocketAsync(CancellationToken ct) { - string runtimeDir = Environment.GetEnvironmentVariable("XDG_RUNTIME_DIR") - ?? throw new AssertFailedException("XDG_RUNTIME_DIR is not set; where is the daemon socket?"); + string runtimeDir = + Environment.GetEnvironmentVariable("XDG_RUNTIME_DIR") + ?? throw new AssertFailedException( + "XDG_RUNTIME_DIR is not set; where is the daemon socket?" + ); string socketPath = Path.Combine(runtimeDir, "pipewire-0"); if (!File.Exists(socketPath)) Assert.Inconclusive($"no PipeWire daemon socket at {socketPath}."); @@ -335,7 +363,8 @@ private static async Task ConnectDaemonSocketAsync(CancellationToken ct) /// Descriptors of this process whose link target is . private static int FdsPointingTo(string path) => - Directory.GetFiles("/proc/self/fd") + Directory + .GetFiles("/proc/self/fd") .Select(fd => new FileInfo(fd).LinkTarget) .Count(target => string.Equals(target, path, StringComparison.Ordinal)); } diff --git a/tests/PipeWire.NET.Tests/FilterTests.cs b/tests/PipeWire.NET.Tests/FilterTests.cs index dfae17f..cbea0d2 100644 --- a/tests/PipeWire.NET.Tests/FilterTests.cs +++ b/tests/PipeWire.NET.Tests/FilterTests.cs @@ -29,10 +29,16 @@ private static void RequireLinux() /// actually does, and it does not depend on session-manager policy being present. /// private static async Task<(PipeWireNode Sink, uint FilterNodeId)> LinkToSinkAsync( - PipeWireRegistry registry, PipeWireFilter filter, string sinkName, CancellationToken cancellationToken) + PipeWireRegistry registry, + PipeWireFilter filter, + string sinkName, + CancellationToken cancellationToken + ) { - PipeWireNode sink = await registry.CreateVirtualSink("FilterSink") - .WithName(sinkName).ExecuteAsync(cancellationToken); + PipeWireNode sink = await registry + .CreateVirtualSink("FilterSink") + .WithName(sinkName) + .ExecuteAsync(cancellationToken); await filter.ConnectAsync(cancellationToken: cancellationToken); uint filterNodeId = await filter.WaitForNodeIdAsync(cancellationToken); @@ -64,7 +70,10 @@ public async Task AConnectedFilter_AppearsInTheGraphWithItsPorts() RequireLinux(); using var cts = new CancellationTokenSource(Budget); - await using var ctx = new PipeWireContext("pwnet-filter", ConsoleTestLoggerFactory.Instance); + await using var ctx = new PipeWireContext( + "pwnet-filter", + ConsoleTestLoggerFactory.Instance + ); await ctx.StartAsync(cts.Token); await using var registry = new PipeWireRegistry(ctx); await registry.WaitForInitialEnumerationAsync(cts.Token); @@ -95,7 +104,8 @@ public async Task AConnectedFilter_AppearsInTheGraphWithItsPorts() { await registry.WaitForInitialEnumerationAsync(cts.Token); ports = registry.Current.GetPortsForNode(nodeId); - if (ports.Length < 4) await Task.Delay(TimeSpan.FromMilliseconds(25), cts.Token); + if (ports.Length < 4) + await Task.Delay(TimeSpan.FromMilliseconds(25), cts.Token); } Assert.IsNotNull(registry.Current.GetNode(nodeId), "the filter must appear as a node"); @@ -109,7 +119,10 @@ public async Task AFilterLinkedIntoTheGraph_IsDrivenAndSeesItsBuffers() RequireLinux(); using var cts = new CancellationTokenSource(Budget); - await using var ctx = new PipeWireContext("pwnet-filter-run", ConsoleTestLoggerFactory.Instance); + await using var ctx = new PipeWireContext( + "pwnet-filter-run", + ConsoleTestLoggerFactory.Instance + ); await ctx.StartAsync(cts.Token); await using var registry = new PipeWireRegistry(ctx); await registry.WaitForInitialEnumerationAsync(cts.Token); @@ -136,12 +149,20 @@ public async Task AFilterLinkedIntoTheGraph_IsDrivenAndSeesItsBuffers() } }; - (PipeWireNode sink, _) = await LinkToSinkAsync(registry, filter, "pwnet_filter_run_sink", cts.Token); + (PipeWireNode sink, _) = await LinkToSinkAsync( + registry, + filter, + "pwnet_filter_run_sink", + cts.Token + ); await ran.Task.WaitAsync(TimeSpan.FromSeconds(10), cts.Token); Assert.IsTrue(Interlocked.Read(ref cycles) > 0, "the filter never processed a cycle"); - Assert.IsTrue(Interlocked.Read(ref buffered) > 0, "the filter never received a buffer to write"); + Assert.IsTrue( + Interlocked.Read(ref buffered) > 0, + "the filter never received a buffer to write" + ); await registry.DestroyGlobalAsync(sink.NodeId, cts.Token); } @@ -152,7 +173,10 @@ public async Task AProcessCallbackThatThrows_DoesNotTakeTheProcessDown() RequireLinux(); using var cts = new CancellationTokenSource(Budget); - await using var ctx = new PipeWireContext("pwnet-filter-throw", ConsoleTestLoggerFactory.Instance); + await using var ctx = new PipeWireContext( + "pwnet-filter-throw", + ConsoleTestLoggerFactory.Instance + ); await ctx.StartAsync(cts.Token); await using var registry = new PipeWireRegistry(ctx); await registry.WaitForInitialEnumerationAsync(cts.Token); @@ -170,7 +194,12 @@ public async Task AProcessCallbackThatThrows_DoesNotTakeTheProcessDown() throw new InvalidOperationException("deliberate"); }; - (PipeWireNode sink, _) = await LinkToSinkAsync(registry, filter, "pwnet_filter_throw_sink", cts.Token); + (PipeWireNode sink, _) = await LinkToSinkAsync( + registry, + filter, + "pwnet_filter_throw_sink", + cts.Token + ); await threw.Task.WaitAsync(TimeSpan.FromSeconds(10), cts.Token); @@ -178,11 +207,13 @@ public async Task AProcessCallbackThatThrows_DoesNotTakeTheProcessDown() // that fails every cycle, which is indistinguishable from one that processed nothing. Assert.IsInstanceOfType( filter.LastProcessError, - "the filter did not record the exception its process callback threw"); + "the filter did not record the exception its process callback threw" + ); Assert.IsTrue( filter.ProcessErrorCount > 0, - "the filter recorded an error but counted no failures"); + "the filter recorded an error but counted no failures" + ); await registry.WaitForInitialEnumerationAsync(cts.Token); await registry.DestroyGlobalAsync(sink.NodeId, cts.Token); @@ -194,7 +225,10 @@ public async Task AddingAPortAfterConnecting_IsRefused() RequireLinux(); using var cts = new CancellationTokenSource(Budget); - await using var ctx = new PipeWireContext("pwnet-filter-late", ConsoleTestLoggerFactory.Instance); + await using var ctx = new PipeWireContext( + "pwnet-filter-late", + ConsoleTestLoggerFactory.Instance + ); await ctx.StartAsync(cts.Token); await using PipeWireFilter filter = PipeWireFilter.Create(ctx, "pwnet_filter_late"); @@ -203,8 +237,9 @@ public async Task AddingAPortAfterConnecting_IsRefused() // Ports are part of what the filter negotiated when it connected, so adding one afterwards // would describe a node the graph has already agreed the shape of. - Assert.ThrowsExactly( - () => filter.AddAudioPort(PipeWirePortDirection.Out, "output_FR")); + Assert.ThrowsExactly(() => + filter.AddAudioPort(PipeWirePortDirection.Out, "output_FR") + ); } [TestMethod] @@ -213,7 +248,10 @@ public async Task AFilterPortWithNoBuffer_ReportsAnEmptySpanRatherThanCrashing() RequireLinux(); using var cts = new CancellationTokenSource(Budget); - await using var ctx = new PipeWireContext("pwnet-filter-nobuf", ConsoleTestLoggerFactory.Instance); + await using var ctx = new PipeWireContext( + "pwnet-filter-nobuf", + ConsoleTestLoggerFactory.Instance + ); await ctx.StartAsync(cts.Token); await using PipeWireFilter filter = PipeWireFilter.Create(ctx, "pwnet_filter_nobuf"); @@ -231,7 +269,10 @@ public async Task AFilterPortOfADisposedFilter_RefusesReadsRatherThanReadingFree RequireLinux(); using var cts = new CancellationTokenSource(Budget); - await using var ctx = new PipeWireContext("pwnet-filter-disposed", ConsoleTestLoggerFactory.Instance); + await using var ctx = new PipeWireContext( + "pwnet-filter-disposed", + ConsoleTestLoggerFactory.Instance + ); await ctx.StartAsync(cts.Token); PipeWireFilter filter = PipeWireFilter.Create(ctx, "pwnet_filter_disposed"); @@ -249,12 +290,18 @@ public async Task AMidiPort_RefusesAudioReadsRatherThanAliasingSequences() RequireLinux(); using var cts = new CancellationTokenSource(Budget); - await using var ctx = new PipeWireContext("pwnet-filter-midi", ConsoleTestLoggerFactory.Instance); + await using var ctx = new PipeWireContext( + "pwnet-filter-midi", + ConsoleTestLoggerFactory.Instance + ); await ctx.StartAsync(cts.Token); await using PipeWireFilter filter = PipeWireFilter.Create(ctx, "pwnet_filter_midi"); PipeWireFilterPort midi = filter.AddMidiPort(PipeWirePortDirection.In, "midi-in"); - PipeWireFilterPort control = filter.AddControlPort(PipeWirePortDirection.Out, "control-out"); + PipeWireFilterPort control = filter.AddControlPort( + PipeWirePortDirection.Out, + "control-out" + ); // A sequence buffer reinterpreted as floats is garbage with a valid-looking type. Refusal // is the contract until sequences get a typed accessor of their own. @@ -295,7 +342,10 @@ public async Task AMidiAndAControlPort_JoinTheGraphBesideAudio() SessionGates.RequireDaemonAtLeast(1, 6, 8); using var cts = new CancellationTokenSource(Budget); - await using var ctx = new PipeWireContext("pwnet-filter-ports", ConsoleTestLoggerFactory.Instance); + await using var ctx = new PipeWireContext( + "pwnet-filter-ports", + ConsoleTestLoggerFactory.Instance + ); await ctx.StartAsync(cts.Token); await using var registry = new PipeWireRegistry(ctx); await registry.WaitForInitialEnumerationAsync(cts.Token); @@ -305,8 +355,11 @@ public async Task AMidiAndAControlPort_JoinTheGraphBesideAudio() PipeWireFilterPort midi = filter.AddMidiPort(PipeWirePortDirection.In, "midi_in"); PipeWireFilterPort control = filter.AddControlPort(PipeWirePortDirection.In, "control_in"); PipeWireFilterPort extra = filter.AddPort( - PipeWirePortDirection.Out, "midi_out", PipeWireDspFormat.Midi, - new Dictionary { ["port.alias"] = "pwnet_midi_out" }); + PipeWirePortDirection.Out, + "midi_out", + PipeWireDspFormat.Midi, + new Dictionary { ["port.alias"] = "pwnet_midi_out" } + ); Assert.AreEqual(4, filter.Ports.Count); Assert.AreEqual("midi_in", midi.Name); @@ -315,10 +368,12 @@ public async Task AMidiAndAControlPort_JoinTheGraphBesideAudio() // Neither an input nor an output, and no such DSP format: both are caller mistakes the // daemon must never see. - Assert.ThrowsExactly( - () => filter.AddPort((PipeWirePortDirection)999, "bad", PipeWireDspFormat.Midi)); - Assert.ThrowsExactly( - () => filter.AddPort(PipeWirePortDirection.In, "bad", (PipeWireDspFormat)999)); + Assert.ThrowsExactly(() => + filter.AddPort((PipeWirePortDirection)999, "bad", PipeWireDspFormat.Midi) + ); + Assert.ThrowsExactly(() => + filter.AddPort(PipeWirePortDirection.In, "bad", (PipeWireDspFormat)999) + ); await filter.ConnectAsync(cancellationToken: cts.Token); await filter.WaitForNodeIdAsync(cts.Token); @@ -340,7 +395,10 @@ public async Task AnUnconnectedFilter_ReportsItsStateHonestly() RequireLinux(); using var cts = new CancellationTokenSource(Budget); - await using var ctx = new PipeWireContext("pwnet-filter-state", ConsoleTestLoggerFactory.Instance); + await using var ctx = new PipeWireContext( + "pwnet-filter-state", + ConsoleTestLoggerFactory.Instance + ); await ctx.StartAsync(cts.Token); await using PipeWireFilter filter = PipeWireFilter.Create(ctx, "pwnet_filter_state"); @@ -369,10 +427,14 @@ public async Task CreatingAFilterOnAnUnstartedContext_IsRefused() RequireLinux(); using var cts = new CancellationTokenSource(Budget); - await using var ctx = new PipeWireContext("pwnet-filter-cold", ConsoleTestLoggerFactory.Instance); + await using var ctx = new PipeWireContext( + "pwnet-filter-cold", + ConsoleTestLoggerFactory.Instance + ); - Assert.ThrowsExactly( - () => PipeWireFilter.Create(ctx, "pwnet_filter_cold")); + Assert.ThrowsExactly(() => + PipeWireFilter.Create(ctx, "pwnet_filter_cold") + ); } /// A port can be taken out of a running filter without rebuilding it. @@ -389,7 +451,10 @@ public async Task APortRemovedFromALiveFilter_LeavesTheRestOfItWorking() RequireLinux(); using var cts = new CancellationTokenSource(Budget); - await using var ctx = new PipeWireContext("pwnet-filter-removeport", ConsoleTestLoggerFactory.Instance); + await using var ctx = new PipeWireContext( + "pwnet-filter-removeport", + ConsoleTestLoggerFactory.Instance + ); await ctx.StartAsync(cts.Token); await using PipeWireFilter filter = PipeWireFilter.Create(ctx, "pwnet_filter_removeport"); @@ -409,7 +474,8 @@ public async Task APortRemovedFromALiveFilter_LeavesTheRestOfItWorking() Assert.ThrowsExactly( () => filter.RemovePort(drop), - "removing a port twice should be refused, not repeated"); + "removing a port twice should be refused, not repeated" + ); } /// A connected filter can be retagged, as every stream type already could. @@ -427,7 +493,10 @@ public async Task AConnectedFilter_CanBeRetagged() RequireLinux(); using var cts = new CancellationTokenSource(Budget); - await using var ctx = new PipeWireContext("pwnet-filter-retag", ConsoleTestLoggerFactory.Instance); + await using var ctx = new PipeWireContext( + "pwnet-filter-retag", + ConsoleTestLoggerFactory.Instance + ); await ctx.StartAsync(cts.Token); await using PipeWireFilter filter = PipeWireFilter.Create(ctx, "pwnet_filter_retag"); @@ -441,21 +510,29 @@ public async Task AConnectedFilter_CanBeRetagged() Assert.IsTrue( filter.UpdateProperties(properties) > 0, - "the new description changed nothing, so it was never stored"); + "the new description changed nothing, so it was never stored" + ); Assert.AreEqual( - 0, filter.UpdateProperties(properties), - "setting the same value twice reported a change, so the first one did not stick"); + 0, + filter.UpdateProperties(properties), + "setting the same value twice reported a change, so the first one did not stick" + ); // And per port, which is the granularity the native call offers and the streams do not. Assert.IsTrue( filter.UpdateProperties( - new Dictionary { [PipeWireKeys.PW_KEY_PORT_NAME] = "renamed" }, port) > 0, - "a per-port retag changed nothing"); + new Dictionary { [PipeWireKeys.PW_KEY_PORT_NAME] = "renamed" }, + port + ) > 0, + "a per-port retag changed nothing" + ); Assert.AreNotEqual( - PipeWireFilterState.Error, filter.State, - "retagging put the filter into the error state"); + PipeWireFilterState.Error, + filter.State, + "retagging put the filter into the error state" + ); } /// A filter can be subscribed to the commands its node is sent. @@ -474,7 +551,10 @@ public async Task AFilter_CanSubscribeToItsNodesCommands() RequireLinux(); using var cts = new CancellationTokenSource(Budget); - await using var ctx = new PipeWireContext("pwnet-filter-command", ConsoleTestLoggerFactory.Instance); + await using var ctx = new PipeWireContext( + "pwnet-filter-command", + ConsoleTestLoggerFactory.Instance + ); await ctx.StartAsync(cts.Token); await using PipeWireFilter filter = PipeWireFilter.Create(ctx, "pwnet_filter_command"); @@ -489,8 +569,10 @@ public async Task AFilter_CanSubscribeToItsNodesCommands() _ = await filter.WaitForNodeIdAsync(cts.Token); Assert.AreNotEqual( - PipeWireFilterState.Error, filter.State, - "subscribing to commands put the filter into the error state"); + PipeWireFilterState.Error, + filter.State, + "subscribing to commands put the filter into the error state" + ); Assert.IsNull(filter.LastProcessError, "the command handler faulted"); diff --git a/tests/PipeWire.NET.Tests/GStreamerIntegrationTests.cs b/tests/PipeWire.NET.Tests/GStreamerIntegrationTests.cs index 1a8dbfa..6b6fe95 100644 --- a/tests/PipeWire.NET.Tests/GStreamerIntegrationTests.cs +++ b/tests/PipeWire.NET.Tests/GStreamerIntegrationTests.cs @@ -28,23 +28,36 @@ public async Task CaptureVideoTestSrc_DeliversRealContent() await ctx.StartAsync(); const string node = "gst-smpte-bgra"; - await using var src = await GstTestSource.StartAsync(ctx, node, + await using var src = await GstTestSource.StartAsync( + ctx, + node, "videotestsrc is-live=true pattern=smpte ! video/x-raw,format=BGRA,width=320,height=240,framerate=30/1", - mediaClass: "Video/Source"); + mediaClass: "Video/Source" + ); var done = new TaskCompletionSource<(int W, int H, PixelFormat F, int Distinct)>( - TaskCreationOptions.RunContinuationsAsynchronously); + TaskCreationOptions.RunContinuationsAsynchronously + ); await using var cap = new PipeWireVideoCapture(ctx, "gst-smpte-sink"); cap.FrameReady += (_, frame) => { - if (frame.Pixels.Length < 1024) return; + if (frame.Pixels.Length < 1024) + return; // A blank/black pre-roll frame is uniform (1 distinct byte). Real SMPTE bars are // non-uniform - 100% bars use pure primaries so it's only 0x00/0xFF per channel, // i.e. >=2 distinct values. So "not uniform" is the correct "real content" gate. Span seen = stackalloc bool[256]; int distinct = 0; - foreach (byte b in frame.Pixels[..1024]) { if (!seen[b]) { seen[b] = true; distinct++; } } - if (distinct < 2) return; + foreach (byte b in frame.Pixels[..1024]) + { + if (!seen[b]) + { + seen[b] = true; + distinct++; + } + } + if (distinct < 2) + return; done.TrySetResult((frame.Width, frame.Height, frame.Format, distinct)); }; cap.Connect(preferredFormats: stackalloc[] { PixelFormat.Bgra }, targetObjectName: node); @@ -53,7 +66,10 @@ public async Task CaptureVideoTestSrc_DeliversRealContent() Assert.AreEqual(320, got.W); Assert.AreEqual(240, got.H); Assert.AreEqual(PixelFormat.Bgra, got.F); - Assert.IsTrue(got.Distinct > 1, $"expected non-uniform SMPTE content, saw only {got.Distinct} distinct byte values"); + Assert.IsTrue( + got.Distinct > 1, + $"expected non-uniform SMPTE content, saw only {got.Distinct} distinct byte values" + ); } [TestMethod] @@ -70,13 +86,22 @@ public async Task CaptureVideo_NegotiatesRequestedFormat(string gstFormat, Pixel await ctx.StartAsync(); string node = $"gst-fmt-{gstFormat.ToLowerInvariant()}"; - await using var src = await GstTestSource.StartAsync(ctx, node, + await using var src = await GstTestSource.StartAsync( + ctx, + node, $"videotestsrc is-live=true ! video/x-raw,format={gstFormat},width=160,height=120,framerate=30/1", - mediaClass: "Video/Source"); + mediaClass: "Video/Source" + ); - var done = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var done = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously + ); await using var cap = new PipeWireVideoCapture(ctx, $"{node}-sink"); - cap.FrameReady += (_, frame) => { if (frame.Pixels.Length > 0) done.TrySetResult(frame.Format); }; + cap.FrameReady += (_, frame) => + { + if (frame.Pixels.Length > 0) + done.TrySetResult(frame.Format); + }; cap.Connect(preferredFormats: stackalloc[] { expected }, targetObjectName: node); PixelFormat negotiated = await done.Task.WaitAsync(TimeSpan.FromSeconds(8)); @@ -97,17 +122,22 @@ public async Task CaptureVideo_AsksForTheGeometryItWasGiven() await ctx.StartAsync(); const string node = "gst-geometry"; - await using var src = await GstTestSource.StartAsync(ctx, node, + await using var src = await GstTestSource.StartAsync( + ctx, + node, "videotestsrc is-live=true ! video/x-raw,format=BGRA,width=320,height=240,framerate=15/1", - mediaClass: "Video/Source"); + mediaClass: "Video/Source" + ); var done = new TaskCompletionSource<(int Width, int Height)>( - TaskCreationOptions.RunContinuationsAsynchronously); + TaskCreationOptions.RunContinuationsAsynchronously + ); await using var cap = new PipeWireVideoCapture(ctx, $"{node}-sink"); cap.FrameReady += (_, frame) => { - if (frame.Pixels.Length > 0) done.TrySetResult((frame.Width, frame.Height)); + if (frame.Pixels.Length > 0) + done.TrySetResult((frame.Width, frame.Height)); }; cap.Connect( @@ -115,12 +145,21 @@ public async Task CaptureVideo_AsksForTheGeometryItWasGiven() targetObjectName: node, preferredWidth: 320, preferredHeight: 240, - preferredFrameRate: 15); + preferredFrameRate: 15 + ); (int width, int height) = await done.Task.WaitAsync(TimeSpan.FromSeconds(8)); - Assert.AreEqual(320, width, "the width the consumer asked for did not reach the negotiation"); - Assert.AreEqual(240, height, "the height the consumer asked for did not reach the negotiation"); + Assert.AreEqual( + 320, + width, + "the width the consumer asked for did not reach the negotiation" + ); + Assert.AreEqual( + 240, + height, + "the height the consumer asked for did not reach the negotiation" + ); } [TestMethod] @@ -162,20 +201,26 @@ public async Task AConsumerThatStaysWithItsSource_EndsWhenTheSourceGoes() cap.StateChanged += (_, _, s) => { states.Enqueue(s); - if (s == PipeWireStreamState.Streaming) streamed.TrySetResult(); - else if (streamed.Task.IsCompleted) left.TrySetResult(); + if (s == PipeWireStreamState.Streaming) + streamed.TrySetResult(); + else if (streamed.Task.IsCompleted) + left.TrySetResult(); }; - GstTestSource src = await GstTestSource.StartAsync(ctx, node, + GstTestSource src = await GstTestSource.StartAsync( + ctx, + node, "videotestsrc is-live=true ! video/x-raw,format=BGRA,width=160,height=120,framerate=30/1", - mediaClass: "Video/Source"); + mediaClass: "Video/Source" + ); try { cap.Connect( preferredFormats: stackalloc[] { PixelFormat.Bgra }, targetObjectName: node, - stayWithTheSource: true); + stayWithTheSource: true + ); await streamed.Task.WaitAsync(TimeSpan.FromSeconds(15)); } @@ -187,8 +232,11 @@ public async Task AConsumerThatStaysWithItsSource_EndsWhenTheSourceGoes() await left.Task.WaitAsync(TimeSpan.FromSeconds(15)); - Assert.AreNotEqual(PipeWireStreamState.Streaming, states.Last(), - $"the stream stayed streaming after its source went: {string.Join(" -> ", states)}"); + Assert.AreNotEqual( + PipeWireStreamState.Streaming, + states.Last(), + $"the stream stayed streaming after its source went: {string.Join(" -> ", states)}" + ); } [TestMethod] @@ -217,72 +265,113 @@ public async Task AStreamNegotiatingASecondTime_ReportsTheNewFormatNotTheOld() await using var reg = new PipeWireRegistry(ctx); await reg.WaitForInitialEnumerationAsync(cts.Token); - var seen = new System.Collections.Concurrent.ConcurrentQueue<(PixelFormat Format, int Width, int Height)>(); + var seen = new System.Collections.Concurrent.ConcurrentQueue<( + PixelFormat Format, + int Width, + int Height + )>(); var ragged = 0; await using var cap = new PipeWireVideoCapture(ctx, "gst-reneg-sink"); cap.FrameReady += (_, frame) => { - if (frame.Pixels.Length == 0) return; + if (frame.Pixels.Length == 0) + return; // The frame has to be self-consistent with the geometry it reports, whichever // negotiation produced it. A stride from the previous format is what this catches. int expected = frame.Height * frame.Stride; - if (frame.Stride > 0 && frame.Pixels.Length < expected) Interlocked.Increment(ref ragged); + if (frame.Stride > 0 && frame.Pixels.Length < expected) + Interlocked.Increment(ref ragged); seen.Enqueue((frame.Format, frame.Width, frame.Height)); }; - await using GstTestSource first = await GstTestSource.StartAsync(ctx, "gst-reneg-a", + await using GstTestSource first = await GstTestSource.StartAsync( + ctx, + "gst-reneg-a", "videotestsrc is-live=true ! video/x-raw,format=BGRA,width=160,height=120,framerate=30/1", - mediaClass: "Video/Source"); - await using GstTestSource second = await GstTestSource.StartAsync(ctx, "gst-reneg-b", + mediaClass: "Video/Source" + ); + await using GstTestSource second = await GstTestSource.StartAsync( + ctx, + "gst-reneg-b", "videotestsrc is-live=true ! video/x-raw,format=I420,width=320,height=240,framerate=30/1", - mediaClass: "Video/Source"); + mediaClass: "Video/Source" + ); - cap.Connect(preferredFormats: stackalloc[] { PixelFormat.Bgra, PixelFormat.Yuv420 }, - autoConnect: false, cancellationToken: cts.Token); + cap.Connect( + preferredFormats: stackalloc[] { PixelFormat.Bgra, PixelFormat.Yuv420 }, + autoConnect: false, + cancellationToken: cts.Token + ); uint capNode = await cap.WaitForNodeIdAsync(cts.Token); PipeWirePort input = await PortAsync(reg, capNode, PipeWirePortDirection.In, cts.Token); PipeWireLink toFirst = await reg.CreateLinkAsync( - await PortAsync(reg, first.NodeId, PipeWirePortDirection.Out, cts.Token), input, cts.Token); - await WaitForAsync(() => seen.Any(f => f.Width == 160), TimeSpan.FromSeconds(15), - "no frame ever arrived from the first producer"); + await PortAsync(reg, first.NodeId, PipeWirePortDirection.Out, cts.Token), + input, + cts.Token + ); + await WaitForAsync( + () => seen.Any(f => f.Width == 160), + TimeSpan.FromSeconds(15), + "no frame ever arrived from the first producer" + ); await reg.DestroyGlobalAsync(toFirst.LinkId, cts.Token); await reg.CreateLinkAsync( - await PortAsync(reg, second.NodeId, PipeWirePortDirection.Out, cts.Token), input, cts.Token); - - await WaitForAsync(() => seen.Any(f => f.Width == 320 && f.Height == 240), TimeSpan.FromSeconds(15), - "the stream linked to the second producer never delivered its geometry, so it did not negotiate again"); - - Assert.AreEqual(0, Volatile.Read(ref ragged), - "a frame carried less data than its own geometry needs, so something survived the first format"); + await PortAsync(reg, second.NodeId, PipeWirePortDirection.Out, cts.Token), + input, + cts.Token + ); + + await WaitForAsync( + () => seen.Any(f => f.Width == 320 && f.Height == 240), + TimeSpan.FromSeconds(15), + "the stream linked to the second producer never delivered its geometry, so it did not negotiate again" + ); + + Assert.AreEqual( + 0, + Volatile.Read(ref ragged), + "a frame carried less data than its own geometry needs, so something survived the first format" + ); (PixelFormat format, int width, int height) = seen.Last(); Assert.AreEqual(320, width, "the stream is still reporting the first producer's width"); Assert.AreEqual(240, height, "the stream is still reporting the first producer's height"); - Assert.AreEqual(PixelFormat.Yuv420, format, "the stream is still reporting the first producer's format"); + Assert.AreEqual( + PixelFormat.Yuv420, + format, + "the stream is still reporting the first producer's format" + ); } /// The node's port facing , once the registry has it. private static async Task PortAsync( - PipeWireRegistry reg, uint nodeId, PipeWirePortDirection direction, CancellationToken ct) + PipeWireRegistry reg, + uint nodeId, + PipeWirePortDirection direction, + CancellationToken ct + ) { while (true) { - PipeWirePort? port = reg.Current.GetPortsForNode(nodeId) + PipeWirePort? port = reg + .Current.GetPortsForNode(nodeId) .FirstOrDefault(p => p.PortDirection == direction); - if (port is not null) return port; + if (port is not null) + return port; await Task.Delay(50, ct); } } private static async Task WaitForAsync(Func condition, TimeSpan budget, string message) { - if (!await TryWaitAsync(condition, budget)) Assert.Fail(message); + if (!await TryWaitAsync(condition, budget)) + Assert.Fail(message); } private static async Task TryWaitAsync(Func condition, TimeSpan budget) @@ -290,7 +379,8 @@ private static async Task TryWaitAsync(Func condition, TimeSpan budg DateTime deadline = DateTime.UtcNow + budget; while (DateTime.UtcNow < deadline) { - if (condition()) return true; + if (condition()) + return true; await Task.Delay(100); } @@ -308,9 +398,12 @@ public async Task Registry_DiscoversGstSource() await ctx.StartAsync(); const string node = "gst-registry-probe"; - await using var src = await GstTestSource.StartAsync(ctx, node, + await using var src = await GstTestSource.StartAsync( + ctx, + node, "videotestsrc is-live=true ! video/x-raw,format=BGRA,width=64,height=64,framerate=30/1", - mediaClass: "Video/Source"); + mediaClass: "Video/Source" + ); await using var reg = new PipeWireRegistry(ctx); await reg.WaitForInitialEnumerationAsync(); @@ -320,14 +413,21 @@ public async Task Registry_DiscoversGstSource() for (int i = 0; i < 20 && found is null; i++) { found = reg.Nodes.FirstOrDefault(s => s.NodeName == node); - if (found is null) await Task.Delay(100); + if (found is null) + await Task.Delay(100); } Assert.IsNotNull(found, "gst source should be discoverable via the registry"); - Assert.AreEqual(PipeWireMediaKind.Video, found!.Media, - $"expected a video node, got class '{found.MediaClass}'"); - Assert.AreEqual(PipeWireMediaFlow.Source, found.Flow, - $"expected a source, got class '{found.MediaClass}'"); + Assert.AreEqual( + PipeWireMediaKind.Video, + found!.Media, + $"expected a video node, got class '{found.MediaClass}'" + ); + Assert.AreEqual( + PipeWireMediaFlow.Source, + found.Flow, + $"expected a source, got class '{found.MediaClass}'" + ); } [TestMethod] @@ -341,22 +441,38 @@ public async Task CaptureAudioTestSrc_DeliversTone() await ctx.StartAsync(); const string node = "gst-tone"; - await using var src = await GstTestSource.StartAsync(ctx, node, + await using var src = await GstTestSource.StartAsync( + ctx, + node, "audiotestsrc is-live=true ! audioconvert ! audio/x-raw,format=F32LE,channels=2,rate=48000", - mediaClass: "Audio/Source"); + mediaClass: "Audio/Source" + ); var done = new TaskCompletionSource<(int Rate, int Ch, bool NonSilent)>( - TaskCreationOptions.RunContinuationsAsynchronously); + TaskCreationOptions.RunContinuationsAsynchronously + ); await using var cap = new PipeWireAudioCapture(ctx, "gst-tone-sink"); cap.FrameReady += (_, frame) => { // Skip the initial silence pre-roll; wait for actual tone samples. bool nonSilent = false; - foreach (byte b in frame.Samples) { if (b != 0) { nonSilent = true; break; } } + foreach (byte b in frame.Samples) + { + if (b != 0) + { + nonSilent = true; + break; + } + } if (nonSilent) done.TrySetResult((frame.SampleRate, frame.Channels, true)); }; - cap.Connect(sampleRate: 48000, channels: 2, format: AudioSampleFormat.F32Le, targetObjectName: node); + cap.Connect( + sampleRate: 48000, + channels: 2, + format: AudioSampleFormat.F32Le, + targetObjectName: node + ); var got = await done.Task.WaitAsync(TimeSpan.FromSeconds(8)); Assert.AreEqual(48000, got.Rate); @@ -375,34 +491,49 @@ public async Task VideoPresentationTimestamps_AreRealMonotonicAndRealTimePaced() await ctx.StartAsync(); const string node = "gst-pts-video"; - await using var src = await GstTestSource.StartAsync(ctx, node, + await using var src = await GstTestSource.StartAsync( + ctx, + node, "videotestsrc is-live=true ! video/x-raw,format=BGRA,width=160,height=120,framerate=30/1", - mediaClass: "Video/Source"); + mediaClass: "Video/Source" + ); // Collect a window of PTS values. These come from the SPA_META_Header we request via // pw_stream_update_params in param_changed (without that, frames carry no timestamp). var pts = new List(); - const int want = 15; // ~0.5s at 30fps + const int want = 15; // ~0.5s at 30fps var enough = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); await using var cap = new PipeWireVideoCapture(ctx, node + "-sink"); cap.FrameReady += (_, f) => { - if (f.PresentationTimestampNs is not { } stamp) return; - lock (pts) { if (pts.Count < want) { pts.Add(stamp); if (pts.Count == want) enough.TrySetResult(); } } + if (f.PresentationTimestampNs is not { } stamp) + return; + lock (pts) + { + if (pts.Count < want) + { + pts.Add(stamp); + if (pts.Count == want) + enough.TrySetResult(); + } + } }; cap.Connect(preferredFormats: stackalloc[] { PixelFormat.Bgra }, targetObjectName: node); await enough.Task.WaitAsync(TimeSpan.FromSeconds(10)); long[] v; - lock (pts) v = [.. pts]; + lock (pts) + v = [.. pts]; Assert.IsTrue(v[0] > 0, "presentation timestamps must be real (SPA_META_Header attached)"); for (int i = 1; i < v.Length; i++) Assert.IsTrue(v[i] > v[i - 1], "video PTS must increase monotonically"); // Wall-clock paced: 30fps => ~33ms/frame. A counter or arbitrary value would not be. double avgFrameMs = (v[^1] - v[0]) / 1_000_000.0 / (v.Length - 1); - Assert.IsTrue(avgFrameMs is > 10 and < 100, - $"video PTS cadence {avgFrameMs:F1}ms/frame should be near 33ms (real-time 30fps)"); + Assert.IsTrue( + avgFrameMs is > 10 and < 100, + $"video PTS cadence {avgFrameMs:F1}ms/frame should be near 33ms (real-time 30fps)" + ); } // NOTE: DMA-BUF zero-copy capture is covered by DmaBufRoundTripTests, which drives a libgbm-backed @@ -423,9 +554,12 @@ public async Task AudioCapture_ExposesSampleAccurateClock() await ctx.StartAsync(); const string node = "gst-audio-clock"; - await using var src = await GstTestSource.StartAsync(ctx, node, + await using var src = await GstTestSource.StartAsync( + ctx, + node, "audiotestsrc is-live=true ! audioconvert ! audio/x-raw,format=F32LE,channels=2,rate=48000", - mediaClass: "Audio/Source"); + mediaClass: "Audio/Source" + ); var media = new List(); long lastDelay = -1; @@ -435,21 +569,39 @@ public async Task AudioCapture_ExposesSampleAccurateClock() cap.FrameReady += (_, f) => { lastDelay = f.DelayNs; - lock (media) { if (media.Count < want && f.StreamPositionNs is { } pos) { media.Add(pos); if (media.Count == want) done.TrySetResult(); } } + lock (media) + { + if (media.Count < want && f.StreamPositionNs is { } pos) + { + media.Add(pos); + if (media.Count == want) + done.TrySetResult(); + } + } }; - cap.Connect(sampleRate: 48000, channels: 2, format: AudioSampleFormat.F32Le, targetObjectName: node); + cap.Connect( + sampleRate: 48000, + channels: 2, + format: AudioSampleFormat.F32Le, + targetObjectName: node + ); await done.Task.WaitAsync(TimeSpan.FromSeconds(10)); long[] m; - lock (media) m = [.. media]; + lock (media) + m = [.. media]; // Media clock advances monotonically at ~real time (sample-accurate: derived from // ticks*rate). It starts near 0 (ticks=0 at stream start), so check advancement, not >0. // A broken rate/ticks read would be constant (span 0) or go backwards. - for (int i = 1; i < m.Length; i++) Assert.IsTrue(m[i] >= m[i - 1], "media clock must be non-decreasing"); + for (int i = 1; i < m.Length; i++) + Assert.IsTrue(m[i] >= m[i - 1], "media clock must be non-decreasing"); Assert.IsTrue(m[^1] > m[0], "media clock must advance"); double spanSec = (m[^1] - m[0]) / 1e9; - Assert.IsTrue(spanSec is > 0.001 and < 5, $"media clock should advance at ~real time, got {spanSec:F3}s over {m.Length} chunks"); + Assert.IsTrue( + spanSec is > 0.001 and < 5, + $"media clock should advance at ~real time, got {spanSec:F3}s over {m.Length} chunks" + ); Assert.IsTrue(lastDelay >= 0, "delay (latency) must be a sane non-negative value"); } @@ -468,10 +620,19 @@ public async Task AudioAndVideo_ShareTheGraphClock_AndCanBeSynced() // the monotonic graph-clock time of the processing cycle and is the SAME reference for // every stream. We surface it as Frame.GraphTimeNs. This test proves audio and video // are stamped on that one shared clock - which is exactly what makes lip-sync possible. - const string vNode = "gst-av-video", aNode = "gst-av-audio"; - await using var src = await GstTestSource.StartTwoAsync(ctx, - video: ("videotestsrc is-live=true ! video/x-raw,format=BGRA,width=160,height=120,framerate=30/1", vNode), - audio: ("audiotestsrc is-live=true ! audioconvert ! audio/x-raw,format=F32LE,channels=2,rate=48000", aNode)); + const string vNode = "gst-av-video", + aNode = "gst-av-audio"; + await using var src = await GstTestSource.StartTwoAsync( + ctx, + video: ( + "videotestsrc is-live=true ! video/x-raw,format=BGRA,width=160,height=120,framerate=30/1", + vNode + ), + audio: ( + "audiotestsrc is-live=true ! audioconvert ! audio/x-raw,format=F32LE,channels=2,rate=48000", + aNode + ) + ); var vClk = new List(); var aClk = new List(); @@ -481,17 +642,37 @@ public async Task AudioAndVideo_ShareTheGraphClock_AndCanBeSynced() await using var vCap = new PipeWireVideoCapture(ctx, "gst-av-video-sink"); vCap.FrameReady += (_, f) => { - lock (vClk) { if (vClk.Count < cap && f.GraphTimeNs is { } t) vClk.Add(t); } + lock (vClk) + { + if (vClk.Count < cap && f.GraphTimeNs is { } t) + vClk.Add(t); + } }; vCap.Connect(preferredFormats: stackalloc[] { PixelFormat.Bgra }, targetObjectName: vNode); await using var aCap = new PipeWireAudioCapture(ctx, "gst-av-audio-sink"); aCap.FrameReady += (_, f) => { - foreach (byte b in f.Samples) { if (b != 0) { audioNonSilent = true; break; } } - lock (aClk) { if (aClk.Count < cap && f.GraphTimeNs is { } t) aClk.Add(t); } + foreach (byte b in f.Samples) + { + if (b != 0) + { + audioNonSilent = true; + break; + } + } + lock (aClk) + { + if (aClk.Count < cap && f.GraphTimeNs is { } t) + aClk.Add(t); + } }; - aCap.Connect(sampleRate: 48000, channels: 2, format: AudioSampleFormat.F32Le, targetObjectName: aNode); + aCap.Connect( + sampleRate: 48000, + channels: 2, + format: AudioSampleFormat.F32Le, + targetObjectName: aNode + ); // Poll until BOTH legs have stamped a few frames (don't couple to either's exact cadence): a live // source flushes its preroll as a burst then paces, and in this dual-sink pipeline the video leg @@ -501,23 +682,31 @@ public async Task AudioAndVideo_ShareTheGraphClock_AndCanBeSynced() var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(12); while (DateTime.UtcNow < deadline) { - int vc, ac; - lock (vClk) vc = vClk.Count; - lock (aClk) ac = aClk.Count; - if (vc >= minSamples && ac >= minSamples) break; + int vc, + ac; + lock (vClk) + vc = vClk.Count; + lock (aClk) + ac = aClk.Count; + if (vc >= minSamples && ac >= minSamples) + break; await Task.Delay(100); } - long[] v, a; - lock (vClk) v = [.. vClk]; - lock (aClk) a = [.. aClk]; + long[] v, + a; + lock (vClk) + v = [.. vClk]; + lock (aClk) + a = [.. aClk]; // Audio and video share one clock only when both legs are in one driver group, only // achievable via a single gst pipeline; inconclusive when a leg never starts. if (v.Length == 0 || a.Length == 0) { Assert.Inconclusive( - $"gst dual-sink graph did not start both legs in time (video={v.Length}, audio={a.Length}) - dual-sink startup race."); + $"gst dual-sink graph did not start both legs in time (video={v.Length}, audio={a.Length}) - dual-sink startup race." + ); return; } @@ -529,8 +718,10 @@ public async Task AudioAndVideo_ShareTheGraphClock_AndCanBeSynced() Assert.IsTrue(a[0] > 0, "audio GraphTimeNs must be a real graph-clock time"); // 2. Each stream's clock advances monotonically. - for (int i = 1; i < v.Length; i++) Assert.IsTrue(v[i] >= v[i - 1], "video clock must be monotonic"); - for (int i = 1; i < a.Length; i++) Assert.IsTrue(a[i] >= a[i - 1], "audio clock must be monotonic"); + for (int i = 1; i < v.Length; i++) + Assert.IsTrue(v[i] >= v[i - 1], "video clock must be monotonic"); + for (int i = 1; i < a.Length; i++) + Assert.IsTrue(a[i] >= a[i - 1], "audio clock must be monotonic"); // 3. THE sync proof: the two streams' clock ranges are mutually close. On one shared timeline // the windows sit on top of each other (gap <= a small tolerance); on different clocks (or a @@ -546,7 +737,9 @@ public async Task AudioAndVideo_ShareTheGraphClock_AndCanBeSynced() // from that skew even though every sample is stamped from the one graph clock - the range // proximity below is the distribution-independent test of "same timeline". long gap = Math.Max(0, Math.Max(v[0], a[0]) - Math.Min(v[^1], a[^1])); - Assert.IsTrue(gap < 250_000_000, - $"audio and video must be stamped on one shared clock (video [{v[0]}..{v[^1]}], audio [{a[0]}..{a[^1]}], gap={gap / 1e6:F1}ms)"); + Assert.IsTrue( + gap < 250_000_000, + $"audio and video must be stamped on one shared clock (video [{v[0]}..{v[^1]}], audio [{a[0]}..{a[^1]}], gap={gap / 1e6:F1}ms)" + ); } } diff --git a/tests/PipeWire.NET.Tests/GStreamerSupport.cs b/tests/PipeWire.NET.Tests/GStreamerSupport.cs index 7fe8e65..7ac43d6 100644 --- a/tests/PipeWire.NET.Tests/GStreamerSupport.cs +++ b/tests/PipeWire.NET.Tests/GStreamerSupport.cs @@ -16,6 +16,7 @@ internal sealed class GstTestSource : IAsyncDisposable private const string GstLaunch = "/usr/bin/gst-launch-1.0"; private readonly Process[] _procs; + private GstTestSource(params Process[] procs) => _procs = procs; /// @@ -29,25 +30,33 @@ internal sealed class GstTestSource : IAsyncDisposable private static bool ComputeAvailable() { - if (!OperatingSystem.IsLinux() || !File.Exists(GstLaunch)) return false; + if (!OperatingSystem.IsLinux() || !File.Exists(GstLaunch)) + return false; try { - using var p = Process.Start(new ProcessStartInfo("/usr/bin/gst-inspect-1.0", "pipewiresink") - { - RedirectStandardOutput = true, - RedirectStandardError = true, - })!; + using var p = Process.Start( + new ProcessStartInfo("/usr/bin/gst-inspect-1.0", "pipewiresink") + { + RedirectStandardOutput = true, + RedirectStandardError = true, + } + )!; p.WaitForExit(5000); return p.ExitCode == 0; } - catch { return false; } + catch + { + return false; + } } /// Skips the calling test (Inconclusive) when GStreamer/pipewiresink isn't present. public static void RequireGStreamer() { if (!IsAvailable) - Assert.Inconclusive("GStreamer (gst-launch-1.0 + pipewiresink) not available - skipping real-source test."); + Assert.Inconclusive( + "GStreamer (gst-launch-1.0 + pipewiresink) not available - skipping real-source test." + ); } /// @@ -56,8 +65,12 @@ public static void RequireGStreamer() /// e.g. videotestsrc is-live=true ! video/x-raw,format=BGRA,width=320,height=240,framerate=30/1. /// public static async Task StartAsync( - PipeWireContext ctx, string nodeName, string pipelineHead, string mediaClass, - TimeSpan? timeout = null) + PipeWireContext ctx, + string nodeName, + string pipelineHead, + string mediaClass, + TimeSpan? timeout = null + ) { var psi = new ProcessStartInfo(GstLaunch) { @@ -74,19 +87,28 @@ public static async Task StartAsync( // an Audio/Source branch advertises a node but never serves samples (a video source happens to // still produce), so an audio capture connects, reaches Streaming, yet no buffer ever flows. psi.ArgumentList.Add("mode=provide"); - psi.ArgumentList.Add($"stream-properties=props,node.name={nodeName},media.class={mediaClass}"); + psi.ArgumentList.Add( + $"stream-properties=props,node.name={nodeName},media.class={mediaClass}" + ); - var proc = Process.Start(psi) ?? throw new InvalidOperationException("Failed to start gst-launch-1.0."); + var proc = + Process.Start(psi) + ?? throw new InvalidOperationException("Failed to start gst-launch-1.0."); var source = new GstTestSource(proc); try { - PipeWireNode? node = await WaitForNodeAsync(ctx, nodeName, timeout ?? TimeSpan.FromSeconds(8)); + PipeWireNode? node = await WaitForNodeAsync( + ctx, + nodeName, + timeout ?? TimeSpan.FromSeconds(8) + ); if (node is null) { string err = await proc.StandardError.ReadToEndAsync(); await source.DisposeAsync(); throw new InvalidOperationException( - $"gst node '{nodeName}' did not appear. gst stderr:\n{err}"); + $"gst node '{nodeName}' did not appear. gst stderr:\n{err}" + ); } // Assigned here or not at all: the property is how a consumer targets this producer by @@ -117,7 +139,8 @@ public static async Task StartTwoAsync( PipeWireContext ctx, (string Head, string Node) video, (string Head, string Node) audio, - TimeSpan? timeout = null) + TimeSpan? timeout = null + ) { Process videoProc = StartBranch(video.Head, video.Node, "Video/Source"); Process audioProc = StartBranch(audio.Head, audio.Node, "Audio/Source"); @@ -126,18 +149,26 @@ public static async Task StartTwoAsync( try { var t = timeout ?? TimeSpan.FromSeconds(10); - if (await WaitForNodeAsync(ctx, video.Node, t) is null || - await WaitForNodeAsync(ctx, audio.Node, t) is null) + if ( + await WaitForNodeAsync(ctx, video.Node, t) is null + || await WaitForNodeAsync(ctx, audio.Node, t) is null + ) { - string err = await videoProc.StandardError.ReadToEndAsync() - + await audioProc.StandardError.ReadToEndAsync(); + string err = + await videoProc.StandardError.ReadToEndAsync() + + await audioProc.StandardError.ReadToEndAsync(); await source.DisposeAsync(); throw new InvalidOperationException( - $"gst A/V nodes did not appear. stderr:{Environment.NewLine}{err}"); + $"gst A/V nodes did not appear. stderr:{Environment.NewLine}{err}" + ); } return source; } - catch { await source.DisposeAsync(); throw; } + catch + { + await source.DisposeAsync(); + throw; + } } /// @@ -157,11 +188,16 @@ await WaitForNodeAsync(ctx, audio.Node, t) is null) /// linked. async=false takes the sinks out of that wait. /// /// - public static async Task<(GstTestSource Source, uint VideoNode, uint AudioNode)> StartOnePipelineAsync( + public static async Task<( + GstTestSource Source, + uint VideoNode, + uint AudioNode + )> StartOnePipelineAsync( PipeWireContext ctx, (string Head, string Node) video, (string Head, string Node) audio, - TimeSpan? timeout = null) + TimeSpan? timeout = null + ) { var psi = new ProcessStartInfo(GstLaunch) { @@ -173,7 +209,9 @@ await WaitForNodeAsync(ctx, audio.Node, t) is null) AppendBranch(psi, video.Head, video.Node, "Video/Source", asyncStateChange: false); AppendBranch(psi, audio.Head, audio.Node, "Audio/Source", asyncStateChange: false); - Process proc = Process.Start(psi) ?? throw new InvalidOperationException("Failed to start gst-launch-1.0."); + Process proc = + Process.Start(psi) + ?? throw new InvalidOperationException("Failed to start gst-launch-1.0."); var source = new GstTestSource(proc); try { @@ -185,7 +223,8 @@ await WaitForNodeAsync(ctx, audio.Node, t) is null) string err = await proc.StandardError.ReadToEndAsync(); await source.DisposeAsync(); throw new InvalidOperationException( - $"the one-pipeline gst A/V nodes did not appear. stderr:{Environment.NewLine}{err}"); + $"the one-pipeline gst A/V nodes did not appear. stderr:{Environment.NewLine}{err}" + ); } return (source, v.NodeId, a.NodeId); @@ -212,7 +251,12 @@ private static Process StartBranch(string head, string node, string mediaClass) } private static void AppendBranch( - ProcessStartInfo psi, string head, string node, string mediaClass, bool asyncStateChange = true) + ProcessStartInfo psi, + string head, + string node, + string mediaClass, + bool asyncStateChange = true + ) { foreach (var tok in head.Split(' ', StringSplitOptions.RemoveEmptyEntries)) psi.ArgumentList.Add(tok); @@ -220,20 +264,38 @@ private static void AppendBranch( psi.ArgumentList.Add("pipewiresink"); psi.ArgumentList.Add("mode=provide"); // see StartAsync: a source branch must provide to serve samples. psi.ArgumentList.Add($"stream-properties=props,node.name={node},media.class={mediaClass}"); - if (!asyncStateChange) psi.ArgumentList.Add("async=false"); + if (!asyncStateChange) + psi.ArgumentList.Add("async=false"); } - private static async Task WaitForNodeAsync(PipeWireContext ctx, string nodeName, TimeSpan timeout) + private static async Task WaitForNodeAsync( + PipeWireContext ctx, + string nodeName, + TimeSpan timeout + ) { await using var reg = new PipeWireRegistry(ctx); - var found = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - reg.NodeAdded += s => { if (s.NodeName == nodeName) found.TrySetResult(s); }; + var found = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously + ); + reg.NodeAdded += s => + { + if (s.NodeName == nodeName) + found.TrySetResult(s); + }; foreach (var s in reg.Nodes) - if (s.NodeName == nodeName) return s; + if (s.NodeName == nodeName) + return s; - try { return await found.Task.WaitAsync(timeout); } - catch (TimeoutException) { return null; } + try + { + return await found.Task.WaitAsync(timeout); + } + catch (TimeoutException) + { + return null; + } } public async ValueTask DisposeAsync() @@ -241,11 +303,18 @@ public async ValueTask DisposeAsync() try { foreach (Process p in _procs) - if (!p.HasExited) p.Kill(entireProcessTree: true); + if (!p.HasExited) + p.Kill(entireProcessTree: true); foreach (Process p in _procs) await p.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(3)); } - catch { /* best-effort teardown */ } - finally { foreach (Process p in _procs) p.Dispose(); } + catch + { /* best-effort teardown */ + } + finally + { + foreach (Process p in _procs) + p.Dispose(); + } } } diff --git a/tests/PipeWire.NET.Tests/GbmAllocator.cs b/tests/PipeWire.NET.Tests/GbmAllocator.cs index cf32060..9782094 100644 --- a/tests/PipeWire.NET.Tests/GbmAllocator.cs +++ b/tests/PipeWire.NET.Tests/GbmAllocator.cs @@ -28,10 +28,20 @@ internal sealed class GbmAllocator : IDisposable public GbmAllocator(string renderNode) { - _drmFd = open(renderNode, 2 /* O_RDWR */); - if (_drmFd < 0) throw new InvalidOperationException($"open({renderNode}) failed errno={Marshal.GetLastPInvokeError()}"); + _drmFd = open( + renderNode, + 2 /* O_RDWR */ + ); + if (_drmFd < 0) + throw new InvalidOperationException( + $"open({renderNode}) failed errno={Marshal.GetLastPInvokeError()}" + ); _device = gbm_create_device(_drmFd); - if (_device == IntPtr.Zero) { close(_drmFd); throw new InvalidOperationException("gbm_create_device failed"); } + if (_device == IntPtr.Zero) + { + close(_drmFd); + throw new InvalidOperationException("gbm_create_device failed"); + } } public Buffer CreateBgra(int width, int height) @@ -41,8 +51,18 @@ public Buffer CreateBgra(int width, int height) ObjectDisposedException.ThrowIf(_disposed, this); ulong mod = LinearModifier; - IntPtr bo = gbm_bo_create_with_modifiers(_device, (uint)width, (uint)height, GbmFormatArgb8888, ref mod, 1); - if (bo == IntPtr.Zero) throw new InvalidOperationException("gbm_bo_create_with_modifiers failed (LINEAR BGRA)"); + IntPtr bo = gbm_bo_create_with_modifiers( + _device, + (uint)width, + (uint)height, + GbmFormatArgb8888, + ref mod, + 1 + ); + if (bo == IntPtr.Zero) + throw new InvalidOperationException( + "gbm_bo_create_with_modifiers failed (LINEAR BGRA)" + ); int fd = gbm_bo_get_fd(bo); uint stride = gbm_bo_get_stride(bo); uint offset = gbm_bo_get_offset(bo, 0); @@ -56,14 +76,18 @@ public void Dispose() { lock (_gate) { - if (_disposed) return; + if (_disposed) + return; // Buffers before the device they were allocated from. - foreach (Buffer b in _live.ToArray()) b.Release(); + foreach (Buffer b in _live.ToArray()) + b.Release(); _live.Clear(); - if (_device != IntPtr.Zero) gbm_device_destroy(_device); - if (_drmFd >= 0) close(_drmFd); + if (_device != IntPtr.Zero) + gbm_device_destroy(_device); + if (_drmFd >= 0) + close(_drmFd); _disposed = true; } } @@ -95,7 +119,8 @@ public void Dispose() lock (_owner._gate) { // Already released with its allocator, or disposed twice: nothing left to free. - if (_bo == IntPtr.Zero) return; + if (_bo == IntPtr.Zero) + return; Release(); _owner._live.Remove(this); } @@ -104,20 +129,46 @@ public void Dispose() /// Frees the descriptor and the buffer object. Called with the allocator's gate held. internal void Release() { - if (_fd >= 0) close(_fd); - if (_bo != IntPtr.Zero) gbm_bo_destroy(_bo); + if (_fd >= 0) + close(_fd); + if (_bo != IntPtr.Zero) + gbm_bo_destroy(_bo); _fd = -1; _bo = IntPtr.Zero; } } - [DllImport("libc", SetLastError = true)] private static extern int open(string path, int flags); - [DllImport("libc")] private static extern int close(int fd); - [DllImport("libgbm.so.1")] private static extern IntPtr gbm_create_device(int fd); - [DllImport("libgbm.so.1")] private static extern void gbm_device_destroy(IntPtr dev); - [DllImport("libgbm.so.1")] private static extern IntPtr gbm_bo_create_with_modifiers(IntPtr dev, uint w, uint h, uint format, ref ulong modifiers, uint count); - [DllImport("libgbm.so.1")] private static extern int gbm_bo_get_fd(IntPtr bo); - [DllImport("libgbm.so.1")] private static extern uint gbm_bo_get_stride(IntPtr bo); - [DllImport("libgbm.so.1")] private static extern uint gbm_bo_get_offset(IntPtr bo, int plane); - [DllImport("libgbm.so.1")] private static extern void gbm_bo_destroy(IntPtr bo); + [DllImport("libc", SetLastError = true)] + private static extern int open(string path, int flags); + + [DllImport("libc")] + private static extern int close(int fd); + + [DllImport("libgbm.so.1")] + private static extern IntPtr gbm_create_device(int fd); + + [DllImport("libgbm.so.1")] + private static extern void gbm_device_destroy(IntPtr dev); + + [DllImport("libgbm.so.1")] + private static extern IntPtr gbm_bo_create_with_modifiers( + IntPtr dev, + uint w, + uint h, + uint format, + ref ulong modifiers, + uint count + ); + + [DllImport("libgbm.so.1")] + private static extern int gbm_bo_get_fd(IntPtr bo); + + [DllImport("libgbm.so.1")] + private static extern uint gbm_bo_get_stride(IntPtr bo); + + [DllImport("libgbm.so.1")] + private static extern uint gbm_bo_get_offset(IntPtr bo, int plane); + + [DllImport("libgbm.so.1")] + private static extern void gbm_bo_destroy(IntPtr bo); } diff --git a/tests/PipeWire.NET.Tests/GeneratedKeyRoundTripTests.cs b/tests/PipeWire.NET.Tests/GeneratedKeyRoundTripTests.cs index 8ad22e2..3c287a6 100644 --- a/tests/PipeWire.NET.Tests/GeneratedKeyRoundTripTests.cs +++ b/tests/PipeWire.NET.Tests/GeneratedKeyRoundTripTests.cs @@ -59,11 +59,19 @@ public async Task PropertiesWrittenUnderGeneratedKeys_AreReadBackByPwDumpUnderTh string description = $"description-{Guid.NewGuid():N}"; string role = "Production"; - await using var ctx = new PipeWireContext("pwnet-keyroundtrip", ConsoleTestLoggerFactory.Instance); + await using var ctx = new PipeWireContext( + "pwnet-keyroundtrip", + ConsoleTestLoggerFactory.Instance + ); await ctx.StartAsync(cts.Token); await using var output = new PipeWireAudioOutput( - ctx, nodeName, Rate, Channels, AudioSampleFormat.F32Le); + ctx, + nodeName, + Rate, + Channels, + AudioSampleFormat.F32Le + ); output.FillSamples += (_, samples, _, _, _) => { @@ -77,11 +85,13 @@ public async Task PropertiesWrittenUnderGeneratedKeys_AreReadBackByPwDumpUnderTh await reg.WaitForInitialEnumerationAsync(cts.Token); // Retag while running, which is the path that writes the keys through to the daemon. - int changed = output.UpdateProperties(new Dictionary - { - [PipeWireKeys.PW_KEY_NODE_DESCRIPTION] = description, - [PipeWireKeys.PW_KEY_MEDIA_ROLE] = role, - }); + int changed = output.UpdateProperties( + new Dictionary + { + [PipeWireKeys.PW_KEY_NODE_DESCRIPTION] = description, + [PipeWireKeys.PW_KEY_MEDIA_ROLE] = role, + } + ); Assert.IsTrue(changed > 0, "the daemon accepted no property change"); @@ -89,8 +99,14 @@ public async Task PropertiesWrittenUnderGeneratedKeys_AreReadBackByPwDumpUnderTh for (var attempt = 0; attempt < 40 && seen is null; attempt++) { PwDump dump = await PwDump.CaptureAsync(cts.Token); - seen = dump.OfKind("Node").FirstOrDefault( - e => string.Equals(e.Prop(PipeWireKeys.PW_KEY_NODE_NAME), nodeName, StringComparison.Ordinal)); + seen = dump.OfKind("Node") + .FirstOrDefault(e => + string.Equals( + e.Prop(PipeWireKeys.PW_KEY_NODE_NAME), + nodeName, + StringComparison.Ordinal + ) + ); if (seen is null || seen.Prop(PipeWireKeys.PW_KEY_NODE_DESCRIPTION) is null) { @@ -106,17 +122,20 @@ public async Task PropertiesWrittenUnderGeneratedKeys_AreReadBackByPwDumpUnderTh Assert.AreEqual( description, seen!.Prop(PipeWireKeys.PW_KEY_NODE_DESCRIPTION), - $"'{PipeWireKeys.PW_KEY_NODE_DESCRIPTION}' is not the name the daemon stored it under"); + $"'{PipeWireKeys.PW_KEY_NODE_DESCRIPTION}' is not the name the daemon stored it under" + ); Assert.AreEqual( role, seen.Prop(PipeWireKeys.PW_KEY_MEDIA_ROLE), - $"'{PipeWireKeys.PW_KEY_MEDIA_ROLE}' is not the name the daemon stored it under"); + $"'{PipeWireKeys.PW_KEY_MEDIA_ROLE}' is not the name the daemon stored it under" + ); Assert.AreEqual( nodeName, seen.Prop(PipeWireKeys.PW_KEY_NODE_NAME), - $"'{PipeWireKeys.PW_KEY_NODE_NAME}' is not the name the daemon stored it under"); + $"'{PipeWireKeys.PW_KEY_NODE_NAME}' is not the name the daemon stored it under" + ); } /// @@ -138,26 +157,32 @@ public async Task TheGeneratedInterfaceTypeNames_AreTheOnesTheDaemonReports() // A sink of our own, so the set below does not depend on the session having hardware. A // headless runner has no card and therefore no ports at all, which made this fail for a // reason that says nothing about whether the generated names match the wire. - await using var ctx = new PipeWireContext("pwnet-typenames", ConsoleTestLoggerFactory.Instance); + await using var ctx = new PipeWireContext( + "pwnet-typenames", + ConsoleTestLoggerFactory.Instance + ); await ctx.StartAsync(cts.Token); await using var reg = new PipeWireRegistry(ctx); await reg.WaitForInitialEnumerationAsync(cts.Token); PipeWireNode sink = await reg.CreateVirtualSinkAsync( - "pwnet type names", cancellationToken: cts.Token); + "pwnet type names", + cancellationToken: cts.Token + ); for (var i = 0; i < 100 && !reg.Current.Ports.Any(p => p.NodeId == sink.NodeId); i++) await Task.Delay(50, cts.Token); Assert.IsTrue( reg.Current.Ports.Any(p => p.NodeId == sink.NodeId), - "the virtual sink never published a port, so there is nothing to compare against"); + "the virtual sink never published a port, so there is nothing to compare against" + ); PwDump dump = await PwDump.CaptureAsync(cts.Token); // pw-dump prints the full "PipeWire:Interface:Node" in each entry's type field. - var types = dump.Entries - .Select(e => e.Type) + var types = dump + .Entries.Select(e => e.Type) .Where(t => t.StartsWith("PipeWire:Interface:", StringComparison.Ordinal)) .ToHashSet(StringComparer.Ordinal); @@ -165,20 +190,23 @@ public async Task TheGeneratedInterfaceTypeNames_AreTheOnesTheDaemonReports() // Only the ones a live session is certain to contain. A daemon with no security context or // profiler bound would make those absent for reasons that are not this library's fault. - foreach (string expected in new[] - { - PipeWireKeys.PW_TYPE_INTERFACE_Core, - PipeWireKeys.PW_TYPE_INTERFACE_Node, - PipeWireKeys.PW_TYPE_INTERFACE_Port, - PipeWireKeys.PW_TYPE_INTERFACE_Client, - PipeWireKeys.PW_TYPE_INTERFACE_Module, - PipeWireKeys.PW_TYPE_INTERFACE_Factory, - }) + foreach ( + string expected in new[] + { + PipeWireKeys.PW_TYPE_INTERFACE_Core, + PipeWireKeys.PW_TYPE_INTERFACE_Node, + PipeWireKeys.PW_TYPE_INTERFACE_Port, + PipeWireKeys.PW_TYPE_INTERFACE_Client, + PipeWireKeys.PW_TYPE_INTERFACE_Module, + PipeWireKeys.PW_TYPE_INTERFACE_Factory, + } + ) { Assert.IsTrue( types.Contains(expected), $"the daemon reports no object of type '{expected}'; the generated name does not " - + $"match the wire. Present: {string.Join(", ", types.Order(StringComparer.Ordinal))}"); + + $"match the wire. Present: {string.Join(", ", types.Order(StringComparer.Ordinal))}" + ); } await reg.DestroyGlobalAsync(sink.NodeId, cts.Token); @@ -199,13 +227,21 @@ public async Task TheGraphClock_AdvancesMonotonicallyWhileStreaming() RequireLinux(); using var cts = new CancellationTokenSource(Budget); - await using var ctx = new PipeWireContext("pwnet-clockadvance", ConsoleTestLoggerFactory.Instance); + await using var ctx = new PipeWireContext( + "pwnet-clockadvance", + ConsoleTestLoggerFactory.Instance + ); await ctx.StartAsync(cts.Token); string nodeName = $"pwnet-clockadvance-{Environment.ProcessId}"; await using var output = new PipeWireAudioOutput( - ctx, nodeName, Rate, Channels, AudioSampleFormat.F32Le); + ctx, + nodeName, + Rate, + Channels, + AudioSampleFormat.F32Le + ); output.FillSamples += (_, samples, _, _, _) => { @@ -234,25 +270,29 @@ public async Task TheGraphClock_AdvancesMonotonicallyWhileStreaming() Assert.IsTrue( readings.Count >= 6, - $"the graph clock produced only {readings.Count} distinct readings in two seconds"); + $"the graph clock produced only {readings.Count} distinct readings in two seconds" + ); for (var i = 1; i < readings.Count; i++) { Assert.IsTrue( readings[i].TimeNs > readings[i - 1].TimeNs, $"the clock went backwards at reading {i}: " - + $"{readings[i - 1].TimeNs} then {readings[i].TimeNs}"); + + $"{readings[i - 1].TimeNs} then {readings[i].TimeNs}" + ); Assert.IsTrue( readings[i].Position >= readings[i - 1].Position, - $"the graph position went backwards at reading {i}"); + $"the graph position went backwards at reading {i}" + ); } // Sampled over roughly two seconds of wall clock, so the reported time has to have moved // by a real amount rather than by a tick or two of jitter. Assert.IsTrue( readings[^1].TimeNs - readings[0].TimeNs > 100_000_000, - "the graph clock advanced by under 100ms while streaming for two seconds"); + "the graph clock advanced by under 100ms while streaming for two seconds" + ); } /// @@ -270,14 +310,22 @@ public async Task ADrivingOutput_ProducesCyclesOnItsTimer() RequireLinux(); using var cts = new CancellationTokenSource(Budget); - await using var ctx = new PipeWireContext("pwnet-drivetimer", ConsoleTestLoggerFactory.Instance); + await using var ctx = new PipeWireContext( + "pwnet-drivetimer", + ConsoleTestLoggerFactory.Instance + ); await ctx.StartAsync(cts.Token); string nodeName = $"pwnet-drivetimer-{Environment.ProcessId}"; var cycles = 0; await using var output = new PipeWireAudioOutput( - ctx, nodeName, Rate, Channels, AudioSampleFormat.F32Le); + ctx, + nodeName, + Rate, + Channels, + AudioSampleFormat.F32Le + ); output.FillSamples += (_, samples, _, _, _) => { diff --git a/tests/PipeWire.NET.Tests/GeneratedKeysTests.cs b/tests/PipeWire.NET.Tests/GeneratedKeysTests.cs index 5a9eb5e..686e63d 100644 --- a/tests/PipeWire.NET.Tests/GeneratedKeysTests.cs +++ b/tests/PipeWire.NET.Tests/GeneratedKeysTests.cs @@ -35,19 +35,32 @@ public sealed class GeneratedKeysTests /// getter is bound as a delegate and the result copied out instead. /// private static List<(string Name, byte[] Utf8)> NativeSpans() => - [.. typeof(NativeConstants) - .GetProperties(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static) - .Where(p => p.PropertyType == typeof(ReadOnlySpan)) - .OrderBy(p => p.Name, StringComparer.Ordinal) - .Select(p => ( - p.Name, - ((SpanGetter)Delegate.CreateDelegate(typeof(SpanGetter), p.GetGetMethod(true)!))().ToArray()))]; + [ + .. typeof(NativeConstants) + .GetProperties(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static) + .Where(p => p.PropertyType == typeof(ReadOnlySpan)) + .OrderBy(p => p.Name, StringComparer.Ordinal) + .Select(p => + ( + p.Name, + ( + (SpanGetter) + Delegate.CreateDelegate(typeof(SpanGetter), p.GetGetMethod(true)!) + )() + .ToArray() + ) + ), + ]; private static Dictionary StringKeys() => typeof(PipeWireKeys) .GetFields(BindingFlags.Public | BindingFlags.Static) .Where(f => f.IsLiteral && f.FieldType == typeof(string)) - .ToDictionary(f => f.Name, f => (string)f.GetRawConstantValue()!, StringComparer.Ordinal); + .ToDictionary( + f => f.Name, + f => (string)f.GetRawConstantValue()!, + StringComparer.Ordinal + ); /// /// The derived string form and the generated span form name the same property, for every key. @@ -66,7 +79,8 @@ public void EveryDerivedStringKey_DecodesToItsGeneratedSpan() foreach ((string name, byte[] utf8) in NativeSpans()) { - if (!strings.TryGetValue(name, out string? asString)) continue; + if (!strings.TryGetValue(name, out string? asString)) + continue; compared++; string decoded = Encoding.UTF8.GetString(utf8); @@ -98,7 +112,8 @@ public unsafe void AGeneratedSpan_IsNulTerminatedForC() Assert.AreEqual( (byte)0, p[NativeConstants.PW_KEY_NODE_NAME.Length], - "a u8 literal handed to native code was not NUL-terminated past its length"); + "a u8 literal handed to native code was not NUL-terminated past its length" + ); } fixed (byte* p = NativeConstants.PW_TYPE_INTERFACE_Node) @@ -117,7 +132,10 @@ public void EveryStringKey_IsAPlausiblePropertyName() foreach ((string name, string value) in keys) { Assert.AreNotEqual(0, value.Length, $"{name} is empty"); - Assert.IsFalse(value.Contains('\0', StringComparison.Ordinal), $"{name} contains a NUL"); + Assert.IsFalse( + value.Contains('\0', StringComparison.Ordinal), + $"{name} contains a NUL" + ); Assert.IsFalse(value.Any(char.IsWhiteSpace), $"{name} contains whitespace: '{value}'"); } } @@ -138,16 +156,27 @@ public void TheLibraryDefinedKeys_AreNotEmittedIntoTheGeneratedFile() // constants is folded at compile time and asserts nothing about the shipped assembly. Dictionary keys = StringKeys(); - Assert.AreEqual("module.filename", keys.GetValueOrDefault(nameof(PipeWireKeys.MODULE_FILENAME))); + Assert.AreEqual( + "module.filename", + keys.GetValueOrDefault(nameof(PipeWireKeys.MODULE_FILENAME)) + ); Assert.AreEqual("module.args", keys.GetValueOrDefault(nameof(PipeWireKeys.MODULE_ARGS))); - string generated = File.ReadAllText(Path.Combine( - PublicSurfaceTests.RepoRoot(), "src", "PipeWire.NET", "generated", "PipeWireKeys.g.cs")); + string generated = File.ReadAllText( + Path.Combine( + PublicSurfaceTests.RepoRoot(), + "src", + "PipeWire.NET", + "generated", + "PipeWireKeys.g.cs" + ) + ); Assert.IsFalse( generated.Contains("MODULE_FILENAME", StringComparison.Ordinal), "a library-defined key is being emitted into the generated file, where the next " - + "regeneration drops it"); + + "regeneration drops it" + ); } /// @@ -166,10 +195,12 @@ public void WhereBothNamespacesNameAKey_TheyAgree() foreach ((string name, string value) in keys) { - if (!name.StartsWith("PW_KEY_", StringComparison.Ordinal)) continue; + if (!name.StartsWith("PW_KEY_", StringComparison.Ordinal)) + continue; string spa = "SPA_KEY_" + name["PW_KEY_".Length..]; - if (!keys.TryGetValue(spa, out string? other)) continue; + if (!keys.TryGetValue(spa, out string? other)) + continue; compared++; Assert.AreEqual(value, other, $"{name} and {spa} name different properties"); diff --git a/tests/PipeWire.NET.Tests/GraphCreationOptionsTests.cs b/tests/PipeWire.NET.Tests/GraphCreationOptionsTests.cs index 402ce90..5c8a015 100644 --- a/tests/PipeWire.NET.Tests/GraphCreationOptionsTests.cs +++ b/tests/PipeWire.NET.Tests/GraphCreationOptionsTests.cs @@ -23,7 +23,9 @@ private static void RequireLinux() } private static async Task<(PipeWireContext Context, PipeWireRegistry Registry)> ConnectAsync( - string name, CancellationToken cancellationToken) + string name, + CancellationToken cancellationToken + ) { var context = new PipeWireContext(name, ConsoleTestLoggerFactory.Instance); await context.StartAsync(cancellationToken); @@ -33,7 +35,10 @@ private static void RequireLinux() } private static async Task WaitForAsync( - PipeWireRegistry registry, Func until, CancellationToken cancellationToken) + PipeWireRegistry registry, + Func until, + CancellationToken cancellationToken + ) { await foreach (PipeWireGraphSnapshot graph in registry.WatchAsync(cancellationToken)) if (until(graph)) @@ -52,15 +57,19 @@ public async Task TargetingANamelessNode_IsRefusedBeforeTheDaemon() // target is not an error the daemon reports but a node that silently never links. RequireLinux(); using var cts = new CancellationTokenSource(Budget); - (PipeWireContext context, PipeWireRegistry registry) = await ConnectAsync("pwnet-targetguard", cts.Token); + (PipeWireContext context, PipeWireRegistry registry) = await ConnectAsync( + "pwnet-targetguard", + cts.Token + ); await using (context) await using (registry) { var nameless = new PipeWireNode(0x7FFF0000, null, null, null); - Assert.ThrowsExactly( - () => registry.CreateVirtualSink("Targeted").WithTarget(nameless)); + Assert.ThrowsExactly(() => + registry.CreateVirtualSink("Targeted").WithTarget(nameless) + ); } } @@ -72,27 +81,37 @@ public async Task ANodeThatStaysWithItsTarget_CarriesDontReconnect() // applies to linked nodes it manages), not a promise this library can keep by itself. RequireLinux(); using var cts = new CancellationTokenSource(Budget); - (PipeWireContext context, PipeWireRegistry registry) = await ConnectAsync("pwnet-stay", cts.Token); + (PipeWireContext context, PipeWireRegistry registry) = await ConnectAsync( + "pwnet-stay", + cts.Token + ); await using (context) await using (registry) { - PipeWireNode target = await registry.CreateVirtualSink("StayTarget") - .WithName(Unique("pwnet_stay_target")).ExecuteAsync(cts.Token); + PipeWireNode target = await registry + .CreateVirtualSink("StayTarget") + .WithName(Unique("pwnet_stay_target")) + .ExecuteAsync(cts.Token); - PipeWireNode node = await registry.CreateVirtualSink("Staying") - .WithName(Unique("pwnet_stay")).WithTarget(target).WithDontReconnect() + PipeWireNode node = await registry + .CreateVirtualSink("Staying") + .WithName(Unique("pwnet_stay")) + .WithTarget(target) + .WithDontReconnect() .ExecuteAsync(cts.Token); try { PwDump dump = await PwDump.CaptureAsync(cts.Token); - PwDump.Entry? seen = dump.OfKind("Node") - .FirstOrDefault(e => e.Id == node.NodeId); + PwDump.Entry? seen = dump.OfKind("Node").FirstOrDefault(e => e.Id == node.NodeId); Assert.IsNotNull(seen, "the node this test made is not in pw-dump's graph"); - Assert.AreEqual("true", seen!.Prop("node.dont-reconnect")?.ToLowerInvariant(), - "node.dont-reconnect did not reach the daemon"); + Assert.AreEqual( + "true", + seen!.Prop("node.dont-reconnect")?.ToLowerInvariant(), + "node.dont-reconnect did not reach the daemon" + ); } finally { @@ -107,19 +126,26 @@ public async Task DescribingANode_DoesNotCreateItUntilExecuted() { RequireLinux(); using var cts = new CancellationTokenSource(Budget); - (PipeWireContext context, PipeWireRegistry registry) = await ConnectAsync("pwnet-inert", cts.Token); + (PipeWireContext context, PipeWireRegistry registry) = await ConnectAsync( + "pwnet-inert", + cts.Token + ); await using (context) await using (registry) { long before = registry.Current.Version; - PipeWireNodeBuilder pending = registry.CreateVirtualSink("Inert") - .WithName("pwnet_inert") - .WithLinger(); + PipeWireNodeBuilder pending = registry + .CreateVirtualSink("Inert") + .WithName("pwnet_inert") + .WithLinger(); - Assert.AreEqual(before, registry.Current.Version, - "describing a node must not touch the daemon"); + Assert.AreEqual( + before, + registry.Current.Version, + "describing a node must not touch the daemon" + ); PipeWireNode node = await pending.ExecuteAsync(cts.Token); Assert.IsNotNull(registry.Current.GetNode(node.NodeId)); @@ -135,8 +161,10 @@ public async Task ALingeringNode_OutlivesTheClientThatCreatedIt() RequireLinux(); using var cts = new CancellationTokenSource(Budget); - (PipeWireContext observerContext, PipeWireRegistry observer) = - await ConnectAsync("pwnet-linger-observer", cts.Token); + (PipeWireContext observerContext, PipeWireRegistry observer) = await ConnectAsync( + "pwnet-linger-observer", + cts.Token + ); await using (observerContext) await using (observer) @@ -144,22 +172,32 @@ public async Task ALingeringNode_OutlivesTheClientThatCreatedIt() uint lingering; uint transient; - (PipeWireContext ownerContext, PipeWireRegistry owner) = - await ConnectAsync("pwnet-linger-owner", cts.Token); + (PipeWireContext ownerContext, PipeWireRegistry owner) = await ConnectAsync( + "pwnet-linger-owner", + cts.Token + ); await using (ownerContext) { - lingering = (await owner.CreateVirtualSink("Lingering") - .WithName("pwnet_lingering") - .WithLinger() - .ExecuteAsync(cts.Token)).NodeId; - - transient = (await owner.CreateVirtualSink("Transient") - .WithName("pwnet_transient") - .ExecuteAsync(cts.Token)).NodeId; - - await WaitForAsync(observer, + lingering = ( + await owner + .CreateVirtualSink("Lingering") + .WithName("pwnet_lingering") + .WithLinger() + .ExecuteAsync(cts.Token) + ).NodeId; + + transient = ( + await owner + .CreateVirtualSink("Transient") + .WithName("pwnet_transient") + .ExecuteAsync(cts.Token) + ).NodeId; + + await WaitForAsync( + observer, g => g.GetNode(lingering) is not null && g.GetNode(transient) is not null, - cts.Token); + cts.Token + ); await owner.DisposeAsync(); } @@ -167,10 +205,15 @@ await WaitForAsync(observer, // The transient node leaving is the signal that the disconnect has been processed, so // the lingering node's survival is then a real observation rather than a race. PipeWireGraphSnapshot after = await WaitForAsync( - observer, g => g.GetNode(transient) is null, cts.Token); + observer, + g => g.GetNode(transient) is null, + cts.Token + ); - Assert.IsNotNull(after.GetNode(lingering), - "object.linger must keep the node alive past its creator's disconnect"); + Assert.IsNotNull( + after.GetNode(lingering), + "object.linger must keep the node alive past its creator's disconnect" + ); await observer.DestroyGlobalAsync(lingering, cts.Token); await WaitForAsync(observer, g => g.GetNode(lingering) is null, cts.Token); @@ -182,7 +225,10 @@ public async Task APassiveLink_IsStillAnOrdinaryLinkInTheGraph() { RequireLinux(); using var cts = new CancellationTokenSource(Budget); - (PipeWireContext context, PipeWireRegistry registry) = await ConnectAsync("pwnet-passive", cts.Token); + (PipeWireContext context, PipeWireRegistry registry) = await ConnectAsync( + "pwnet-passive", + cts.Token + ); await using (context) await using (registry) @@ -192,15 +238,21 @@ public async Task APassiveLink_IsStillAnOrdinaryLinkInTheGraph() PipeWireGraphSnapshot ready = await WaitForAsync( registry, - g => g.GetPortsForNode(a.NodeId).Length == 4 && g.GetPortsForNode(b.NodeId).Length == 4, - cts.Token); - - PipeWirePort output = ready.GetPortsForNode(a.NodeId, PipeWirePortDirection.Out).First(); + g => + g.GetPortsForNode(a.NodeId).Length == 4 + && g.GetPortsForNode(b.NodeId).Length == 4, + cts.Token + ); + + PipeWirePort output = ready + .GetPortsForNode(a.NodeId, PipeWirePortDirection.Out) + .First(); PipeWirePort input = ready.GetPortsForNode(b.NodeId, PipeWirePortDirection.In).First(); - PipeWireLink link = await registry.CreateLink(output, input) - .WithPassive() - .ExecuteAsync(cts.Token); + PipeWireLink link = await registry + .CreateLink(output, input) + .WithPassive() + .ExecuteAsync(cts.Token); Assert.IsNotNull(registry.Current.GetLink(link.LinkId)); Assert.AreEqual(1, registry.Current.GetOutputLinksForPort(output.PortId).Length); @@ -212,17 +264,29 @@ public async Task EveryObjectCarriesTheVersionTheDaemonAnnounced() { RequireLinux(); using var cts = new CancellationTokenSource(Budget); - (PipeWireContext context, PipeWireRegistry registry) = await ConnectAsync("pwnet-version", cts.Token); + (PipeWireContext context, PipeWireRegistry registry) = await ConnectAsync( + "pwnet-version", + cts.Token + ); await using (context) await using (registry) { - PipeWireNode node = await registry.CreateVirtualSinkAsync("V", "pwnet_version", cts.Token); + PipeWireNode node = await registry.CreateVirtualSinkAsync( + "V", + "pwnet_version", + cts.Token + ); PipeWireGraphSnapshot graph = await WaitForAsync( - registry, g => g.GetPortsForNode(node.NodeId).Length == 4, cts.Token); + registry, + g => g.GetPortsForNode(node.NodeId).Length == 4, + cts.Token + ); - Assert.IsTrue(graph.GetNode(node.NodeId)!.InterfaceVersion > 0, - "the registry must record the version from the global event"); + Assert.IsTrue( + graph.GetNode(node.NodeId)!.InterfaceVersion > 0, + "the registry must record the version from the global event" + ); foreach (PipeWirePort port in graph.GetPortsForNode(node.NodeId)) Assert.IsTrue(port.InterfaceVersion > 0, $"port {port.PortId} has no version"); @@ -241,37 +305,61 @@ public async Task ALingeringLink_OutlivesTheClientThatMadeIt() uint sourceId; uint sinkId; - (PipeWireContext maker, PipeWireRegistry makerRegistry) = await ConnectAsync("pwnet-linger-link", cts.Token); + (PipeWireContext maker, PipeWireRegistry makerRegistry) = await ConnectAsync( + "pwnet-linger-link", + cts.Token + ); await using (maker) await using (makerRegistry) { - PipeWireNode source = await makerRegistry.CreateVirtualSink("LingerSrc") + PipeWireNode source = await makerRegistry + .CreateVirtualSink("LingerSrc") .WithName($"pwnet_linger_src_{Environment.ProcessId}_{Random.Shared.Next():x}") - .WithLinger().ExecuteAsync(cts.Token); - PipeWireNode sink = await makerRegistry.CreateVirtualSink("LingerSink") + .WithLinger() + .ExecuteAsync(cts.Token); + PipeWireNode sink = await makerRegistry + .CreateVirtualSink("LingerSink") .WithName($"pwnet_linger_sink_{Environment.ProcessId}_{Random.Shared.Next():x}") - .WithLinger().ExecuteAsync(cts.Token); + .WithLinger() + .ExecuteAsync(cts.Token); sourceId = source.NodeId; sinkId = sink.NodeId; - PipeWirePort output = await PortAsync(makerRegistry, sourceId, PipeWirePortDirection.Out, cts.Token); - PipeWirePort input = await PortAsync(makerRegistry, sinkId, PipeWirePortDirection.In, cts.Token); - - PipeWireLink link = await makerRegistry.CreateLink(output, input).WithLinger() + PipeWirePort output = await PortAsync( + makerRegistry, + sourceId, + PipeWirePortDirection.Out, + cts.Token + ); + PipeWirePort input = await PortAsync( + makerRegistry, + sinkId, + PipeWirePortDirection.In, + cts.Token + ); + + PipeWireLink link = await makerRegistry + .CreateLink(output, input) + .WithLinger() .ExecuteAsync(cts.Token); linkId = link.LinkId; } // The maker is gone. A second client must still see the link, and be able to remove it. - (PipeWireContext other, PipeWireRegistry otherRegistry) = await ConnectAsync("pwnet-linger-peer", cts.Token); + (PipeWireContext other, PipeWireRegistry otherRegistry) = await ConnectAsync( + "pwnet-linger-peer", + cts.Token + ); await using (other) await using (otherRegistry) { await otherRegistry.WaitForInitialEnumerationAsync(cts.Token); - Assert.IsNotNull(otherRegistry.Current.GetLink(linkId), - "a lingering link did not outlive the client that created it"); + Assert.IsNotNull( + otherRegistry.Current.GetLink(linkId), + "a lingering link did not outlive the client that created it" + ); await otherRegistry.DestroyGlobalAsync(linkId, cts.Token); await otherRegistry.DestroyGlobalAsync(sourceId, cts.Token); @@ -286,20 +374,33 @@ public async Task ANodeCreatedAsASource_IsReadFromRatherThanWrittenTo() // a source publishes outputs, and the daemon decides that from the class we send. RequireLinux(); using var cts = new CancellationTokenSource(Budget); - (PipeWireContext ctx, PipeWireRegistry registry) = await ConnectAsync("pwnet-source-class", cts.Token); + (PipeWireContext ctx, PipeWireRegistry registry) = await ConnectAsync( + "pwnet-source-class", + cts.Token + ); await using (ctx) await using (registry) { - PipeWireNode source = await registry.CreateVirtualSink("VirtualMic") + PipeWireNode source = await registry + .CreateVirtualSink("VirtualMic") .WithName($"pwnet_vmic_{Environment.ProcessId}_{Random.Shared.Next():x}") .WithMediaClass("Audio/Source") .ExecuteAsync(cts.Token); - PipeWirePort output = await PortAsync(registry, source.NodeId, PipeWirePortDirection.Out, cts.Token); + PipeWirePort output = await PortAsync( + registry, + source.NodeId, + PipeWirePortDirection.Out, + cts.Token + ); Assert.AreEqual(PipeWirePortDirection.Out, output.PortDirection); PipeWireNode read = registry.Current.GetNode(source.NodeId)!; - Assert.AreEqual("Audio/Source", read.MediaClass, "the daemon did not take the media class"); + Assert.AreEqual( + "Audio/Source", + read.MediaClass, + "the daemon did not take the media class" + ); await registry.DestroyGlobalAsync(source.NodeId, cts.Token); } @@ -312,22 +413,39 @@ public async Task ANodeCreatedWithAChannelMap_PublishesOnePortPerChannel() // is what shows the map reached the daemon rather than being accepted and ignored. RequireLinux(); using var cts = new CancellationTokenSource(Budget); - (PipeWireContext ctx, PipeWireRegistry registry) = await ConnectAsync("pwnet-channel-map", cts.Token); + (PipeWireContext ctx, PipeWireRegistry registry) = await ConnectAsync( + "pwnet-channel-map", + cts.Token + ); await using (ctx) await using (registry) { - PipeWireNode mono = await registry.CreateVirtualSink("MonoSink") + PipeWireNode mono = await registry + .CreateVirtualSink("MonoSink") .WithName($"pwnet_mono_{Environment.ProcessId}_{Random.Shared.Next():x}") .WithChannelPositions("[ MONO ]") .ExecuteAsync(cts.Token); - PipeWireNode surround = await registry.CreateVirtualSink("SurroundSink") + PipeWireNode surround = await registry + .CreateVirtualSink("SurroundSink") .WithName($"pwnet_surround_{Environment.ProcessId}_{Random.Shared.Next():x}") .WithChannelPositions("[ FL FR FC LFE SL SR ]") .ExecuteAsync(cts.Token); - int monoIn = await CountPortsAsync(registry, mono.NodeId, PipeWirePortDirection.In, 1, cts.Token); - int surroundIn = await CountPortsAsync(registry, surround.NodeId, PipeWirePortDirection.In, 6, cts.Token); + int monoIn = await CountPortsAsync( + registry, + mono.NodeId, + PipeWirePortDirection.In, + 1, + cts.Token + ); + int surroundIn = await CountPortsAsync( + registry, + surround.NodeId, + PipeWirePortDirection.In, + 6, + cts.Token + ); Assert.AreEqual(1, monoIn, "a mono node published a port count that is not one"); Assert.AreEqual(6, surroundIn, "a six-channel map did not produce six input ports"); @@ -344,8 +462,12 @@ public async Task ANodeCreatedWithAChannelMap_PublishesOnePortPerChannel() /// the assertion with the real number rather than timing out with none. /// private static async Task CountPortsAsync( - PipeWireRegistry registry, uint nodeId, PipeWirePortDirection direction, int expected, - CancellationToken cancellationToken) + PipeWireRegistry registry, + uint nodeId, + PipeWirePortDirection direction, + int expected, + CancellationToken cancellationToken + ) { var seen = 0; @@ -353,8 +475,11 @@ private static async Task CountPortsAsync( { await registry.WaitForInitialEnumerationAsync(cancellationToken); - seen = registry.Current.GetPortsForNode(nodeId).Count(p => p.PortDirection == direction); - if (seen >= expected) return seen; + seen = registry + .Current.GetPortsForNode(nodeId) + .Count(p => p.PortDirection == direction); + if (seen >= expected) + return seen; await Task.Delay(TimeSpan.FromMilliseconds(50), cancellationToken); } @@ -371,32 +496,57 @@ public async Task ALinkMadeFromPortIds_IsTheSameAsOneMadeFromPortRecords() // check on this side so a mistake is an ArgumentException rather than a daemon refusal. RequireLinux(); using var cts = new CancellationTokenSource(Budget); - (PipeWireContext ctx, PipeWireRegistry registry) = await ConnectAsync("pwnet-link-by-id", cts.Token); + (PipeWireContext ctx, PipeWireRegistry registry) = await ConnectAsync( + "pwnet-link-by-id", + cts.Token + ); await using (ctx) await using (registry) { - PipeWireNode source = await registry.CreateVirtualSink("LinkById") + PipeWireNode source = await registry + .CreateVirtualSink("LinkById") .WithName($"pwnet_lbi_src_{Environment.ProcessId}_{Random.Shared.Next():x}") .ExecuteAsync(cts.Token); - PipeWireNode sink = await registry.CreateVirtualSink("LinkById") + PipeWireNode sink = await registry + .CreateVirtualSink("LinkById") .WithName($"pwnet_lbi_sink_{Environment.ProcessId}_{Random.Shared.Next():x}") .ExecuteAsync(cts.Token); - PipeWirePort output = await PortAsync(registry, source.NodeId, PipeWirePortDirection.Out, cts.Token); - PipeWirePort input = await PortAsync(registry, sink.NodeId, PipeWirePortDirection.In, cts.Token); + PipeWirePort output = await PortAsync( + registry, + source.NodeId, + PipeWirePortDirection.Out, + cts.Token + ); + PipeWirePort input = await PortAsync( + registry, + sink.NodeId, + PipeWirePortDirection.In, + cts.Token + ); - PipeWireLink link = await registry.CreateLinkAsync(output.PortId, input.PortId, cts.Token); + PipeWireLink link = await registry.CreateLinkAsync( + output.PortId, + input.PortId, + cts.Token + ); Assert.AreEqual(output.PortId, link.OutputPortId); Assert.AreEqual(input.PortId, link.InputPortId); Assert.IsNotNull(registry.Current.GetLink(link.LinkId)); // The wrong way round is caught here, not by the daemon. - Assert.ThrowsExactly(() => registry.CreateLink(input.PortId, output.PortId)); + Assert.ThrowsExactly(() => + registry.CreateLink(input.PortId, output.PortId) + ); // And an id nothing has ever used. - Assert.ThrowsExactly(() => registry.CreateLink(0x7FFF_0000, input.PortId)); - Assert.ThrowsExactly(() => registry.CreateLink(output.PortId, 0x7FFF_0000)); + Assert.ThrowsExactly(() => + registry.CreateLink(0x7FFF_0000, input.PortId) + ); + Assert.ThrowsExactly(() => + registry.CreateLink(output.PortId, 0x7FFF_0000) + ); await registry.DestroyGlobalAsync(link.LinkId, cts.Token); await registry.DestroyGlobalAsync(source.NodeId, cts.Token); @@ -412,20 +562,28 @@ public async Task ArbitraryCreationProperties_ReachTheDaemon() // with no named method on the builder still comes back on the created object. RequireLinux(); using var cts = new CancellationTokenSource(Budget); - (PipeWireContext ctx, PipeWireRegistry registry) = await ConnectAsync("pwnet-props", cts.Token); + (PipeWireContext ctx, PipeWireRegistry registry) = await ConnectAsync( + "pwnet-props", + cts.Token + ); await using (ctx) await using (registry) { string nick = $"pwnet nick {Random.Shared.Next():x}"; - PipeWireNode node = await registry.CreateVirtualSink("Props") + PipeWireNode node = await registry + .CreateVirtualSink("Props") .WithName($"pwnet_props_{Environment.ProcessId}_{Random.Shared.Next():x}") .WithProperty("node.nick", nick) .WithProperty("node.virtual", "true") .ExecuteAsync(cts.Token); PipeWireNode read = registry.Current.GetNode(node.NodeId)!; - Assert.AreEqual(nick, read.NodeNick, "a property with no named builder method was dropped"); + Assert.AreEqual( + nick, + read.NodeNick, + "a property with no named builder method was dropped" + ); await registry.DestroyGlobalAsync(node.NodeId, cts.Token); } @@ -439,20 +597,27 @@ public async Task ACallerProperty_OverridesTheLibrarysDefault() // so does every caller override, which is worth pinning rather than assuming. RequireLinux(); using var cts = new CancellationTokenSource(Budget); - (PipeWireContext ctx, PipeWireRegistry registry) = await ConnectAsync("pwnet-override", cts.Token); + (PipeWireContext ctx, PipeWireRegistry registry) = await ConnectAsync( + "pwnet-override", + cts.Token + ); await using (ctx) await using (registry) { // media.class is one the library sets itself, reached here through the general form // rather than the named helper, so this covers both. - PipeWireNode node = await registry.CreateVirtualSink("Override") + PipeWireNode node = await registry + .CreateVirtualSink("Override") .WithName($"pwnet_override_{Environment.ProcessId}_{Random.Shared.Next():x}") .WithProperty("media.class", "Audio/Source") .ExecuteAsync(cts.Token); PipeWireNode read = registry.Current.GetNode(node.NodeId)!; - Assert.AreEqual("Audio/Source", read.MediaClass, - "the library's default won over the caller's property"); + Assert.AreEqual( + "Audio/Source", + read.MediaClass, + "the library's default won over the caller's property" + ); await registry.DestroyGlobalAsync(node.NodeId, cts.Token); } @@ -466,11 +631,15 @@ public async Task ManyCreationProperties_AreAllSent() // proves the sizing is driven by the caller rather than by a fixed guess. RequireLinux(); using var cts = new CancellationTokenSource(Budget); - (PipeWireContext ctx, PipeWireRegistry registry) = await ConnectAsync("pwnet-manyprops", cts.Token); + (PipeWireContext ctx, PipeWireRegistry registry) = await ConnectAsync( + "pwnet-manyprops", + cts.Token + ); await using (ctx) await using (registry) { - PipeWireNodeBuilder creation = registry.CreateVirtualSink("ManyProps") + PipeWireNodeBuilder creation = registry + .CreateVirtualSink("ManyProps") .WithName($"pwnet_many_{Environment.ProcessId}_{Random.Shared.Next():x}"); // Past the eight the library sets itself, and long enough to overflow the stack scratch. @@ -479,8 +648,10 @@ public async Task ManyCreationProperties_AreAllSent() PipeWireNode node = await creation.ExecuteAsync(cts.Token); - Assert.IsNotNull(registry.Current.GetNode(node.NodeId), - "a node with many properties did not reach the graph"); + Assert.IsNotNull( + registry.Current.GetNode(node.NodeId), + "a node with many properties did not reach the graph" + ); await registry.DestroyGlobalAsync(node.NodeId, cts.Token); } @@ -491,14 +662,17 @@ public async Task ACreationPropertyWithNoKey_IsRefused() { RequireLinux(); using var cts = new CancellationTokenSource(Budget); - (PipeWireContext ctx, PipeWireRegistry registry) = await ConnectAsync("pwnet-props-guard", cts.Token); + (PipeWireContext ctx, PipeWireRegistry registry) = await ConnectAsync( + "pwnet-props-guard", + cts.Token + ); await using (ctx) await using (registry) { - PipeWireNodeBuilder creation = registry.CreateVirtualSink("Guard"); + PipeWireNodeBuilder creation = registry.CreateVirtualSink("Guard"); - Assert.ThrowsExactly(() => creation.WithProperty("", "v")); - Assert.ThrowsExactly(() => creation.WithProperty("k", null!)); + Assert.ThrowsExactly(() => creation.WithProperty("", "v")); + Assert.ThrowsExactly(() => creation.WithProperty("k", null!)); } } @@ -509,31 +683,46 @@ public async Task LingeringIds_ListsWhatWasLeftBehindUntilDestroyed() // what a later session destroys explicitly instead of rediscovering ids by name. RequireLinux(); using var cts = new CancellationTokenSource(Budget); - (PipeWireContext context, PipeWireRegistry registry) = await ConnectAsync("pwnet-linger-list", cts.Token); + (PipeWireContext context, PipeWireRegistry registry) = await ConnectAsync( + "pwnet-linger-list", + cts.Token + ); await using (context) await using (registry) { - PipeWireNode node = await registry.CreateVirtualSink("Listed") + PipeWireNode node = await registry + .CreateVirtualSink("Listed") .WithName(Unique("pwnet_linger_listed")) .WithLinger() .ExecuteAsync(cts.Token); - Assert.IsTrue(registry.LingeringIds.Contains(node.NodeId), - "a node created lingering must be listed until destroyed"); + Assert.IsTrue( + registry.LingeringIds.Contains(node.NodeId), + "a node created lingering must be listed until destroyed" + ); await registry.DestroyGlobalAsync(node.NodeId, cts.Token); PipeWireGraphSnapshot gone = await WaitForAsync( - registry, g => g.GetNode(node.NodeId) is null, cts.Token); + registry, + g => g.GetNode(node.NodeId) is null, + cts.Token + ); Assert.IsNotNull(gone); - Assert.IsFalse(registry.LingeringIds.Contains(node.NodeId), - "a destroyed node must leave the listing with its removal"); + Assert.IsFalse( + registry.LingeringIds.Contains(node.NodeId), + "a destroyed node must leave the listing with its removal" + ); } } private static async Task PortAsync( - PipeWireRegistry registry, uint nodeId, PipeWirePortDirection direction, CancellationToken cancellationToken) + PipeWireRegistry registry, + uint nodeId, + PipeWirePortDirection direction, + CancellationToken cancellationToken + ) { while (true) { @@ -542,7 +731,8 @@ private static async Task PortAsync( foreach (PipeWirePort port in registry.Current.GetPortsForNode(nodeId)) { - if (port.PortDirection == direction) return port; + if (port.PortDirection == direction) + return port; } } } diff --git a/tests/PipeWire.NET.Tests/GraphIntegrationTests.cs b/tests/PipeWire.NET.Tests/GraphIntegrationTests.cs index 73c755a..c21a188 100644 --- a/tests/PipeWire.NET.Tests/GraphIntegrationTests.cs +++ b/tests/PipeWire.NET.Tests/GraphIntegrationTests.cs @@ -23,7 +23,8 @@ private static void RequireLinux() } private static async Task<(PipeWireContext Context, PipeWireRegistry Registry)> ConnectAsync( - CancellationToken cancellationToken) + CancellationToken cancellationToken + ) { var context = new PipeWireContext("pwnet-graph-tests", ConsoleTestLoggerFactory.Instance); await context.StartAsync(cancellationToken); @@ -36,7 +37,8 @@ private static void RequireLinux() private static async Task WaitForAsync( PipeWireRegistry registry, Func until, - CancellationToken cancellationToken) + CancellationToken cancellationToken + ) { await foreach (PipeWireGraphSnapshot graph in registry.WatchAsync(cancellationToken)) if (until(graph)) @@ -46,8 +48,10 @@ private static async Task WaitForAsync( } private static Task WaitForPortsAsync( - PipeWireRegistry registry, uint nodeId, CancellationToken cancellationToken) => - WaitForAsync(registry, g => g.GetPortsForNode(nodeId).Length == 4, cancellationToken); + PipeWireRegistry registry, + uint nodeId, + CancellationToken cancellationToken + ) => WaitForAsync(registry, g => g.GetPortsForNode(nodeId).Length == 4, cancellationToken); [TestMethod] public async Task InitialEnumeration_ReportsTheGraphWithoutASettleDelay() @@ -74,15 +78,26 @@ public async Task CreateVirtualNode_AppearsInTheGraphWithFourPorts() await using (registry) { PipeWireNode node = await registry.CreateVirtualSinkAsync( - "PipeWire.NET test sink", "pwnet_test_sink", cts.Token); + "PipeWire.NET test sink", + "pwnet_test_sink", + cts.Token + ); - Assert.IsNotNull(registry.Current.GetNode(node.NodeId), - "the node must be in the graph by the time the call returns"); + Assert.IsNotNull( + registry.Current.GetNode(node.NodeId), + "the node must be in the graph by the time the call returns" + ); PipeWireGraphSnapshot graph = await WaitForPortsAsync(registry, node.NodeId, cts.Token); - Assert.AreEqual(2, graph.GetPortsForNode(node.NodeId, PipeWirePortDirection.In).Count()); - Assert.AreEqual(2, graph.GetPortsForNode(node.NodeId, PipeWirePortDirection.Out).Count()); + Assert.AreEqual( + 2, + graph.GetPortsForNode(node.NodeId, PipeWirePortDirection.In).Count() + ); + Assert.AreEqual( + 2, + graph.GetPortsForNode(node.NodeId, PipeWirePortDirection.Out).Count() + ); } } @@ -100,22 +115,36 @@ public async Task CreateLink_ThenRemove_IsVisibleFromBothPorts() PipeWireGraphSnapshot ready = await WaitForAsync( registry, - g => g.GetPortsForNode(a.NodeId).Length == 4 && g.GetPortsForNode(b.NodeId).Length == 4, - cts.Token); + g => + g.GetPortsForNode(a.NodeId).Length == 4 + && g.GetPortsForNode(b.NodeId).Length == 4, + cts.Token + ); - PipeWirePort output = ready.GetPortsForNode(a.NodeId, PipeWirePortDirection.Out).First(); + PipeWirePort output = ready + .GetPortsForNode(a.NodeId, PipeWirePortDirection.Out) + .First(); PipeWirePort input = ready.GetPortsForNode(b.NodeId, PipeWirePortDirection.In).First(); PipeWireLink link = await registry.CreateLinkAsync(output, input, cts.Token); PipeWireGraphSnapshot linked = registry.Current; Assert.IsNotNull(linked.GetLink(link.LinkId)); - Assert.AreEqual(1, linked.GetOutputLinksForPort(output.PortId).Length, - "the link must be reachable from the output port"); - Assert.AreEqual(1, linked.GetInputLinksForPort(input.PortId).Length, - "and from the input port"); - Assert.AreEqual(0, linked.GetInputLinksForPort(output.PortId).Length, - "but not as an input of the output port"); + Assert.AreEqual( + 1, + linked.GetOutputLinksForPort(output.PortId).Length, + "the link must be reachable from the output port" + ); + Assert.AreEqual( + 1, + linked.GetInputLinksForPort(input.PortId).Length, + "and from the input port" + ); + Assert.AreEqual( + 0, + linked.GetInputLinksForPort(output.PortId).Length, + "but not as an input of the output port" + ); await registry.RemoveLinkAsync(link, cts.Token); await WaitForAsync(registry, g => g.GetLink(link.LinkId) is null, cts.Token); @@ -131,14 +160,23 @@ public async Task CreateLink_RejectsPortsFacingTheWrongWay() await using (context) await using (registry) { - PipeWireNode node = await registry.CreateVirtualSinkAsync("R", "pwnet_reject", cts.Token); + PipeWireNode node = await registry.CreateVirtualSinkAsync( + "R", + "pwnet_reject", + cts.Token + ); PipeWireGraphSnapshot graph = await WaitForPortsAsync(registry, node.NodeId, cts.Token); - PipeWirePort output = graph.GetPortsForNode(node.NodeId, PipeWirePortDirection.Out).First(); - PipeWirePort input = graph.GetPortsForNode(node.NodeId, PipeWirePortDirection.In).First(); + PipeWirePort output = graph + .GetPortsForNode(node.NodeId, PipeWirePortDirection.Out) + .First(); + PipeWirePort input = graph + .GetPortsForNode(node.NodeId, PipeWirePortDirection.In) + .First(); - await Assert.ThrowsExactlyAsync( - () => registry.CreateLinkAsync(input, output, cts.Token)); + await Assert.ThrowsExactlyAsync(() => + registry.CreateLinkAsync(input, output, cts.Token) + ); } } @@ -163,11 +201,18 @@ public async Task GranularEvents_NeverPrecedeTheSnapshotTheyDescribe() violations.Add($"node {n.NodeId}"); }; - PipeWireNode node = await registry.CreateVirtualSinkAsync("O", "pwnet_order", cts.Token); + PipeWireNode node = await registry.CreateVirtualSinkAsync( + "O", + "pwnet_order", + cts.Token + ); await WaitForPortsAsync(registry, node.NodeId, cts.Token); - CollectionAssert.AreEqual(Array.Empty(), violations, - "Current must already contain whatever an event announces"); + CollectionAssert.AreEqual( + Array.Empty(), + violations, + "Current must already contain whatever an event announces" + ); } } @@ -183,8 +228,9 @@ public async Task CreateVirtualNode_HonoursCancellation() using var cancelled = new CancellationTokenSource(); await cancelled.CancelAsync(); - await Assert.ThrowsAsync( - () => registry.CreateVirtualSinkAsync("C", "pwnet_cancel", cancelled.Token)); + await Assert.ThrowsAsync(() => + registry.CreateVirtualSinkAsync("C", "pwnet_cancel", cancelled.Token) + ); } } @@ -202,8 +248,15 @@ public async Task PublishedSnapshots_DoNotChangeUnderTheirHolder() await registry.CreateVirtualSinkAsync("I", "pwnet_immutable", cts.Token); - Assert.AreEqual(nodesBefore, before.Nodes.Length, "an already-published snapshot must not change"); - Assert.IsTrue(registry.Current.Version > before.Version, "a new snapshot must have been published"); + Assert.AreEqual( + nodesBefore, + before.Nodes.Length, + "an already-published snapshot must not change" + ); + Assert.IsTrue( + registry.Current.Version > before.Version, + "a new snapshot must have been published" + ); } } } diff --git a/tests/PipeWire.NET.Tests/GraphInvariantTests.cs b/tests/PipeWire.NET.Tests/GraphInvariantTests.cs index 4d9d26f..996c34b 100644 --- a/tests/PipeWire.NET.Tests/GraphInvariantTests.cs +++ b/tests/PipeWire.NET.Tests/GraphInvariantTests.cs @@ -63,7 +63,10 @@ public async Task TheRegistryReportsEachGlobalOnce_EvenWhileTheObjectChanges() // InfoChanged. This pins the assumption that costs. RequireLinux(); using var cts = new CancellationTokenSource(Budget); - await using var ctx = new PipeWireContext("pwnet-global-once", ConsoleTestLoggerFactory.Instance); + await using var ctx = new PipeWireContext( + "pwnet-global-once", + ConsoleTestLoggerFactory.Instance + ); await ctx.StartAsync(cts.Token); await using var registry = new PipeWireRegistry(ctx); await registry.WaitForInitialEnumerationAsync(cts.Token); @@ -74,10 +77,14 @@ public async Task TheRegistryReportsEachGlobalOnce_EvenWhileTheObjectChanges() registry.NodeAdded += OnAdded; try { - PipeWireNode source = await registry.CreateVirtualSink("GlobalOnce") - .WithName($"pwnet_once_src_{Environment.ProcessId}_{Random.Shared.Next():x}").ExecuteAsync(cts.Token); - PipeWireNode sink = await registry.CreateVirtualSink("GlobalOnce") - .WithName($"pwnet_once_sink_{Environment.ProcessId}_{Random.Shared.Next():x}").ExecuteAsync(cts.Token); + PipeWireNode source = await registry + .CreateVirtualSink("GlobalOnce") + .WithName($"pwnet_once_src_{Environment.ProcessId}_{Random.Shared.Next():x}") + .ExecuteAsync(cts.Token); + PipeWireNode sink = await registry + .CreateVirtualSink("GlobalOnce") + .WithName($"pwnet_once_sink_{Environment.ProcessId}_{Random.Shared.Next():x}") + .ExecuteAsync(cts.Token); // Things that change a node without creating or destroying it: linking it, which // moves it out of idle, and writing a param, which the daemon applies to it. @@ -98,17 +105,25 @@ async Task PortsAsync(uint nodeId, PipeWirePortDirection directi await registry.WaitForInitialEnumerationAsync(cts.Token); PipeWirePort[] found = - [.. registry.Current.GetPortsForNode(nodeId).Where(p => p.PortDirection == direction)]; - if (found.Length > 0) return found; + [ + .. registry + .Current.GetPortsForNode(nodeId) + .Where(p => p.PortDirection == direction), + ]; + if (found.Length > 0) + return found; } } PipeWireLink link = await registry.CreateLinkAsync(outs[0], ins[0], cts.Token); await registry.WaitForInitialEnumerationAsync(cts.Token); - Assert.AreEqual(1, seen.GetValueOrDefault(source.NodeId), + Assert.AreEqual( + 1, + seen.GetValueOrDefault(source.NodeId), "the registry announced a node more than once, so it does carry updates and " - + "the graph could raise a change event without binding anything"); + + "the graph could raise a change event without binding anything" + ); await registry.DestroyGlobalAsync(link.LinkId, cts.Token); await registry.DestroyGlobalAsync(source.NodeId, cts.Token); @@ -127,7 +142,10 @@ public async Task DanglingReferencesSeenDuringChurn_DoNotSurviveIt() RequireLinux(); using var cts = new CancellationTokenSource(Budget); - await using var ctx = new PipeWireContext("pwnet-invariant", ConsoleTestLoggerFactory.Instance); + await using var ctx = new PipeWireContext( + "pwnet-invariant", + ConsoleTestLoggerFactory.Instance + ); await ctx.StartAsync(cts.Token); await using var registry = new PipeWireRegistry(ctx); await registry.WaitForInitialEnumerationAsync(cts.Token); @@ -139,7 +157,8 @@ void OnChanged(PipeWireRegistry _, PipeWireGraphSnapshot g) { Interlocked.Increment(ref snapshots); List broken = DanglingReferences(g); - if (broken.Count > 0) observed.Enqueue((g.Version, broken)); + if (broken.Count > 0) + observed.Enqueue((g.Version, broken)); } registry.GraphChanged += OnChanged; @@ -147,7 +166,8 @@ void OnChanged(PipeWireRegistry _, PipeWireGraphSnapshot g) { for (int round = 0; round < 25; round++) { - PipeWireNode node = await registry.CreateVirtualSink("Invariant") + PipeWireNode node = await registry + .CreateVirtualSink("Invariant") .WithName($"pwnet_inv_{Environment.ProcessId}_{round}_{Random.Shared.Next():x}") .ExecuteAsync(cts.Token); @@ -161,7 +181,10 @@ void OnChanged(PipeWireRegistry _, PipeWireGraphSnapshot g) registry.GraphChanged -= OnChanged; } - Assert.IsTrue(Volatile.Read(ref snapshots) > 0, "the churn published no snapshots to check"); + Assert.IsTrue( + Volatile.Read(ref snapshots) > 0, + "the churn published no snapshots to check" + ); // Not asserted per snapshot: a node and its ports are removed by separate global_remove // events, so a snapshot published between them legitimately shows ports whose node is @@ -169,12 +192,17 @@ void OnChanged(PipeWireRegistry _, PipeWireGraphSnapshot g) // observations are carried into the failure message, because "the settled graph has a // dangling port" is only diagnosable alongside when that reference first appeared. List settled = DanglingReferences(registry.Current); - Assert.AreEqual(0, settled.Count, + Assert.AreEqual( + 0, + settled.Count, $"the settled graph has dangling references: {string.Join("; ", settled)}. " - + $"{observed.Count} of {Volatile.Read(ref snapshots)} snapshots dangled mid-churn" - + (observed.TryPeek(out (long Version, List Broken) first) - ? $", first at version {first.Version}: {string.Join("; ", first.Broken)}" - : string.Empty)); + + $"{observed.Count} of {Volatile.Read(ref snapshots)} snapshots dangled mid-churn" + + ( + observed.TryPeek(out (long Version, List Broken) first) + ? $", first at version {first.Version}: {string.Join("; ", first.Broken)}" + : string.Empty + ) + ); } [TestMethod] @@ -183,12 +211,16 @@ public async Task ASnapshotHeldAcrossChurn_KeepsEveryReferenceItCouldResolveWhen RequireLinux(); using var cts = new CancellationTokenSource(Budget); - await using var ctx = new PipeWireContext("pwnet-invariant-held", ConsoleTestLoggerFactory.Instance); + await using var ctx = new PipeWireContext( + "pwnet-invariant-held", + ConsoleTestLoggerFactory.Instance + ); await ctx.StartAsync(cts.Token); await using var registry = new PipeWireRegistry(ctx); await registry.WaitForInitialEnumerationAsync(cts.Token); - PipeWireNode node = await registry.CreateVirtualSink("Held") + PipeWireNode node = await registry + .CreateVirtualSink("Held") .WithName($"pwnet_held_{Environment.ProcessId}_{Random.Shared.Next():x}") .ExecuteAsync(cts.Token); @@ -209,9 +241,15 @@ public async Task ASnapshotHeldAcrossChurn_KeepsEveryReferenceItCouldResolveWhen await registry.WaitForInitialEnumerationAsync(cts.Token); Assert.IsNotNull(held.GetNode(node.NodeId), "a held snapshot lost a node it had"); - Assert.AreEqual(ports.Length, held.GetPortsForNode(node.NodeId).Length, - "a held snapshot lost ports it had"); - Assert.AreEqual(0, DanglingReferences(held).Count, - "a held snapshot developed dangling references after the graph moved on"); + Assert.AreEqual( + ports.Length, + held.GetPortsForNode(node.NodeId).Length, + "a held snapshot lost ports it had" + ); + Assert.AreEqual( + 0, + DanglingReferences(held).Count, + "a held snapshot developed dangling references after the graph moved on" + ); } } diff --git a/tests/PipeWire.NET.Tests/GraphLifetimeTests.cs b/tests/PipeWire.NET.Tests/GraphLifetimeTests.cs index 62bf2cf..2052b7e 100644 --- a/tests/PipeWire.NET.Tests/GraphLifetimeTests.cs +++ b/tests/PipeWire.NET.Tests/GraphLifetimeTests.cs @@ -30,7 +30,9 @@ private static void RequireLinux() } private static async Task<(PipeWireContext Context, PipeWireRegistry Registry)> ConnectAsync( - string name, CancellationToken cancellationToken) + string name, + CancellationToken cancellationToken + ) { var context = new PipeWireContext(name, ConsoleTestLoggerFactory.Instance); await context.StartAsync(cancellationToken); @@ -42,7 +44,8 @@ private static void RequireLinux() private static async Task WaitForAsync( PipeWireRegistry registry, Func until, - CancellationToken cancellationToken) + CancellationToken cancellationToken + ) { await foreach (PipeWireGraphSnapshot graph in registry.WatchAsync(cancellationToken)) if (until(graph)) @@ -58,8 +61,10 @@ public async Task WhenANodeGoesAway_ItsPortsLeaveTheGraphToo() using var cts = new CancellationTokenSource(Budget); // The observer outlives the owner, so it sees the whole removal sequence. - (PipeWireContext observerContext, PipeWireRegistry observer) = - await ConnectAsync("pwnet-observer", cts.Token); + (PipeWireContext observerContext, PipeWireRegistry observer) = await ConnectAsync( + "pwnet-observer", + cts.Token + ); await using (observerContext) await using (observer) @@ -67,26 +72,40 @@ public async Task WhenANodeGoesAway_ItsPortsLeaveTheGraphToo() uint nodeId; uint[] portIds; - (PipeWireContext ownerContext, PipeWireRegistry owner) = - await ConnectAsync("pwnet-owner", cts.Token); + (PipeWireContext ownerContext, PipeWireRegistry owner) = await ConnectAsync( + "pwnet-owner", + cts.Token + ); await using (ownerContext) { PipeWireNode node = await owner.CreateVirtualSinkAsync( - "Cascade", "pwnet_cascade", cts.Token); + "Cascade", + "pwnet_cascade", + cts.Token + ); nodeId = node.NodeId; PipeWireGraphSnapshot seen = await WaitForAsync( - observer, g => g.GetPortsForNode(nodeId).Length == 4, cts.Token); + observer, + g => g.GetPortsForNode(nodeId).Length == 4, + cts.Token + ); portIds = [.. seen.GetPortsForNode(nodeId).Select(p => p.PortId)]; await owner.DisposeAsync(); } PipeWireGraphSnapshot after = await WaitForAsync( - observer, g => g.GetNode(nodeId) is null, cts.Token); - - Assert.AreEqual(0, after.GetPortsForNode(nodeId).Length, - "the node's ports must not outlive it in the adjacency index"); + observer, + g => g.GetNode(nodeId) is null, + cts.Token + ); + + Assert.AreEqual( + 0, + after.GetPortsForNode(nodeId).Length, + "the node's ports must not outlive it in the adjacency index" + ); foreach (uint portId in portIds) Assert.IsNull(after.GetPort(portId), $"port {portId} is still in the graph"); } @@ -97,12 +116,18 @@ public async Task DisposingTheOwningRegistryTwice_DestroysEachProxyOnce() { RequireLinux(); using var cts = new CancellationTokenSource(Budget); - (PipeWireContext context, PipeWireRegistry registry) = await ConnectAsync("pwnet-dispose", cts.Token); + (PipeWireContext context, PipeWireRegistry registry) = await ConnectAsync( + "pwnet-dispose", + cts.Token + ); await using (context) { PipeWireNode node = await registry.CreateVirtualSinkAsync( - "D", "pwnet_dispose_node", cts.Token); + "D", + "pwnet_dispose_node", + cts.Token + ); // A second pw_proxy_destroy trips an assertion in PipeWire and aborts the process, so // surviving this is necessary - but survival alone would also hold if disposal did @@ -110,15 +135,22 @@ public async Task DisposingTheOwningRegistryTwice_DestroysEachProxyOnce() await registry.DisposeAsync(); await registry.DisposeAsync(); - (PipeWireContext watcherContext, PipeWireRegistry watcher) = - await ConnectAsync("pwnet-dispose-watch", cts.Token); + (PipeWireContext watcherContext, PipeWireRegistry watcher) = await ConnectAsync( + "pwnet-dispose-watch", + cts.Token + ); await using (watcherContext) await using (watcher) { PipeWireGraphSnapshot after = await WaitForAsync( - watcher, g => g.GetNode(node.NodeId) is null, cts.Token); - Assert.IsNull(after.GetNode(node.NodeId), - "disposing the owner must destroy the node it created"); + watcher, + g => g.GetNode(node.NodeId) is null, + cts.Token + ); + Assert.IsNull( + after.GetNode(node.NodeId), + "disposing the owner must destroy the node it created" + ); } } } @@ -128,7 +160,10 @@ public async Task ADestroyedNodesProxyIsReleasedByTheRemovalPath() { RequireLinux(); using var cts = new CancellationTokenSource(Budget); - (PipeWireContext context, PipeWireRegistry registry) = await ConnectAsync("pwnet-release", cts.Token); + (PipeWireContext context, PipeWireRegistry registry) = await ConnectAsync( + "pwnet-release", + cts.Token + ); await using (context) await using (registry) @@ -138,25 +173,38 @@ public async Task ADestroyedNodesProxyIsReleasedByTheRemovalPath() PipeWireGraphSnapshot ready = await WaitForAsync( registry, - g => g.GetPortsForNode(a.NodeId).Length == 4 && g.GetPortsForNode(b.NodeId).Length == 4, - cts.Token); + g => + g.GetPortsForNode(a.NodeId).Length == 4 + && g.GetPortsForNode(b.NodeId).Length == 4, + cts.Token + ); PipeWireLink link = await registry.CreateLinkAsync( ready.GetPortsForNode(a.NodeId, PipeWirePortDirection.Out).First(), ready.GetPortsForNode(b.NodeId, PipeWirePortDirection.In).First(), - cts.Token); + cts.Token + ); // Removal arrives as global_remove, which hands the proxy to the handle; disposal then // must not destroy it a second time. - Assert.IsNotNull(registry.Current.GetLink(link.LinkId), "the link must exist before removal"); + Assert.IsNotNull( + registry.Current.GetLink(link.LinkId), + "the link must exist before removal" + ); await registry.RemoveLinkAsync(link, cts.Token); PipeWireGraphSnapshot after = await WaitForAsync( - registry, g => g.GetLink(link.LinkId) is null, cts.Token); + registry, + g => g.GetLink(link.LinkId) is null, + cts.Token + ); Assert.IsNull(after.GetLink(link.LinkId)); - Assert.AreEqual(0, after.GetOutputLinksForPort(link.OutputPortId).Length, - "the removed link must leave the adjacency index too"); + Assert.AreEqual( + 0, + after.GetOutputLinksForPort(link.OutputPortId).Length, + "the removed link must leave the adjacency index too" + ); } } @@ -165,13 +213,20 @@ public async Task WatchAsync_NeverGoesBackwardsAndEndsAtTheCurrentGraph() { RequireLinux(); using var cts = new CancellationTokenSource(Budget); - (PipeWireContext context, PipeWireRegistry registry) = await ConnectAsync("pwnet-watch", cts.Token); + (PipeWireContext context, PipeWireRegistry registry) = await ConnectAsync( + "pwnet-watch", + cts.Token + ); await using (context) await using (registry) { var seen = new List(); - PipeWireNode node = await registry.CreateVirtualSinkAsync("W", "pwnet_watch", cts.Token); + PipeWireNode node = await registry.CreateVirtualSinkAsync( + "W", + "pwnet_watch", + cts.Token + ); await foreach (PipeWireGraphSnapshot graph in registry.WatchAsync(cts.Token)) { @@ -179,8 +234,10 @@ public async Task WatchAsync_NeverGoesBackwardsAndEndsAtTheCurrentGraph() // A dropped snapshot is allowed; an out-of-order one is not. if (seen.Count > 1) - Assert.IsTrue(seen[^1] > seen[^2], - $"version went {seen[^2]} -> {seen[^1]}; the stream must only move forward"); + Assert.IsTrue( + seen[^1] > seen[^2], + $"version went {seen[^2]} -> {seen[^1]}; the stream must only move forward" + ); if (graph.GetPortsForNode(node.NodeId).Length == 4) break; @@ -198,7 +255,10 @@ public async Task DisposingTheRegistry_EndsAnOpenWatch() // for ever, holding the test host with it. RequireLinux(); using var cts = new CancellationTokenSource(Budget); - (PipeWireContext context, PipeWireRegistry registry) = await ConnectAsync("pwnet-watchend", cts.Token); + (PipeWireContext context, PipeWireRegistry registry) = await ConnectAsync( + "pwnet-watchend", + cts.Token + ); await using (context) { @@ -218,24 +278,39 @@ public async Task ACreatedNodeCarriesUsablePermissions() { RequireLinux(); using var cts = new CancellationTokenSource(Budget); - (PipeWireContext context, PipeWireRegistry registry) = await ConnectAsync("pwnet-perms", cts.Token); + (PipeWireContext context, PipeWireRegistry registry) = await ConnectAsync( + "pwnet-perms", + cts.Token + ); await using (context) await using (registry) { - PipeWireNode node = await registry.CreateVirtualSinkAsync("P", "pwnet_perms", cts.Token); - - Assert.AreNotEqual(PipeWirePermissions.None, node.Permissions, - "the registry must decode the permission bits the daemon reports"); + PipeWireNode node = await registry.CreateVirtualSinkAsync( + "P", + "pwnet_perms", + cts.Token + ); + + Assert.AreNotEqual( + PipeWirePermissions.None, + node.Permissions, + "the registry must decode the permission bits the daemon reports" + ); Assert.IsTrue(node.Permissions.HasFlag(PipeWirePermissions.Read)); Assert.IsTrue(node.CanInvokeMethods, "a node this client created is its own to drive"); PipeWireGraphSnapshot graph = await WaitForAsync( - registry, g => g.GetPortsForNode(node.NodeId).Length == 4, cts.Token); + registry, + g => g.GetPortsForNode(node.NodeId).Length == 4, + cts.Token + ); foreach (PipeWirePort port in graph.GetPortsForNode(node.NodeId)) - Assert.IsTrue(port.Permissions.HasFlag(PipeWirePermissions.Read), - $"port {port.PortId} came back unreadable"); + Assert.IsTrue( + port.Permissions.HasFlag(PipeWirePermissions.Read), + $"port {port.PortId} came back unreadable" + ); } } @@ -249,30 +324,61 @@ public async Task EverythingBuiltDeliberatelyLeftBehind_CanStillBeTornDown() // of all of them. Anything left behind is a disposal path that does not work. RequireLinux(); using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(60)); - (PipeWireContext context, PipeWireRegistry registry) = await ConnectAsync("pwnet-cleanup", cts.Token); + (PipeWireContext context, PipeWireRegistry registry) = await ConnectAsync( + "pwnet-cleanup", + cts.Token + ); string tag = $"pwnet_cleanup_{Environment.ProcessId}_{Random.Shared.Next():x}"; await using (context) await using (registry) { - PipeWireNode a = await registry.CreateVirtualSink("Cleanup A") - .WithName(tag + "_a").WithLinger().ExecuteAsync(cts.Token); - PipeWireNode b = await registry.CreateVirtualSink("Cleanup B") - .WithName(tag + "_b").WithLinger().ExecuteAsync(cts.Token); - - PipeWirePort output = await WaitForPortAsync(registry, a.NodeId, PipeWirePortDirection.Out, cts.Token); - PipeWirePort input = await WaitForPortAsync(registry, b.NodeId, PipeWirePortDirection.In, cts.Token); - PipeWireLink link = await registry.CreateLink(output, input).WithLinger().ExecuteAsync(cts.Token); + PipeWireNode a = await registry + .CreateVirtualSink("Cleanup A") + .WithName(tag + "_a") + .WithLinger() + .ExecuteAsync(cts.Token); + PipeWireNode b = await registry + .CreateVirtualSink("Cleanup B") + .WithName(tag + "_b") + .WithLinger() + .ExecuteAsync(cts.Token); + + PipeWirePort output = await WaitForPortAsync( + registry, + a.NodeId, + PipeWirePortDirection.Out, + cts.Token + ); + PipeWirePort input = await WaitForPortAsync( + registry, + b.NodeId, + PipeWirePortDirection.In, + cts.Token + ); + PipeWireLink link = await registry + .CreateLink(output, input) + .WithLinger() + .ExecuteAsync(cts.Token); using PipeWireDeviceProvider device = PipeWireDeviceProvider.Create( - context, tag + "_device", "A device this test withdraws"); - using PipeWireMetadataProvider served = PipeWireMetadataProvider.Create(context, tag + "_meta"); + context, + tag + "_device", + "A device this test withdraws" + ); + using PipeWireMetadataProvider served = PipeWireMetadataProvider.Create( + context, + tag + "_meta" + ); await served.ReadyAsync(cts.Token); // Bound from a second connection, which is what stores are for: binding a store // this same connection serves wedges the session, so no test does that here. - await using var reader = new PipeWireContext("pwnet-cleanup-read", ConsoleTestLoggerFactory.Instance); + await using var reader = new PipeWireContext( + "pwnet-cleanup-read", + ConsoleTestLoggerFactory.Instance + ); await reader.StartAsync(cts.Token); await using var readerRegistry = new PipeWireRegistry(reader); await readerRegistry.WaitForInitialEnumerationAsync(cts.Token); @@ -300,7 +406,9 @@ public async Task EverythingBuiltDeliberatelyLeftBehind_CanStillBeTornDown() Assert.IsNotNull(built.GetNode(a.NodeId)); Assert.IsNotNull(built.GetNode(b.NodeId)); Assert.IsNotNull(built.GetLink(link.LinkId)); - Assert.IsNotNull(built.Devices.FirstOrDefault(d => d.DeviceName == tag + "_device")); + Assert.IsNotNull( + built.Devices.FirstOrDefault(d => d.DeviceName == tag + "_device") + ); Assert.AreEqual("v", store.Get(tag + ".k")); // Tear down through each kind's own API, in dependency order. @@ -324,9 +432,12 @@ public async Task EverythingBuiltDeliberatelyLeftBehind_CanStillBeTornDown() { await registry.WaitForInitialEnumerationAsync(cts.Token); PipeWireGraphSnapshot seen = registry.Current; - if (!LeftoversPresent(seen, tag) + if ( + !LeftoversPresent(seen, tag) && !seen.Links.Any(l => - l.OutputPortId == output.PortId && l.InputPortId == input.PortId)) + l.OutputPortId == output.PortId && l.InputPortId == input.PortId + ) + ) { break; } @@ -336,15 +447,22 @@ public async Task EverythingBuiltDeliberatelyLeftBehind_CanStillBeTornDown() PipeWireGraphSnapshot final = registry.Current; Assert.IsFalse(LeftoversPresent(final, tag), DescribeLeftovers(final, tag)); - Assert.IsFalse(final.Links.Any(l => - l.OutputPortId == output.PortId && l.InputPortId == input.PortId), - "a link through our ports survived their nodes"); + Assert.IsFalse( + final.Links.Any(l => + l.OutputPortId == output.PortId && l.InputPortId == input.PortId + ), + "a link through our ports survived their nodes" + ); Assert.IsTrue(final.Nodes.Length > 0, "the session stopped answering"); } } private static async Task WaitForPortAsync( - PipeWireRegistry registry, uint nodeId, PipeWirePortDirection direction, CancellationToken cancellationToken) + PipeWireRegistry registry, + uint nodeId, + PipeWirePortDirection direction, + CancellationToken cancellationToken + ) { // Ports arrive after their node, on the daemon's own schedule, which under load is // seconds, not milliseconds. Bounded by the caller's budget rather than an attempt @@ -357,7 +475,8 @@ private static async Task WaitForPortAsync( { cancellationToken.ThrowIfCancellationRequested(); - PipeWirePort? port = registry.Current.GetPortsForNode(nodeId) + PipeWirePort? port = registry + .Current.GetPortsForNode(nodeId) .FirstOrDefault(p => p.PortDirection == direction); if (port is not null) return port; @@ -374,12 +493,17 @@ private static async Task WaitForPortAsync( } private static bool LeftoversPresent(PipeWireGraphSnapshot graph, string tag) => - graph.Nodes.Any(n => n.NodeName is not null && n.NodeName.Contains(tag, StringComparison.Ordinal)) - || graph.Devices.Any(d => d.DeviceName is not null && d.DeviceName.Contains(tag, StringComparison.Ordinal)) + graph.Nodes.Any(n => + n.NodeName is not null && n.NodeName.Contains(tag, StringComparison.Ordinal) + ) + || graph.Devices.Any(d => + d.DeviceName is not null && d.DeviceName.Contains(tag, StringComparison.Ordinal) + ) || graph.Objects.Any(o => o is PipeWireMetadata metadata && metadata.MetadataName is not null - && metadata.MetadataName.Contains(tag, StringComparison.Ordinal)); + && metadata.MetadataName.Contains(tag, StringComparison.Ordinal) + ); private static string DescribeLeftovers(PipeWireGraphSnapshot graph, string tag) { @@ -391,9 +515,11 @@ private static string DescribeLeftovers(PipeWireGraphSnapshot graph, string tag) if (d.DeviceName is not null && d.DeviceName.Contains(tag, StringComparison.Ordinal)) left.Add($"device {d.Id} '{d.DeviceName}'"); foreach (IPipeWireObject o in graph.Objects) - if (o is PipeWireMetadata metadata + if ( + o is PipeWireMetadata metadata && metadata.MetadataName is not null - && metadata.MetadataName.Contains(tag, StringComparison.Ordinal)) + && metadata.MetadataName.Contains(tag, StringComparison.Ordinal) + ) left.Add($"metadata {o.Id} '{metadata.MetadataName}'"); return $"the graph still holds ours: {string.Join(", ", left)}"; } diff --git a/tests/PipeWire.NET.Tests/GraphModelTests.cs b/tests/PipeWire.NET.Tests/GraphModelTests.cs index 7840197..28239c0 100644 --- a/tests/PipeWire.NET.Tests/GraphModelTests.cs +++ b/tests/PipeWire.NET.Tests/GraphModelTests.cs @@ -16,23 +16,39 @@ namespace PipeWire.NET.Tests; public sealed class GraphModelTests : PipeWireTestBase { private static PipeWireGraphSnapshot Build( - PipeWireNode[]? nodes = null, PipeWirePort[]? ports = null, PipeWireLink[]? links = null) => - new(1, nodes ?? [], ports ?? [], links ?? []); + PipeWireNode[]? nodes = null, + PipeWirePort[]? ports = null, + PipeWireLink[]? links = null + ) => new(1, nodes ?? [], ports ?? [], links ?? []); private static PipeWirePort Port( - uint id, uint nodeId, PipeWirePortDirection dir, bool control = false) => - new(id, nodeId, $"p{id}", dir, Monitor: false, IsControl: control); + uint id, + uint nodeId, + PipeWirePortDirection dir, + bool control = false + ) => new(id, nodeId, $"p{id}", dir, Monitor: false, IsControl: control); [TestMethod] public void GetPortsForNode_ReturnsOnlyThatNodesPorts() { var graph = Build( nodes: [new(1, "a", null, null), new(2, "b", null, null)], - ports: [Port(10, 1, PipeWirePortDirection.Out), Port(11, 1, PipeWirePortDirection.In), - Port(20, 2, PipeWirePortDirection.In)]); - - CollectionAssert.AreEquivalent(new uint[] { 10, 11 }, graph.GetPortsForNode(1).Select(p => p.PortId).ToArray()); - CollectionAssert.AreEquivalent(new uint[] { 20 }, graph.GetPortsForNode(2).Select(p => p.PortId).ToArray()); + ports: + [ + Port(10, 1, PipeWirePortDirection.Out), + Port(11, 1, PipeWirePortDirection.In), + Port(20, 2, PipeWirePortDirection.In), + ] + ); + + CollectionAssert.AreEquivalent( + new uint[] { 10, 11 }, + graph.GetPortsForNode(1).Select(p => p.PortId).ToArray() + ); + CollectionAssert.AreEquivalent( + new uint[] { 20 }, + graph.GetPortsForNode(2).Select(p => p.PortId).ToArray() + ); Assert.AreEqual(0, graph.GetPortsForNode(99).Length); } @@ -42,7 +58,8 @@ public void InputAndOutputLinks_AreNotTheSameSet() // OutputLinks must filter on the output port, so both sides are checked separately. var graph = Build( ports: [Port(10, 1, PipeWirePortDirection.Out), Port(20, 2, PipeWirePortDirection.In)], - links: [new(100, InputNodeId: 2, InputPortId: 20, OutputNodeId: 1, OutputPortId: 10)]); + links: [new(100, InputNodeId: 2, InputPortId: 20, OutputNodeId: 1, OutputPortId: 10)] + ); Assert.AreEqual(1, graph.GetOutputLinksForPort(10).Length); Assert.AreEqual(0, graph.GetInputLinksForPort(10).Length); @@ -65,10 +82,13 @@ public void GetLinksForNode_CoversBothDirections() { var graph = Build( ports: [Port(10, 1, PipeWirePortDirection.Out), Port(11, 1, PipeWirePortDirection.In)], - links: [new(100, 2, 20, 1, 10), new(101, 1, 11, 3, 30)]); + links: [new(100, 2, 20, 1, 10), new(101, 1, 11, 3, 30)] + ); - CollectionAssert.AreEquivalent(new uint[] { 100, 101 }, - graph.GetLinksForNode(1).Select(l => l.LinkId).ToArray()); + CollectionAssert.AreEquivalent( + new uint[] { 100, 101 }, + graph.GetLinksForNode(1).Select(l => l.LinkId).ToArray() + ); } [TestMethod] @@ -80,12 +100,16 @@ public void GetLinksForNode_DoesNotReportAnIntraNodeLinkTwice() var graph = Build( nodes: [new(1, "loopback", null, null)], ports: [Port(10, 1, PipeWirePortDirection.Out), Port(11, 1, PipeWirePortDirection.In)], - links: [new(100, InputNodeId: 1, InputPortId: 11, OutputNodeId: 1, OutputPortId: 10)]); + links: [new(100, InputNodeId: 1, InputPortId: 11, OutputNodeId: 1, OutputPortId: 10)] + ); PipeWireLink[] links = [.. graph.GetLinksForNode(1)]; - Assert.AreEqual(1, links.Length, - $"one link reported {links.Length} times; intra-node links are reachable from both ends"); + Assert.AreEqual( + 1, + links.Length, + $"one link reported {links.Length} times; intra-node links are reachable from both ends" + ); Assert.AreEqual(100u, links[0].LinkId); } @@ -99,10 +123,13 @@ public void GetLinksForNode_StillReportsBothSidesOfSeparateLinks() [ new(100, InputNodeId: 2, InputPortId: 20, OutputNodeId: 1, OutputPortId: 10), new(101, InputNodeId: 1, InputPortId: 11, OutputNodeId: 3, OutputPortId: 30), - ]); + ] + ); - CollectionAssert.AreEquivalent(new uint[] { 100, 101 }, - graph.GetLinksForNode(1).Select(l => l.LinkId).ToArray()); + CollectionAssert.AreEquivalent( + new uint[] { 100, 101 }, + graph.GetLinksForNode(1).Select(l => l.LinkId).ToArray() + ); } [TestMethod] @@ -111,7 +138,8 @@ public void TryGetObject_DispatchesByKind() var graph = Build( nodes: [new(1, "a", null, null)], ports: [Port(10, 1, PipeWirePortDirection.Out)], - links: [new(100, 2, 20, 1, 10)]); + links: [new(100, 2, 20, 1, 10)] + ); Assert.IsTrue(graph.TryGetObject(1, out var node)); Assert.AreEqual(PipeWireObjectKind.Node, node!.Kind); @@ -126,7 +154,14 @@ public void TryGetObject_DispatchesByKind() public void Permissions_DecodeFromTheOctalBitsPipeWireReports() { // 0710 octal is what the daemon reported for a node we created. - var node = new PipeWireNode(1, "a", null, null, null, (PipeWirePermissions)Convert.ToUInt32("710", 8)); + var node = new PipeWireNode( + 1, + "a", + null, + null, + null, + (PipeWirePermissions)Convert.ToUInt32("710", 8) + ); Assert.IsTrue(node.Permissions.HasFlag(PipeWirePermissions.Read)); Assert.IsTrue(node.Permissions.HasFlag(PipeWirePermissions.Write)); Assert.IsTrue(node.Permissions.HasFlag(PipeWirePermissions.Execute)); @@ -140,7 +175,8 @@ public void TryGetPairs_AgreeWithTheNullableGetters() var graph = Build( nodes: [new(1, "a", null, null)], ports: [Port(10, 1, PipeWirePortDirection.Out)], - links: [new(100, 2, 20, 1, 10)]); + links: [new(100, 2, 20, 1, 10)] + ); Assert.IsTrue(graph.TryGetNode(1, out PipeWireNode? node)); Assert.AreSame(graph.GetNode(1), node); @@ -189,12 +225,16 @@ public void AControlPortFacingIn_IsNotOfferedAsADataInput() Assert.IsTrue(control.IsControl); PipeWireGraphSnapshot graph = Build( - nodes: [new(1, "a", null, null)], ports: [control, audio]); + nodes: [new(1, "a", null, null)], + ports: [control, audio] + ); Assert.IsTrue(graph.CanSendTo(graph.Nodes[0])); Assert.IsFalse( - Build(nodes: [new(1, "a", null, null)], ports: [control]).CanSendTo(new(1, "a", null, null)), - "a node whose only input is a control port cannot be sent media"); + Build(nodes: [new(1, "a", null, null)], ports: [control]) + .CanSendTo(new(1, "a", null, null)), + "a node whose only input is a control port cannot be sent media" + ); } [TestMethod] @@ -231,7 +271,9 @@ public void ManyThreadsTouchingALazyIndexAtOnce_BuildItExactlyOnce() PipeWireNode? node = graph.GetNode(n + 1); if (node is null || node.NodeId != n + 1) { - failures.Enqueue($"thread {t} read {node?.NodeId.ToString() ?? "null"} for node {n + 1}"); + failures.Enqueue( + $"thread {t} read {node?.NodeId.ToString() ?? "null"} for node {n + 1}" + ); return; } } @@ -246,7 +288,10 @@ public void ManyThreadsTouchingALazyIndexAtOnce_BuildItExactlyOnce() builds[t] = ImmutableCollectionsMarshal.AsArray(bucket)!; }) - { IsBackground = true, Name = $"lazy-index-{t}" }; + { + IsBackground = true, + Name = $"lazy-index-{t}", + }; workers[t].Start(); } @@ -255,8 +300,10 @@ public void ManyThreadsTouchingALazyIndexAtOnce_BuildItExactlyOnce() worker.Join(); Assert.IsTrue(failures.IsEmpty, string.Join("; ", failures)); - Assert.AreEqual(1, builds.Distinct(ReferenceEqualityComparer.Instance).Count(), - "every racing reader must share one index, not build its own"); + Assert.AreEqual( + 1, + builds.Distinct(ReferenceEqualityComparer.Instance).Count(), + "every racing reader must share one index, not build its own" + ); } - } diff --git a/tests/PipeWire.NET.Tests/GraphScenarioTests.cs b/tests/PipeWire.NET.Tests/GraphScenarioTests.cs index b899436..9c1b78e 100644 --- a/tests/PipeWire.NET.Tests/GraphScenarioTests.cs +++ b/tests/PipeWire.NET.Tests/GraphScenarioTests.cs @@ -29,7 +29,9 @@ private static void RequireLinux() } private static async Task<(PipeWireContext Context, PipeWireRegistry Registry)> ConnectAsync( - string name, CancellationToken cancellationToken) + string name, + CancellationToken cancellationToken + ) { var context = new PipeWireContext(name, ConsoleTestLoggerFactory.Instance); await context.StartAsync(cancellationToken); @@ -39,7 +41,10 @@ private static void RequireLinux() } private static async Task WaitForAsync( - PipeWireRegistry registry, Func until, CancellationToken cancellationToken) + PipeWireRegistry registry, + Func until, + CancellationToken cancellationToken + ) { await foreach (PipeWireGraphSnapshot graph in registry.WatchAsync(cancellationToken)) if (until(graph)) @@ -49,8 +54,10 @@ private static async Task WaitForAsync( } private static Task WaitForPortsAsync( - PipeWireRegistry registry, uint nodeId, CancellationToken cancellationToken) => - WaitForAsync(registry, g => g.GetPortsForNode(nodeId).Length == 4, cancellationToken); + PipeWireRegistry registry, + uint nodeId, + CancellationToken cancellationToken + ) => WaitForAsync(registry, g => g.GetPortsForNode(nodeId).Length == 4, cancellationToken); // ---------------------------------------------------------------- patchbay @@ -64,18 +71,32 @@ public async Task Patchbay_RendersRewiresAndRedrawsFromNotifications() { RequireLinux(); using var cts = new CancellationTokenSource(Budget); - (PipeWireContext context, PipeWireRegistry registry) = await ConnectAsync("pwnet-patchbay", cts.Token); + (PipeWireContext context, PipeWireRegistry registry) = await ConnectAsync( + "pwnet-patchbay", + cts.Token + ); await using (context) await using (registry) { - PipeWireNode source = await registry.CreateVirtualSinkAsync("Deck A", "pwnet_pb_a", cts.Token); - PipeWireNode sink = await registry.CreateVirtualSinkAsync("Deck B", "pwnet_pb_b", cts.Token); + PipeWireNode source = await registry.CreateVirtualSinkAsync( + "Deck A", + "pwnet_pb_a", + cts.Token + ); + PipeWireNode sink = await registry.CreateVirtualSinkAsync( + "Deck B", + "pwnet_pb_b", + cts.Token + ); PipeWireGraphSnapshot ready = await WaitForAsync( registry, - g => g.GetPortsForNode(source.NodeId).Length == 4 && g.GetPortsForNode(sink.NodeId).Length == 4, - cts.Token); + g => + g.GetPortsForNode(source.NodeId).Length == 4 + && g.GetPortsForNode(sink.NodeId).Length == 4, + cts.Token + ); // 1. Draw. Everything the UI needs must come off one snapshot, with no second call that // could observe a different graph half way through rendering. @@ -84,7 +105,11 @@ public async Task Patchbay_RendersRewiresAndRedrawsFromNotifications() Assert.IsNotNull(ready.GetNode(node.NodeId)); foreach (PipeWirePort port in ready.GetPortsForNode(node.NodeId)) { - Assert.AreEqual(node.NodeId, port.NodeId, "a port must agree with the node it is filed under"); + Assert.AreEqual( + node.NodeId, + port.NodeId, + "a port must agree with the node it is filed under" + ); Assert.IsNotNull(ready.GetPort(port.PortId)); } } @@ -93,10 +118,14 @@ public async Task Patchbay_RendersRewiresAndRedrawsFromNotifications() var redraws = 0; registry.GraphChanged += (_, _) => Interlocked.Increment(ref redraws); - PipeWirePort outLeft = ready.GetPortsForNode(source.NodeId, PipeWirePortDirection.Out) - .OrderBy(p => p.PortId).First(); - PipeWirePort inLeft = ready.GetPortsForNode(sink.NodeId, PipeWirePortDirection.In) - .OrderBy(p => p.PortId).First(); + PipeWirePort outLeft = ready + .GetPortsForNode(source.NodeId, PipeWirePortDirection.Out) + .OrderBy(p => p.PortId) + .First(); + PipeWirePort inLeft = ready + .GetPortsForNode(sink.NodeId, PipeWirePortDirection.In) + .OrderBy(p => p.PortId) + .First(); PipeWireLink link = await registry.CreateLink(outLeft, inLeft).ExecuteAsync(cts.Token); @@ -112,7 +141,10 @@ public async Task Patchbay_RendersRewiresAndRedrawsFromNotifications() // 4. Unwire, and confirm the cable disappears from both endpoints, not just one. await registry.RemoveLinkAsync(link, cts.Token); PipeWireGraphSnapshot unwired = await WaitForAsync( - registry, g => g.GetLink(link.LinkId) is null, cts.Token); + registry, + g => g.GetLink(link.LinkId) is null, + cts.Token + ); Assert.AreEqual(0, unwired.GetOutputLinksForPort(outLeft.PortId).Length); Assert.AreEqual(0, unwired.GetInputLinksForPort(inLeft.PortId).Length); @@ -130,28 +162,44 @@ public async Task Patchbay_MustNotTrustAStaleIdAfterRemoval() { RequireLinux(); using var cts = new CancellationTokenSource(Budget); - (PipeWireContext context, PipeWireRegistry registry) = await ConnectAsync("pwnet-staleid", cts.Token); + (PipeWireContext context, PipeWireRegistry registry) = await ConnectAsync( + "pwnet-staleid", + cts.Token + ); await using (context) await using (registry) { - PipeWireNode first = await registry.CreateVirtualSinkAsync("First", "pwnet_stale_1", cts.Token); + PipeWireNode first = await registry.CreateVirtualSinkAsync( + "First", + "pwnet_stale_1", + cts.Token + ); uint id = first.NodeId; await registry.DestroyGlobalAsync(id, cts.Token); await WaitForAsync(registry, g => g.GetNode(id) is null, cts.Token); - PipeWireNode second = await registry.CreateVirtualSinkAsync("Second", "pwnet_stale_2", cts.Token); + PipeWireNode second = await registry.CreateVirtualSinkAsync( + "Second", + "pwnet_stale_2", + cts.Token + ); if (second.NodeId != id) - Assert.Inconclusive("the daemon did not reuse the id this run; the hazard is unchanged"); + Assert.Inconclusive( + "the daemon did not reuse the id this run; the hazard is unchanged" + ); // Same id, different object. Nothing on the entity distinguishes them, so a consumer // has to compare against the snapshot it is currently rendering, not a remembered id. PipeWireNode? live = registry.Current.GetNode(id); Assert.IsNotNull(live); - Assert.AreEqual("pwnet_stale_2", live!.NodeName, - "the reused id now names a different node; holding the old reference is a bug"); + Assert.AreEqual( + "pwnet_stale_2", + live!.NodeName, + "the reused id now names a different node; holding the old reference is a bug" + ); Assert.AreNotEqual(first.NodeName, live.NodeName); } } @@ -168,29 +216,49 @@ public async Task TransportAgent_PublishedNodeIsDiscoverableByNameAndAgreesOnIts { RequireLinux(); using var cts = new CancellationTokenSource(Budget); - (PipeWireContext context, PipeWireRegistry registry) = await ConnectAsync("pwnet-agent", cts.Token); + (PipeWireContext context, PipeWireRegistry registry) = await ConnectAsync( + "pwnet-agent", + cts.Token + ); await using (context) await using (registry) { const string NodeName = "pwnet_agent_publish"; - await using var publisher = new PipeWireVideoOutput(context, NodeName, 320, 240, PixelFormat.Bgra); + await using var publisher = new PipeWireVideoOutput( + context, + NodeName, + 320, + 240, + PixelFormat.Bgra + ); publisher.Connect(); PipeWireGraphSnapshot graph = await WaitForAsync( - registry, g => g.Nodes.Any(n => n.NodeName == NodeName), cts.Token); + registry, + g => g.Nodes.Any(n => n.NodeName == NodeName), + cts.Token + ); PipeWireNode published = graph.Nodes.First(n => n.NodeName == NodeName); - Assert.IsNotNull(publisher.NodeId, - "a connected stream must expose its node id, that is how a consumer targets it"); - Assert.AreEqual(published.NodeId, (await publisher.WaitForNodeIdAsync(cts.Token)), - "the stream and the registry must agree on which node this is"); + Assert.IsNotNull( + publisher.NodeId, + "a connected stream must expose its node id, that is how a consumer targets it" + ); + Assert.AreEqual( + published.NodeId, + (await publisher.WaitForNodeIdAsync(cts.Token)), + "the stream and the registry must agree on which node this is" + ); // The agent then looks up ports to link or to inspect; they must be filed under that id. PipeWireGraphSnapshot withPorts = await WaitForAsync( - registry, g => g.GetPortsForNode(published.NodeId).Length > 0, cts.Token); + registry, + g => g.GetPortsForNode(published.NodeId).Length > 0, + cts.Token + ); Assert.IsTrue(withPorts.GetPortsForNode(published.NodeId).Length > 0); foreach (PipeWirePort port in withPorts.GetPortsForNode(published.NodeId)) @@ -207,12 +275,19 @@ public async Task TransportAgent_SelectsTargetsByClassAndDataDirectionOnly() { RequireLinux(); using var cts = new CancellationTokenSource(Budget); - (PipeWireContext context, PipeWireRegistry registry) = await ConnectAsync("pwnet-select", cts.Token); + (PipeWireContext context, PipeWireRegistry registry) = await ConnectAsync( + "pwnet-select", + cts.Token + ); await using (context) await using (registry) { - PipeWireNode sink = await registry.CreateVirtualSinkAsync("Target", "pwnet_select", cts.Token); + PipeWireNode sink = await registry.CreateVirtualSinkAsync( + "Target", + "pwnet_select", + cts.Token + ); PipeWireGraphSnapshot graph = await WaitForPortsAsync(registry, sink.NodeId, cts.Token); PipeWireNode node = graph.GetNode(sink.NodeId)!; @@ -224,21 +299,34 @@ public async Task TransportAgent_SelectsTargetsByClassAndDataDirectionOnly() // Capability says what you can do with it, and the two disagree on purpose: a sink is // still readable through its monitor ports. - Assert.IsTrue(graph.CanCaptureFrom(node), "a sink is readable through its monitor ports"); + Assert.IsTrue( + graph.CanCaptureFrom(node), + "a sink is readable through its monitor ports" + ); Assert.IsTrue(graph.CanSendTo(node), "and writable through its playback ports"); // And it must not appear as video by either measure. Assert.AreNotEqual(PipeWireMediaKind.Video, node.Media); CollectionAssert.DoesNotContain(graph.GetVideoSources().ToArray(), node); - CollectionAssert.Contains(graph.GetAudioSources().ToArray(), node, - "an audio sink with monitor ports is a legitimate audio source to capture from"); + CollectionAssert.Contains( + graph.GetAudioSources().ToArray(), + node, + "an audio sink with monitor ports is a legitimate audio source to capture from" + ); foreach (PipeWirePort port in graph.GetPortsForNode(sink.NodeId)) { // Exactly one of the four categories, never two. - int categories = (port.IsDataInput ? 1 : 0) + (port.IsDataOutput ? 1 : 0) - + (port.IsControl ? 1 : 0) + (port.IsNotify ? 1 : 0); - Assert.AreEqual(1, categories, $"port {port.PortId} classified as {categories} kinds"); + int categories = + (port.IsDataInput ? 1 : 0) + + (port.IsDataOutput ? 1 : 0) + + (port.IsControl ? 1 : 0) + + (port.IsNotify ? 1 : 0); + Assert.AreEqual( + 1, + categories, + $"port {port.PortId} classified as {categories} kinds" + ); } // And the audio-source view must not invent nodes that are not in the graph. @@ -260,34 +348,57 @@ public async Task RoutingSetup_SurvivesTheProcessThatBuiltItAndIsRemovableLater( RequireLinux(); using var cts = new CancellationTokenSource(Budget); - (PipeWireContext observerContext, PipeWireRegistry observer) = - await ConnectAsync("pwnet-routing-observer", cts.Token); + (PipeWireContext observerContext, PipeWireRegistry observer) = await ConnectAsync( + "pwnet-routing-observer", + cts.Token + ); await using (observerContext) await using (observer) { - uint sourceId, sinkId, linkId; + uint sourceId, + sinkId, + linkId; // --- first "run of the app": build the routing and leave. - (PipeWireContext builderContext, PipeWireRegistry builder) = - await ConnectAsync("pwnet-routing-builder", cts.Token); + (PipeWireContext builderContext, PipeWireRegistry builder) = await ConnectAsync( + "pwnet-routing-builder", + cts.Token + ); await using (builderContext) { - PipeWireNode a = await builder.CreateVirtualSink("Routed A") - .WithName("pwnet_route_a").WithLinger().ExecuteAsync(cts.Token); - PipeWireNode b = await builder.CreateVirtualSink("Routed B") - .WithName("pwnet_route_b").WithLinger().ExecuteAsync(cts.Token); + PipeWireNode a = await builder + .CreateVirtualSink("Routed A") + .WithName("pwnet_route_a") + .WithLinger() + .ExecuteAsync(cts.Token); + PipeWireNode b = await builder + .CreateVirtualSink("Routed B") + .WithName("pwnet_route_b") + .WithLinger() + .ExecuteAsync(cts.Token); sourceId = a.NodeId; sinkId = b.NodeId; PipeWireGraphSnapshot ready = await WaitForAsync( builder, - g => g.GetPortsForNode(sourceId).Length == 4 && g.GetPortsForNode(sinkId).Length == 4, - cts.Token); - - PipeWireLink link = await builder.CreateLink( - ready.GetPortsForNode(sourceId, PipeWirePortDirection.Out).OrderBy(p => p.PortId).First(), - ready.GetPortsForNode(sinkId, PipeWirePortDirection.In).OrderBy(p => p.PortId).First()) + g => + g.GetPortsForNode(sourceId).Length == 4 + && g.GetPortsForNode(sinkId).Length == 4, + cts.Token + ); + + PipeWireLink link = await builder + .CreateLink( + ready + .GetPortsForNode(sourceId, PipeWirePortDirection.Out) + .OrderBy(p => p.PortId) + .First(), + ready + .GetPortsForNode(sinkId, PipeWirePortDirection.In) + .OrderBy(p => p.PortId) + .First() + ) .WithLinger() .ExecuteAsync(cts.Token); linkId = link.LinkId; @@ -300,15 +411,25 @@ public async Task RoutingSetup_SurvivesTheProcessThatBuiltItAndIsRemovableLater( PipeWireGraphSnapshot after = await WaitForAsync( observer, g => g.Nodes.All(n => n.NodeName != "pwnet-routing-builder"), - cts.Token); + cts.Token + ); await Task.Delay(250, cts.Token); after = observer.Current; - Assert.IsNotNull(after.GetNode(sourceId), "a lingering source node must outlive its creator"); - Assert.IsNotNull(after.GetNode(sinkId), "a lingering sink node must outlive its creator"); + Assert.IsNotNull( + after.GetNode(sourceId), + "a lingering source node must outlive its creator" + ); + Assert.IsNotNull( + after.GetNode(sinkId), + "a lingering sink node must outlive its creator" + ); Assert.IsNotNull(after.GetLink(linkId), "a lingering link must outlive its creator"); - Assert.AreEqual(1, after.GetOutputLinksForPort(after.GetLink(linkId)!.OutputPortId).Length, - "the surviving link must still be wired to its ports"); + Assert.AreEqual( + 1, + after.GetOutputLinksForPort(after.GetLink(linkId)!.OutputPortId).Length, + "the surviving link must still be wired to its ports" + ); // --- second "run": tear the setup down deliberately. await observer.DestroyGlobalAsync(linkId, cts.Token); @@ -317,9 +438,16 @@ public async Task RoutingSetup_SurvivesTheProcessThatBuiltItAndIsRemovableLater( await observer.DestroyGlobalAsync(sinkId, cts.Token); PipeWireGraphSnapshot cleaned = await WaitForAsync( - observer, g => g.GetNode(sourceId) is null && g.GetNode(sinkId) is null, cts.Token); - - Assert.AreEqual(0, cleaned.GetPortsForNode(sourceId).Length, "removed nodes must leave no ports behind"); + observer, + g => g.GetNode(sourceId) is null && g.GetNode(sinkId) is null, + cts.Token + ); + + Assert.AreEqual( + 0, + cleaned.GetPortsForNode(sourceId).Length, + "removed nodes must leave no ports behind" + ); Assert.AreEqual(0, cleaned.GetPortsForNode(sinkId).Length); } } @@ -333,7 +461,10 @@ public async Task RoutingSetup_IsRebuildableWithoutAccumulating() { RequireLinux(); using var cts = new CancellationTokenSource(Budget); - (PipeWireContext context, PipeWireRegistry registry) = await ConnectAsync("pwnet-rebuild", cts.Token); + (PipeWireContext context, PipeWireRegistry registry) = await ConnectAsync( + "pwnet-rebuild", + cts.Token + ); await using (context) await using (registry) @@ -343,25 +474,45 @@ public async Task RoutingSetup_IsRebuildableWithoutAccumulating() // starting value says nothing about whether this test cleaned up after itself. const string prefix = "pwnet_rb_"; static int Ours(PipeWireGraphSnapshot graph) => - graph.Nodes.Count(n => n.NodeName?.StartsWith(prefix, StringComparison.Ordinal) == true); + graph.Nodes.Count(n => + n.NodeName?.StartsWith(prefix, StringComparison.Ordinal) == true + ); int nodesAtStart = Ours(registry.Current); int linksAtStart = registry.Current.Links.Length; for (int cycle = 0; cycle < 5; cycle++) { - PipeWireNode a = await registry.CreateVirtualSinkAsync($"RB A{cycle}", $"{prefix}a{cycle}", cts.Token); - PipeWireNode b = await registry.CreateVirtualSinkAsync($"RB B{cycle}", $"{prefix}b{cycle}", cts.Token); + PipeWireNode a = await registry.CreateVirtualSinkAsync( + $"RB A{cycle}", + $"{prefix}a{cycle}", + cts.Token + ); + PipeWireNode b = await registry.CreateVirtualSinkAsync( + $"RB B{cycle}", + $"{prefix}b{cycle}", + cts.Token + ); PipeWireGraphSnapshot ready = await WaitForAsync( registry, - g => g.GetPortsForNode(a.NodeId).Length == 4 && g.GetPortsForNode(b.NodeId).Length == 4, - cts.Token); + g => + g.GetPortsForNode(a.NodeId).Length == 4 + && g.GetPortsForNode(b.NodeId).Length == 4, + cts.Token + ); PipeWireLink link = await registry.CreateLinkAsync( - ready.GetPortsForNode(a.NodeId, PipeWirePortDirection.Out).OrderBy(p => p.PortId).First(), - ready.GetPortsForNode(b.NodeId, PipeWirePortDirection.In).OrderBy(p => p.PortId).First(), - cts.Token); + ready + .GetPortsForNode(a.NodeId, PipeWirePortDirection.Out) + .OrderBy(p => p.PortId) + .First(), + ready + .GetPortsForNode(b.NodeId, PipeWirePortDirection.In) + .OrderBy(p => p.PortId) + .First(), + cts.Token + ); await registry.DestroyGlobalAsync(link.LinkId, cts.Token); await registry.DestroyGlobalAsync(a.NodeId, cts.Token); @@ -369,19 +520,28 @@ static int Ours(PipeWireGraphSnapshot graph) => await WaitForAsync( registry, - g => g.GetNode(a.NodeId) is null && g.GetNode(b.NodeId) is null && g.GetLink(link.LinkId) is null, - cts.Token); + g => + g.GetNode(a.NodeId) is null + && g.GetNode(b.NodeId) is null + && g.GetLink(link.LinkId) is null, + cts.Token + ); } PipeWireGraphSnapshot end = registry.Current; - Assert.AreEqual(nodesAtStart, Ours(end), + Assert.AreEqual( + nodesAtStart, + Ours(end), $"this test's nodes accumulated across five build/teardown cycles: " - + $"{nodesAtStart} -> {Ours(end)}"); + + $"{nodesAtStart} -> {Ours(end)}" + ); // Links carry no name, so this one stays graph-wide and only has to not grow. The // cycles above each waited for their own link to disappear before the next began. - Assert.IsTrue(end.Links.Length <= linksAtStart + 1, - $"links accumulated across five build/teardown cycles: {linksAtStart} -> {end.Links.Length}"); + Assert.IsTrue( + end.Links.Length <= linksAtStart + 1, + $"links accumulated across five build/teardown cycles: {linksAtStart} -> {end.Links.Length}" + ); } } } diff --git a/tests/PipeWire.NET.Tests/GraphStressTests.cs b/tests/PipeWire.NET.Tests/GraphStressTests.cs index d373245..6348fb4 100644 --- a/tests/PipeWire.NET.Tests/GraphStressTests.cs +++ b/tests/PipeWire.NET.Tests/GraphStressTests.cs @@ -1,9 +1,9 @@ using System.Diagnostics; using System.Runtime.Versioning; using Microsoft.VisualStudio.TestTools.UnitTesting; +using PipeWire.NET.Graph; using PipeWire.NET.Interop; using PipeWire.NET.Spa; -using PipeWire.NET.Graph; namespace PipeWire.NET.Tests; @@ -39,7 +39,9 @@ private static void RequireLinux() } private static async Task<(PipeWireContext Context, PipeWireRegistry Registry)> ConnectAsync( - string name, CancellationToken cancellationToken) + string name, + CancellationToken cancellationToken + ) { var context = new PipeWireContext(name, ConsoleTestLoggerFactory.Instance); await context.StartAsync(cancellationToken); @@ -49,7 +51,10 @@ private static void RequireLinux() } private static async Task WaitForAsync( - PipeWireRegistry registry, Func until, CancellationToken cancellationToken) + PipeWireRegistry registry, + Func until, + CancellationToken cancellationToken + ) { await foreach (PipeWireGraphSnapshot graph in registry.WatchAsync(cancellationToken)) if (until(graph)) @@ -72,7 +77,9 @@ private static async Task WaitForAsync( private static int OpenFileDescriptors() { int ours = 0; - foreach (string entry in Directory.GetFileSystemEntries($"/proc/{Environment.ProcessId}/fd")) + foreach ( + string entry in Directory.GetFileSystemEntries($"/proc/{Environment.ProcessId}/fd") + ) { string? target; try @@ -80,14 +87,23 @@ private static int OpenFileDescriptors() // Racing a descriptor being closed is not a failure: it is one fewer to count. target = File.ResolveLinkTarget(entry, returnFinalTarget: false)?.FullName; } - catch (IOException) { continue; } - catch (UnauthorizedAccessException) { continue; } + catch (IOException) + { + continue; + } + catch (UnauthorizedAccessException) + { + continue; + } - if (target is null) continue; - if (target.StartsWith("socket:", StringComparison.Ordinal) + if (target is null) + continue; + if ( + target.StartsWith("socket:", StringComparison.Ordinal) || target.StartsWith("pipe:", StringComparison.Ordinal) || target.StartsWith("anon_inode:", StringComparison.Ordinal) - || target.Contains("memfd:", StringComparison.Ordinal)) + || target.Contains("memfd:", StringComparison.Ordinal) + ) { ours++; } @@ -104,7 +120,9 @@ private static int OpenFileDescriptors() private static string[] OpenDescriptorTargets() { var seen = new List(); - foreach (string entry in Directory.GetFileSystemEntries($"/proc/{Environment.ProcessId}/fd")) + foreach ( + string entry in Directory.GetFileSystemEntries($"/proc/{Environment.ProcessId}/fd") + ) { try { @@ -126,7 +144,10 @@ public async Task ALongDescription_IsAcceptedRatherThanHittingOurOwnBuffer(int l { RequireLinux(); using var cts = new CancellationTokenSource(Budget); - (PipeWireContext context, PipeWireRegistry registry) = await ConnectAsync("pwnet-long", cts.Token); + (PipeWireContext context, PipeWireRegistry registry) = await ConnectAsync( + "pwnet-long", + cts.Token + ); await using (context) await using (registry) @@ -136,11 +157,17 @@ public async Task ALongDescription_IsAcceptedRatherThanHittingOurOwnBuffer(int l var huge = new string('x', length); PipeWireNode node = await registry.CreateVirtualSinkAsync( - huge, $"pwnet_long_{length}", cts.Token); + huge, + $"pwnet_long_{length}", + cts.Token + ); Assert.IsNotNull(registry.Current.GetNode(node.NodeId)); - Assert.AreEqual(length, node.Description?.Length, - "the description must survive the round-trip intact"); + Assert.AreEqual( + length, + node.Description?.Length, + "the description must survive the round-trip intact" + ); } } @@ -149,7 +176,10 @@ public async Task AMultiByteDescription_IsMeasuredInBytesNotChars() { RequireLinux(); using var cts = new CancellationTokenSource(Budget); - (PipeWireContext context, PipeWireRegistry registry) = await ConnectAsync("pwnet-utf8", cts.Token); + (PipeWireContext context, PipeWireRegistry registry) = await ConnectAsync( + "pwnet-utf8", + cts.Token + ); await using (context) await using (registry) @@ -157,7 +187,11 @@ public async Task AMultiByteDescription_IsMeasuredInBytesNotChars() // Four bytes per char, so a char-based size estimate would under-reserve by 4x. var emoji = string.Concat(Enumerable.Repeat("🔊", 300)); - PipeWireNode node = await registry.CreateVirtualSinkAsync(emoji, "pwnet_utf8", cts.Token); + PipeWireNode node = await registry.CreateVirtualSinkAsync( + emoji, + "pwnet_utf8", + cts.Token + ); Assert.AreEqual(emoji, node.Description); } } @@ -167,7 +201,10 @@ public async Task RepeatedCreateAndDestroy_LeaksNeitherDescriptorsNorMemory() { RequireLinux(); using var cts = new CancellationTokenSource(Budget); - (PipeWireContext context, PipeWireRegistry registry) = await ConnectAsync("pwnet-churn", cts.Token); + (PipeWireContext context, PipeWireRegistry registry) = await ConnectAsync( + "pwnet-churn", + cts.Token + ); await using (context) await using (registry) @@ -175,7 +212,11 @@ public async Task RepeatedCreateAndDestroy_LeaksNeitherDescriptorsNorMemory() // Warm up so first-call allocations are not counted as growth. for (int i = 0; i < 5; i++) { - PipeWireNode warm = await registry.CreateVirtualSinkAsync($"W{i}", $"pwnet_w{i}", cts.Token); + PipeWireNode warm = await registry.CreateVirtualSinkAsync( + $"W{i}", + $"pwnet_w{i}", + cts.Token + ); await registry.DestroyGlobalAsync(warm.NodeId, cts.Token); await WaitForAsync(registry, g => g.GetNode(warm.NodeId) is null, cts.Token); } @@ -193,10 +234,11 @@ public async Task RepeatedCreateAndDestroy_LeaksNeitherDescriptorsNorMemory() { // No media.class: see the storm test. A node that lives for a few milliseconds // has no business being offered to the session manager as something to route to. - PipeWireNode node = await registry.CreateVirtualSink($"Churn {i}") - .WithName($"pwnet_churn_{i}") - .WithMediaClass("") - .ExecuteAsync(cts.Token); + PipeWireNode node = await registry + .CreateVirtualSink($"Churn {i}") + .WithName($"pwnet_churn_{i}") + .WithMediaClass("") + .ExecuteAsync(cts.Token); await registry.DestroyGlobalAsync(node.NodeId, cts.Token); await WaitForAsync(registry, g => g.GetNode(node.NodeId) is null, cts.Token); await Task.Delay(ChurnPause, cts.Token); @@ -210,7 +252,8 @@ public async Task RepeatedCreateAndDestroy_LeaksNeitherDescriptorsNorMemory() long heapAfter = GC.GetTotalMemory(forceFullCollection: true); Console.Error.WriteLine( - $"churn x{Iterations}: fds {fdsBefore} -> {fdsAfter}, heap {heapBefore} -> {heapAfter}"); + $"churn x{Iterations}: fds {fdsBefore} -> {fdsAfter}, heap {heapBefore} -> {heapAfter}" + ); if (fdsAfter > fdsBefore + 2) { @@ -233,12 +276,16 @@ public async Task RepeatedCreateAndDestroy_LeaksNeitherDescriptorsNorMemory() } } - Assert.IsTrue(fdsAfter <= fdsBefore + 2, - $"file descriptors grew {fdsBefore} -> {fdsAfter} over {Iterations} create/destroy cycles"); + Assert.IsTrue( + fdsAfter <= fdsBefore + 2, + $"file descriptors grew {fdsBefore} -> {fdsAfter} over {Iterations} create/destroy cycles" + ); // Each cycle allocating even 1KB that never returns would show as >60KB here. - Assert.IsTrue(heapAfter - heapBefore < 512 * 1024, - $"managed heap grew {heapAfter - heapBefore} bytes over {Iterations} cycles"); + Assert.IsTrue( + heapAfter - heapBefore < 512 * 1024, + $"managed heap grew {heapAfter - heapBefore} bytes over {Iterations} cycles" + ); } } @@ -247,23 +294,46 @@ public async Task ConcurrentCreates_AllSucceedAndAllReachTheGraph() { RequireLinux(); using var cts = new CancellationTokenSource(Budget); - (PipeWireContext context, PipeWireRegistry registry) = await ConnectAsync("pwnet-parallel", cts.Token); + (PipeWireContext context, PipeWireRegistry registry) = await ConnectAsync( + "pwnet-parallel", + cts.Token + ); await using (context) await using (registry) { const int Degree = 12; - Task[] creates = [.. Enumerable.Range(0, Degree).Select( - i => Task.Run(() => registry.CreateVirtualSinkAsync($"P{i}", $"pwnet_par_{i}", cts.Token), cts.Token))]; + Task[] creates = + [ + .. Enumerable + .Range(0, Degree) + .Select(i => + Task.Run( + () => + registry.CreateVirtualSinkAsync( + $"P{i}", + $"pwnet_par_{i}", + cts.Token + ), + cts.Token + ) + ), + ]; PipeWireNode[] nodes = await Task.WhenAll(creates); - Assert.AreEqual(Degree, nodes.Select(n => n.NodeId).Distinct().Count(), - "concurrent creations must not collide on an id"); + Assert.AreEqual( + Degree, + nodes.Select(n => n.NodeId).Distinct().Count(), + "concurrent creations must not collide on an id" + ); PipeWireGraphSnapshot graph = registry.Current; foreach (PipeWireNode node in nodes) - Assert.IsNotNull(graph.GetNode(node.NodeId), $"node {node.NodeId} never reached the graph"); + Assert.IsNotNull( + graph.GetNode(node.NodeId), + $"node {node.NodeId} never reached the graph" + ); } } @@ -272,12 +342,21 @@ public async Task DisposingWhileCreationsAreInFlight_DoesNotCrash() { RequireLinux(); using var cts = new CancellationTokenSource(Budget); - (PipeWireContext context, PipeWireRegistry registry) = await ConnectAsync("pwnet-teardown", cts.Token); + (PipeWireContext context, PipeWireRegistry registry) = await ConnectAsync( + "pwnet-teardown", + cts.Token + ); await using (context) { - Task[] creates = [.. Enumerable.Range(0, 8).Select( - i => registry.CreateVirtualSinkAsync($"T{i}", $"pwnet_tear_{i}", cts.Token))]; + Task[] creates = + [ + .. Enumerable + .Range(0, 8) + .Select(i => + registry.CreateVirtualSinkAsync($"T{i}", $"pwnet_tear_{i}", cts.Token) + ), + ]; // Tear down underneath them. Whatever each task does, the process must survive: an // ObjectDisposedException or a completed node are both fine, an abort is not. @@ -290,10 +369,16 @@ public async Task DisposingWhileCreationsAreInFlight_DoesNotCrash() int completed = 0; var refusals = new List(); var settle = Task.WhenAll(creates); - Task finished = await Task.WhenAny(settle, Task.Delay(TimeSpan.FromSeconds(10), cts.Token)); + Task finished = await Task.WhenAny( + settle, + Task.Delay(TimeSpan.FromSeconds(10), cts.Token) + ); - Assert.AreSame(settle, finished, - "creations in flight did not settle within 10s of disposal; disposal is not failing them"); + Assert.AreSame( + settle, + finished, + "creations in flight did not settle within 10s of disposal; disposal is not failing them" + ); // Naming the acceptable failures rather than catching a broad set. foreach (Task create in creates) @@ -301,18 +386,33 @@ public async Task DisposingWhileCreationsAreInFlight_DoesNotCrash() try { PipeWireNode node = await create; - Assert.AreNotEqual(0u, node.NodeId, "a completed creation must yield a real node"); + Assert.AreNotEqual( + 0u, + node.NodeId, + "a completed creation must yield a real node" + ); completed++; } - catch (ObjectDisposedException) { refusals.Add(nameof(ObjectDisposedException)); } - catch (OperationCanceledException) { refusals.Add(nameof(OperationCanceledException)); } + catch (ObjectDisposedException) + { + refusals.Add(nameof(ObjectDisposedException)); + } + catch (OperationCanceledException) + { + refusals.Add(nameof(OperationCanceledException)); + } } - Assert.AreEqual(creates.Length, completed + refusals.Count, - "every in-flight creation must settle one way or the other"); - Assert.IsTrue(refusals.Count > 0, + Assert.AreEqual( + creates.Length, + completed + refusals.Count, + "every in-flight creation must settle one way or the other" + ); + Assert.IsTrue( + refusals.Count > 0, $"disposal under load refused nothing; all {completed} creations beat it, so the " - + "race this test exists for was never exercised"); + + "race this test exists for was never exercised" + ); } } @@ -321,21 +421,40 @@ public async Task DisposingWithNoCancellationToken_StillReleasesInFlightCreation { RequireLinux(); using var cts = new CancellationTokenSource(Budget); - (PipeWireContext context, PipeWireRegistry registry) = await ConnectAsync("pwnet-notoken", cts.Token); + (PipeWireContext context, PipeWireRegistry registry) = await ConnectAsync( + "pwnet-notoken", + cts.Token + ); await using (context) { // CancellationToken.None is the case with no escape hatch: if disposal does not fail // the waiters, nothing ever will. - Task[] creates = [.. Enumerable.Range(0, 6).Select( - i => registry.CreateVirtualSinkAsync($"N{i}", $"pwnet_notok_{i}", CancellationToken.None))]; + Task[] creates = + [ + .. Enumerable + .Range(0, 6) + .Select(i => + registry.CreateVirtualSinkAsync( + $"N{i}", + $"pwnet_notok_{i}", + CancellationToken.None + ) + ), + ]; await registry.DisposeAsync(); Task settle = Task.WhenAll(creates); - Task finished = await Task.WhenAny(settle, Task.Delay(TimeSpan.FromSeconds(10), cts.Token)); - Assert.AreSame(settle, finished, - "with no token, only disposal can release an in-flight creation - and it did not"); + Task finished = await Task.WhenAny( + settle, + Task.Delay(TimeSpan.FromSeconds(10), cts.Token) + ); + Assert.AreSame( + settle, + finished, + "with no token, only disposal can release an in-flight creation - and it did not" + ); // Classify every outcome. Swallowing them lets an unexpected failure, or a task that // resolved to nonsense after disposal, pass as success. @@ -345,17 +464,29 @@ public async Task DisposingWithNoCancellationToken_StillReleasesInFlightCreation try { PipeWireNode node = await create; - Assert.AreNotEqual(0u, node.NodeId, "a completed creation must yield a real node"); + Assert.AreNotEqual( + 0u, + node.NodeId, + "a completed creation must yield a real node" + ); outcomes.Add("created"); } - catch (ObjectDisposedException) { outcomes.Add("disposed"); } - catch (OperationCanceledException) { outcomes.Add("cancelled"); } + catch (ObjectDisposedException) + { + outcomes.Add("disposed"); + } + catch (OperationCanceledException) + { + outcomes.Add("cancelled"); + } } Assert.AreEqual(creates.Length, outcomes.Count); - Assert.IsTrue(outcomes.Contains("disposed"), + Assert.IsTrue( + outcomes.Contains("disposed"), "with no token, disposal is the only thing that can release these; it released none. " - + $"outcomes: [{string.Join(", ", outcomes)}]"); + + $"outcomes: [{string.Join(", ", outcomes)}]" + ); } } @@ -364,13 +495,17 @@ public async Task CancellingMidCreation_LeavesNothingBehind() { RequireLinux(); using var cts = new CancellationTokenSource(Budget); - (PipeWireContext context, PipeWireRegistry registry) = await ConnectAsync("pwnet-cancel", cts.Token); + (PipeWireContext context, PipeWireRegistry registry) = await ConnectAsync( + "pwnet-cancel", + cts.Token + ); await using (context) await using (registry) { int fdsBefore = OpenFileDescriptors(); - int cancelled = 0, created = 0; + int cancelled = 0, + created = 0; for (int i = 0; i < 25; i++) { @@ -380,17 +515,25 @@ public async Task CancellingMidCreation_LeavesNothingBehind() try { PipeWireNode node = await registry.CreateVirtualSinkAsync( - $"C{i}", $"pwnet_cx_{i}", tight.Token); + $"C{i}", + $"pwnet_cx_{i}", + tight.Token + ); created++; await registry.DestroyGlobalAsync(node.NodeId, cts.Token); } - catch (OperationCanceledException) { cancelled++; } + catch (OperationCanceledException) + { + cancelled++; + } } // Without this the test degrades silently into a plain create/remove loop whenever the // cancellations all lose the race, and then proves nothing about the cancelled path. - Assert.IsTrue(cancelled > 0, - $"no creation was actually cancelled ({created} completed); the test exercised nothing"); + Assert.IsTrue( + cancelled > 0, + $"no creation was actually cancelled ({created} completed); the test exercised nothing" + ); GC.Collect(); GC.WaitForPendingFinalizers(); @@ -398,8 +541,10 @@ public async Task CancellingMidCreation_LeavesNothingBehind() int fdsAfter = OpenFileDescriptors(); Console.Error.WriteLine($"cancel churn: fds {fdsBefore} -> {fdsAfter}"); - Assert.IsTrue(fdsAfter <= fdsBefore + 2, - $"cancelled creations leaked descriptors: {fdsBefore} -> {fdsAfter}"); + Assert.IsTrue( + fdsAfter <= fdsBefore + 2, + $"cancelled creations leaked descriptors: {fdsBefore} -> {fdsAfter}" + ); } } @@ -408,7 +553,10 @@ public async Task ManyWatchersSubscribingAndLeaving_DoNotAccumulate() { RequireLinux(); using var cts = new CancellationTokenSource(Budget); - (PipeWireContext context, PipeWireRegistry registry) = await ConnectAsync("pwnet-watchers", cts.Token); + (PipeWireContext context, PipeWireRegistry registry) = await ConnectAsync( + "pwnet-watchers", + cts.Token + ); await using (context) await using (registry) @@ -419,15 +567,17 @@ public async Task ManyWatchersSubscribingAndLeaving_DoNotAccumulate() { using var stop = new CancellationTokenSource(); await foreach (PipeWireGraphSnapshot _ in registry.WatchAsync(stop.Token)) - break; // take the priming snapshot and abandon the stream + break; // take the priming snapshot and abandon the stream } long heapAfter = GC.GetTotalMemory(forceFullCollection: true); Console.Error.WriteLine($"watchers: heap {heapBefore} -> {heapAfter}"); // A handler left subscribed per iteration would retain its channel and grow steadily. - Assert.IsTrue(heapAfter - heapBefore < 512 * 1024, - $"abandoned watchers retained {heapAfter - heapBefore} bytes"); + Assert.IsTrue( + heapAfter - heapBefore < 512 * 1024, + $"abandoned watchers retained {heapAfter - heapBefore} bytes" + ); } } @@ -436,27 +586,40 @@ public async Task ASnapshotHeldAcrossHeavyChurn_StaysInternallyConsistent() { RequireLinux(); using var cts = new CancellationTokenSource(Budget); - (PipeWireContext context, PipeWireRegistry registry) = await ConnectAsync("pwnet-consistency", cts.Token); + (PipeWireContext context, PipeWireRegistry registry) = await ConnectAsync( + "pwnet-consistency", + cts.Token + ); await using (context) await using (registry) { PipeWireNode a = await registry.CreateVirtualSinkAsync("CA", "pwnet_ca", cts.Token); PipeWireGraphSnapshot held = await WaitForAsync( - registry, g => g.GetPortsForNode(a.NodeId).Length == 4, cts.Token); + registry, + g => g.GetPortsForNode(a.NodeId).Length == 4, + cts.Token + ); int portsAtCapture = held.Ports.Length; for (int i = 0; i < 15; i++) { - PipeWireNode churn = await registry.CreateVirtualSinkAsync($"CC{i}", $"pwnet_cc_{i}", cts.Token); + PipeWireNode churn = await registry.CreateVirtualSinkAsync( + $"CC{i}", + $"pwnet_cc_{i}", + cts.Token + ); await registry.DestroyGlobalAsync(churn.NodeId, cts.Token); } Assert.AreEqual(portsAtCapture, held.Ports.Length, "a held snapshot must not change"); foreach (PipeWirePort port in held.Ports) - Assert.IsNotNull(held.GetPort(port.PortId), $"port {port.PortId} is in Ports but not the index"); + Assert.IsNotNull( + held.GetPort(port.PortId), + $"port {port.PortId} is in Ports but not the index" + ); foreach (PipeWireLink link in held.Links) { @@ -471,13 +634,17 @@ public async Task RemovingAnIdThatNeverExisted_IsReportedAsAFailure() { RequireLinux(); using var cts = new CancellationTokenSource(Budget); - (PipeWireContext context, PipeWireRegistry registry) = await ConnectAsync("pwnet-bogus", cts.Token); + (PipeWireContext context, PipeWireRegistry registry) = await ConnectAsync( + "pwnet-bogus", + cts.Token + ); await using (context) await using (registry) { - await Assert.ThrowsAsync( - () => registry.DestroyGlobalAsync(999_999, cts.Token)); + await Assert.ThrowsAsync(() => + registry.DestroyGlobalAsync(999_999, cts.Token) + ); } } @@ -486,7 +653,10 @@ public async Task RemovingAnObjectTwice_IsRefusedRatherThanFatal() { RequireLinux(); using var cts = new CancellationTokenSource(Budget); - (PipeWireContext context, PipeWireRegistry registry) = await ConnectAsync("pwnet-double", cts.Token); + (PipeWireContext context, PipeWireRegistry registry) = await ConnectAsync( + "pwnet-double", + cts.Token + ); await using (context) await using (registry) @@ -506,13 +676,15 @@ public async Task RemovingAnObjectTwice_IsRefusedRatherThanFatal() // monotonic and never reused, so this cannot be satisfied by a recycled id. Assert.IsFalse( registry.Current.Objects.Any(o => o.ObjectSerial == serial), - "the object we destroyed is still in the graph under its own serial"); + "the object we destroyed is still in the graph under its own serial" + ); // The id is gone from the daemon, so the second destroy must be *reported* as refused. // destroy_global returns SPA_ASYNC_BIT | seq for both outcomes, so an implementation // that inspects the return value alone silently reports success here. - await Assert.ThrowsAsync( - () => registry.DestroyGlobalAsync(node.NodeId, cts.Token)); + await Assert.ThrowsAsync(() => + registry.DestroyGlobalAsync(node.NodeId, cts.Token) + ); } } } diff --git a/tests/PipeWire.NET.Tests/GraphSurfaceTests.cs b/tests/PipeWire.NET.Tests/GraphSurfaceTests.cs index 1185fe4..6e7606e 100644 --- a/tests/PipeWire.NET.Tests/GraphSurfaceTests.cs +++ b/tests/PipeWire.NET.Tests/GraphSurfaceTests.cs @@ -37,14 +37,19 @@ private static void RequireLinux() } private static async Task WaitForNodeAsync( - PipeWireRegistry reg, string name, CancellationToken ct) + PipeWireRegistry reg, + string name, + CancellationToken ct + ) { for (var i = 0; i < 100; i++) { - PipeWireNode? n = reg.Current.Nodes.FirstOrDefault( - x => string.Equals(x.NodeName, name, StringComparison.Ordinal)); + PipeWireNode? n = reg.Current.Nodes.FirstOrDefault(x => + string.Equals(x.NodeName, name, StringComparison.Ordinal) + ); - if (n is not null) return n; + if (n is not null) + return n; await Task.Delay(50, ct); } @@ -61,11 +66,15 @@ private static async Task WaitForNodeAsync( /// arrives. Waiting on the key that proves the info landed keeps that race out of the assertions. /// private static async Task WaitForBoundPropertiesAsync( - Func read, string key, CancellationToken ct) + Func read, + string key, + CancellationToken ct + ) { for (var i = 0; i < 100; i++) { - if (read() is { } props && props.GetValueOrDefault(key) is not null) return props; + if (read() is { } props && props.GetValueOrDefault(key) is not null) + return props; await Task.Delay(50, ct); } @@ -89,14 +98,16 @@ public async Task EveryNodeCreationBuilder_ReachesTheCreatedNode() string nodeName = $"pwnet-builder-{Environment.ProcessId}"; const string description = "a described node"; - await using var ctx = new PipeWireContext("pwnet-builder", ConsoleTestLoggerFactory.Instance); + await using var ctx = new PipeWireContext( + "pwnet-builder", + ConsoleTestLoggerFactory.Instance + ); await ctx.StartAsync(cts.Token); await using var reg = new PipeWireRegistry(ctx); await reg.WaitForInitialEnumerationAsync(cts.Token); - PipeWireNode created = await reg - .CreateVirtualSink(nodeName, "Audio/Sink") + PipeWireNode created = await reg.CreateVirtualSink(nodeName, "Audio/Sink") .WithName(nodeName) .WithMediaClass("Audio/Sink") .WithChannelPositions("[ FL FR ]") @@ -118,22 +129,26 @@ public async Task EveryNodeCreationBuilder_ReachesTheCreatedNode() PipeWireProperties props = await WaitForBoundPropertiesAsync( () => reg.Current.Nodes.FirstOrDefault(n => n.NodeId == node.NodeId)?.Properties, PipeWireKeys.SPA_KEY_AUDIO_POSITION, - cts.Token); + cts.Token + ); Assert.AreEqual( description, props.GetValueOrDefault(PipeWireKeys.PW_KEY_NODE_DESCRIPTION), - "WithProperty did not reach the node"); + "WithProperty did not reach the node" + ); Assert.AreEqual( "[ FL FR ]", props.GetValueOrDefault(PipeWireKeys.SPA_KEY_AUDIO_POSITION), - "WithChannelPositions did not reach the node"); + "WithChannelPositions did not reach the node" + ); Assert.AreEqual( "false", props.GetValueOrDefault(PipeWireKeys.PW_KEY_NODE_AUTOCONNECT)?.ToLowerInvariant(), - "WithAutoConnect did not reach the node"); + "WithAutoConnect did not reach the node" + ); } /// @@ -160,7 +175,10 @@ public async Task TheLinkCreationBuilders_ReachTheCreatedLink() string name = $"pwnet-linkbuilder-{Environment.ProcessId}"; - await using var ctx = new PipeWireContext("pwnet-linkbuilder", ConsoleTestLoggerFactory.Instance); + await using var ctx = new PipeWireContext( + "pwnet-linkbuilder", + ConsoleTestLoggerFactory.Instance + ); await ctx.StartAsync(cts.Token); await using var reg = new PipeWireRegistry(ctx); @@ -168,7 +186,12 @@ public async Task TheLinkCreationBuilders_ReachTheCreatedLink() // A producer and a sink of our own, so the link is between things this test controls. await using var output = new PipeWireAudioOutput( - ctx, $"{name}-src", 48000, 2, AudioSampleFormat.F32Le); + ctx, + $"{name}-src", + 48000, + 2, + AudioSampleFormat.F32Le + ); output.FillSamples += (_, samples, _, _, _) => { @@ -179,8 +202,7 @@ public async Task TheLinkCreationBuilders_ReachTheCreatedLink() output.Connect(autoConnect: false); await WaitForNodeAsync(reg, $"{name}-src", cts.Token); - PipeWireNode sink = await reg - .CreateVirtualSink($"{name}-sink", "Audio/Sink") + PipeWireNode sink = await reg.CreateVirtualSink($"{name}-sink", "Audio/Sink") .WithName($"{name}-sink") .ExecuteAsync(cts.Token); @@ -192,19 +214,21 @@ public async Task TheLinkCreationBuilders_ReachTheCreatedLink() for (var i = 0; i < 100 && (outPort is null || inPort is null); i++) { PipeWireGraphSnapshot g = reg.Current; - outPort ??= g.Ports.FirstOrDefault( - p => p.NodeId == srcNode.NodeId && p.PortDirection == PipeWirePortDirection.Out); - inPort ??= g.Ports.FirstOrDefault( - p => p.NodeId == sinkNode.NodeId && p.PortDirection == PipeWirePortDirection.In); - - if (outPort is null || inPort is null) await Task.Delay(50, cts.Token); + outPort ??= g.Ports.FirstOrDefault(p => + p.NodeId == srcNode.NodeId && p.PortDirection == PipeWirePortDirection.Out + ); + inPort ??= g.Ports.FirstOrDefault(p => + p.NodeId == sinkNode.NodeId && p.PortDirection == PipeWirePortDirection.In + ); + + if (outPort is null || inPort is null) + await Task.Delay(50, cts.Token); } if (outPort is null || inPort is null) Assert.Inconclusive("the two nodes never both exposed a port to link."); - PipeWireLink link = await reg - .CreateLink(outPort!, inPort!) + PipeWireLink link = await reg.CreateLink(outPort!, inPort!) .WithPassive() .WithProperty("pwnet.test.marker", name) .ExecuteAsync(cts.Token); @@ -217,29 +241,48 @@ public async Task TheLinkCreationBuilders_ReachTheCreatedLink() PipeWireProperties made = await WaitForBoundPropertiesAsync( () => reg.Current.Links.FirstOrDefault(l => l.LinkId == link.LinkId)?.Properties, "pwnet.test.marker", - cts.Token); - - Assert.AreEqual(name, made.GetValueOrDefault("pwnet.test.marker"), "WithProperty did not reach the created link"); + cts.Token + ); - PipeWireModule? factoryModule = reg.Current.Modules.FirstOrDefault( - m => m.ModuleName == "libpipewire-module-link-factory"); - Assert.IsNotNull(factoryModule, "no link-factory module, so the link could not have been created"); + Assert.AreEqual( + name, + made.GetValueOrDefault("pwnet.test.marker"), + "WithProperty did not reach the created link" + ); + + PipeWireModule? factoryModule = reg.Current.Modules.FirstOrDefault(m => + m.ModuleName == "libpipewire-module-link-factory" + ); + Assert.IsNotNull( + factoryModule, + "no link-factory module, so the link could not have been created" + ); PipeWireModule details = await reg.ReadModuleDetailsAsync(factoryModule.Id, cts.Token); - string args = details.Properties.GetValueOrDefault(PipeWireKeys.MODULE_ARGS) ?? string.Empty; + string args = + details.Properties.GetValueOrDefault(PipeWireKeys.MODULE_ARGS) ?? string.Empty; bool passiveAllowed = System.Text.RegularExpressions.Regex.IsMatch( - args, @"allow\.link\.passive\s*[=:]\s*true", System.Text.RegularExpressions.RegexOptions.IgnoreCase); + args, + @"allow\.link\.passive\s*[=:]\s*true", + System.Text.RegularExpressions.RegexOptions.IgnoreCase + ); - string? passive = made.GetValueOrDefault(PipeWireKeys.PW_KEY_LINK_PASSIVE)?.ToLowerInvariant(); + string? passive = made.GetValueOrDefault(PipeWireKeys.PW_KEY_LINK_PASSIVE) + ?.ToLowerInvariant(); if (passiveAllowed) { - Assert.AreEqual("true", passive, - "the link factory allows link.passive, but Passive() did not reach the created link"); + Assert.AreEqual( + "true", + passive, + "the link factory allows link.passive, but Passive() did not reach the created link" + ); } else { - Assert.IsNull(passive, - $"the link factory does not allow link.passive (args '{args}'), so the daemon should have removed it; it reads '{passive}'"); + Assert.IsNull( + passive, + $"the link factory does not allow link.passive (args '{args}'), so the daemon should have removed it; it reads '{passive}'" + ); } } @@ -260,11 +303,19 @@ public async Task TheGraphClock_DescribesARealRateAndABoundedDrift() string nodeName = $"pwnet-clockrate-{Environment.ProcessId}"; - await using var ctx = new PipeWireContext("pwnet-clockrate", ConsoleTestLoggerFactory.Instance); + await using var ctx = new PipeWireContext( + "pwnet-clockrate", + ConsoleTestLoggerFactory.Instance + ); await ctx.StartAsync(cts.Token); await using var output = new PipeWireAudioOutput( - ctx, nodeName, 48000, 2, AudioSampleFormat.F32Le); + ctx, + nodeName, + 48000, + 2, + AudioSampleFormat.F32Le + ); output.FillSamples += (_, samples, _, _, _) => { @@ -291,35 +342,51 @@ public async Task TheGraphClock_DescribesARealRateAndABoundedDrift() if (output.GraphClock is { RateDen: > 0 } read) { firstPosition ??= read.Position; - if (read.Position > firstPosition) clock = read; + if (read.Position > firstPosition) + clock = read; } - if (clock is null) await Task.Delay(50, cts.Token); + if (clock is null) + await Task.Delay(50, cts.Token); } - Assert.IsNotNull(clock, "the stream never saw a running driver's clock: the position never advanced"); + Assert.IsNotNull( + clock, + "the stream never saw a running driver's clock: the position never advanced" + ); PipeWireGraphClock c = clock!.Value; - Assert.AreNotEqual(0u, c.RateDen, "the clock's rate denominator is zero, so the period is undefined"); - Assert.AreNotEqual(0u, c.RateNum, "the clock's rate numerator is zero, so the period is undefined"); + Assert.AreNotEqual( + 0u, + c.RateDen, + "the clock's rate denominator is zero, so the period is undefined" + ); + Assert.AreNotEqual( + 0u, + c.RateNum, + "the clock's rate numerator is zero, so the period is undefined" + ); // A graph tick is a fraction of a second, not seconds and not nanoseconds-as-an-integer. double period = (double)c.RateNum / c.RateDen; Assert.IsTrue( period is > 0 and < 1, - $"the clock period reads {period}s, which is not a graph tick"); + $"the clock period reads {period}s, which is not a graph tick" + ); // RateDiff is a ratio around 1. Zero means it was never written; far from 1 means it was // read from the wrong place. Assert.IsTrue( c.RateDiff is > 0.5 and < 2.0, - $"the clock's rate difference reads {c.RateDiff}, which is not a drift ratio"); + $"the clock's rate difference reads {c.RateDiff}, which is not a drift ratio" + ); // NextTimeNs is when the next cycle is due, so it is ahead of the current cycle's time. Assert.IsTrue( c.NextTimeNs >= c.TimeNs, - $"the next cycle ({c.NextTimeNs}) is scheduled before the current one ({c.TimeNs})"); + $"the next cycle ({c.NextTimeNs}) is scheduled before the current one ({c.TimeNs})" + ); } /// @@ -344,20 +411,31 @@ public async Task TheCaptureMetadata_IsCoherentOnFramesCarryingRealPixels() RequireLinux(); using var cts = new CancellationTokenSource(Budget); - const int width = 64, height = 32; + const int width = 64, + height = 32; - await using var ctx = new PipeWireContext("pwnet-cursor", ConsoleTestLoggerFactory.Instance); + await using var ctx = new PipeWireContext( + "pwnet-cursor", + ConsoleTestLoggerFactory.Instance + ); await ctx.StartAsync(cts.Token); var produced = 0; await using var output = new PipeWireVideoOutput( - ctx, "pwnet-cursor-src", width, height, PixelFormat.Bgra, 30); + ctx, + "pwnet-cursor-src", + width, + height, + PixelFormat.Bgra, + 30 + ); output.FillFrame += (_, pixels, stride, w, h, _) => { byte tag = (byte)(Interlocked.Increment(ref produced) & 0xFF); - for (var y = 0; y < h; y++) pixels.Slice(y * stride, w * 4).Fill(tag); + for (var y = 0; y < h; y++) + pixels.Slice(y * stride, w * 4).Fill(tag); return true; }; @@ -371,7 +449,8 @@ public async Task TheCaptureMetadata_IsCoherentOnFramesCarryingRealPixels() await using var capture = new PipeWireVideoCapture(ctx, "pwnet-cursor-sink"); capture.FrameReady += (_, f) => { - if (f.Pixels.IsEmpty) return; + if (f.Pixels.IsEmpty) + return; // Only inspect metadata on a frame whose pixels are confirmed intact. byte tag = f.Pixels[0]; @@ -379,7 +458,8 @@ public async Task TheCaptureMetadata_IsCoherentOnFramesCarryingRealPixels() { foreach (byte b in f.Pixels.Slice(y * f.Stride, f.Width * 4)) { - if (b != tag) return; + if (b != tag) + return; } } @@ -388,7 +468,8 @@ public async Task TheCaptureMetadata_IsCoherentOnFramesCarryingRealPixels() foreach (VideoRegion r in f.Damage) { - if (r.Width == 0 || r.Height == 0) failures.Add("a damage region is degenerate"); + if (r.Width == 0 || r.Height == 0) + failures.Add("a damage region is degenerate"); if (r.X + r.Width > f.Width || r.Y + r.Height > f.Height) failures.Add("a damage region falls outside the frame"); } @@ -402,13 +483,21 @@ public async Task TheCaptureMetadata_IsCoherentOnFramesCarryingRealPixels() Assert.IsTrue( Volatile.Read(ref verified) > 5, - $"only {Volatile.Read(ref verified)} intact frames arrived to inspect"); + $"only {Volatile.Read(ref verified)} intact frames arrived to inspect" + ); Assert.AreEqual(0, failures.Count, string.Join("; ", failures.Distinct())); } [System.Runtime.InteropServices.DllImport("libc", SetLastError = true)] - private static extern unsafe void* mmap(void* addr, nuint length, int prot, int flags, int fd, long offset); + private static extern unsafe void* mmap( + void* addr, + nuint length, + int prot, + int flags, + int fd, + long offset + ); [System.Runtime.InteropServices.DllImport("libc")] private static extern unsafe int munmap(void* addr, nuint length); @@ -419,7 +508,8 @@ public async Task TheCaptureMetadata_IsCoherentOnFramesCarryingRealPixels() /// Null when the mapped bytes are the frame's, otherwise what went wrong. internal static unsafe string? CompareMappedPixels(VideoFrame f) { - const int ProtRead = 1, MapShared = 1; + const int ProtRead = 1, + MapShared = 1; long pageSize = Environment.SystemPageSize; long pageStart = f.MapOffset - (f.MapOffset % pageSize); diff --git a/tests/PipeWire.NET.Tests/GraphThreadingTests.cs b/tests/PipeWire.NET.Tests/GraphThreadingTests.cs index 0ca9aa9..4332254 100644 --- a/tests/PipeWire.NET.Tests/GraphThreadingTests.cs +++ b/tests/PipeWire.NET.Tests/GraphThreadingTests.cs @@ -32,7 +32,9 @@ private static void RequireLinux() } private static async Task<(PipeWireContext Context, PipeWireRegistry Registry)> ConnectAsync( - string name, CancellationToken cancellationToken) + string name, + CancellationToken cancellationToken + ) { var context = new PipeWireContext(name, ConsoleTestLoggerFactory.Instance); await context.StartAsync(cancellationToken); @@ -42,7 +44,10 @@ private static void RequireLinux() } private static async Task WaitForAsync( - PipeWireRegistry registry, Func until, CancellationToken cancellationToken) + PipeWireRegistry registry, + Func until, + CancellationToken cancellationToken + ) { await foreach (PipeWireGraphSnapshot graph in registry.WatchAsync(cancellationToken)) if (until(graph)) @@ -56,7 +61,10 @@ public async Task ReadingTheGraphFromInsideAHandler_DoesNotDeadlock() { RequireLinux(); using var cts = new CancellationTokenSource(Budget); - (PipeWireContext context, PipeWireRegistry registry) = await ConnectAsync("pwnet-reentrant", cts.Token); + (PipeWireContext context, PipeWireRegistry registry) = await ConnectAsync( + "pwnet-reentrant", + cts.Token + ); await using (context) await using (registry) @@ -73,14 +81,26 @@ public async Task ReadingTheGraphFromInsideAHandler_DoesNotDeadlock() int nodes = snapshot.Nodes.Length; int ports = sender.Current.Ports.Length; int forNode = sender.Current.GetPortsForNode(1).Length; - if (nodes < 0 || ports < 0 || forNode < 0) throw new InvalidOperationException("impossible"); + if (nodes < 0 || ports < 0 || forNode < 0) + throw new InvalidOperationException("impossible"); Interlocked.Increment(ref readsFromHandler); } - catch (Exception ex) { faulted ??= ex; } + catch (Exception ex) + { + faulted ??= ex; + } }; - PipeWireNode node = await registry.CreateVirtualSinkAsync("RE", "pwnet_reentrant", cts.Token); - await WaitForAsync(registry, g => g.GetPortsForNode(node.NodeId).Length == 4, cts.Token); + PipeWireNode node = await registry.CreateVirtualSinkAsync( + "RE", + "pwnet_reentrant", + cts.Token + ); + await WaitForAsync( + registry, + g => g.GetPortsForNode(node.NodeId).Length == 4, + cts.Token + ); Assert.IsNull(faulted, $"reading the graph from a handler threw: {faulted}"); Assert.IsTrue(Volatile.Read(ref readsFromHandler) > 0, "no handler ever ran"); @@ -92,7 +112,10 @@ public async Task HandlersRunOnTheLoopThread_NotTheCallersThread() { RequireLinux(); using var cts = new CancellationTokenSource(Budget); - (PipeWireContext context, PipeWireRegistry registry) = await ConnectAsync("pwnet-threadid", cts.Token); + (PipeWireContext context, PipeWireRegistry registry) = await ConnectAsync( + "pwnet-threadid", + cts.Token + ); await using (context) await using (registry) @@ -102,14 +125,27 @@ public async Task HandlersRunOnTheLoopThread_NotTheCallersThread() registry.PortAdded += _ => handlerThreads.Add(Environment.CurrentManagedThreadId); - PipeWireNode node = await registry.CreateVirtualSinkAsync("TI", "pwnet_threadid", cts.Token); - await WaitForAsync(registry, g => g.GetPortsForNode(node.NodeId).Length == 4, cts.Token); + PipeWireNode node = await registry.CreateVirtualSinkAsync( + "TI", + "pwnet_threadid", + cts.Token + ); + await WaitForAsync( + registry, + g => g.GetPortsForNode(node.NodeId).Length == 4, + cts.Token + ); Assert.IsFalse(handlerThreads.IsEmpty, "no PortAdded handler ran"); - Assert.IsFalse(handlerThreads.Contains(callerThread), - "handlers must not run on the caller's thread; consumers marshal off the loop themselves"); - Assert.AreEqual(1, handlerThreads.Distinct().Count(), - "every callback must arrive on the one loop thread"); + Assert.IsFalse( + handlerThreads.Contains(callerThread), + "handlers must not run on the caller's thread; consumers marshal off the loop themselves" + ); + Assert.AreEqual( + 1, + handlerThreads.Distinct().Count(), + "every callback must arrive on the one loop thread" + ); } } @@ -118,7 +154,10 @@ public async Task AThrowingHandler_DoesNotKillTheLoopOrLoseLaterEvents() { RequireLinux(); using var cts = new CancellationTokenSource(Budget); - (PipeWireContext context, PipeWireRegistry registry) = await ConnectAsync("pwnet-throwing", cts.Token); + (PipeWireContext context, PipeWireRegistry registry) = await ConnectAsync( + "pwnet-throwing", + cts.Token + ); await using (context) await using (registry) @@ -127,18 +166,43 @@ public async Task AThrowingHandler_DoesNotKillTheLoopOrLoseLaterEvents() // swallow it at the boundary. Surviving is necessary but not sufficient: the loop must // still deliver everything afterwards. var seen = 0; - registry.PortAdded += _ => { Interlocked.Increment(ref seen); throw new InvalidOperationException("boom"); }; - - PipeWireNode first = await registry.CreateVirtualSinkAsync("T1", "pwnet_throw_1", cts.Token); - await WaitForAsync(registry, g => g.GetPortsForNode(first.NodeId).Length == 4, cts.Token); + registry.PortAdded += _ => + { + Interlocked.Increment(ref seen); + throw new InvalidOperationException("boom"); + }; - PipeWireNode second = await registry.CreateVirtualSinkAsync("T2", "pwnet_throw_2", cts.Token); + PipeWireNode first = await registry.CreateVirtualSinkAsync( + "T1", + "pwnet_throw_1", + cts.Token + ); + await WaitForAsync( + registry, + g => g.GetPortsForNode(first.NodeId).Length == 4, + cts.Token + ); + + PipeWireNode second = await registry.CreateVirtualSinkAsync( + "T2", + "pwnet_throw_2", + cts.Token + ); PipeWireGraphSnapshot after = await WaitForAsync( - registry, g => g.GetPortsForNode(second.NodeId).Length == 4, cts.Token); - - Assert.AreEqual(4, after.GetPortsForNode(second.NodeId).Length, - "events after a throwing handler must still be delivered"); - Assert.IsTrue(Volatile.Read(ref seen) >= 8, $"expected at least 8 port events, saw {seen}"); + registry, + g => g.GetPortsForNode(second.NodeId).Length == 4, + cts.Token + ); + + Assert.AreEqual( + 4, + after.GetPortsForNode(second.NodeId).Length, + "events after a throwing handler must still be delivered" + ); + Assert.IsTrue( + Volatile.Read(ref seen) >= 8, + $"expected at least 8 port events, saw {seen}" + ); } } @@ -147,7 +211,10 @@ public async Task ManyReadersRacingTheWriter_NeverSeeATornGraph() { RequireLinux(); using var cts = new CancellationTokenSource(Budget); - (PipeWireContext context, PipeWireRegistry registry) = await ConnectAsync("pwnet-race", cts.Token); + (PipeWireContext context, PipeWireRegistry registry) = await ConnectAsync( + "pwnet-race", + cts.Token + ); await using (context) await using (registry) @@ -162,34 +229,60 @@ public async Task ManyReadersRacingTheWriter_NeverSeeATornGraph() // thread injection instead of the daemon. On a four-core runner that alone spends the // whole budget. Yielding each pass keeps the loop thread schedulable against the // oversubscription that is the point of the test. - Task[] readers = [.. Enumerable.Range(0, 8).Select(_ => Task.Factory.StartNew(() => - { - try - { - while (!stop.IsCancellationRequested) - { - PipeWireGraphSnapshot g = registry.Current; - - // Every index must agree with the arrays inside one snapshot, whatever the - // writer is doing concurrently. - foreach (PipeWirePort port in g.Ports) - if (g.GetPort(port.PortId) is null) - throw new InvalidOperationException($"port {port.PortId} missing from its own index"); - - foreach (PipeWireLink link in g.Links) - if (!g.GetOutputLinksForPort(link.OutputPortId).Contains(link)) - throw new InvalidOperationException($"link {link.LinkId} missing from its own index"); - - Interlocked.Increment(ref reads); - Thread.Yield(); - } - } - catch (Exception ex) { faulted ??= ex; } - }, CancellationToken.None, TaskCreationOptions.LongRunning, TaskScheduler.Default))]; + Task[] readers = + [ + .. Enumerable + .Range(0, 8) + .Select(_ => + Task.Factory.StartNew( + () => + { + try + { + while (!stop.IsCancellationRequested) + { + PipeWireGraphSnapshot g = registry.Current; + + // Every index must agree with the arrays inside one snapshot, whatever the + // writer is doing concurrently. + foreach (PipeWirePort port in g.Ports) + if (g.GetPort(port.PortId) is null) + throw new InvalidOperationException( + $"port {port.PortId} missing from its own index" + ); + + foreach (PipeWireLink link in g.Links) + if ( + !g.GetOutputLinksForPort(link.OutputPortId) + .Contains(link) + ) + throw new InvalidOperationException( + $"link {link.LinkId} missing from its own index" + ); + + Interlocked.Increment(ref reads); + Thread.Yield(); + } + } + catch (Exception ex) + { + faulted ??= ex; + } + }, + CancellationToken.None, + TaskCreationOptions.LongRunning, + TaskScheduler.Default + ) + ), + ]; for (int i = 0; i < 20; i++) { - PipeWireNode n = await registry.CreateVirtualSinkAsync($"R{i}", $"pwnet_race_{i}", cts.Token); + PipeWireNode n = await registry.CreateVirtualSinkAsync( + $"R{i}", + $"pwnet_race_{i}", + cts.Token + ); await registry.DestroyGlobalAsync(n.NodeId, cts.Token); } @@ -197,7 +290,10 @@ public async Task ManyReadersRacingTheWriter_NeverSeeATornGraph() await Task.WhenAll(readers); Assert.IsNull(faulted, $"a reader saw an inconsistent graph: {faulted}"); - Assert.IsTrue(Volatile.Read(ref reads) > 100, $"readers barely ran ({reads} reads); the race was not exercised"); + Assert.IsTrue( + Volatile.Read(ref reads) > 100, + $"readers barely ran ({reads} reads); the race was not exercised" + ); } } @@ -210,7 +306,10 @@ public async Task CreatingFromInsideAHandler_CompletesRatherThanDeadlocking() // and 1.0.5 answers with a hang followed by a dead daemon. SessionGates.RequireDaemonAtLeast(1, 6, 8); using var cts = new CancellationTokenSource(Budget); - (PipeWireContext context, PipeWireRegistry registry) = await ConnectAsync("pwnet-nested", cts.Token); + (PipeWireContext context, PipeWireRegistry registry) = await ConnectAsync( + "pwnet-nested", + cts.Token + ); await using (context) await using (registry) @@ -218,27 +317,39 @@ public async Task CreatingFromInsideAHandler_CompletesRatherThanDeadlocking() // Issuing a request from a handler means taking the loop lock while already holding it. // pw_thread_loop uses a recursive mutex, so this is legal; awaiting the *result* from // the loop thread would not be, which is why the handler fires and forgets. - var issued = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var issued = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously + ); var once = 0; registry.NodeAdded += node => { - if (node.NodeName != "pwnet_nested_trigger") return; - if (Interlocked.Exchange(ref once, 1) != 0) return; + if (node.NodeName != "pwnet_nested_trigger") + return; + if (Interlocked.Exchange(ref once, 1) != 0) + return; try { _ = registry.DestroyGlobalAsync(node.NodeId, CancellationToken.None); issued.TrySetResult(true); } - catch (Exception ex) { issued.TrySetException(ex); } + catch (Exception ex) + { + issued.TrySetException(ex); + } }; PipeWireNode trigger = await registry.CreateVirtualSinkAsync( - "Nested", "pwnet_nested_trigger", cts.Token); + "Nested", + "pwnet_nested_trigger", + cts.Token + ); - Assert.IsTrue(await issued.Task.WaitAsync(TimeSpan.FromSeconds(10), cts.Token), - "the handler never issued its request"); + Assert.IsTrue( + await issued.Task.WaitAsync(TimeSpan.FromSeconds(10), cts.Token), + "the handler never issued its request" + ); await WaitForAsync(registry, g => g.GetNode(trigger.NodeId) is null, cts.Token); } @@ -249,7 +360,10 @@ public async Task ConcurrentWatchersAndMutators_AllMakeProgress() { RequireLinux(); using var cts = new CancellationTokenSource(Budget); - (PipeWireContext context, PipeWireRegistry registry) = await ConnectAsync("pwnet-multiwatch", cts.Token); + (PipeWireContext context, PipeWireRegistry registry) = await ConnectAsync( + "pwnet-multiwatch", + cts.Token + ); await using (context) await using (registry) @@ -257,23 +371,39 @@ public async Task ConcurrentWatchersAndMutators_AllMakeProgress() using var stop = new CancellationTokenSource(); var counts = new int[6]; - Task[] watchers = [.. Enumerable.Range(0, 6).Select(i => Task.Run(async () => - { - try - { - await foreach (PipeWireGraphSnapshot _ in registry.WatchAsync(stop.Token)) - Interlocked.Increment(ref counts[i]); - } - catch (OperationCanceledException) - { - // The expected end: `stop` was cancelled. Any other exception fails the task, - // and Task.WhenAll below surfaces it. - } - }, CancellationToken.None))]; + Task[] watchers = + [ + .. Enumerable + .Range(0, 6) + .Select(i => + Task.Run( + async () => + { + try + { + await foreach ( + PipeWireGraphSnapshot _ in registry.WatchAsync(stop.Token) + ) + Interlocked.Increment(ref counts[i]); + } + catch (OperationCanceledException) + { + // The expected end: `stop` was cancelled. Any other exception fails the task, + // and Task.WhenAll below surfaces it. + } + }, + CancellationToken.None + ) + ), + ]; for (int i = 0; i < 10; i++) { - PipeWireNode n = await registry.CreateVirtualSinkAsync($"MW{i}", $"pwnet_mw_{i}", cts.Token); + PipeWireNode n = await registry.CreateVirtualSinkAsync( + $"MW{i}", + $"pwnet_mw_{i}", + cts.Token + ); await registry.DestroyGlobalAsync(n.NodeId, cts.Token); } @@ -281,7 +411,10 @@ public async Task ConcurrentWatchersAndMutators_AllMakeProgress() await Task.WhenAll(watchers); for (int i = 0; i < counts.Length; i++) - Assert.IsTrue(counts[i] > 0, $"watcher {i} received nothing; a slow consumer starved it"); + Assert.IsTrue( + counts[i] > 0, + $"watcher {i} received nothing; a slow consumer starved it" + ); } } @@ -290,7 +423,10 @@ public async Task DisposingFromAThreadPoolThreadWhileEventsFlow_IsClean() { RequireLinux(); using var cts = new CancellationTokenSource(Budget); - (PipeWireContext context, PipeWireRegistry registry) = await ConnectAsync("pwnet-dispose-race", cts.Token); + (PipeWireContext context, PipeWireRegistry registry) = await ConnectAsync( + "pwnet-dispose-race", + cts.Token + ); await using (context) { @@ -300,36 +436,49 @@ public async Task DisposingFromAThreadPoolThreadWhileEventsFlow_IsClean() // iteration threw, which would mean the disposal never raced anything. int churned = 0; Exception? churnEnded = null; - Task churn = Task.Run(async () => - { - try + Task churn = Task.Run( + async () => { - for (int i = 0; i < 30; i++) + try { - PipeWireNode n = await registry.CreateVirtualSinkAsync($"DR{i}", $"pwnet_dr_{i}", cts.Token); - await registry.DestroyGlobalAsync(n.NodeId, cts.Token); - Interlocked.Increment(ref churned); + for (int i = 0; i < 30; i++) + { + PipeWireNode n = await registry.CreateVirtualSinkAsync( + $"DR{i}", + $"pwnet_dr_{i}", + cts.Token + ); + await registry.DestroyGlobalAsync(n.NodeId, cts.Token); + Interlocked.Increment(ref churned); + } } - } - catch (Exception ex) when (ex is ObjectDisposedException or OperationCanceledException) - { - churnEnded = ex; // the expected way for disposal to stop the loop - } - }, CancellationToken.None); + catch (Exception ex) + when (ex is ObjectDisposedException or OperationCanceledException) + { + churnEnded = ex; // the expected way for disposal to stop the loop + } + }, + CancellationToken.None + ); await Task.Delay(150, cts.Token); await Task.Run(async () => await registry.DisposeAsync(), CancellationToken.None); await churn; - Assert.IsTrue(Volatile.Read(ref churned) > 0, - "the churn loop never completed an iteration, so disposal raced nothing"); - Assert.IsTrue(churnEnded is not null || Volatile.Read(ref churned) == 30, - $"the churn stopped after {churned} iterations without a disposal-related exception"); + Assert.IsTrue( + Volatile.Read(ref churned) > 0, + "the churn loop never completed an iteration, so disposal raced nothing" + ); + Assert.IsTrue( + churnEnded is not null || Volatile.Read(ref churned) == 30, + $"the churn stopped after {churned} iterations without a disposal-related exception" + ); // Reaching here without an abort is the point, but assert the registry is actually shut // so a disposal that silently did nothing cannot pass. - Assert.ThrowsExactly( - () => registry.DestroyGlobalAsync(1, CancellationToken.None)); + Assert.ThrowsExactly(() => + registry.DestroyGlobalAsync(1, CancellationToken.None) + ); } } } diff --git a/tests/PipeWire.NET.Tests/HostDrivenLoopTests.cs b/tests/PipeWire.NET.Tests/HostDrivenLoopTests.cs index 6626665..810e6d7 100644 --- a/tests/PipeWire.NET.Tests/HostDrivenLoopTests.cs +++ b/tests/PipeWire.NET.Tests/HostDrivenLoopTests.cs @@ -62,7 +62,10 @@ public async Task AHostThatPumpsTheLoop_ReceivesEventsOnItsOwnThread() RequireLinux(); using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(45)); - await using var ctx = new PipeWireContext("pwnet-hostloop", ConsoleTestLoggerFactory.Instance) + await using var ctx = new PipeWireContext( + "pwnet-hostloop", + ConsoleTestLoggerFactory.Instance + ) { DriveExternally = true, }; @@ -91,8 +94,7 @@ public async Task AHostThatPumpsTheLoop_ReceivesEventsOnItsOwnThread() var pfd = new PollFd { Fd = fd, Events = PollIn }; if (Poll(ref pfd, 1, 50) > 0 && (pfd.Revents & PollIn) != 0) { - Assert.IsTrue( - ctx.IterateLoop(0) >= 0, "iterating the loop failed"); + Assert.IsTrue(ctx.IterateLoop(0) >= 0, "iterating the loop failed"); iterations++; } @@ -101,22 +103,25 @@ public async Task AHostThatPumpsTheLoop_ReceivesEventsOnItsOwnThread() cts.Token.ThrowIfCancellationRequested(); } - Assert.IsTrue(iterations > 0, "the loop descriptor never signalled, so nothing was pumped"); + Assert.IsTrue( + iterations > 0, + "the loop descriptor never signalled, so nothing was pumped" + ); Assert.IsTrue( graph.Nodes.Any(), - "the graph never filled, so the host's pumping delivered no daemon events"); + "the graph never filled, so the host's pumping delivered no daemon events" + ); // The whole point: the events arrived on the thread that pumped, not a PipeWire one. - Assert.IsTrue( - callbackThreads.Count > 0, - "no graph change was observed at all"); + Assert.IsTrue(callbackThreads.Count > 0, "no graph change was observed at all"); CollectionAssert.AreEquivalent( new[] { drivingThread }, callbackThreads.ToArray(), "events were dispatched on a thread other than the one driving the loop, so a UI " - + "host would still have to marshal"); + + "host would still have to marshal" + ); } finally { @@ -138,7 +143,10 @@ public async Task TheHostDrivenCalls_RefuseAContextThatOwnsItsThread() RequireLinux(); using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); - await using var ctx = new PipeWireContext("pwnet-hostloop-refuse", ConsoleTestLoggerFactory.Instance); + await using var ctx = new PipeWireContext( + "pwnet-hostloop-refuse", + ConsoleTestLoggerFactory.Instance + ); await ctx.StartAsync(cts.Token); Assert.ThrowsExactly(() => ctx.IterateLoop()); diff --git a/tests/PipeWire.NET.Tests/HostileConditionsTests.cs b/tests/PipeWire.NET.Tests/HostileConditionsTests.cs index 0f8f243..06bd0b1 100644 --- a/tests/PipeWire.NET.Tests/HostileConditionsTests.cs +++ b/tests/PipeWire.NET.Tests/HostileConditionsTests.cs @@ -39,7 +39,10 @@ private static void RequireLinux() /// A small host-memory producer of this library's own, publishing, unrouted. private static async Task StartSourceAsync( - PipeWireContext ctx, string name, CancellationToken ct) + PipeWireContext ctx, + string name, + CancellationToken ct + ) { var output = new PipeWireVideoOutput(ctx, name, 64, 48, PixelFormat.Bgra, 30); output.FillFrame += (_, pixels, _, _, _, _) => @@ -66,13 +69,20 @@ public async Task EveryAccessorOnADisposedCapture_AnswersInsteadOfTouchingTheStr { RequireLinux(); using var cts = new CancellationTokenSource(Budget); - await using var ctx = new PipeWireContext("pwnet-hc-disposed", ConsoleTestLoggerFactory.Instance); + await using var ctx = new PipeWireContext( + "pwnet-hc-disposed", + ConsoleTestLoggerFactory.Instance + ); await ctx.StartAsync(cts.Token); // Targeted at a producer of this test's own. Untargeted, the session manager routes a video // capture to the default camera, and on a machine whose camera nothing feeds - the lab box's // v4l2 loopback - the daemon logs a failed VIDIOC_STREAMON for every run of this test. - await using PipeWireVideoOutput source = await StartSourceAsync(ctx, "pwnet-hc-disposed-src", cts.Token); + await using PipeWireVideoOutput source = await StartSourceAsync( + ctx, + "pwnet-hc-disposed-src", + cts.Token + ); var capture = new PipeWireVideoCapture(ctx, "pwnet-hc-disposed-consumer"); capture.Retention = FrameRetention.Owned; @@ -114,12 +124,19 @@ public async Task PullingWithoutOptingIn_ReturnsNothing() { RequireLinux(); using var cts = new CancellationTokenSource(Budget); - await using var ctx = new PipeWireContext("pwnet-hc-nopull", ConsoleTestLoggerFactory.Instance); + await using var ctx = new PipeWireContext( + "pwnet-hc-nopull", + ConsoleTestLoggerFactory.Instance + ); await ctx.StartAsync(cts.Token); // Frames have to be flowing for "nothing retained" to mean anything: with no producer a // puller gets nothing whether or not retention is on. - await using PipeWireVideoOutput source = await StartSourceAsync(ctx, "pwnet-hc-nopull-src", cts.Token); + await using PipeWireVideoOutput source = await StartSourceAsync( + ctx, + "pwnet-hc-nopull-src", + cts.Token + ); await using var capture = new PipeWireVideoCapture(ctx, "pwnet-hc-nopull-consumer"); Assert.AreEqual(FrameRetention.None, capture.Retention, "retention must be off by default"); @@ -129,8 +146,12 @@ public async Task PullingWithoutOptingIn_ReturnsNothing() capture.Connect((await source.WaitForNodeIdAsync(cts.Token)), [PixelFormat.Bgra]); await capture.WaitForStreamingAsync(cts.Token); - for (int i = 0; i < 60 && Volatile.Read(ref delivered) < 5; i++) await Task.Delay(50, cts.Token); - Assert.IsTrue(Volatile.Read(ref delivered) >= 5, "no frames flowed, so there was nothing to not retain"); + for (int i = 0; i < 60 && Volatile.Read(ref delivered) < 5; i++) + await Task.Delay(50, cts.Token); + Assert.IsTrue( + Volatile.Read(ref delivered) >= 5, + "no frames flowed, so there was nothing to not retain" + ); Assert.IsFalse(capture.TryGetFrame(out _)); Assert.IsFalse(capture.TryGetBorrowedFrame(out _)); @@ -141,7 +162,10 @@ public async Task StartingTwice_IsRefusedWithoutLeakingTheFirstLoop() { RequireLinux(); using var cts = new CancellationTokenSource(Budget); - await using var ctx = new PipeWireContext("pwnet-hc-twice", ConsoleTestLoggerFactory.Instance); + await using var ctx = new PipeWireContext( + "pwnet-hc-twice", + ConsoleTestLoggerFactory.Instance + ); await ctx.StartAsync(cts.Token); @@ -150,21 +174,25 @@ public async Task StartingTwice_IsRefusedWithoutLeakingTheFirstLoop() { await ctx.StartAsync(cts.Token); } - catch (InvalidOperationException) - { - } + catch (InvalidOperationException) { } // Either way the context must still be usable afterwards. await using var reg = new PipeWireRegistry(ctx); await reg.WaitForInitialEnumerationAsync(cts.Token); - Assert.IsTrue(reg.Current.Nodes.Length > 0, "the context stopped working after a double start"); + Assert.IsTrue( + reg.Current.Nodes.Length > 0, + "the context stopped working after a double start" + ); } [TestMethod] public async Task BuildingARegistryOnAnUnstartedContext_FailsRatherThanCrashing() { RequireLinux(); - await using var ctx = new PipeWireContext("pwnet-hc-unstarted", ConsoleTestLoggerFactory.Instance); + await using var ctx = new PipeWireContext( + "pwnet-hc-unstarted", + ConsoleTestLoggerFactory.Instance + ); // There is no core to get a registry from yet. Dereferencing a null core in native code // would be a segfault, so this has to be a managed failure. @@ -173,9 +201,12 @@ public async Task BuildingARegistryOnAnUnstartedContext_FailsRatherThanCrashing( await using var reg = new PipeWireRegistry(ctx); Assert.Fail("a registry on an unstarted context must not appear to work"); } - catch (Exception ex) when (ex is InvalidOperationException or ObjectDisposedException or ArgumentNullException) - { - } + catch (Exception ex) + when (ex + is InvalidOperationException + or ObjectDisposedException + or ArgumentNullException + ) { } } [TestMethod] @@ -204,9 +235,12 @@ public async Task UsingAContextAfterDisposal_ThrowsRatherThanTouchingFreedMemory await using var reg = new PipeWireRegistry(ctx); Assert.Fail("a registry on a disposed context must not appear to work"); } - catch (Exception ex) when (ex is ObjectDisposedException or InvalidOperationException or ArgumentNullException) - { - } + catch (Exception ex) + when (ex + is ObjectDisposedException + or InvalidOperationException + or ArgumentNullException + ) { } } [TestMethod] @@ -240,7 +274,10 @@ public async Task ANodeNameThatIsHostileOrExotic_IsHandledOrRefusedCleanly(strin { RequireLinux(); using var cts = new CancellationTokenSource(Budget); - await using var ctx = new PipeWireContext("pwnet-hc-names", ConsoleTestLoggerFactory.Instance); + await using var ctx = new PipeWireContext( + "pwnet-hc-names", + ConsoleTestLoggerFactory.Instance + ); await ctx.StartAsync(cts.Token); await using var reg = new PipeWireRegistry(ctx); await reg.WaitForInitialEnumerationAsync(cts.Token); @@ -249,14 +286,22 @@ public async Task ANodeNameThatIsHostileOrExotic_IsHandledOrRefusedCleanly(strin // the interesting ones: a name must not be able to inject a second property. try { - PipeWireNode node = await reg.CreateVirtualSink("Hostile").WithName(name) - .ExecuteAsync(cts.Token); + PipeWireNode node = await reg.CreateVirtualSink("Hostile") + .WithName(name) + .ExecuteAsync(cts.Token); PipeWireNode? live = reg.Current.GetNode(node.NodeId); Assert.IsNotNull(live); - Assert.AreEqual(name, live!.NodeName, "the name must round-trip exactly, not be reinterpreted"); - Assert.AreEqual("Audio/Sink", live.MediaClass, - "a name must never be able to change another property"); + Assert.AreEqual( + name, + live!.NodeName, + "the name must round-trip exactly, not be reinterpreted" + ); + Assert.AreEqual( + "Audio/Sink", + live.MediaClass, + "a name must never be able to change another property" + ); await reg.DestroyGlobalAsync(node.NodeId, cts.Token); } @@ -271,7 +316,10 @@ public async Task ANameContainingAnEmbeddedNul_DoesNotTruncateSilentlyIntoAnothe { RequireLinux(); using var cts = new CancellationTokenSource(Budget); - await using var ctx = new PipeWireContext("pwnet-hc-nul", ConsoleTestLoggerFactory.Instance); + await using var ctx = new PipeWireContext( + "pwnet-hc-nul", + ConsoleTestLoggerFactory.Instance + ); await ctx.StartAsync(cts.Token); await using var reg = new PipeWireRegistry(ctx); await reg.WaitForInitialEnumerationAsync(cts.Token); @@ -281,19 +329,20 @@ public async Task ANameContainingAnEmbeddedNul_DoesNotTruncateSilentlyIntoAnothe const string Name = "pwnet_hc_nul\0hidden"; try { - PipeWireNode node = await reg.CreateVirtualSink("Nul").WithName(Name) - .ExecuteAsync(cts.Token); + PipeWireNode node = await reg.CreateVirtualSink("Nul") + .WithName(Name) + .ExecuteAsync(cts.Token); string? stored = reg.Current.GetNode(node.NodeId)?.NodeName; Assert.IsNotNull(stored); - Assert.IsFalse(stored!.Contains("hidden", StringComparison.Ordinal), - "text after a NUL must not reappear in the graph"); + Assert.IsFalse( + stored!.Contains("hidden", StringComparison.Ordinal), + "text after a NUL must not reappear in the graph" + ); await reg.DestroyGlobalAsync(node.NodeId, cts.Token); } - catch (ArgumentException) - { - } + catch (ArgumentException) { } } [TestMethod] @@ -301,13 +350,18 @@ public async Task AVeryLongNodeName_IsAcceptedOrRefusedButNeverTruncatedSilently { RequireLinux(); using var cts = new CancellationTokenSource(Budget); - await using var ctx = new PipeWireContext("pwnet-hc-long", ConsoleTestLoggerFactory.Instance); + await using var ctx = new PipeWireContext( + "pwnet-hc-long", + ConsoleTestLoggerFactory.Instance + ); await ctx.StartAsync(cts.Token); await using var reg = new PipeWireRegistry(ctx); await reg.WaitForInitialEnumerationAsync(cts.Token); var name = "pwnet_hc_" + new string('x', 8192); - PipeWireNode node = await reg.CreateVirtualSink("Long").WithName(name).ExecuteAsync(cts.Token); + PipeWireNode node = await reg.CreateVirtualSink("Long") + .WithName(name) + .ExecuteAsync(cts.Token); string? stored = reg.Current.GetNode(node.NodeId)?.NodeName; Assert.AreEqual(name, stored, "a name that was accepted must come back whole"); @@ -322,7 +376,10 @@ public async Task CapturingFromANodeThatDoesNotExist_FailsOrIdlesButNeverHangs() { RequireLinux(); using var cts = new CancellationTokenSource(Budget); - await using var ctx = new PipeWireContext("pwnet-hc-nowhere", ConsoleTestLoggerFactory.Instance); + await using var ctx = new PipeWireContext( + "pwnet-hc-nowhere", + ConsoleTestLoggerFactory.Instance + ); await ctx.StartAsync(cts.Token); var states = new List(); @@ -336,7 +393,11 @@ public async Task CapturingFromANodeThatDoesNotExist_FailsOrIdlesButNeverHangs() { ExtraProperties = new Dictionary { ["node.dont-fallback"] = "true" }, }; - capture.StateChanged += (_, _, s) => { lock (states) states.Add(s); }; + capture.StateChanged += (_, _, s) => + { + lock (states) + states.Add(s); + }; capture.FrameReady += (_, _) => Interlocked.Increment(ref frames); // 999999 is not a live global. Connecting to it must reach a definite state rather than @@ -347,7 +408,7 @@ public async Task CapturingFromANodeThatDoesNotExist_FailsOrIdlesButNeverHangs() } catch (Exception e) when (e is InvalidOperationException or PipeWireException) { - return; // refusing outright is the cleanest answer + return; // refusing outright is the cleanest answer } await Task.Delay(2000, cts.Token); @@ -355,11 +416,17 @@ public async Task CapturingFromANodeThatDoesNotExist_FailsOrIdlesButNeverHangs() lock (states) { Assert.IsTrue(states.Count > 0, "the stream never reported any state at all"); - Assert.IsFalse(states[^1] is PipeWireStreamState.Streaming, - "a stream targeting a nonexistent node must not claim to be streaming"); + Assert.IsFalse( + states[^1] is PipeWireStreamState.Streaming, + "a stream targeting a nonexistent node must not claim to be streaming" + ); } - Assert.AreEqual(0, Volatile.Read(ref frames), "frames arrived from a node that does not exist"); + Assert.AreEqual( + 0, + Volatile.Read(ref frames), + "frames arrived from a node that does not exist" + ); } [TestMethod] @@ -367,14 +434,18 @@ public async Task CapturingFromANodeThatDisappearsBeforeWeConnect_IsHandled() { RequireLinux(); using var cts = new CancellationTokenSource(Budget); - await using var ctx = new PipeWireContext("pwnet-hc-gone", ConsoleTestLoggerFactory.Instance); + await using var ctx = new PipeWireContext( + "pwnet-hc-gone", + ConsoleTestLoggerFactory.Instance + ); await ctx.StartAsync(cts.Token); await using var reg = new PipeWireRegistry(ctx); await reg.WaitForInitialEnumerationAsync(cts.Token); // A patchbay holding a stale id and acting on it is the everyday version of this. PipeWireNode node = await reg.CreateVirtualSink("Gone") - .WithName("pwnet_hc_gone").ExecuteAsync(cts.Token); + .WithName("pwnet_hc_gone") + .ExecuteAsync(cts.Token); uint staleId = node.NodeId; await reg.DestroyGlobalAsync(staleId, cts.Token); await WaitForAsync(reg, g => g.GetNode(staleId) is null, cts.Token); @@ -383,11 +454,9 @@ public async Task CapturingFromANodeThatDisappearsBeforeWeConnect_IsHandled() try { capture.Connect(staleId); - await Task.Delay(1500, cts.Token); // must not hang, whatever it decides - } - catch (Exception e) when (e is InvalidOperationException or PipeWireException) - { + await Task.Delay(1500, cts.Token); // must not hang, whatever it decides } + catch (Exception e) when (e is InvalidOperationException or PipeWireException) { } } // ------------------------------------------------------------------ pressure @@ -407,7 +476,10 @@ public async Task ManySimultaneousContexts_AllConnectAndAllReleaseTheirDescripto { for (int i = 0; i < Count; i++) { - var ctx = new PipeWireContext($"pwnet-hc-many-{i}", ConsoleTestLoggerFactory.Instance); + var ctx = new PipeWireContext( + $"pwnet-hc-many-{i}", + ConsoleTestLoggerFactory.Instance + ); await ctx.StartAsync(cts.Token); contexts.Add(ctx); @@ -422,8 +494,10 @@ public async Task ManySimultaneousContexts_AllConnectAndAllReleaseTheirDescripto } finally { - foreach (PipeWireRegistry reg in registries) await reg.DisposeAsync(); - foreach (PipeWireContext ctx in contexts) await ctx.DisposeAsync(); + foreach (PipeWireRegistry reg in registries) + await reg.DisposeAsync(); + foreach (PipeWireContext ctx in contexts) + await ctx.DisposeAsync(); } GC.Collect(); @@ -432,8 +506,10 @@ public async Task ManySimultaneousContexts_AllConnectAndAllReleaseTheirDescripto await Task.Delay(300, cts.Token); int fdsAfter = OpenFds(); - Assert.IsTrue(fdsAfter <= fdsBefore + 4, - $"{Count} connect/disconnect cycles leaked descriptors: {fdsBefore} -> {fdsAfter}"); + Assert.IsTrue( + fdsAfter <= fdsBefore + 4, + $"{Count} connect/disconnect cycles leaked descriptors: {fdsBefore} -> {fdsAfter}" + ); } [TestMethod] @@ -441,7 +517,10 @@ public async Task AStormOfCreatesAndRemoves_KeepsTheGraphAccurate() { RequireLinux(); using var cts = new CancellationTokenSource(Budget); - await using var ctx = new PipeWireContext("pwnet-hc-storm", ConsoleTestLoggerFactory.Instance); + await using var ctx = new PipeWireContext( + "pwnet-hc-storm", + ConsoleTestLoggerFactory.Instance + ); await ctx.StartAsync(cts.Token); await using var reg = new PipeWireRegistry(ctx); await reg.WaitForInitialEnumerationAsync(cts.Token); @@ -461,25 +540,42 @@ public async Task AStormOfCreatesAndRemoves_KeepsTheGraphAccurate() // of them and WirePlumber ends up no longer reading its core socket at all, which starves // every client that connects afterwards. The concurrency and the id reuse are the point // here and both survive the pause. - Task[] workers = [.. Enumerable.Range(0, 8).Select(w => Task.Run(async () => - { - for (int i = 0; i < 10; i++) - { - await Task.Delay(TimeSpan.FromMilliseconds(25), cts.Token); - - // No media.class. The session manager reads that as a routable endpoint and builds - // a session item for it, then tries to link it - and these nodes exist for a few - // milliseconds, so it never finds a target and every one of them ends as an - // aborted activation. Nothing here needs the node to be a sink. - PipeWireNode n = await reg.CreateVirtualSink($"Storm {w}-{i}") - .WithName($"pwnet_hc_storm_{w}_{i}") - .WithMediaClass("").ExecuteAsync(cts.Token); - Assert.IsNotNull(n.ObjectSerial, "a created node arrived without a serial"); - mine.Add(n.ObjectSerial!.Value); - Assert.IsNotNull(reg.Current.GetNode(n.NodeId), "a created node was not in the graph"); - await reg.DestroyGlobalAsync(n.NodeId, cts.Token); - } - }, cts.Token))]; + Task[] workers = + [ + .. Enumerable + .Range(0, 8) + .Select(w => + Task.Run( + async () => + { + for (int i = 0; i < 10; i++) + { + await Task.Delay(TimeSpan.FromMilliseconds(25), cts.Token); + + // No media.class. The session manager reads that as a routable endpoint and builds + // a session item for it, then tries to link it - and these nodes exist for a few + // milliseconds, so it never finds a target and every one of them ends as an + // aborted activation. Nothing here needs the node to be a sink. + PipeWireNode n = await reg.CreateVirtualSink($"Storm {w}-{i}") + .WithName($"pwnet_hc_storm_{w}_{i}") + .WithMediaClass("") + .ExecuteAsync(cts.Token); + Assert.IsNotNull( + n.ObjectSerial, + "a created node arrived without a serial" + ); + mine.Add(n.ObjectSerial!.Value); + Assert.IsNotNull( + reg.Current.GetNode(n.NodeId), + "a created node was not in the graph" + ); + await reg.DestroyGlobalAsync(n.NodeId, cts.Token); + } + }, + cts.Token + ) + ), + ]; await Task.WhenAll(workers); @@ -498,8 +594,10 @@ public async Task AStormOfCreatesAndRemoves_KeepsTheGraphAccurate() left = StillOurs(reg.Current, mine); } - Assert.IsEmpty(left, - $"the storm left {left.Length} of its own nodes in the graph: {string.Join(", ", left)}"); + Assert.IsEmpty( + left, + $"the storm left {left.Length} of its own nodes in the graph: {string.Join(", ", left)}" + ); } /// Ids this test created that still resolve to one of its nodes, or to no name. @@ -507,9 +605,12 @@ public async Task AStormOfCreatesAndRemoves_KeepsTheGraphAccurate() private static ulong[] StillOurs(PipeWireGraphSnapshot graph, ConcurrentBag mine) { var ours = new HashSet(mine); - return [.. graph.Nodes - .Where(n => n.ObjectSerial is { } serial && ours.Contains(serial)) - .Select(n => n.ObjectSerial!.Value)]; + return + [ + .. graph + .Nodes.Where(n => n.ObjectSerial is { } serial && ours.Contains(serial)) + .Select(n => n.ObjectSerial!.Value), + ]; } // ------------------------------------------------------------------ losing the daemon @@ -528,7 +629,8 @@ public async Task BeingDisconnectedByTheDaemon_IsSurvivedAndObservable() await reg.WaitForInitialEnumerationAsync(cts.Token); PipeWireNode node = await reg.CreateVirtualSink("Doomed") - .WithName("pwnet_hc_kicked_node").ExecuteAsync(cts.Token); + .WithName("pwnet_hc_kicked_node") + .ExecuteAsync(cts.Token); Assert.IsNotNull(reg.Current.GetNode(node.NodeId)); uint? clientId = await FindOurClientIdAsync(AppName, cts.Token); if (clientId is null) @@ -554,11 +656,18 @@ public async Task BeingDisconnectedByTheDaemon_IsSurvivedAndObservable() long startedTicks = Environment.TickCount64; try { - await reg.CreateVirtualSink("After").WithName("pwnet_hc_after").ExecuteAsync(prompt.Token); + await reg.CreateVirtualSink("After") + .WithName("pwnet_hc_after") + .ExecuteAsync(prompt.Token); Assert.Fail("a create on a destroyed connection reported success"); } - catch (Exception ex) when (ex is InvalidOperationException or ObjectDisposedException - or TimeoutException or PipeWireException) + catch (Exception ex) + when (ex + is InvalidOperationException + or ObjectDisposedException + or TimeoutException + or PipeWireException + ) { // A definite refusal, which is the point. } @@ -566,7 +675,8 @@ public async Task BeingDisconnectedByTheDaemon_IsSurvivedAndObservable() { Assert.Fail( $"a create on a destroyed connection was still outstanding after " - + $"{(Environment.TickCount64 - startedTicks) / 1000.0:F1}s rather than failing"); + + $"{(Environment.TickCount64 - startedTicks) / 1000.0:F1}s rather than failing" + ); } // And disposal after the connection is gone must still be clean. await reg.DisposeAsync(); @@ -578,7 +688,10 @@ private static int OpenFds() => Directory.GetFileSystemEntries($"/proc/{Environment.ProcessId}/fd").Length; private static async Task WaitForAsync( - PipeWireRegistry registry, Func until, CancellationToken ct) + PipeWireRegistry registry, + Func until, + CancellationToken ct + ) { await foreach (PipeWireGraphSnapshot graph in registry.WatchAsync(ct)) if (until(graph)) @@ -609,20 +722,28 @@ private static async Task WaitForAsync( using System.Text.Json.JsonDocument doc = System.Text.Json.JsonDocument.Parse(json); foreach (System.Text.Json.JsonElement o in doc.RootElement.EnumerateArray()) { - if (!o.TryGetProperty("type", out System.Text.Json.JsonElement type)) continue; - if (!(type.GetString()?.EndsWith("Client", StringComparison.Ordinal) ?? false)) continue; - if (!o.TryGetProperty("info", out System.Text.Json.JsonElement info)) continue; - if (!info.TryGetProperty("props", out System.Text.Json.JsonElement props)) continue; - if (!o.TryGetProperty("id", out System.Text.Json.JsonElement id)) continue; - - bool ourPid = props.TryGetProperty("pipewire.sec.pid", out System.Text.Json.JsonElement pid) - && pid.TryGetInt32(out int value) - && value == Environment.ProcessId; - - bool ourName = props.TryGetProperty("application.name", out System.Text.Json.JsonElement name) - && name.GetString() == appName; - - if (ourPid || ourName) return id.GetUInt32(); + if (!o.TryGetProperty("type", out System.Text.Json.JsonElement type)) + continue; + if (!(type.GetString()?.EndsWith("Client", StringComparison.Ordinal) ?? false)) + continue; + if (!o.TryGetProperty("info", out System.Text.Json.JsonElement info)) + continue; + if (!info.TryGetProperty("props", out System.Text.Json.JsonElement props)) + continue; + if (!o.TryGetProperty("id", out System.Text.Json.JsonElement id)) + continue; + + bool ourPid = + props.TryGetProperty("pipewire.sec.pid", out System.Text.Json.JsonElement pid) + && pid.TryGetInt32(out int value) + && value == Environment.ProcessId; + + bool ourName = + props.TryGetProperty("application.name", out System.Text.Json.JsonElement name) + && name.GetString() == appName; + + if (ourPid || ourName) + return id.GetUInt32(); } return null; } diff --git a/tests/PipeWire.NET.Tests/HostileControlTests.cs b/tests/PipeWire.NET.Tests/HostileControlTests.cs index e5b5bca..278f639 100644 --- a/tests/PipeWire.NET.Tests/HostileControlTests.cs +++ b/tests/PipeWire.NET.Tests/HostileControlTests.cs @@ -31,7 +31,9 @@ private static void RequireLinux() } private static async Task<(PipeWireContext Context, PipeWireRegistry Registry)> ConnectAsync( - string name, CancellationToken cancellationToken) + string name, + CancellationToken cancellationToken + ) { var context = new PipeWireContext(name, ConsoleTestLoggerFactory.Instance); await context.StartAsync(cancellationToken); @@ -48,26 +50,34 @@ public async Task DestroyingANodeWhileItsParametersAreBeingRead_FailsCleanly() { RequireLinux(); using var cts = new CancellationTokenSource(Budget); - (PipeWireContext ctx, PipeWireRegistry registry) = await ConnectAsync("pwnet-hostile-vanish", cts.Token); + (PipeWireContext ctx, PipeWireRegistry registry) = await ConnectAsync( + "pwnet-hostile-vanish", + cts.Token + ); await using (ctx) await using (registry) { for (int round = 0; round < 6; round++) { - PipeWireNode node = await registry.CreateVirtualSink("Vanish") - .WithName(UniqueName("pwnet_vanish")).ExecuteAsync(cts.Token); + PipeWireNode node = await registry + .CreateVirtualSink("Vanish") + .WithName(UniqueName("pwnet_vanish")) + .ExecuteAsync(cts.Token); await using PipeWireNodeProxy control = registry.BindNode(node.NodeId); // Read and destroy at the same time. Whichever wins, the read must end - with a // value, with an empty answer, or with an exception - and never hang. - Task> reading = - control.EnumerateParametersAsync(SpaParamType.Props, cts.Token); + Task> reading = control.EnumerateParametersAsync( + SpaParamType.Props, + cts.Token + ); Task destroying = registry.DestroyGlobalAsync(node.NodeId, cts.Token); try { - await Task.WhenAll(reading, destroying).WaitAsync(TimeSpan.FromSeconds(8), cts.Token); + await Task.WhenAll(reading, destroying) + .WaitAsync(TimeSpan.FromSeconds(8), cts.Token); } catch (ObjectDisposedException) { } catch (Exception e) when (e is InvalidOperationException or PipeWireException) { } @@ -80,12 +90,17 @@ public async Task DisposingAControlWhileItIsBeingUsedFromAnotherThread_DoesNotCr { RequireLinux(); using var cts = new CancellationTokenSource(Budget); - (PipeWireContext ctx, PipeWireRegistry registry) = await ConnectAsync("pwnet-hostile-dispose", cts.Token); + (PipeWireContext ctx, PipeWireRegistry registry) = await ConnectAsync( + "pwnet-hostile-dispose", + cts.Token + ); await using (ctx) await using (registry) { - PipeWireNode node = await registry.CreateVirtualSink("DisposeRace") - .WithName(UniqueName("pwnet_disposerace")).ExecuteAsync(cts.Token); + PipeWireNode node = await registry + .CreateVirtualSink("DisposeRace") + .WithName(UniqueName("pwnet_disposerace")) + .ExecuteAsync(cts.Token); for (int round = 0; round < 10; round++) { @@ -94,13 +109,25 @@ public async Task DisposingAControlWhileItIsBeingUsedFromAnotherThread_DoesNotCr // Every reader must end one way or another. Task[] readers = [ - .. Enumerable.Range(0, 8).Select(_ => Task.Run(async () => - { - try { await control.GetVolumeAsync(cts.Token); } - catch (ObjectDisposedException) { } - catch (Exception e) when (e is InvalidOperationException or PipeWireException) { } - catch (OperationCanceledException) { } - }, cts.Token)), + .. Enumerable + .Range(0, 8) + .Select(_ => + Task.Run( + async () => + { + try + { + await control.GetVolumeAsync(cts.Token); + } + catch (ObjectDisposedException) { } + catch (Exception e) + when (e is InvalidOperationException or PipeWireException) + { } + catch (OperationCanceledException) { } + }, + cts.Token + ) + ), ]; await control.DisposeAsync(); @@ -119,12 +146,17 @@ public async Task CancellingEveryParameterReadAtAnArbitraryPoint_LeavesNothingBe { RequireLinux(); using var cts = new CancellationTokenSource(Budget); - (PipeWireContext ctx, PipeWireRegistry registry) = await ConnectAsync("pwnet-hostile-cancel", cts.Token); + (PipeWireContext ctx, PipeWireRegistry registry) = await ConnectAsync( + "pwnet-hostile-cancel", + cts.Token + ); await using (ctx) await using (registry) { - PipeWireNode node = await registry.CreateVirtualSink("CancelRace") - .WithName(UniqueName("pwnet_cancelrace")).ExecuteAsync(cts.Token); + PipeWireNode node = await registry + .CreateVirtualSink("CancelRace") + .WithName(UniqueName("pwnet_cancelrace")) + .ExecuteAsync(cts.Token); await using PipeWireNodeProxy control = registry.BindNode(node.NodeId); @@ -135,14 +167,19 @@ public async Task CancellingEveryParameterReadAtAnArbitraryPoint_LeavesNothingBe using var attempt = new CancellationTokenSource(); attempt.CancelAfter(TimeSpan.FromMicroseconds(micros)); - try { await control.EnumerateParametersAsync(SpaParamType.Props, attempt.Token); } + try + { + await control.EnumerateParametersAsync(SpaParamType.Props, attempt.Token); + } catch (OperationCanceledException) { } } // Whatever happened above, the control still works: no waiter left in the map, no lock // left held, no state corrupted by an abandoned request. - Assert.IsNotNull(await control.GetVolumeAsync(cts.Token), - "the control must still answer after every read before it was cancelled"); + Assert.IsNotNull( + await control.GetVolumeAsync(cts.Token), + "the control must still answer after every read before it was cancelled" + ); await registry.DestroyGlobalAsync(node.NodeId, cts.Token); } @@ -153,12 +190,17 @@ public async Task ManyOverlappingReadsOfDifferentParameters_DoNotCrossTheirAnswe { RequireLinux(); using var cts = new CancellationTokenSource(Budget); - (PipeWireContext ctx, PipeWireRegistry registry) = await ConnectAsync("pwnet-hostile-overlap", cts.Token); + (PipeWireContext ctx, PipeWireRegistry registry) = await ConnectAsync( + "pwnet-hostile-overlap", + cts.Token + ); await using (ctx) await using (registry) { - PipeWireNode node = await registry.CreateVirtualSink("Overlap") - .WithName(UniqueName("pwnet_overlap")).ExecuteAsync(cts.Token); + PipeWireNode node = await registry + .CreateVirtualSink("Overlap") + .WithName(UniqueName("pwnet_overlap")) + .ExecuteAsync(cts.Token); await using PipeWireNodeProxy control = registry.BindNode(node.NodeId); await control.ReadyAsync(cts.Token); @@ -167,21 +209,39 @@ public async Task ManyOverlappingReadsOfDifferentParameters_DoNotCrossTheirAnswe // shared a key, or one collected the other's answers, this is where it shows. for (int round = 0; round < 5; round++) { - Task> props = - control.EnumerateParametersAsync(SpaParamType.Props, cts.Token); - Task> propInfo = - control.EnumerateParametersAsync(SpaParamType.PropInfo, cts.Token); - Task> formats = - control.EnumerateParametersAsync(SpaParamType.EnumFormat, cts.Token); + Task> props = control.EnumerateParametersAsync( + SpaParamType.Props, + cts.Token + ); + Task> propInfo = control.EnumerateParametersAsync( + SpaParamType.PropInfo, + cts.Token + ); + Task> formats = control.EnumerateParametersAsync( + SpaParamType.EnumFormat, + cts.Token + ); await Task.WhenAll(props, propInfo, formats); foreach (SpaObject o in await props) - Assert.AreEqual(SpaParamType.Props, o.ObjectId, "a Props read collected something else"); + Assert.AreEqual( + SpaParamType.Props, + o.ObjectId, + "a Props read collected something else" + ); foreach (SpaObject o in await propInfo) - Assert.AreEqual(SpaParamType.PropInfo, o.ObjectId, "a PropInfo read collected something else"); + Assert.AreEqual( + SpaParamType.PropInfo, + o.ObjectId, + "a PropInfo read collected something else" + ); foreach (SpaObject o in await formats) - Assert.AreEqual(SpaParamType.EnumFormat, o.ObjectId, "a format read collected something else"); + Assert.AreEqual( + SpaParamType.EnumFormat, + o.ObjectId, + "a format read collected something else" + ); Assert.IsTrue((await props).Length > 0); Assert.IsTrue((await propInfo).Length > 0); @@ -196,12 +256,17 @@ public async Task WritingAParameterThatIsNonsenseForTheObject_IsIgnoredRatherTha { RequireLinux(); using var cts = new CancellationTokenSource(Budget); - (PipeWireContext ctx, PipeWireRegistry registry) = await ConnectAsync("pwnet-hostile-write", cts.Token); + (PipeWireContext ctx, PipeWireRegistry registry) = await ConnectAsync( + "pwnet-hostile-write", + cts.Token + ); await using (ctx) await using (registry) { - PipeWireNode node = await registry.CreateVirtualSink("Nonsense") - .WithName(UniqueName("pwnet_nonsense")).ExecuteAsync(cts.Token); + PipeWireNode node = await registry + .CreateVirtualSink("Nonsense") + .WithName(UniqueName("pwnet_nonsense")) + .ExecuteAsync(cts.Token); await using PipeWireNodeProxy control = registry.BindNode(node.NodeId); @@ -210,18 +275,30 @@ public async Task WritingAParameterThatIsNonsenseForTheObject_IsIgnoredRatherTha // drop any of these; what it must not do is take the connection down with it. SpaObject[] nonsense = [ - new(SpaType.ObjectProps, SpaParamType.Props, - [new SpaPodProperty((uint)SpaParamRoute.Index, 0, new SpaInt(9999))]), - new(SpaType.ObjectProps, SpaParamType.Props, - [new SpaPodProperty((uint)SpaProp.Volume, 0, new SpaString("not a float"))]), - new(SpaType.ObjectProps, SpaParamType.Props, - [new SpaPodProperty(0xDEAD_BEEF, 0, new SpaBool(true))]), + new( + SpaType.ObjectProps, + SpaParamType.Props, + [new SpaPodProperty((uint)SpaParamRoute.Index, 0, new SpaInt(9999))] + ), + new( + SpaType.ObjectProps, + SpaParamType.Props, + [new SpaPodProperty((uint)SpaProp.Volume, 0, new SpaString("not a float"))] + ), + new( + SpaType.ObjectProps, + SpaParamType.Props, + [new SpaPodProperty(0xDEAD_BEEF, 0, new SpaBool(true))] + ), new(SpaType.ObjectProps, SpaParamType.Props, []), ]; foreach (SpaObject value in nonsense) { - try { await control.SetParameterAsync(SpaParamType.Props, value, cts.Token); } + try + { + await control.SetParameterAsync(SpaParamType.Props, value, cts.Token); + } catch (PipeWireException) { } } @@ -235,24 +312,33 @@ public async Task AVolumeAtTheEdgesOfWhatAFloatCanHold_IsRefusedOrClampedButNeve { RequireLinux(); using var cts = new CancellationTokenSource(Budget); - (PipeWireContext ctx, PipeWireRegistry registry) = await ConnectAsync("pwnet-hostile-volume", cts.Token); + (PipeWireContext ctx, PipeWireRegistry registry) = await ConnectAsync( + "pwnet-hostile-volume", + cts.Token + ); await using (ctx) await using (registry) { - PipeWireNode node = await registry.CreateVirtualSink("EdgeVolume") - .WithName(UniqueName("pwnet_edgevol")).ExecuteAsync(cts.Token); + PipeWireNode node = await registry + .CreateVirtualSink("EdgeVolume") + .WithName(UniqueName("pwnet_edgevol")) + .ExecuteAsync(cts.Token); await using PipeWireNodeProxy control = registry.BindNode(node.NodeId); // The API refuses what it can prove is wrong before it reaches the wire. - await Assert.ThrowsExactlyAsync( - async () => await control.SetVolumeAsync(-1f, cts.Token)); - Assert.ThrowsExactly( - () => control.SetChannelVolumesAsync([], cts.Token)); - Assert.ThrowsExactly( - () => control.SetChannelVolumesAsync([0.5f, -0.5f], cts.Token)); - Assert.ThrowsExactly( - () => control.SetChannelVolumesAsync([float.NaN], cts.Token)); + await Assert.ThrowsExactlyAsync(async () => + await control.SetVolumeAsync(-1f, cts.Token) + ); + Assert.ThrowsExactly(() => + control.SetChannelVolumesAsync([], cts.Token) + ); + Assert.ThrowsExactly(() => + control.SetChannelVolumesAsync([0.5f, -0.5f], cts.Token) + ); + Assert.ThrowsExactly(() => + control.SetChannelVolumesAsync([float.NaN], cts.Token) + ); // And what it cannot prove is wrong, it sends: the daemon decides. Infinity is a real // float, so it goes, and whatever comes back must still be readable. @@ -260,7 +346,10 @@ await Assert.ThrowsExactlyAsync( { await control.SetVolumeAsync(extreme, cts.Token); float? read = await control.GetVolumeAsync(cts.Token); - Assert.IsNotNull(read, $"the node stopped reporting a volume after being sent {extreme}"); + Assert.IsNotNull( + read, + $"the node stopped reporting a volume after being sent {extreme}" + ); } // Read back on the same connection, so the daemon orders it against the write. What it @@ -279,12 +368,17 @@ public async Task AChannelVolumeCountThatDoesNotMatchTheNode_DoesNotCorruptTheOn { RequireLinux(); using var cts = new CancellationTokenSource(Budget); - (PipeWireContext ctx, PipeWireRegistry registry) = await ConnectAsync("pwnet-hostile-chan", cts.Token); + (PipeWireContext ctx, PipeWireRegistry registry) = await ConnectAsync( + "pwnet-hostile-chan", + cts.Token + ); await using (ctx) await using (registry) { - PipeWireNode node = await registry.CreateVirtualSink("ChannelCount") - .WithName(UniqueName("pwnet_chancount")).ExecuteAsync(cts.Token); + PipeWireNode node = await registry + .CreateVirtualSink("ChannelCount") + .WithName(UniqueName("pwnet_chancount")) + .ExecuteAsync(cts.Token); await using PipeWireNodeProxy control = registry.BindNode(node.NodeId); @@ -303,14 +397,21 @@ public async Task AChannelVolumeCountThatDoesNotMatchTheNode_DoesNotCorruptTheOn // anything else is not. await control.SetChannelVolumesAsync([.. Enumerable.Repeat(0.1f, 8)], cts.Token); ImmutableArray mismatched = await control.GetChannelVolumesAsync(cts.Token); - bool storedAsGiven = mismatched.Length == 8 && mismatched.All(v => Math.Abs(v - 0.1f) < 0.001f); - bool folded = mismatched.Length == 2 && mismatched.All(v => Math.Abs(v - 0.1f) < 0.001f); - Assert.IsTrue(storedAsGiven || folded, - $"a mismatched volume array was neither stored as given nor folded to the channel count: [{string.Join(", ", mismatched)}]"); + bool storedAsGiven = + mismatched.Length == 8 && mismatched.All(v => Math.Abs(v - 0.1f) < 0.001f); + bool folded = + mismatched.Length == 2 && mismatched.All(v => Math.Abs(v - 0.1f) < 0.001f); + Assert.IsTrue( + storedAsGiven || folded, + $"a mismatched volume array was neither stored as given nor folded to the channel count: [{string.Join(", ", mismatched)}]" + ); // The channel map does not follow it, which is what makes the map the authority. - Assert.AreEqual(2, (await control.GetChannelMapAsync(cts.Token)).Length, - "the channel map must still describe the node, not the last bad write"); + Assert.AreEqual( + 2, + (await control.GetChannelMapAsync(cts.Token)).Length, + "the channel map must still describe the node, not the last bad write" + ); // Read until the node has applied it, and write again if something else wrote over it. // A set is a request the node processes on its own time. Under the full suite's load, @@ -328,18 +429,24 @@ public async Task AChannelVolumeCountThatDoesNotMatchTheNode_DoesNotCorruptTheOn { after = await control.GetChannelVolumesAsync(cts.Token); history.Add($"w{write}:[{string.Join(",", after)}]"); - if (Matches(after)) break; + if (Matches(after)) + break; await Task.Delay(50, cts.Token); } } if (history.Count > 1) - Console.Error.WriteLine($"channel volumes converged after: {string.Join(" ", history)}"); + Console.Error.WriteLine( + $"channel volumes converged after: {string.Join(" ", history)}" + ); - Assert.IsTrue(Matches(after), - $"a matching write never took after the mismatched one; reads: {string.Join(" ", history)}"); + Assert.IsTrue( + Matches(after), + $"a matching write never took after the mismatched one; reads: {string.Join(" ", history)}" + ); - static bool Matches(ImmutableArray v) => v.Length == 2 && Math.Abs(v[0] - 0.3f) < 0.01f; + static bool Matches(ImmutableArray v) => + v.Length == 2 && Math.Abs(v[0] - 0.3f) < 0.01f; await registry.DestroyGlobalAsync(node.NodeId, cts.Token); } @@ -350,7 +457,10 @@ public async Task BindingEveryObjectInTheGraphAtOnce_AndDroppingThemAllOutOfOrde { RequireLinux(); using var cts = new CancellationTokenSource(Budget); - (PipeWireContext ctx, PipeWireRegistry registry) = await ConnectAsync("pwnet-hostile-bindall", cts.Token); + (PipeWireContext ctx, PipeWireRegistry registry) = await ConnectAsync( + "pwnet-hostile-bindall", + cts.Token + ); await using (ctx) await using (registry) { @@ -361,19 +471,28 @@ public async Task BindingEveryObjectInTheGraphAtOnce_AndDroppingThemAllOutOfOrde // or a shared-state mistake between bindings. foreach (PipeWireNode node in graph.Nodes) { - try { bound.Add(registry.BindNode(node.NodeId)); } + try + { + bound.Add(registry.BindNode(node.NodeId)); + } catch (PipeWireException) { } } foreach (PipeWireDevice device in graph.Devices) { - try { bound.Add(registry.BindDevice(device.Id)); } + try + { + bound.Add(registry.BindDevice(device.Id)); + } catch (PipeWireException) { } } foreach (PipeWireClient client in graph.Clients) { - try { bound.Add(registry.BindClient(client.Id)); } + try + { + bound.Add(registry.BindClient(client.Id)); + } catch (PipeWireException) { } } @@ -391,18 +510,27 @@ public async Task SubscribingThenDestroyingTheNodeUnderneath_DoesNotFireIntoFree { RequireLinux(); using var cts = new CancellationTokenSource(Budget); - (PipeWireContext ctx, PipeWireRegistry registry) = await ConnectAsync("pwnet-hostile-sub", cts.Token); + (PipeWireContext ctx, PipeWireRegistry registry) = await ConnectAsync( + "pwnet-hostile-sub", + cts.Token + ); await using (ctx) await using (registry) { for (int round = 0; round < 5; round++) { - PipeWireNode node = await registry.CreateVirtualSink("SubDestroy") - .WithName(UniqueName("pwnet_subdestroy")).ExecuteAsync(cts.Token); + PipeWireNode node = await registry + .CreateVirtualSink("SubDestroy") + .WithName(UniqueName("pwnet_subdestroy")) + .ExecuteAsync(cts.Token); PipeWireNodeProxy control = registry.BindNode(node.NodeId); control.ParameterChanged += (_, _) => { }; - control.SubscribeParameters(SpaParamType.Props, SpaParamType.Format, SpaParamType.Latency); + control.SubscribeParameters( + SpaParamType.Props, + SpaParamType.Format, + SpaParamType.Latency + ); // Destroy the object the subscription points at, then dispose the subscriber. The // daemon may still be dispatching for it; the listener has to be detached before its @@ -418,12 +546,17 @@ public async Task AHandlerThatThrowsOnEveryEvent_DoesNotStopTheOthersOrTheGraph( { RequireLinux(); using var cts = new CancellationTokenSource(Budget); - (PipeWireContext ctx, PipeWireRegistry registry) = await ConnectAsync("pwnet-hostile-throw", cts.Token); + (PipeWireContext ctx, PipeWireRegistry registry) = await ConnectAsync( + "pwnet-hostile-throw", + cts.Token + ); await using (ctx) await using (registry) { - PipeWireNode node = await registry.CreateVirtualSink("ThrowingSub") - .WithName(UniqueName("pwnet_throwsub")).ExecuteAsync(cts.Token); + PipeWireNode node = await registry + .CreateVirtualSink("ThrowingSub") + .WithName(UniqueName("pwnet_throwsub")) + .ExecuteAsync(cts.Token); await using PipeWireNodeProxy control = registry.BindNode(node.NodeId); @@ -439,8 +572,10 @@ public async Task AHandlerThatThrowsOnEveryEvent_DoesNotStopTheOthersOrTheGraph( // The subscriber between two throwing ones still ran, which is the property that a bare // multicast Invoke would not have. - Assert.IsTrue(Volatile.Read(ref survivors) > 0, - "a handler after a throwing one was never reached"); + Assert.IsTrue( + Volatile.Read(ref survivors) > 0, + "a handler after a throwing one was never reached" + ); Assert.IsNotNull(await control.GetVolumeAsync(cts.Token)); await registry.DestroyGlobalAsync(node.NodeId, cts.Token); @@ -452,12 +587,16 @@ public async Task APropertyDictionaryBigEnoughToLeaveTheStack_IsSentIntact() { RequireLinux(); using var cts = new CancellationTokenSource(Budget); - (PipeWireContext ctx, PipeWireRegistry registry) = await ConnectAsync("pwnet-hostile-props", cts.Token); + (PipeWireContext ctx, PipeWireRegistry registry) = await ConnectAsync( + "pwnet-hostile-props", + cts.Token + ); await using (ctx) await using (registry) { - PipeWireClient? me = registry.Current.Clients - .FirstOrDefault(c => c.ProcessId == Environment.ProcessId); + PipeWireClient? me = registry.Current.Clients.FirstOrDefault(c => + c.ProcessId == Environment.ProcessId + ); if (me is null) Assert.Inconclusive("this connection is not visible as a client object."); @@ -479,7 +618,9 @@ public async Task APropertyDictionaryBigEnoughToLeaveTheStack_IsSentIntact() GC.WaitForPendingFinalizers(); await control.UpdatePropertiesAsync( - new Dictionary { ["application.name"] = "pwnet-after-big" }, cts.Token); + new Dictionary { ["application.name"] = "pwnet-after-big" }, + cts.Token + ); } } @@ -488,7 +629,10 @@ public async Task ConcurrentWritersToTheSameMetadataKey_AllCompleteAndTheStoreSt { RequireLinux(); using var cts = new CancellationTokenSource(Budget); - (PipeWireContext ctx, PipeWireRegistry registry) = await ConnectAsync("pwnet-hostile-meta", cts.Token); + (PipeWireContext ctx, PipeWireRegistry registry) = await ConnectAsync( + "pwnet-hostile-meta", + cts.Token + ); await using (ctx) await using (registry) { @@ -502,19 +646,39 @@ public async Task ConcurrentWritersToTheSameMetadataKey_AllCompleteAndTheStoreSt string key = $"pwnet.test.concurrent.{Environment.ProcessId}"; - try { await store.SetAsync(key, "seed", cancellationToken: cts.Token); } - catch (PipeWireException e) { Assert.Inconclusive($"cannot write metadata here: {e.Message}"); } + try + { + await store.SetAsync(key, "seed", cancellationToken: cts.Token); + } + catch (PipeWireException e) + { + Assert.Inconclusive($"cannot write metadata here: {e.Message}"); + } // Ten writers to one key. Each waits for an echo, and the echoes are not // distinguishable per writer - so the property being tested is that none of them // hangs or is left waiting on a completion source nobody will set. Task[] writers = [ - .. Enumerable.Range(0, 10).Select(i => Task.Run(async () => - { - try { await store.SetAsync(key, $"value-{i}", cancellationToken: cts.Token); } - catch (InvalidOperationException) { } - }, cts.Token)), + .. Enumerable + .Range(0, 10) + .Select(i => + Task.Run( + async () => + { + try + { + await store.SetAsync( + key, + $"value-{i}", + cancellationToken: cts.Token + ); + } + catch (InvalidOperationException) { } + }, + cts.Token + ) + ), ]; await Task.WhenAll(writers).WaitAsync(TimeSpan.FromSeconds(15), cts.Token); @@ -531,7 +695,10 @@ public async Task DisposingTheStoreWhileAWriteIsWaitingForItsEcho_ReleasesTheWri { RequireLinux(); using var cts = new CancellationTokenSource(Budget); - (PipeWireContext ctx, PipeWireRegistry registry) = await ConnectAsync("pwnet-hostile-metadisp", cts.Token); + (PipeWireContext ctx, PipeWireRegistry registry) = await ConnectAsync( + "pwnet-hostile-metadisp", + cts.Token + ); await using (ctx) await using (registry) { @@ -543,13 +710,19 @@ public async Task DisposingTheStoreWhileAWriteIsWaitingForItsEcho_ReleasesTheWri // The write waits for the store to report the change back. Disposing means that report // will never come, so the waiter has to be released rather than left for the token. - Task writing = Task.Run(async () => - { - try { await store!.SetAsync(key, "value", cancellationToken: cts.Token); } - catch (ObjectDisposedException) { } - catch (InvalidOperationException) { } - catch (OperationCanceledException) { } - }, cts.Token); + Task writing = Task.Run( + async () => + { + try + { + await store!.SetAsync(key, "value", cancellationToken: cts.Token); + } + catch (ObjectDisposedException) { } + catch (InvalidOperationException) { } + catch (OperationCanceledException) { } + }, + cts.Token + ); await store!.DisposeAsync(); await writing.WaitAsync(TimeSpan.FromSeconds(10), cts.Token); @@ -561,7 +734,10 @@ public async Task ABurstOfWritesToOneKey_NeverReadsBackAnOlderValue() { RequireLinux(); using var cts = new CancellationTokenSource(Budget); - (PipeWireContext ctx, PipeWireRegistry registry) = await ConnectAsync("pwnet-hostile-burst", cts.Token); + (PipeWireContext ctx, PipeWireRegistry registry) = await ConnectAsync( + "pwnet-hostile-burst", + cts.Token + ); await using (ctx) await using (registry) { @@ -574,8 +750,14 @@ public async Task ABurstOfWritesToOneKey_NeverReadsBackAnOlderValue() await store.ReadyAsync(cts.Token); string key = $"pwnet.test.burst.{Environment.ProcessId}"; - try { await store.SetAsync(key, "seed", cancellationToken: cts.Token); } - catch (InvalidOperationException e) { Assert.Inconclusive($"cannot write metadata here: {e.Message}"); } + try + { + await store.SetAsync(key, "seed", cancellationToken: cts.Token); + } + catch (InvalidOperationException e) + { + Assert.Inconclusive($"cannot write metadata here: {e.Message}"); + } // The store echoes every change back, and those echoes lag the sync that reports a // write as processed. Under a burst, the echo of an older value lands after a newer @@ -592,8 +774,10 @@ public async Task ABurstOfWritesToOneKey_NeverReadsBackAnOlderValue() regressions.Add($"wrote {expected}, read {actual ?? "null"}"); } - Assert.IsTrue(regressions.Count == 0, - $"{regressions.Count} reads returned a superseded value, e.g. {regressions.FirstOrDefault()}"); + Assert.IsTrue( + regressions.Count == 0, + $"{regressions.Count} reads returned a superseded value, e.g. {regressions.FirstOrDefault()}" + ); await store.SetAsync(key, null, cancellationToken: cts.Token); Assert.IsNull(store.Get(key)); @@ -609,8 +793,14 @@ public async Task AnotherClientChangingAKeyWeJustWrote_IsStillReported() // Two independent connections: suppressing our own superseded echoes must not suppress // somebody else's change to the same key, which is invisible with one connection. - (PipeWireContext ctxA, PipeWireRegistry regA) = await ConnectAsync("pwnet-ext-a", cts.Token); - (PipeWireContext ctxB, PipeWireRegistry regB) = await ConnectAsync("pwnet-ext-b", cts.Token); + (PipeWireContext ctxA, PipeWireRegistry regA) = await ConnectAsync( + "pwnet-ext-a", + cts.Token + ); + (PipeWireContext ctxB, PipeWireRegistry regB) = await ConnectAsync( + "pwnet-ext-b", + cts.Token + ); await using (ctxA) await using (regA) @@ -631,7 +821,8 @@ public async Task AnotherClientChangingAKeyWeJustWrote_IsStillReported() string key = $"pwnet.test.external.{Environment.ProcessId}"; var sawTheirs = new TaskCompletionSource( - TaskCreationOptions.RunContinuationsAsynchronously); + TaskCreationOptions.RunContinuationsAsynchronously + ); mine.EntryChanged += (_, entry) => { @@ -639,8 +830,14 @@ public async Task AnotherClientChangingAKeyWeJustWrote_IsStillReported() sawTheirs.TrySetResult(entry.Value); }; - try { await mine.SetAsync(key, "from-a", cancellationToken: cts.Token); } - catch (InvalidOperationException e) { Assert.Inconclusive($"cannot write metadata here: {e.Message}"); } + try + { + await mine.SetAsync(key, "from-a", cancellationToken: cts.Token); + } + catch (InvalidOperationException e) + { + Assert.Inconclusive($"cannot write metadata here: {e.Message}"); + } Assert.AreEqual("from-a", mine.Get(key)); @@ -665,13 +862,15 @@ public async Task AnotherClientChangingAKeyWeJustWrote_IsStillReported() { Assert.Fail( "the cache took the other client's write but raised no event, " - + "so the echo was suppressed"); + + "so the echo was suppressed" + ); } Assert.Inconclusive( $"the session manager did not relay the other client's write within {MetadataRelay.Budget}. " - + $"cache holds '{mine.Get(key) ?? "(null)"}', " - + $"peer holds '{theirs.Get(key) ?? "(null)"}'"); + + $"cache holds '{mine.Get(key) ?? "(null)"}', " + + $"peer holds '{theirs.Get(key) ?? "(null)"}'" + ); } Assert.AreEqual("from-b", reported, "the other client's change must be reported"); @@ -693,15 +892,28 @@ public async Task StartingAContextFromSeveralThreadsAtOnce_StartsItExactlyOnce() // the first one is connecting on - leaving a context that looks started and is not. for (int round = 0; round < 8; round++) { - await using var ctx = new PipeWireContext($"pwnet-startrace-{round}", ConsoleTestLoggerFactory.Instance); + await using var ctx = new PipeWireContext( + $"pwnet-startrace-{round}", + ConsoleTestLoggerFactory.Instance + ); Task[] starters = [ - .. Enumerable.Range(0, 6).Select(_ => Task.Run(async () => - { - try { await ctx.StartAsync(cts.Token); } - catch (ObjectDisposedException) { } - }, cts.Token)), + .. Enumerable + .Range(0, 6) + .Select(_ => + Task.Run( + async () => + { + try + { + await ctx.StartAsync(cts.Token); + } + catch (ObjectDisposedException) { } + }, + cts.Token + ) + ), ]; await Task.WhenAll(starters).WaitAsync(TimeSpan.FromSeconds(10), cts.Token); diff --git a/tests/PipeWire.NET.Tests/HostileInputTests.cs b/tests/PipeWire.NET.Tests/HostileInputTests.cs index dc3f2fd..08c9811 100644 --- a/tests/PipeWire.NET.Tests/HostileInputTests.cs +++ b/tests/PipeWire.NET.Tests/HostileInputTests.cs @@ -32,7 +32,7 @@ public void AStringWithNoTerminator_StopsAtTheCapRatherThanScanningOn() var raw = (byte*)NativeMemory.Alloc(length); try { - NativeMemory.Fill(raw, length, 0x41); // 'A', no NUL anywhere + NativeMemory.Fill(raw, length, 0x41); // 'A', no NUL anywhere ReadOnlySpan bytes = DaemonText.Bytes((sbyte*)raw); Assert.AreEqual(DaemonText.MaxBytes, bytes.Length); @@ -54,7 +54,10 @@ public void ATerminatedString_IsReadWholeAndNullIsNull() fixed (byte* p = utf8) { Assert.AreEqual(utf8.Length - 1, DaemonText.Bytes((sbyte*)p).Length); - Assert.AreEqual("alsa_output.pci-0000_00_1f.3.analog-stereo", DaemonText.String((sbyte*)p)); + Assert.AreEqual( + "alsa_output.pci-0000_00_1f.3.analog-stereo", + DaemonText.String((sbyte*)p) + ); } Assert.IsTrue(DaemonText.Bytes(null).IsEmpty); @@ -91,8 +94,11 @@ public void ABufferClaimingMoreMetasThanExist_IsWalkedNoFurtherThanTheCap() buffer.metas[cap].size = (uint)sizeof(spa_meta_header); buffer.metas[cap].data = &header; - Assert.AreEqual(-1, SpaFormatPod.FindPresentationTimestampNs(&buffer), - "the walk read past the entry the cap should have stopped it at"); + Assert.AreEqual( + -1, + SpaFormatPod.FindPresentationTimestampNs(&buffer), + "the walk read past the entry the cap should have stopped it at" + ); } finally { @@ -156,16 +162,21 @@ public void TheKeysThatSayWhatAnObjectIs_AreRefusedAsProperties() // The caller's properties are written into the dict ahead of the library's own and // spa_dict_lookup returns the first match, so accepting these would let a caller route a // link somewhere else or hand the request to a different factory. - Assert.ThrowsExactly( - () => PipeWireObjectOptions.ThrowIfReserved("factory.name", forLink: false)); - Assert.ThrowsExactly( - () => PipeWireObjectOptions.ThrowIfReserved("factory.name", forLink: true)); - - foreach (string endpoint in (string[]) - ["link.output.node", "link.output.port", "link.input.node", "link.input.port"]) + Assert.ThrowsExactly(() => + PipeWireObjectOptions.ThrowIfReserved("factory.name", forLink: false) + ); + Assert.ThrowsExactly(() => + PipeWireObjectOptions.ThrowIfReserved("factory.name", forLink: true) + ); + + foreach ( + string endpoint in (string[]) + ["link.output.node", "link.output.port", "link.input.node", "link.input.port"] + ) { - Assert.ThrowsExactly( - () => PipeWireObjectOptions.ThrowIfReserved(endpoint, forLink: true)); + Assert.ThrowsExactly(() => + PipeWireObjectOptions.ThrowIfReserved(endpoint, forLink: true) + ); // Not reserved on a node: the key means nothing there, and refusing it would be // refusing a property the daemon would simply ignore. @@ -176,8 +187,10 @@ public void TheKeysThatSayWhatAnObjectIs_AreRefusedAsProperties() [TestMethod] public void TheKeysACallerIsMeantToSet_AreStillAccepted() { - foreach (string key in (string[]) - ["media.class", "audio.position", "node.name", "object.linger", "link.passive"]) + foreach ( + string key in (string[]) + ["media.class", "audio.position", "node.name", "object.linger", "link.passive"] + ) { PipeWireObjectOptions.ThrowIfReserved(key, forLink: false); PipeWireObjectOptions.ThrowIfReserved(key, forLink: true); @@ -214,14 +227,22 @@ static void AddSpanWithANul() [TestMethod] public void AWellFormedProfilerReport_ParsesToItsObject() { - var expected = new SpaObject(SpaType.ObjectProfiler, SpaParamType.Props, - [new SpaPodProperty(1, 0, new SpaInt(7))]); + var expected = new SpaObject( + SpaType.ObjectProfiler, + SpaParamType.Props, + [new SpaPodProperty(1, 0, new SpaInt(7))] + ); byte[] pod = SpaPod.ToBytes(expected); fixed (byte* p = pod) { - Assert.IsTrue(PipeWireProfilerProxy.TryParseReport( - (spa_pod*)p, out System.Collections.Immutable.ImmutableArray reports, out int size)); + Assert.IsTrue( + PipeWireProfilerProxy.TryParseReport( + (spa_pod*)p, + out System.Collections.Immutable.ImmutableArray reports, + out int size + ) + ); Assert.AreEqual(pod.Length, size); Assert.HasCount(1, reports); Assert.AreEqual(expected, reports[0]); @@ -251,8 +272,7 @@ public void AMalformedProfilerReport_IsRefusedRatherThanSpanned() BitConverter.TryWriteBytes(huge.AsSpan(4), (uint)SpaType.Object); fixed (byte* h = huge) { - Assert.IsFalse( - PipeWireProfilerProxy.TryParseReport((spa_pod*)h, out _, out int size)); + Assert.IsFalse(PipeWireProfilerProxy.TryParseReport((spa_pod*)h, out _, out int size)); Assert.AreEqual(int.MaxValue, size); } @@ -285,27 +305,62 @@ public void TheCodesTheDaemonReturns_AreNamedInTheMessage() // The number is what a caller branches on, but the message is read by a person, and the // symbolic name is the half of it that says what went wrong. Every mapped code is named; // anything else still reports the number rather than inventing a name for it. - foreach ((int code, string name) in new[] - { - (-1, "EPERM"), (-2, "ENOENT"), (-4, "EINTR"), (-5, "EIO"), (-9, "EBADF"), - (-11, "EAGAIN"), (-12, "ENOMEM"), (-13, "EACCES"), (-14, "EFAULT"), (-16, "EBUSY"), - (-17, "EEXIST"), (-19, "ENODEV"), (-22, "EINVAL"), (-24, "EMFILE"), (-25, "ENOTTY"), - (-28, "ENOSPC"), (-32, "EPIPE"), (-34, "ERANGE"), (-38, "ENOSYS"), (-39, "ENOTEMPTY"), - (-71, "EPROTO"), (-74, "EBADMSG"), (-75, "EOVERFLOW"), (-84, "EILSEQ"), - (-88, "ENOTSOCK"), (-90, "EMSGSIZE"), (-93, "EPROTONOSUPPORT"), (-95, "EOPNOTSUPP"), - (-98, "EADDRINUSE"), (-103, "ECONNABORTED"), (-104, "ECONNRESET"), (-105, "ENOBUFS"), - (-107, "ENOTCONN"), (-108, "ESHUTDOWN"), (-110, "ETIMEDOUT"), (-111, "ECONNREFUSED"), - (-125, "ECANCELED"), - }) + foreach ( + (int code, string name) in new[] + { + (-1, "EPERM"), + (-2, "ENOENT"), + (-4, "EINTR"), + (-5, "EIO"), + (-9, "EBADF"), + (-11, "EAGAIN"), + (-12, "ENOMEM"), + (-13, "EACCES"), + (-14, "EFAULT"), + (-16, "EBUSY"), + (-17, "EEXIST"), + (-19, "ENODEV"), + (-22, "EINVAL"), + (-24, "EMFILE"), + (-25, "ENOTTY"), + (-28, "ENOSPC"), + (-32, "EPIPE"), + (-34, "ERANGE"), + (-38, "ENOSYS"), + (-39, "ENOTEMPTY"), + (-71, "EPROTO"), + (-74, "EBADMSG"), + (-75, "EOVERFLOW"), + (-84, "EILSEQ"), + (-88, "ENOTSOCK"), + (-90, "EMSGSIZE"), + (-93, "EPROTONOSUPPORT"), + (-95, "EOPNOTSUPP"), + (-98, "EADDRINUSE"), + (-103, "ECONNABORTED"), + (-104, "ECONNRESET"), + (-105, "ENOBUFS"), + (-107, "ENOTCONN"), + (-108, "ESHUTDOWN"), + (-110, "ETIMEDOUT"), + (-111, "ECONNREFUSED"), + (-125, "ECANCELED"), + } + ) { - StringAssert.Contains(new PipeWireException("op", code).Message, name, - $"{code} is not named in the message"); + StringAssert.Contains( + new PipeWireException("op", code).Message, + name, + $"{code} is not named in the message" + ); } string unmapped = new PipeWireException("op", -9999).Message; StringAssert.Contains(unmapped, "-9999"); - Assert.IsFalse(unmapped.Contains('(', StringComparison.Ordinal), - "an unmapped code was given a symbolic name"); + Assert.IsFalse( + unmapped.Contains('(', StringComparison.Ordinal), + "an unmapped code was given a symbolic name" + ); } [TestMethod] @@ -331,12 +386,14 @@ public void ThrowIfFailed_ThrowsOnlyOnFailure() PipeWireException.ThrowIfFailed(0, "op"); PipeWireException.ThrowIfFailed(3, "op"); - PipeWireException thrown = Assert.ThrowsExactly( - () => PipeWireException.ThrowIfFailed(-2, "op", 7)); + PipeWireException thrown = Assert.ThrowsExactly(() => + PipeWireException.ThrowIfFailed(-2, "op", 7) + ); Assert.AreEqual(-2, thrown.Result); Assert.AreEqual("op", thrown.Operation); Assert.AreEqual((uint)7, thrown.ObjectId); } + [TestMethod] public void BothRefusalCodes_ReadAsPermissionDenied() { @@ -354,12 +411,19 @@ public void BothRefusalCodes_ReadAsPermissionDenied() public void ADaemonFailureCarriesItsCode_RatherThanASentence() { // The code is the part a caller branches on; the message is for a person reading a log. - var error = new PipeWireException("pw_context_connect", -2, null, "ensure the daemon is running"); + var error = new PipeWireException( + "pw_context_connect", + -2, + null, + "ensure the daemon is running" + ); Assert.AreEqual(-2, error.Result); Assert.AreEqual("pw_context_connect", error.Operation); Assert.IsTrue(error.Message.Contains("ENOENT", StringComparison.Ordinal)); - Assert.IsTrue(error.Message.Contains("ensure the daemon is running", StringComparison.Ordinal)); + Assert.IsTrue( + error.Message.Contains("ensure the daemon is running", StringComparison.Ordinal) + ); } // - Interface dispatch - @@ -396,15 +460,20 @@ public void DispatchAgainstAnObjectWithNoMethods_RefusesRatherThanCrashing() Assert.AreEqual(-1, Native.pw_profiler_add_listener(obj, null, null, null)); Assert.AreEqual(-1, Native.pw_security_context_create(obj, 0, 0, null)); Assert.AreEqual(-1, Native.pw_metadata_add_listener((pw_metadata*)obj, null, null, null)); - Assert.AreEqual(-1, Native.pw_metadata_set_property((pw_metadata*)obj, 0, null, null, null)); + Assert.AreEqual( + -1, + Native.pw_metadata_set_property((pw_metadata*)obj, 0, null, null, null) + ); Assert.AreEqual(-1, Native.pw_metadata_clear((pw_metadata*)obj)); // Creating is not reportable as -1: there is no call to fail, so it throws ENOSYS. - PipeWireException refused = Assert.ThrowsExactly( - () => Native.pw_core_get_registry((pw_core*)obj, 0, 0)); + PipeWireException refused = Assert.ThrowsExactly(() => + Native.pw_core_get_registry((pw_core*)obj, 0, 0) + ); Assert.AreEqual(-38, refused.Result); - refused = Assert.ThrowsExactly( - () => Native.pw_core_create_object((pw_core*)obj, null, null, 0, null, 0)); + refused = Assert.ThrowsExactly(() => + Native.pw_core_create_object((pw_core*)obj, null, null, 0, null, 0) + ); Assert.AreEqual(-38, refused.Result); // Detaching nothing detaches nothing. diff --git a/tests/PipeWire.NET.Tests/InProcessGraphTests.cs b/tests/PipeWire.NET.Tests/InProcessGraphTests.cs index bcb0903..0c7eddf 100644 --- a/tests/PipeWire.NET.Tests/InProcessGraphTests.cs +++ b/tests/PipeWire.NET.Tests/InProcessGraphTests.cs @@ -55,7 +55,10 @@ public async Task ASelfConnectedContext_HasItsOwnGraphWithNoDaemon() RequireLinux(); using var cts = new CancellationTokenSource(Budget); - await using var ctx = new PipeWireContext("pwnet-inprocess", ConsoleTestLoggerFactory.Instance) + await using var ctx = new PipeWireContext( + "pwnet-inprocess", + ConsoleTestLoggerFactory.Instance + ) { RunInProcess = true, }; @@ -74,25 +77,30 @@ public async Task ASelfConnectedContext_HasItsOwnGraphWithNoDaemon() Assert.IsNotNull(graph.Core, "the in-process graph has no core object"); - string[] factories = [.. graph.Factories - .Select(f => f.FactoryName) - .Where(n => n is not null) - .Select(n => n!)]; + string[] factories = + [ + .. graph.Factories.Select(f => f.FactoryName).Where(n => n is not null).Select(n => n!), + ]; Assert.IsTrue( factories.Contains("spa-node-factory", StringComparer.Ordinal), $"the spa-node-factory module did not load; factories present: " - + $"{string.Join(", ", factories)}"); + + $"{string.Join(", ", factories)}" + ); Assert.IsTrue( factories.Contains("link-factory", StringComparer.Ordinal), $"the link-factory module did not load; factories present: " - + $"{string.Join(", ", factories)}"); + + $"{string.Join(", ", factories)}" + ); Assert.IsFalse( - graph.Nodes.Any(n => n.NodeName?.StartsWith("alsa_", StringComparison.Ordinal) ?? false), + graph.Nodes.Any(n => + n.NodeName?.StartsWith("alsa_", StringComparison.Ordinal) ?? false + ), "the graph contains the session's ALSA nodes, so this connected to a daemon rather " - + "than running its own graph"); + + "than running its own graph" + ); } /// @@ -109,7 +117,10 @@ public async Task LoadingAModuleAfterStarting_IsRefused() RequireLinux(); using var cts = new CancellationTokenSource(Budget); - await using var ctx = new PipeWireContext("pwnet-inprocess-late", ConsoleTestLoggerFactory.Instance) + await using var ctx = new PipeWireContext( + "pwnet-inprocess-late", + ConsoleTestLoggerFactory.Instance + ) { RunInProcess = true, }; @@ -117,8 +128,9 @@ public async Task LoadingAModuleAfterStarting_IsRefused() ctx.LoadModule("libpipewire-module-spa-node-factory"); await ctx.StartAsync(cts.Token); - Assert.ThrowsExactly( - () => ctx.LoadModule("libpipewire-module-link-factory")); + Assert.ThrowsExactly(() => + ctx.LoadModule("libpipewire-module-link-factory") + ); } /// A module that does not exist fails at start, naming what could not be loaded. @@ -128,18 +140,23 @@ public async Task AMissingModule_FailsTheStartRatherThanBeingIgnored() RequireLinux(); using var cts = new CancellationTokenSource(Budget); - await using var ctx = new PipeWireContext("pwnet-inprocess-missing", ConsoleTestLoggerFactory.Instance) + await using var ctx = new PipeWireContext( + "pwnet-inprocess-missing", + ConsoleTestLoggerFactory.Instance + ) { RunInProcess = true, }; ctx.LoadModule("libpipewire-module-pwnet-does-not-exist"); - PipeWireException ex = await Assert.ThrowsExactlyAsync( - () => ctx.StartAsync(cts.Token)); + PipeWireException ex = await Assert.ThrowsExactlyAsync(() => + ctx.StartAsync(cts.Token) + ); Assert.IsTrue( ex.Message.Contains("pwnet-does-not-exist", StringComparison.Ordinal), - $"the failure did not name the module that could not be loaded: {ex.Message}"); + $"the failure did not name the module that could not be loaded: {ex.Message}" + ); } } diff --git a/tests/PipeWire.NET.Tests/LatencyAndTagTests.cs b/tests/PipeWire.NET.Tests/LatencyAndTagTests.cs index 01e40c8..70fd2a8 100644 --- a/tests/PipeWire.NET.Tests/LatencyAndTagTests.cs +++ b/tests/PipeWire.NET.Tests/LatencyAndTagTests.cs @@ -23,7 +23,15 @@ public sealed class LatencyAndTagTests : PipeWireTestBase [TestMethod] public void AFullLatencyObject_ReadsEveryUnit() { - var written = new PipeWireLatency(SpaDirection.Output, 0.5f, 1.5f, 24, 72, 500_000L, 1_500_000L); + var written = new PipeWireLatency( + SpaDirection.Output, + 0.5f, + 1.5f, + 24, + 72, + 500_000L, + 1_500_000L + ); PipeWireLatency? read = PipeWireLatency.From(written.ToParameter()); @@ -35,12 +43,19 @@ public void ALatencyObjectCarryingOnlyNanoseconds_ReadsTheRestAsZero() { // What a producer that thinks in nanoseconds actually sends. Requiring all seven members // would reject it, and there is no meaningful value to substitute for a unit not sent. - var partial = new SpaObject(SpaType.ObjectParamLatency, SpaParamType.Latency, - [ - new SpaPodProperty((uint)SpaParamLatency.Direction, 0, new SpaId((uint)SpaDirection.Input)), - new SpaPodProperty((uint)SpaParamLatency.MinNs, 0, new SpaLong(1_000L)), - new SpaPodProperty((uint)SpaParamLatency.MaxNs, 0, new SpaLong(2_000L)), - ]); + var partial = new SpaObject( + SpaType.ObjectParamLatency, + SpaParamType.Latency, + [ + new SpaPodProperty( + (uint)SpaParamLatency.Direction, + 0, + new SpaId((uint)SpaDirection.Input) + ), + new SpaPodProperty((uint)SpaParamLatency.MinNs, 0, new SpaLong(1_000L)), + new SpaPodProperty((uint)SpaParamLatency.MaxNs, 0, new SpaLong(2_000L)), + ] + ); PipeWireLatency? read = PipeWireLatency.From(partial); @@ -57,8 +72,11 @@ public void AnObjectOfAnotherType_IsNotReadAsLatency() { // Props also carries floats and longs, so reading it as a latency would produce a // plausible-looking value out of unrelated properties rather than an obvious failure. - var props = new SpaObject(SpaType.ObjectProps, SpaParamType.Props, - [new SpaPodProperty(SpaProp.Volume, 0, new SpaFloat(0.5f))]); + var props = new SpaObject( + SpaType.ObjectProps, + SpaParamType.Props, + [new SpaPodProperty(SpaProp.Volume, 0, new SpaFloat(0.5f))] + ); Assert.IsNull(PipeWireLatency.From(props)); Assert.IsNull(PipeWireLatency.From(null)); @@ -100,12 +118,14 @@ public void ATag_SurvivesTheRoundTripWithItsOrderIntact() { // Order is preserved deliberately: the info is a Struct, not a dictionary, and a producer // writing the same key twice is expressing something a set would discard. - var written = new PipeWireTag(SpaDirection.Output, - [ - new KeyValuePair("media.title", "Something"), - new KeyValuePair("media.artist", "Someone"), - new KeyValuePair("media.title", "Something Else"), - ]); + var written = new PipeWireTag( + SpaDirection.Output, + [ + new KeyValuePair("media.title", "Something"), + new KeyValuePair("media.artist", "Someone"), + new KeyValuePair("media.title", "Something Else"), + ] + ); PipeWireTag? read = PipeWireTag.From(written.ToParameter()); @@ -119,16 +139,22 @@ public void ATagWhoseCountDisagreesWithItsPairs_ReadsThePairsThatAreThere() { // The count is the producer's word for how many pairs follow. Trusting it over the pairs // actually present reads past the end of the struct or invents empty entries. - var lying = new SpaObject(SpaType.ObjectParamTag, SpaParamType.Tag, - [ - new SpaPodProperty((uint)SpaParamTag.Direction, 0, new SpaId((uint)SpaDirection.Input)), - new SpaPodProperty((uint)SpaParamTag.Info, 0, new SpaStruct( + var lying = new SpaObject( + SpaType.ObjectParamTag, + SpaParamType.Tag, [ - new SpaInt(99), - new SpaString("k"), - new SpaString("v"), - ])), - ]); + new SpaPodProperty( + (uint)SpaParamTag.Direction, + 0, + new SpaId((uint)SpaDirection.Input) + ), + new SpaPodProperty( + (uint)SpaParamTag.Info, + 0, + new SpaStruct([new SpaInt(99), new SpaString("k"), new SpaString("v")]) + ), + ] + ); PipeWireTag? read = PipeWireTag.From(lying); @@ -141,16 +167,22 @@ public void ATagWhoseCountDisagreesWithItsPairs_ReadsThePairsThatAreThere() [TestMethod] public void ATagWithATrailingKeyAndNoValue_DropsTheIncompletePair() { - var truncated = new SpaObject(SpaType.ObjectParamTag, SpaParamType.Tag, - [ - new SpaPodProperty((uint)SpaParamTag.Info, 0, new SpaStruct( + var truncated = new SpaObject( + SpaType.ObjectParamTag, + SpaParamType.Tag, [ - new SpaInt(2), - new SpaString("k"), - new SpaString("v"), - new SpaString("orphan"), - ])), - ]); + new SpaPodProperty( + (uint)SpaParamTag.Info, + 0, + new SpaStruct([ + new SpaInt(2), + new SpaString("k"), + new SpaString("v"), + new SpaString("orphan"), + ]) + ), + ] + ); PipeWireTag? read = PipeWireTag.From(truncated); @@ -174,11 +206,17 @@ public void ATagInfoWithNoLeadingCount_IsStillReadAsPairs() { // The struct's first field is the count in every pod the daemon sends, and reading pairs // from index 0 when it is absent costs one type check. - var noCount = new SpaObject(SpaType.ObjectParamTag, SpaParamType.Tag, - [ - new SpaPodProperty((uint)SpaParamTag.Info, 0, new SpaStruct( - [new SpaString("k"), new SpaString("v")])), - ]); + var noCount = new SpaObject( + SpaType.ObjectParamTag, + SpaParamType.Tag, + [ + new SpaPodProperty( + (uint)SpaParamTag.Info, + 0, + new SpaStruct([new SpaString("k"), new SpaString("v")]) + ), + ] + ); PipeWireTag? read = PipeWireTag.From(noCount); @@ -204,12 +242,16 @@ public async Task ALiveAdapterNode_AnswersLatencyAndTagQueries() using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(40)); - await using var ctx = new PipeWireContext("pwnet-latency-live", ConsoleTestLoggerFactory.Instance); + await using var ctx = new PipeWireContext( + "pwnet-latency-live", + ConsoleTestLoggerFactory.Instance + ); await ctx.StartAsync(cts.Token); await using var registry = new PipeWireRegistry(ctx); await registry.WaitForInitialEnumerationAsync(cts.Token); - PipeWireNode node = await registry.CreateVirtualSink("LatencyLive") + PipeWireNode node = await registry + .CreateVirtualSink("LatencyLive") .WithName($"pwnet_lat_{Environment.ProcessId}_{Random.Shared.Next():x}") .ExecuteAsync(cts.Token); @@ -218,7 +260,8 @@ public async Task ALiveAdapterNode_AnswersLatencyAndTagQueries() Assert.IsNull( await control.GetProcessLatencyAsync(cts.Token), - "the adapter reported a process latency it was never given"); + "the adapter reported a process latency it was never given" + ); // Tags may or may not be announced yet: the adapter publishes its parameters in // stages after binding, so absence reads as a refusal when unannounced and as empty @@ -235,16 +278,23 @@ await control.GetProcessLatencyAsync(cts.Token), Console.Error.WriteLine($"tags: {tags.Length}"); - PipeWireException latencyRefused = await Assert.ThrowsExactlyAsync( - () => control.SetProcessLatencyAsync(new PipeWireProcessLatency(Quantum: 128f), cts.Token)); + PipeWireException latencyRefused = + await Assert.ThrowsExactlyAsync(() => + control.SetProcessLatencyAsync(new PipeWireProcessLatency(Quantum: 128f), cts.Token) + ); Assert.IsTrue(latencyRefused.Result < 0, "a refusal must carry the daemon's code"); Console.Error.WriteLine($"process latency write refused: {latencyRefused.Message}"); - PipeWireException tagRefused = await Assert.ThrowsExactlyAsync( - () => control.SetTagAsync( - new PipeWireTag(SpaDirection.Output, - ImmutableArray.Create(new KeyValuePair("pwnet", "live"))), - cts.Token)); + PipeWireException tagRefused = + await Assert.ThrowsExactlyAsync(() => + control.SetTagAsync( + new PipeWireTag( + SpaDirection.Output, + ImmutableArray.Create(new KeyValuePair("pwnet", "live")) + ), + cts.Token + ) + ); Assert.IsTrue(tagRefused.Result < 0, "a refusal must carry the daemon's code"); Console.Error.WriteLine($"tag write refused: {tagRefused.Message}"); diff --git a/tests/PipeWire.NET.Tests/LibraryErrorGuard.cs b/tests/PipeWire.NET.Tests/LibraryErrorGuard.cs index 1545b90..c6a810b 100644 --- a/tests/PipeWire.NET.Tests/LibraryErrorGuard.cs +++ b/tests/PipeWire.NET.Tests/LibraryErrorGuard.cs @@ -38,8 +38,9 @@ public abstract class PipeWireTestBase /// mentioning, so there is otherwise no way to say what ran immediately before a failure - which /// is the whole question when one test leaves the session unfit for the next. /// - private static readonly string? TracePath = - Environment.GetEnvironmentVariable("PWNET_TEST_TRACE"); + private static readonly string? TracePath = Environment.GetEnvironmentVariable( + "PWNET_TEST_TRACE" + ); private static readonly Lock TraceGate = new(); @@ -58,7 +59,8 @@ public void BeginLibraryErrorWatch() private void Trace(string outcome) { - if (TracePath is null) return; + if (TracePath is null) + return; // Best effort by design: a trace that throws would fail tests that are otherwise fine, and // this exists to explain failures rather than to cause them. @@ -66,9 +68,11 @@ private void Trace(string outcome) { lock (TraceGate) { - File.AppendAllText(TracePath, - $"{DateTime.Now:HH:mm:ss.fff} {Environment.TickCount64 - _startedTicks,6}ms " - + $"{outcome,-7} {TestContext?.TestName}{Environment.NewLine}"); + File.AppendAllText( + TracePath, + $"{DateTime.Now:HH:mm:ss.fff} {Environment.TickCount64 - _startedTicks, 6}ms " + + $"{outcome, -7} {TestContext?.TestName}{Environment.NewLine}" + ); } } catch (IOException) { } @@ -80,27 +84,36 @@ public void EndLibraryErrorWatch() { IReadOnlyList seen = ConsoleTestLoggerFactory.StopCollectingErrors(); Trace(TestContext?.CurrentTestOutcome.ToString() ?? "?"); - if (seen.Count == 0) return; + if (seen.Count == 0) + return; string[] allowed = [.. Expected()]; List unexpected = - [.. seen.Where(line => !allowed.Any(a => line.Contains(a, StringComparison.Ordinal)))]; + [ + .. seen.Where(line => !allowed.Any(a => line.Contains(a, StringComparison.Ordinal))), + ]; if (unexpected.Count > 0) { throw new AssertFailedException( $"the library logged {unexpected.Count} error(s) this test did not declare. " - + $"Add [ExpectsLibraryError(\"...\")] if they are the point of the test:" - + Environment.NewLine + string.Join(Environment.NewLine, unexpected.Distinct())); + + $"Add [ExpectsLibraryError(\"...\")] if they are the point of the test:" + + Environment.NewLine + + string.Join(Environment.NewLine, unexpected.Distinct()) + ); } } private IEnumerable Expected() { Type type = GetType(); - foreach (ExpectsLibraryErrorAttribute a in - type.GetCustomAttributes(typeof(ExpectsLibraryErrorAttribute), inherit: true) - .Cast()) + foreach ( + ExpectsLibraryErrorAttribute a in type.GetCustomAttributes( + typeof(ExpectsLibraryErrorAttribute), + inherit: true + ) + .Cast() + ) { yield return a.Substring; } @@ -109,14 +122,20 @@ private IEnumerable Expected() // method is matched on the part before it. string name = TestContext?.TestName ?? string.Empty; int paren = name.IndexOf('(', StringComparison.Ordinal); - if (paren > 0) name = name[..paren]; + if (paren > 0) + name = name[..paren]; foreach (System.Reflection.MethodInfo m in type.GetMethods()) { - if (!string.Equals(m.Name, name, StringComparison.Ordinal)) continue; - foreach (ExpectsLibraryErrorAttribute a in - m.GetCustomAttributes(typeof(ExpectsLibraryErrorAttribute), inherit: true) - .Cast()) + if (!string.Equals(m.Name, name, StringComparison.Ordinal)) + continue; + foreach ( + ExpectsLibraryErrorAttribute a in m.GetCustomAttributes( + typeof(ExpectsLibraryErrorAttribute), + inherit: true + ) + .Cast() + ) { yield return a.Substring; } diff --git a/tests/PipeWire.NET.Tests/LifecycleLockTests.cs b/tests/PipeWire.NET.Tests/LifecycleLockTests.cs index ac2aca8..fcaf39f 100644 --- a/tests/PipeWire.NET.Tests/LifecycleLockTests.cs +++ b/tests/PipeWire.NET.Tests/LifecycleLockTests.cs @@ -24,7 +24,6 @@ public sealed class LifecycleLockTests : PipeWireTestBase [TestMethod] public async Task HoldingAScopeAcrossAnotherThreadsStart_CompletesBoth() { - using var ctx = new PipeWireContext("PipeWire.NET.Test.LifecycleStart"); using var starterEntered = new ManualResetEventSlim(false); @@ -36,9 +35,7 @@ public async Task HoldingAScopeAcrossAnotherThreadsStart_CompletesBoth() { starterEntered.Set(); Thread.Sleep(500); - using (ctx.Lock()) - { - } + using (ctx.Lock()) { } } }); @@ -102,9 +99,7 @@ public async Task DisposingACopyAndTheOriginal_UnlocksExactlyOnce() copy.Dispose(); original.Dispose(); - using (ctx.Lock()) - { - } + using (ctx.Lock()) { } ctx.Dispose(); Assert.IsTrue(ctx.IsDisposed); @@ -127,7 +122,10 @@ public async Task AbandonedStreamsAndFilters_FinalizeWithoutWaitingForABusyLoop( bool done = finalize.Wait(TimeSpan.FromSeconds(30)); scope.Dispose(); - Assert.IsTrue(done, "finalizers never finished; abandoned handles are waiting on the held loop"); + Assert.IsTrue( + done, + "finalizers never finished; abandoned handles are waiting on the held loop" + ); GC.Collect(); GC.WaitForPendingFinalizers(); @@ -142,8 +140,13 @@ private static System.Collections.Generic.List AbandonMedia(PipeW { var abandoned = new System.Collections.Generic.List(); - var output = new PipeWireAudioOutput(ctx, "PipeWire.NET.Test.Abandoned", - sampleRate: 48000, channels: 2, format: PipeWire.NET.Media.AudioSampleFormat.F32Le); + var output = new PipeWireAudioOutput( + ctx, + "PipeWire.NET.Test.Abandoned", + sampleRate: 48000, + channels: 2, + format: PipeWire.NET.Media.AudioSampleFormat.F32Le + ); output.Connect(autoConnect: false); abandoned.Add(new WeakReference(output)); @@ -163,7 +166,9 @@ public async Task LockingAfterDispose_RefusesInsteadOfLocking() Assert.IsFalse(ctx.TryLock(out _)); Assert.ThrowsExactly(() => ctx.Lock()); - Assert.ThrowsExactly(() => ctx.StartAsync().GetAwaiter().GetResult()); + Assert.ThrowsExactly(() => + ctx.StartAsync().GetAwaiter().GetResult() + ); } [TestMethod] @@ -179,14 +184,29 @@ public unsafe void ADisposedUnstartedContext_RefusesItsHandles() Assert.IsTrue(ctx.IsDisposed); Assert.IsFalse(ctx.TryLock(out _)); Assert.ThrowsExactly(() => ctx.Lock()); - Assert.ThrowsExactly(() => { _ = ctx.LoopOwner; }); + Assert.ThrowsExactly(() => + { + _ = ctx.LoopOwner; + }); Assert.IsFalse(ctx.IsOnLoopThread); - try { _ = (nint)ctx.CoreHandle; Assert.Fail("CoreHandle must refuse"); } + try + { + _ = (nint)ctx.CoreHandle; + Assert.Fail("CoreHandle must refuse"); + } catch (ObjectDisposedException) { } - try { _ = (nint)ctx.ContextHandle; Assert.Fail("ContextHandle must refuse"); } + try + { + _ = (nint)ctx.ContextHandle; + Assert.Fail("ContextHandle must refuse"); + } catch (ObjectDisposedException) { } - try { _ = (nint)ctx.LoopHandle; Assert.Fail("LoopHandle must refuse"); } + try + { + _ = (nint)ctx.LoopHandle; + Assert.Fail("LoopHandle must refuse"); + } catch (ObjectDisposedException) { } } @@ -202,13 +222,18 @@ public async Task AbandonedHandles_FinalizeWithoutWaitingForABusyLoop() await registry.WaitForInitialEnumerationAsync(cts.Token); PipeWireNode node = await registry.CreateVirtualSinkAsync( - "ReaperSrc", "pwnet_reaper_src", cts.Token); + "ReaperSrc", + "pwnet_reaper_src", + cts.Token + ); // Abandoned in a separate frame: values created inside this async method can stay rooted // by its state machine, which would make the collection below vacuous. Nothing here // outlives the call, so everything it made is collectable when it returns. - System.Collections.Generic.List abandoned = - AbandonBindings(registry, node.NodeId); + System.Collections.Generic.List abandoned = AbandonBindings( + registry, + node.NodeId + ); // Queue the finalizers without running them: allocated but unreachable after this. GC.Collect(); @@ -224,9 +249,14 @@ public async Task AbandonedHandles_FinalizeWithoutWaitingForABusyLoop() scope.Dispose(); - Assert.IsTrue(done, "finalizers never finished; abandoned handles are waiting on the held loop"); - Assert.IsTrue(sw.Elapsed < TimeSpan.FromSeconds(4), - $"finalizers took {sw.Elapsed} with the loop held; abandoned handles must not block on it"); + Assert.IsTrue( + done, + "finalizers never finished; abandoned handles are waiting on the held loop" + ); + Assert.IsTrue( + sw.Elapsed < TimeSpan.FromSeconds(4), + $"finalizers took {sw.Elapsed} with the loop held; abandoned handles must not block on it" + ); // And they really did run: still-alive references mean the measurement above was vacuous. GC.Collect(); @@ -241,7 +271,9 @@ public async Task AbandonedHandles_FinalizeWithoutWaitingForABusyLoop() } private static System.Collections.Generic.List AbandonBindings( - PipeWireRegistry registry, uint nodeId) + PipeWireRegistry registry, + uint nodeId + ) { var abandoned = new System.Collections.Generic.List(); for (int i = 0; i < 8; i++) diff --git a/tests/PipeWire.NET.Tests/LifetimeInvariantTests.cs b/tests/PipeWire.NET.Tests/LifetimeInvariantTests.cs index b4a9a7f..baf62a9 100644 --- a/tests/PipeWire.NET.Tests/LifetimeInvariantTests.cs +++ b/tests/PipeWire.NET.Tests/LifetimeInvariantTests.cs @@ -29,7 +29,10 @@ public async Task TheSnapshotVersionNeverGoesBackwards_AcrossEveryEventPath() RequireLinux(); using var cts = new CancellationTokenSource(Budget); - await using var ctx = new PipeWireContext("pwnet-monotonic", ConsoleTestLoggerFactory.Instance); + await using var ctx = new PipeWireContext( + "pwnet-monotonic", + ConsoleTestLoggerFactory.Instance + ); await ctx.StartAsync(cts.Token); await using var registry = new PipeWireRegistry(ctx); @@ -46,15 +49,21 @@ public async Task TheSnapshotVersionNeverGoesBackwards_AcrossEveryEventPath() // The invariant that makes publish-before-notify worth having: a handler must never see // an event describing a graph that Current does not yet contain. if (registry.Current.Version < snapshot.Version) - faults.Add($"Current is behind the event: {registry.Current.Version} < {snapshot.Version}"); + faults.Add( + $"Current is behind the event: {registry.Current.Version} < {snapshot.Version}" + ); }; await registry.WaitForInitialEnumerationAsync(cts.Token); - PipeWireNode sink = await registry.CreateVirtualSink("Monotonic") - .WithName("pwnet_monotonic_sink").ExecuteAsync(cts.Token); - PipeWireNode source = await registry.CreateVirtualSink("MonotonicSrc") - .WithName("pwnet_monotonic_src").ExecuteAsync(cts.Token); + PipeWireNode sink = await registry + .CreateVirtualSink("Monotonic") + .WithName("pwnet_monotonic_sink") + .ExecuteAsync(cts.Token); + PipeWireNode source = await registry + .CreateVirtualSink("MonotonicSrc") + .WithName("pwnet_monotonic_src") + .ExecuteAsync(cts.Token); await registry.DestroyGlobalAsync(source.NodeId, cts.Token); await registry.DestroyGlobalAsync(sink.NodeId, cts.Token); @@ -72,7 +81,10 @@ public async Task ACreationWaiterCannotMissItsGlobal_HoweverTheEventsInterleave( // The race is between the bound event registering a waiter and OnGlobal completing it. It // has no deterministic trigger from outside, so it is provoked by creating repeatedly and // concurrently: a miss shows up as a creation that never returns, which the budget catches. - await using var ctx = new PipeWireContext("pwnet-waiter", ConsoleTestLoggerFactory.Instance); + await using var ctx = new PipeWireContext( + "pwnet-waiter", + ConsoleTestLoggerFactory.Instance + ); await ctx.StartAsync(cts.Token); await using var registry = new PipeWireRegistry(ctx); @@ -80,18 +92,24 @@ public async Task ACreationWaiterCannotMissItsGlobal_HoweverTheEventsInterleave( { Task[] creations = [ - .. Enumerable.Range(0, 6).Select(i => - registry.CreateVirtualSink($"Waiter{round}_{i}") + .. Enumerable + .Range(0, 6) + .Select(i => + registry + .CreateVirtualSink($"Waiter{round}_{i}") .WithName($"pwnet_waiter_{round}_{i}") - .ExecuteAsync(cts.Token)), + .ExecuteAsync(cts.Token) + ), ]; PipeWireNode[] created = await Task.WhenAll(creations); foreach (PipeWireNode node in created) { - Assert.IsNotNull(registry.Current.GetNode(node.NodeId), - $"node {node.NodeId} was reported created but is not in the graph"); + Assert.IsNotNull( + registry.Current.GetNode(node.NodeId), + $"node {node.NodeId} was reported created but is not in the graph" + ); await registry.DestroyGlobalAsync(node.NodeId, cts.Token); } } @@ -108,8 +126,10 @@ public async Task DisposingTheContextFirst_LeavesBoundControlsSafeToDispose() var registry = new PipeWireRegistry(ctx); await registry.WaitForInitialEnumerationAsync(cts.Token); - PipeWireNode node = await registry.CreateVirtualSink("Order") - .WithName("pwnet_order_sink").ExecuteAsync(cts.Token); + PipeWireNode node = await registry + .CreateVirtualSink("Order") + .WithName("pwnet_order_sink") + .ExecuteAsync(cts.Token); PipeWireNodeProxy control = registry.BindNode(node.NodeId); @@ -136,24 +156,36 @@ public async Task DisposingTheContextConcurrentlyWithTeardown_DoesNotThrowOutOfD // Per-round token: a cancellation from a torn-down context propagates as an // ObjectDisposedException once the in-flight creation hooks the shutdown token. using var roundCts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); - using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cts.Token, roundCts.Token); - - var ctx = new PipeWireContext($"pwnet-teardown-{round}", ConsoleTestLoggerFactory.Instance); + using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource( + cts.Token, + roundCts.Token + ); + + var ctx = new PipeWireContext( + $"pwnet-teardown-{round}", + ConsoleTestLoggerFactory.Instance + ); await ctx.StartAsync(linkedCts.Token); var registry = new PipeWireRegistry(ctx); - Task creating = Task.Run(async () => - { - try + Task creating = Task.Run( + async () => { - PipeWireNode node = await registry.CreateVirtualSink($"Teardown{round}") - .WithName($"pwnet_teardown_{round}").ExecuteAsync(linkedCts.Token); - await registry.DestroyGlobalAsync(node.NodeId, linkedCts.Token); - } - catch (ObjectDisposedException) { } - catch (Exception e) when (e is InvalidOperationException or PipeWireException) { } - catch (OperationCanceledException) { } - }, linkedCts.Token); + try + { + PipeWireNode node = await registry + .CreateVirtualSink($"Teardown{round}") + .WithName($"pwnet_teardown_{round}") + .ExecuteAsync(linkedCts.Token); + await registry.DestroyGlobalAsync(node.NodeId, linkedCts.Token); + } + catch (ObjectDisposedException) { } + catch (Exception e) when (e is InvalidOperationException or PipeWireException) + { } + catch (OperationCanceledException) { } + }, + linkedCts.Token + ); Task disposing = Task.Run(async () => await ctx.DisposeAsync(), linkedCts.Token); @@ -168,7 +200,10 @@ public async Task DisposingTheContextWithACreationInFlight_FailsTheCreationFast( RequireLinux(); using var cts = new CancellationTokenSource(Budget); - var ctx = new PipeWireContext("pwnet-disposing-inflight", ConsoleTestLoggerFactory.Instance); + var ctx = new PipeWireContext( + "pwnet-disposing-inflight", + ConsoleTestLoggerFactory.Instance + ); await ctx.StartAsync(cts.Token); var registry = new PipeWireRegistry(ctx); await registry.WaitForInitialEnumerationAsync(cts.Token); @@ -176,8 +211,12 @@ public async Task DisposingTheContextWithACreationInFlight_FailsTheCreationFast( // No delay: the creation needs a full daemon round trip while disposal only needs to // signal, so disposal wins and the shutdown hook surfaces ObjectDisposedException to the // in-flight wait instead of leaving it parked on a stopped loop. - Task creation = Task.Run(() => registry.CreateVirtualSink("Disposing") - .WithName("pwnet_disposing_inflight").ExecuteAsync(cts.Token)); + Task creation = Task.Run(() => + registry + .CreateVirtualSink("Disposing") + .WithName("pwnet_disposing_inflight") + .ExecuteAsync(cts.Token) + ); await ctx.DisposeAsync(); await Assert.ThrowsExactlyAsync(async () => await creation); @@ -196,8 +235,9 @@ public async Task StartingADisposedContext_IsRefused() var ctx = new PipeWireContext("pwnet-deadstart", ConsoleTestLoggerFactory.Instance); await ctx.DisposeAsync(); - await Assert.ThrowsExactlyAsync( - async () => await ctx.StartAsync(cts.Token)); + await Assert.ThrowsExactlyAsync(async () => + await ctx.StartAsync(cts.Token) + ); } [TestMethod] @@ -206,13 +246,18 @@ public async Task AControlLeftToTheFinalizer_ReleasesWithoutAborting() RequireLinux(); using var cts = new CancellationTokenSource(Budget); - await using var ctx = new PipeWireContext("pwnet-finalize", ConsoleTestLoggerFactory.Instance); + await using var ctx = new PipeWireContext( + "pwnet-finalize", + ConsoleTestLoggerFactory.Instance + ); await ctx.StartAsync(cts.Token); await using var registry = new PipeWireRegistry(ctx); await registry.WaitForInitialEnumerationAsync(cts.Token); - PipeWireNode node = await registry.CreateVirtualSink("Finalize") - .WithName("pwnet_finalize_sink").ExecuteAsync(cts.Token); + PipeWireNode node = await registry + .CreateVirtualSink("Finalize") + .WithName("pwnet_finalize_sink") + .ExecuteAsync(cts.Token); // Bound and dropped without disposing, which is what an application will eventually do by // accident. The SafeHandle chain has to hold everything alive until the finalizer runs. @@ -243,7 +288,10 @@ public async Task DisposingAFilterWhileItsCallbackIsRunning_DoesNotFreeWhatTheCa // lock serialises destruction against dispatch. for (int round = 0; round < 8; round++) { - await using var ctx = new PipeWireContext($"pwnet-cb-{round}", ConsoleTestLoggerFactory.Instance); + await using var ctx = new PipeWireContext( + $"pwnet-cb-{round}", + ConsoleTestLoggerFactory.Instance + ); await ctx.StartAsync(cts.Token); await using var registry = new PipeWireRegistry(ctx); await registry.WaitForInitialEnumerationAsync(cts.Token); @@ -251,7 +299,10 @@ public async Task DisposingAFilterWhileItsCallbackIsRunning_DoesNotFreeWhatTheCa long entered = 0; long inside = 0; - PipeWireFilter filter = PipeWireFilter.Create(ctx, $"pwnet_cb_{Environment.ProcessId}_{round}"); + PipeWireFilter filter = PipeWireFilter.Create( + ctx, + $"pwnet_cb_{Environment.ProcessId}_{round}" + ); filter.ProcessCallback = (f, samples, in _) => { Interlocked.Increment(ref inside); @@ -260,8 +311,13 @@ public async Task DisposingAFilterWhileItsCallbackIsRunning_DoesNotFreeWhatTheCa // Touch state the disposal would free, for as long as the callback is on the stack. foreach (PipeWireFilterPort port in f.Ports) { - try { GC.KeepAlive(port.GetSamples(samples).Length); } - catch (ObjectDisposedException) { /* the documented answer once the filter is gone. */ } + try + { + GC.KeepAlive(port.GetSamples(samples).Length); + } + catch (ObjectDisposedException) + { /* the documented answer once the filter is gone. */ + } } Interlocked.Decrement(ref inside); @@ -274,8 +330,11 @@ public async Task DisposingAFilterWhileItsCallbackIsRunning_DoesNotFreeWhatTheCa // Dispose without waiting for quiet: the point is to overlap. await filter.DisposeAsync(); - Assert.AreEqual(0, Interlocked.Read(ref inside), - "a callback was still running after DisposeAsync returned"); + Assert.AreEqual( + 0, + Interlocked.Read(ref inside), + "a callback was still running after DisposeAsync returned" + ); // And the graph is still usable, which a corrupted listener list would not leave it. await registry.WaitForInitialEnumerationAsync(cts.Token); @@ -307,28 +366,46 @@ public async Task AStreamWhoseNodeTheDaemonDestroys_ReportsItAndStaysSafeToUse() RequireLinux(); using var cts = new CancellationTokenSource(Budget * 2); - await using var ctx = new PipeWireContext("pwnet-destroyed-stream", ConsoleTestLoggerFactory.Instance); + await using var ctx = new PipeWireContext( + "pwnet-destroyed-stream", + ConsoleTestLoggerFactory.Instance + ); await ctx.StartAsync(cts.Token); - await using var other = new PipeWireContext("pwnet-destroyer", ConsoleTestLoggerFactory.Instance); + await using var other = new PipeWireContext( + "pwnet-destroyer", + ConsoleTestLoggerFactory.Instance + ); await other.StartAsync(cts.Token); await using var registry = new PipeWireRegistry(other); await registry.WaitForInitialEnumerationAsync(cts.Token); var states = new List(); - var output = new PipeWireAudioOutput(ctx, "pwnet-destroyed-stream-src", 48000, 1, AudioSampleFormat.F32Le); + var output = new PipeWireAudioOutput( + ctx, + "pwnet-destroyed-stream-src", + 48000, + 1, + AudioSampleFormat.F32Le + ); output.FillSamples += (_, samples, _, _, _) => { samples.Clear(); return samples.Length; }; - output.StateChanged += (_, _, now) => { lock (states) states.Add(now); }; + output.StateChanged += (_, _, now) => + { + lock (states) + states.Add(now); + }; // A sink of our own to play into, rather than whatever the session happens to route to. // Auto-connect needs the session manager to find a target, and a headless session has // none - the stream is then refused with "no target node available" before it can reach // the state this test is about. PipeWireNode sink = await registry.CreateVirtualSinkAsync( - "pwnet destroyed stream sink", cancellationToken: cts.Token); + "pwnet destroyed stream sink", + cancellationToken: cts.Token + ); output.Connect(sink.NodeId); @@ -340,8 +417,12 @@ public async Task AStreamWhoseNodeTheDaemonDestroys_ReportsItAndStaysSafeToUse() bool reported = false; for (var i = 0; i < 100 && !reported; i++) { - lock (states) reported = states.Any(s => s is PipeWireStreamState.Error or PipeWireStreamState.Unconnected); - if (!reported) await Task.Delay(50, cts.Token); + lock (states) + reported = states.Any(s => + s is PipeWireStreamState.Error or PipeWireStreamState.Unconnected + ); + if (!reported) + await Task.Delay(50, cts.Token); } Assert.IsTrue(reported, "the stream never reported that its node was destroyed"); @@ -357,7 +438,13 @@ public async Task AStreamWhoseNodeTheDaemonDestroys_ReportsItAndStaysSafeToUse() await output.DisposeAsync(); // And the context it lived on is still usable. - await using var next = new PipeWireAudioOutput(ctx, "pwnet-destroyed-stream-next", 48000, 1, AudioSampleFormat.F32Le); + await using var next = new PipeWireAudioOutput( + ctx, + "pwnet-destroyed-stream-next", + 48000, + 1, + AudioSampleFormat.F32Le + ); next.FillSamples += (_, samples, _, _, _) => { samples.Clear(); diff --git a/tests/PipeWire.NET.Tests/LinkStateTests.cs b/tests/PipeWire.NET.Tests/LinkStateTests.cs index e979700..31e7016 100644 --- a/tests/PipeWire.NET.Tests/LinkStateTests.cs +++ b/tests/PipeWire.NET.Tests/LinkStateTests.cs @@ -30,7 +30,9 @@ private static void RequireLinux() } private static async Task<(PipeWireContext Context, PipeWireRegistry Registry)> ConnectAsync( - string name, CancellationToken cancellationToken) + string name, + CancellationToken cancellationToken + ) { var context = new PipeWireContext(name, ConsoleTestLoggerFactory.Instance); await context.StartAsync(cancellationToken); @@ -39,10 +41,15 @@ private static void RequireLinux() return (context, registry); } - private static string Unique(string p) => $"{p}_{Environment.ProcessId}_{Random.Shared.Next():x}"; + private static string Unique(string p) => + $"{p}_{Environment.ProcessId}_{Random.Shared.Next():x}"; private static async Task PortAsync( - PipeWireRegistry registry, uint nodeId, PipeWirePortDirection direction, CancellationToken ct) + PipeWireRegistry registry, + uint nodeId, + PipeWirePortDirection direction, + CancellationToken ct + ) { while (true) { @@ -51,7 +58,8 @@ private static async Task PortAsync( foreach (PipeWirePort port in registry.Current.GetPortsForNode(nodeId)) { - if (port.PortDirection == direction) return port; + if (port.PortDirection == direction) + return port; } } } @@ -61,17 +69,34 @@ public async Task ABoundLink_ReportsItsStateAndItsEndpoints() { RequireLinux(); using var cts = new CancellationTokenSource(Budget); - (PipeWireContext ctx, PipeWireRegistry registry) = await ConnectAsync("pwnet-linkstate", cts.Token); + (PipeWireContext ctx, PipeWireRegistry registry) = await ConnectAsync( + "pwnet-linkstate", + cts.Token + ); await using (ctx) await using (registry) { - PipeWireNode source = await registry.CreateVirtualSink("LinkState") - .WithName(Unique("pwnet_ls_src")).ExecuteAsync(cts.Token); - PipeWireNode sink = await registry.CreateVirtualSink("LinkState") - .WithName(Unique("pwnet_ls_sink")).ExecuteAsync(cts.Token); + PipeWireNode source = await registry + .CreateVirtualSink("LinkState") + .WithName(Unique("pwnet_ls_src")) + .ExecuteAsync(cts.Token); + PipeWireNode sink = await registry + .CreateVirtualSink("LinkState") + .WithName(Unique("pwnet_ls_sink")) + .ExecuteAsync(cts.Token); - PipeWirePort output = await PortAsync(registry, source.NodeId, PipeWirePortDirection.Out, cts.Token); - PipeWirePort input = await PortAsync(registry, sink.NodeId, PipeWirePortDirection.In, cts.Token); + PipeWirePort output = await PortAsync( + registry, + source.NodeId, + PipeWirePortDirection.Out, + cts.Token + ); + PipeWirePort input = await PortAsync( + registry, + sink.NodeId, + PipeWirePortDirection.In, + cts.Token + ); PipeWireLink link = await registry.CreateLinkAsync(output, input, cts.Token); @@ -82,16 +107,27 @@ public async Task ABoundLink_ReportsItsStateAndItsEndpoints() // The endpoints the daemon reports must be the ones we asked it to join. Reading // them from the link rather than from the registry is what proves the info event // arrived rather than the record being echoed back. - Assert.AreEqual(source.NodeId, control.OutputNodeId, "the link reports another output node"); + Assert.AreEqual( + source.NodeId, + control.OutputNodeId, + "the link reports another output node" + ); Assert.AreEqual(output.PortId, control.OutputPortId); - Assert.AreEqual(sink.NodeId, control.InputNodeId, "the link reports another input node"); + Assert.AreEqual( + sink.NodeId, + control.InputNodeId, + "the link reports another input node" + ); Assert.AreEqual(input.PortId, control.InputPortId); // A freshly created link between two idle virtual nodes settles somewhere between // negotiating and paused. Which one is the daemon's business; what matters is that // it is no longer the value the control was constructed with and is not an error. - Assert.AreNotEqual(PipeWireLinkState.Error, control.State, - $"the link failed: {control.Error}"); + Assert.AreNotEqual( + PipeWireLinkState.Error, + control.State, + $"the link failed: {control.Error}" + ); Assert.IsNull(control.Error, "a link that is not in error must not carry a reason"); Console.Error.WriteLine($"link {link.LinkId} settled at {control.State}"); @@ -108,17 +144,34 @@ public async Task ALinkThatChangesState_ReportsEveryChange() { RequireLinux(); using var cts = new CancellationTokenSource(Budget); - (PipeWireContext ctx, PipeWireRegistry registry) = await ConnectAsync("pwnet-linkstate-events", cts.Token); + (PipeWireContext ctx, PipeWireRegistry registry) = await ConnectAsync( + "pwnet-linkstate-events", + cts.Token + ); await using (ctx) await using (registry) { - PipeWireNode source = await registry.CreateVirtualSink("LinkEvents") - .WithName(Unique("pwnet_le_src")).ExecuteAsync(cts.Token); - PipeWireNode sink = await registry.CreateVirtualSink("LinkEvents") - .WithName(Unique("pwnet_le_sink")).ExecuteAsync(cts.Token); + PipeWireNode source = await registry + .CreateVirtualSink("LinkEvents") + .WithName(Unique("pwnet_le_src")) + .ExecuteAsync(cts.Token); + PipeWireNode sink = await registry + .CreateVirtualSink("LinkEvents") + .WithName(Unique("pwnet_le_sink")) + .ExecuteAsync(cts.Token); - PipeWirePort output = await PortAsync(registry, source.NodeId, PipeWirePortDirection.Out, cts.Token); - PipeWirePort input = await PortAsync(registry, sink.NodeId, PipeWirePortDirection.In, cts.Token); + PipeWirePort output = await PortAsync( + registry, + source.NodeId, + PipeWirePortDirection.Out, + cts.Token + ); + PipeWirePort input = await PortAsync( + registry, + sink.NodeId, + PipeWirePortDirection.In, + cts.Token + ); PipeWireLink link = await registry.CreateLinkAsync(output, input, cts.Token); @@ -143,15 +196,25 @@ public async Task ALinkThatChangesState_ReportsEveryChange() for (int attempt = 0; attempt < 100 && seen.IsEmpty; attempt++) await Task.Delay(TimeSpan.FromMilliseconds(50), cts.Token); - Assert.IsFalse(seen.IsEmpty, + Assert.IsFalse( + seen.IsEmpty, $"the link started at {starting} and its far end was destroyed, " - + "but no change was ever reported"); - Assert.AreEqual(control.State, seen.Last(), - "the last event and the property disagree about the state"); + + "but no change was ever reported" + ); + Assert.AreEqual( + control.State, + seen.Last(), + "the last event and the property disagree about the state" + ); } - try { await registry.DestroyGlobalAsync(link.LinkId, cts.Token); } - catch (PipeWireException) { /* it went with the node its far end was on */ } + try + { + await registry.DestroyGlobalAsync(link.LinkId, cts.Token); + } + catch (PipeWireException) + { /* it went with the node its far end was on */ + } await registry.DestroyGlobalAsync(source.NodeId, cts.Token); } @@ -165,24 +228,46 @@ public async Task ABoundPort_ReportsTheFormatsItAcceptsAndTheOneItNegotiated() // will accept before linking to it, nor what it settled on afterwards. RequireLinux(); using var cts = new CancellationTokenSource(Budget); - (PipeWireContext ctx, PipeWireRegistry registry) = await ConnectAsync("pwnet-portparams", cts.Token); + (PipeWireContext ctx, PipeWireRegistry registry) = await ConnectAsync( + "pwnet-portparams", + cts.Token + ); await using (ctx) await using (registry) { - PipeWireNode source = await registry.CreateVirtualSink("PortParams") - .WithName(Unique("pwnet_pp_src")).ExecuteAsync(cts.Token); - PipeWireNode sink = await registry.CreateVirtualSink("PortParams") - .WithName(Unique("pwnet_pp_sink")).ExecuteAsync(cts.Token); + PipeWireNode source = await registry + .CreateVirtualSink("PortParams") + .WithName(Unique("pwnet_pp_src")) + .ExecuteAsync(cts.Token); + PipeWireNode sink = await registry + .CreateVirtualSink("PortParams") + .WithName(Unique("pwnet_pp_sink")) + .ExecuteAsync(cts.Token); - PipeWirePort output = await PortAsync(registry, source.NodeId, PipeWirePortDirection.Out, cts.Token); - PipeWirePort input = await PortAsync(registry, sink.NodeId, PipeWirePortDirection.In, cts.Token); + PipeWirePort output = await PortAsync( + registry, + source.NodeId, + PipeWirePortDirection.Out, + cts.Token + ); + PipeWirePort input = await PortAsync( + registry, + sink.NodeId, + PipeWirePortDirection.In, + cts.Token + ); await using (PipeWirePortProxy port = registry.BindPort(output.PortId)) { // What it will accept, before anything is linked to it. A port that offers nothing // could not be linked at all, so this is the one that must not be empty. - ImmutableArray supported = await port.EnumerateSupportedFormatsAsync(cts.Token); - Assert.IsFalse(supported.IsEmpty, "a port that accepts no format could never be linked"); + ImmutableArray supported = await port.EnumerateSupportedFormatsAsync( + cts.Token + ); + Assert.IsFalse( + supported.IsEmpty, + "a port that accepts no format could never be linked" + ); PipeWireLink link = await registry.CreateLinkAsync(output, input, cts.Token); @@ -192,11 +277,13 @@ public async Task ABoundPort_ReportsTheFormatsItAcceptsAndTheOneItNegotiated() for (int attempt = 0; attempt < 60 && negotiated.IsEmpty; attempt++) { negotiated = await port.EnumerateFormatsAsync(cts.Token); - if (negotiated.IsEmpty) await Task.Delay(TimeSpan.FromMilliseconds(100), cts.Token); + if (negotiated.IsEmpty) + await Task.Delay(TimeSpan.FromMilliseconds(100), cts.Token); } Console.Error.WriteLine( - $"port {output.PortId}: {supported.Length} accepted, {negotiated.Length} negotiated"); + $"port {output.PortId}: {supported.Length} accepted, {negotiated.Length} negotiated" + ); // Latency is reported once the graph has settled on one, and a port with no link // has none, so this only has to not throw. @@ -218,22 +305,36 @@ public async Task SettingAPortParameter_IsRefusedRatherThanSent() // protocol cannot express, and it should fail here rather than look like a daemon problem. RequireLinux(); using var cts = new CancellationTokenSource(Budget); - (PipeWireContext ctx, PipeWireRegistry registry) = await ConnectAsync("pwnet-portset", cts.Token); + (PipeWireContext ctx, PipeWireRegistry registry) = await ConnectAsync( + "pwnet-portset", + cts.Token + ); await using (ctx) await using (registry) { - PipeWireNode node = await registry.CreateVirtualSink("PortSet") - .WithName(Unique("pwnet_ps")).ExecuteAsync(cts.Token); + PipeWireNode node = await registry + .CreateVirtualSink("PortSet") + .WithName(Unique("pwnet_ps")) + .ExecuteAsync(cts.Token); - PipeWirePort input = await PortAsync(registry, node.NodeId, PipeWirePortDirection.In, cts.Token); + PipeWirePort input = await PortAsync( + registry, + node.NodeId, + PipeWirePortDirection.In, + cts.Token + ); await using (PipeWirePortProxy port = registry.BindPort(input.PortId)) { - ImmutableArray supported = await port.EnumerateSupportedFormatsAsync(cts.Token); - if (supported.IsEmpty) Assert.Inconclusive("the port reported no format to try setting."); + ImmutableArray supported = await port.EnumerateSupportedFormatsAsync( + cts.Token + ); + if (supported.IsEmpty) + Assert.Inconclusive("the port reported no format to try setting."); - await Assert.ThrowsExactlyAsync( - async () => await port.SetParameterAsync(SpaParamType.Format, supported[0], cts.Token)); + await Assert.ThrowsExactlyAsync(async () => + await port.SetParameterAsync(SpaParamType.Format, supported[0], cts.Token) + ); } await registry.DestroyGlobalAsync(node.NodeId, cts.Token); @@ -245,7 +346,10 @@ public async Task BindingSomethingThatIsNotALink_IsRefusedBeforeItReachesTheDaem { RequireLinux(); using var cts = new CancellationTokenSource(Budget); - (PipeWireContext ctx, PipeWireRegistry registry) = await ConnectAsync("pwnet-linkstate-wrong", cts.Token); + (PipeWireContext ctx, PipeWireRegistry registry) = await ConnectAsync( + "pwnet-linkstate-wrong", + cts.Token + ); await using (ctx) await using (registry) { @@ -257,7 +361,10 @@ public async Task BindingSomethingThatIsNotALink_IsRefusedBeforeItReachesTheDaem Assert.ThrowsExactly(() => registry.BindLink(0x7FFF_0000)); await registry.WaitForInitialEnumerationAsync(cts.Token); - Assert.IsTrue(registry.Current.Nodes.Length > 0, "the refusal disturbed the connection"); + Assert.IsTrue( + registry.Current.Nodes.Length > 0, + "the refusal disturbed the connection" + ); } } @@ -268,17 +375,34 @@ public async Task ASubscribedPort_ReportsItsFormatsAsEvents() // graph change to arrive. A handler that throws must be contained the same way. RequireLinux(); using var cts = new CancellationTokenSource(Budget); - (PipeWireContext ctx, PipeWireRegistry registry) = await ConnectAsync("pwnet-portsub", cts.Token); + (PipeWireContext ctx, PipeWireRegistry registry) = await ConnectAsync( + "pwnet-portsub", + cts.Token + ); await using (ctx) await using (registry) { - PipeWireNode source = await registry.CreateVirtualSink("PortSub") - .WithName(Unique("pwnet_psub_src")).ExecuteAsync(cts.Token); - PipeWireNode sink = await registry.CreateVirtualSink("PortSub") - .WithName(Unique("pwnet_psub_sink")).ExecuteAsync(cts.Token); + PipeWireNode source = await registry + .CreateVirtualSink("PortSub") + .WithName(Unique("pwnet_psub_src")) + .ExecuteAsync(cts.Token); + PipeWireNode sink = await registry + .CreateVirtualSink("PortSub") + .WithName(Unique("pwnet_psub_sink")) + .ExecuteAsync(cts.Token); - PipeWirePort output = await PortAsync(registry, source.NodeId, PipeWirePortDirection.Out, cts.Token); - PipeWirePort input = await PortAsync(registry, sink.NodeId, PipeWirePortDirection.In, cts.Token); + PipeWirePort output = await PortAsync( + registry, + source.NodeId, + PipeWirePortDirection.Out, + cts.Token + ); + PipeWirePort input = await PortAsync( + registry, + sink.NodeId, + PipeWirePortDirection.In, + cts.Token + ); PipeWireLink link = await registry.CreateLinkAsync(output, input, cts.Token); @@ -286,17 +410,21 @@ public async Task ASubscribedPort_ReportsItsFormatsAsEvents() { // Linked, so there are formats to report. An unlinked port answers empty and // would leave the wait below hanging on nothing. - ImmutableArray supported = - await port.EnumerateSupportedFormatsAsync(cts.Token); + ImmutableArray supported = await port.EnumerateSupportedFormatsAsync( + cts.Token + ); if (supported.IsEmpty) Assert.Inconclusive("the linked port reported no formats to subscribe to."); port.SubscribeParameters(SpaParamType.EnumFormat); CollectionAssert.AreEqual( - new[] { SpaParamType.EnumFormat }, port.SubscribedParameters.ToArray()); + new[] { SpaParamType.EnumFormat }, + port.SubscribedParameters.ToArray() + ); var first = new TaskCompletionSource( - TaskCreationOptions.RunContinuationsAsynchronously); + TaskCreationOptions.RunContinuationsAsynchronously + ); port.ParameterChanged += (_, value) => { if (value.ObjectType == SpaType.ObjectFormat) @@ -311,15 +439,22 @@ public async Task ASubscribedPort_ReportsItsFormatsAsEvents() // A throwing subscriber is reported and does not stop the binding: subscribing // again fires the faulting handler, and the binding still answers afterwards. - port.ParameterChanged += (_, _) => throw new InvalidOperationException("deliberate"); + port.ParameterChanged += (_, _) => + throw new InvalidOperationException("deliberate"); port.SubscribeParameters(SpaParamType.EnumFormat); - ImmutableArray stillThere = - await port.EnumerateSupportedFormatsAsync(cts.Token); - Assert.IsFalse(stillThere.IsEmpty, "the binding stopped answering after a faulting handler"); + ImmutableArray stillThere = await port.EnumerateSupportedFormatsAsync( + cts.Token + ); + Assert.IsFalse( + stillThere.IsEmpty, + "the binding stopped answering after a faulting handler" + ); ImmutableArray latencies = await port.GetLatenciesAsync(cts.Token); - Console.Error.WriteLine($"port {input.PortId} reports {latencies.Length} latencies"); + Console.Error.WriteLine( + $"port {input.PortId} reports {latencies.Length} latencies" + ); ImmutableArray tags = await port.GetTagsAsync(cts.Token); Console.Error.WriteLine($"port {input.PortId} reports {tags.Length} tags"); @@ -340,26 +475,47 @@ public async Task LinkCreationProperties_AreGuardedAndReachTheDaemon() // one in as a property would route the link elsewhere, so the builder refuses them. RequireLinux(); using var cts = new CancellationTokenSource(Budget); - (PipeWireContext ctx, PipeWireRegistry registry) = await ConnectAsync("pwnet-linkprops", cts.Token); + (PipeWireContext ctx, PipeWireRegistry registry) = await ConnectAsync( + "pwnet-linkprops", + cts.Token + ); await using (ctx) await using (registry) { - PipeWireNode source = await registry.CreateVirtualSink("LinkProps") - .WithName(Unique("pwnet_lp_src")).ExecuteAsync(cts.Token); - PipeWireNode sink = await registry.CreateVirtualSink("LinkProps") - .WithName(Unique("pwnet_lp_sink")).ExecuteAsync(cts.Token); - - PipeWirePort output = await PortAsync(registry, source.NodeId, PipeWirePortDirection.Out, cts.Token); - PipeWirePort input = await PortAsync(registry, sink.NodeId, PipeWirePortDirection.In, cts.Token); - - Assert.ThrowsExactly( - () => registry.CreateLink(output, input).WithProperty("", "v")); - Assert.ThrowsExactly( - () => registry.CreateLink(output, input).WithProperty("k", null!)); - Assert.ThrowsExactly( - () => registry.CreateLink(output, input).WithProperty("factory.name", "other")); - - PipeWireLink link = await registry.CreateLink(output, input) + PipeWireNode source = await registry + .CreateVirtualSink("LinkProps") + .WithName(Unique("pwnet_lp_src")) + .ExecuteAsync(cts.Token); + PipeWireNode sink = await registry + .CreateVirtualSink("LinkProps") + .WithName(Unique("pwnet_lp_sink")) + .ExecuteAsync(cts.Token); + + PipeWirePort output = await PortAsync( + registry, + source.NodeId, + PipeWirePortDirection.Out, + cts.Token + ); + PipeWirePort input = await PortAsync( + registry, + sink.NodeId, + PipeWirePortDirection.In, + cts.Token + ); + + Assert.ThrowsExactly(() => + registry.CreateLink(output, input).WithProperty("", "v") + ); + Assert.ThrowsExactly(() => + registry.CreateLink(output, input).WithProperty("k", null!) + ); + Assert.ThrowsExactly(() => + registry.CreateLink(output, input).WithProperty("factory.name", "other") + ); + + PipeWireLink link = await registry + .CreateLink(output, input) .WithProperty("link.passive", "true") .ExecuteAsync(cts.Token); diff --git a/tests/PipeWire.NET.Tests/LiveTimingAndRateTests.cs b/tests/PipeWire.NET.Tests/LiveTimingAndRateTests.cs index 9e74de6..9ca4d73 100644 --- a/tests/PipeWire.NET.Tests/LiveTimingAndRateTests.cs +++ b/tests/PipeWire.NET.Tests/LiveTimingAndRateTests.cs @@ -42,14 +42,22 @@ private static void RequireLinux() } /// Starts a silent output and a capture bound to it, both streaming. - private static async Task<(PipeWireContext Ctx, PipeWireAudioOutput Out, PipeWireAudioCapture Cap)> - RunningPairAsync(string name, CancellationToken ct, int rate = Rate) + private static async Task<( + PipeWireContext Ctx, + PipeWireAudioOutput Out, + PipeWireAudioCapture Cap + )> RunningPairAsync(string name, CancellationToken ct, int rate = Rate) { var ctx = new PipeWireContext(name, ConsoleTestLoggerFactory.Instance); await ctx.StartAsync(ct); var output = new PipeWireAudioOutput( - ctx, $"{name}-{Environment.ProcessId}", rate, Channels, AudioSampleFormat.F32Le); + ctx, + $"{name}-{Environment.ProcessId}", + rate, + Channels, + AudioSampleFormat.F32Le + ); output.FillSamples += (_, samples, _, _, _) => { @@ -100,28 +108,45 @@ public async Task AStreamingPair_ReportsEveryQueueFieldAndTheDepthMoves() var samples = new List(); for (var i = 0; i < 40 && samples.Count < 10; i++) { - if (output.Queue is { } q) samples.Add(q); + if (output.Queue is { } q) + samples.Add(q); await Task.Delay(50, cts.Token); } - Assert.IsTrue(samples.Count >= 10, $"the stream reported its queue only {samples.Count} times"); + Assert.IsTrue( + samples.Count >= 10, + $"the stream reported its queue only {samples.Count} times" + ); // AvailableBuffers is the pool the stream draws from. Zero forever means the reader is // looking at the wrong field, because a running stream always has a pool. Assert.IsTrue( samples.Any(s => s.AvailableBuffers > 0 || s.QueuedBuffers > 0), - "no reading reported any buffer at all, queued or available"); + "no reading reported any buffer at all, queued or available" + ); - string seen = string.Join(", ", samples.Select(s => $"q={s.Queued} b={s.Buffered} qb={s.QueuedBuffers} ab={s.AvailableBuffers}")); + string seen = string.Join( + ", ", + samples.Select(s => + $"q={s.Queued} b={s.Buffered} qb={s.QueuedBuffers} ab={s.AvailableBuffers}" + ) + ); Assert.IsTrue( samples.Any(s => s.Buffered > 0), - $"the resampler's held frames read as zero on every sample with a resampler in the path [{seen}]"); + $"the resampler's held frames read as zero on every sample with a resampler in the path [{seen}]" + ); // A depth pinned to one value is indistinguishable from a field that is never updated, // and a controller cannot converge on a constant. - var distinct = samples.Select(s => (s.Queued, s.Buffered, s.QueuedBuffers, s.AvailableBuffers)).Distinct().Count(); - Assert.IsTrue(distinct > 1, $"the queue reported an identical depth on every sample [{seen}]"); + var distinct = samples + .Select(s => (s.Queued, s.Buffered, s.QueuedBuffers, s.AvailableBuffers)) + .Distinct() + .Count(); + Assert.IsTrue( + distinct > 1, + $"the queue reported an identical depth on every sample [{seen}]" + ); } } @@ -172,7 +197,8 @@ public async Task TheRateController_StaysBoundedWhenDrivenFromALiveQueue() // The first reading sets the target the loop then holds against, which is how a // receiver picks one: there is no absolute correct depth, only a stable one. - if (observations == 0) target = avail; + if (observations == 0) + target = avail; corrections.Add(dll.Update(target - avail)); observations++; @@ -181,25 +207,32 @@ public async Task TheRateController_StaysBoundedWhenDrivenFromALiveQueue() await Task.Delay(20, cts.Token); } - Assert.IsTrue(observations >= 25, $"only {observations} live queue readings were taken"); + Assert.IsTrue( + observations >= 25, + $"only {observations} live queue readings were taken" + ); foreach (double c in corrections) { Assert.IsTrue( double.IsFinite(c), - "the controller produced a non-finite correction from a live queue reading"); + "the controller produced a non-finite correction from a live queue reading" + ); Assert.IsTrue( c is > 0.9 and < 1.1, $"the controller ran away to {c:F6} on live data; a real queue must not drive " - + "the rate more than a few percent"); + + "the rate more than a few percent" + ); } // And it is actually responding, rather than returning the neutral 1.0 because the // measurement never changes. Assert.IsTrue( - corrections.Distinct().Count() > 1 || corrections.All(c => Math.Abs(c - 1.0) < 1e-9), - "the controller neither moved nor sat at neutral, which is not a coherent state"); + corrections.Distinct().Count() > 1 + || corrections.All(c => Math.Abs(c - 1.0) < 1e-9), + "the controller neither moved nor sat at neutral, which is not a coherent state" + ); } } @@ -218,13 +251,21 @@ public async Task AStreamGoingLive_DeliversItsNodeCommandsToSubscribers() RequireLinux(); using var cts = new CancellationTokenSource(Budget); - await using var ctx = new PipeWireContext("pwnet-commands", ConsoleTestLoggerFactory.Instance); + await using var ctx = new PipeWireContext( + "pwnet-commands", + ConsoleTestLoggerFactory.Instance + ); await ctx.StartAsync(cts.Token); var commands = new ConcurrentBag(); await using var output = new PipeWireAudioOutput( - ctx, $"pwnet-commands-{Environment.ProcessId}", Rate, Channels, AudioSampleFormat.F32Le); + ctx, + $"pwnet-commands-{Environment.ProcessId}", + Rate, + Channels, + AudioSampleFormat.F32Le + ); // Subscribed before connecting: the hook has to be installed at core creation, not on // subscription, or the commands raised while going live are missed entirely. @@ -238,7 +279,10 @@ public async Task AStreamGoingLive_DeliversItsNodeCommandsToSubscribers() output.Connect(autoConnect: false); - await using var capture = new PipeWireAudioCapture(ctx, $"pwnet-commands-sink-{Environment.ProcessId}"); + await using var capture = new PipeWireAudioCapture( + ctx, + $"pwnet-commands-sink-{Environment.ProcessId}" + ); capture.Connect((await output.WaitForNodeIdAsync(cts.Token))); await capture.WaitForStreamingAsync(cts.Token); @@ -247,14 +291,16 @@ public async Task AStreamGoingLive_DeliversItsNodeCommandsToSubscribers() Assert.IsFalse( commands.IsEmpty, - "a stream that went live delivered no node command, so the dispatch is not wired"); + "a stream that went live delivered no node command, so the dispatch is not wired" + ); SpaNodeCommand[] seen = [.. commands]; Assert.IsTrue( seen.Contains(SpaNodeCommand.Start), $"a stream that reached Streaming never reported Start; got: " - + $"{string.Join(", ", seen.Select(c => c.ToString()).Distinct())}"); + + $"{string.Join(", ", seen.Select(c => c.ToString()).Distinct())}" + ); } /// @@ -274,11 +320,19 @@ public async Task PropertiesSetBeforeConnecting_ReachTheDaemonsNode() string marker = $"marker-{Guid.NewGuid():N}"; string sinkName = $"pwnet-extraprops-sink-{Environment.ProcessId}"; - await using var ctx = new PipeWireContext("pwnet-extraprops", ConsoleTestLoggerFactory.Instance); + await using var ctx = new PipeWireContext( + "pwnet-extraprops", + ConsoleTestLoggerFactory.Instance + ); await ctx.StartAsync(cts.Token); await using var output = new PipeWireAudioOutput( - ctx, $"pwnet-extraprops-{Environment.ProcessId}", Rate, Channels, AudioSampleFormat.F32Le); + ctx, + $"pwnet-extraprops-{Environment.ProcessId}", + Rate, + Channels, + AudioSampleFormat.F32Le + ); output.FillSamples += (_, samples, _, _, _) => { @@ -306,8 +360,9 @@ public async Task PropertiesSetBeforeConnecting_ReachTheDaemonsNode() PipeWireNode? node = null; for (var i = 0; i < 60 && node is null; i++) { - node = reg.Current.Nodes.FirstOrDefault( - n => string.Equals(n.NodeName, sinkName, StringComparison.Ordinal)); + node = reg.Current.Nodes.FirstOrDefault(n => + string.Equals(n.NodeName, sinkName, StringComparison.Ordinal) + ); if (node?.Properties.GetValueOrDefault(PipeWireKeys.PW_KEY_NODE_DESCRIPTION) is null) { @@ -316,22 +371,28 @@ public async Task PropertiesSetBeforeConnecting_ReachTheDaemonsNode() } } - Assert.IsNotNull(node, $"the capture node {sinkName} never appeared with its extra properties"); + Assert.IsNotNull( + node, + $"the capture node {sinkName} never appeared with its extra properties" + ); Assert.AreEqual( marker, node!.Properties.GetValueOrDefault(PipeWireKeys.PW_KEY_NODE_DESCRIPTION), - "a property set through ExtraProperties never reached the daemon's node"); + "a property set through ExtraProperties never reached the daemon's node" + ); Assert.AreEqual( "Production", node.Properties.GetValueOrDefault(PipeWireKeys.PW_KEY_MEDIA_ROLE), - "a property set through ExtraProperties never reached the daemon's node"); + "a property set through ExtraProperties never reached the daemon's node" + ); // Both ends of one link cannot both be the graph's driver. Assert.IsFalse( output.IsDriving && capture.IsDriving, - "both ends of one link claimed the driver role"); + "both ends of one link claimed the driver role" + ); // Asking about lazy scheduling is safe on a running stream and stays coherent with it. if (capture.IsLazy) diff --git a/tests/PipeWire.NET.Tests/LocalV4l2Tests.cs b/tests/PipeWire.NET.Tests/LocalV4l2Tests.cs index b1120a0..be6c330 100644 --- a/tests/PipeWire.NET.Tests/LocalV4l2Tests.cs +++ b/tests/PipeWire.NET.Tests/LocalV4l2Tests.cs @@ -63,20 +63,29 @@ public async Task AV4l2Camera_IsEnumeratedAndCapturedFrom() PipeWireNode? cam = null; for (var i = 0; i < 150 && cam is null; i++) { - cam = reg.Current.Nodes.FirstOrDefault( - n => n.Media == PipeWireMediaKind.Video - && n.Flow == PipeWireMediaFlow.Source - && ((n.Description?.Contains(VirtualCamera.CardLabel, StringComparison.OrdinalIgnoreCase) ?? false) - || (n.NodeName?.Contains("v4l2", StringComparison.OrdinalIgnoreCase) ?? false))); - - if (cam is null) await Task.Delay(100, cts.Token); + cam = reg.Current.Nodes.FirstOrDefault(n => + n.Media == PipeWireMediaKind.Video + && n.Flow == PipeWireMediaFlow.Source + && ( + ( + n.Description?.Contains( + VirtualCamera.CardLabel, + StringComparison.OrdinalIgnoreCase + ) ?? false + ) || (n.NodeName?.Contains("v4l2", StringComparison.OrdinalIgnoreCase) ?? false) + ) + ); + + if (cam is null) + await Task.Delay(100, cts.Token); } if (cam is null) { Assert.Inconclusive( $"no v4l2 video source appeared for {camera.DevicePath}; the session manager's " - + "v4l2 monitor may not be running."); + + "v4l2 monitor may not be running." + ); } var frames = 0; @@ -89,11 +98,16 @@ public async Task AV4l2Camera_IsEnumeratedAndCapturedFrom() Interlocked.Increment(ref frames); geometry = (f.Width, f.Height); - if (f.Pixels.IsEmpty) return; + if (f.Pixels.IsEmpty) + return; foreach (byte b in f.Pixels) { - if (b != 0) { Interlocked.Increment(ref nonBlank); return; } + if (b != 0) + { + Interlocked.Increment(ref nonBlank); + return; + } } }; @@ -103,16 +117,19 @@ public async Task AV4l2Camera_IsEnumeratedAndCapturedFrom() Assert.IsTrue( Volatile.Read(ref frames) > 0, - "the camera node was linked but delivered no frames"); + "the camera node was linked but delivered no frames" + ); Assert.IsTrue( geometry.W > 0 && geometry.H > 0, - $"frames arrived with a degenerate geometry {geometry.W}x{geometry.H}"); + $"frames arrived with a degenerate geometry {geometry.W}x{geometry.H}" + ); Assert.IsTrue( Volatile.Read(ref nonBlank) > 0, "every frame from the camera was entirely zero, so the capture negotiated a format and " - + "then received nothing"); + + "then received nothing" + ); } /// @@ -129,7 +146,10 @@ public async Task TheV4l2MonitorFactory_CanBeExportedDirectly() RequireLinux(); using var cts = new CancellationTokenSource(Budget); - await using var ctx = new PipeWireContext("pwnet-v4l2-export", ConsoleTestLoggerFactory.Instance); + await using var ctx = new PipeWireContext( + "pwnet-v4l2-export", + ConsoleTestLoggerFactory.Instance + ); await ctx.StartAsync(cts.Token); PipeWireDeviceProvider device; @@ -138,7 +158,10 @@ public async Task TheV4l2MonitorFactory_CanBeExportedDirectly() // The library is named outright: a client's context.spa-libs map has no api.v4l2.* entry, // so resolving by factory name alone fails even though libspa-v4l2 is installed. device = PipeWireDeviceProvider.FromSpaFactory( - ctx, "api.v4l2.enum.udev", libraryName: "v4l2/libspa-v4l2"); + ctx, + "api.v4l2.enum.udev", + libraryName: "v4l2/libspa-v4l2" + ); } catch (InvalidOperationException ex) { diff --git a/tests/PipeWire.NET.Tests/MetadataProtocolTests.cs b/tests/PipeWire.NET.Tests/MetadataProtocolTests.cs index c0c8ae5..990895f 100644 --- a/tests/PipeWire.NET.Tests/MetadataProtocolTests.cs +++ b/tests/PipeWire.NET.Tests/MetadataProtocolTests.cs @@ -30,7 +30,9 @@ private static void RequireLinux() } private static async Task<(PipeWireContext Context, PipeWireRegistry Registry)> ConnectAsync( - string name, CancellationToken cancellationToken) + string name, + CancellationToken cancellationToken + ) { var context = new PipeWireContext(name, ConsoleTestLoggerFactory.Instance); await context.StartAsync(cancellationToken); @@ -39,7 +41,8 @@ private static void RequireLinux() return (context, registry); } - private static string Unique(string p) => $"{p}.{Environment.ProcessId}.{Random.Shared.Next():x}"; + private static string Unique(string p) => + $"{p}.{Environment.ProcessId}.{Random.Shared.Next():x}"; [TestMethod] public async Task AWriteThatLetsTheDaemonChooseTheType_IsReportedOnce() @@ -52,12 +55,16 @@ public async Task AWriteThatLetsTheDaemonChooseTheType_IsReportedOnce() // every one of its own writes twice. RequireLinux(); using var cts = new CancellationTokenSource(Budget); - (PipeWireContext ctx, PipeWireRegistry registry) = await ConnectAsync("pwnet-meta-type", cts.Token); + (PipeWireContext ctx, PipeWireRegistry registry) = await ConnectAsync( + "pwnet-meta-type", + cts.Token + ); await using (ctx) await using (registry) { PipeWireMetadataProxy? store = registry.BindMetadata("default"); - if (store is null) Assert.Inconclusive("no session manager, so no default store."); + if (store is null) + Assert.Inconclusive("no session manager, so no default store."); await using (store) { @@ -68,21 +75,31 @@ public async Task AWriteThatLetsTheDaemonChooseTheType_IsReportedOnce() void OnChanged(PipeWireMetadataProxy _, PipeWireMetadataEntry e) { - if (e.Key == key) raised.Enqueue(e.Value); + if (e.Key == key) + raised.Enqueue(e.Value); } store.EntryChanged += OnChanged; try { - try { await store.SetAsync(key, "once", cancellationToken: cts.Token); } - catch (PipeWireException e) { Assert.Inconclusive($"cannot write metadata here: {e.Message}"); } + try + { + await store.SetAsync(key, "once", cancellationToken: cts.Token); + } + catch (PipeWireException e) + { + Assert.Inconclusive($"cannot write metadata here: {e.Message}"); + } // Long enough for an echo to arrive if one is coming. A barrier does not order // the session manager's hop, so this waits rather than syncing. await Task.Delay(TimeSpan.FromSeconds(3), cts.Token); - Assert.AreEqual(1, raised.Count, - $"one write raised {raised.Count} changes: {string.Join(", ", raised)}"); + Assert.AreEqual( + 1, + raised.Count, + $"one write raised {raised.Count} changes: {string.Join(", ", raised)}" + ); } finally { @@ -103,8 +120,14 @@ public async Task AStoreClearedByItsServer_EmptiesEveryBoundConsumer() // holds the machine's audio routing. RequireLinux(); using var cts = new CancellationTokenSource(Budget); - (PipeWireContext serverCtx, PipeWireRegistry serverReg) = await ConnectAsync("pwnet-clear-server", cts.Token); - (PipeWireContext clientCtx, PipeWireRegistry clientReg) = await ConnectAsync("pwnet-clear-client", cts.Token); + (PipeWireContext serverCtx, PipeWireRegistry serverReg) = await ConnectAsync( + "pwnet-clear-server", + cts.Token + ); + (PipeWireContext clientCtx, PipeWireRegistry clientReg) = await ConnectAsync( + "pwnet-clear-client", + cts.Token + ); await using (serverCtx) await using (serverReg) await using (clientCtx) @@ -112,8 +135,11 @@ public async Task AStoreClearedByItsServer_EmptiesEveryBoundConsumer() { string storeName = Unique("pwnet-clear-wire"); // Exported, which is the only way a second client can find it at all. - await using PipeWireMetadataProvider provider = - PipeWireMetadataProvider.Create(serverCtx, storeName, export: true); + await using PipeWireMetadataProvider provider = PipeWireMetadataProvider.Create( + serverCtx, + storeName, + export: true + ); // Export is a request, not a transaction: settle before treating the store as served. await provider.ReadyAsync(cts.Token); @@ -126,7 +152,8 @@ public async Task AStoreClearedByItsServer_EmptiesEveryBoundConsumer() { await clientReg.WaitForInitialEnumerationAsync(cts.Token); consumer = clientReg.BindMetadata(storeName); - if (consumer is null) await Task.Delay(TimeSpan.FromMilliseconds(50), cts.Token); + if (consumer is null) + await Task.Delay(TimeSpan.FromMilliseconds(50), cts.Token); } if (consumer is null) @@ -162,8 +189,10 @@ public async Task AStoreClearedByItsServer_EmptiesEveryBoundConsumer() for (int attempt = 0; attempt < 80 && consumer.Get("a") is not null; attempt++) await Task.Delay(TimeSpan.FromMilliseconds(50), cts.Token); - Assert.IsNull(consumer.Get("a"), - "a cleared store left entries in a bound consumer, so the clear's subject was not understood"); + Assert.IsNull( + consumer.Get("a"), + "a cleared store left entries in a bound consumer, so the clear's subject was not understood" + ); Assert.IsNull(consumer.Get("b")); } } @@ -201,9 +230,18 @@ public async Task AChangeWhileAnotherClientBinds_ReachesTheConsumersAlreadyBound { RequireLinux(); using var cts = new CancellationTokenSource(Budget); - (PipeWireContext serverCtx, PipeWireRegistry serverReg) = await ConnectAsync("pwnet-bindrace-server", cts.Token); - (PipeWireContext clientCtx, PipeWireRegistry clientReg) = await ConnectAsync("pwnet-bindrace-client", cts.Token); - (PipeWireContext lateCtx, PipeWireRegistry lateReg) = await ConnectAsync("pwnet-bindrace-late", cts.Token); + (PipeWireContext serverCtx, PipeWireRegistry serverReg) = await ConnectAsync( + "pwnet-bindrace-server", + cts.Token + ); + (PipeWireContext clientCtx, PipeWireRegistry clientReg) = await ConnectAsync( + "pwnet-bindrace-client", + cts.Token + ); + (PipeWireContext lateCtx, PipeWireRegistry lateReg) = await ConnectAsync( + "pwnet-bindrace-late", + cts.Token + ); await using (serverCtx) await using (serverReg) await using (clientCtx) @@ -212,8 +250,11 @@ public async Task AChangeWhileAnotherClientBinds_ReachesTheConsumersAlreadyBound await using (lateReg) { string storeName = Unique("pwnet-bindrace"); - await using PipeWireMetadataProvider provider = - PipeWireMetadataProvider.Create(serverCtx, storeName, export: true); + await using PipeWireMetadataProvider provider = PipeWireMetadataProvider.Create( + serverCtx, + storeName, + export: true + ); await provider.ReadyAsync(cts.Token); provider.Set("a", "1"); @@ -222,7 +263,8 @@ public async Task AChangeWhileAnotherClientBinds_ReachesTheConsumersAlreadyBound { await clientReg.WaitForInitialEnumerationAsync(cts.Token); consumer = clientReg.BindMetadata(storeName); - if (consumer is null) await Task.Delay(TimeSpan.FromMilliseconds(50), cts.Token); + if (consumer is null) + await Task.Delay(TimeSpan.FromMilliseconds(50), cts.Token); } Assert.IsNotNull(consumer, "the exported store never reached the other client"); @@ -270,7 +312,8 @@ public async Task AChangeWhileAnotherClientBinds_ReachesTheConsumersAlreadyBound }); window.Start(); window.Join(); - if (inWindow is not null) throw new AssertFailedException("the bind window failed", inWindow); + if (inWindow is not null) + throw new AssertFailedException("the bind window failed", inWindow); await using (late) { @@ -279,10 +322,12 @@ public async Task AChangeWhileAnotherClientBinds_ReachesTheConsumersAlreadyBound for (int attempt = 0; attempt < 80 && consumer.Get("a") is not null; attempt++) await Task.Delay(TimeSpan.FromMilliseconds(50), cts.Token); - Assert.IsNull(consumer.Get("a"), + Assert.IsNull( + consumer.Get("a"), "a clear made while another client was binding never reached the consumer " - + "already bound: the daemon forwarded it only to the binder (module-metadata " - + "metadata_property, fixed by repro/module-metadata.patch)"); + + "already bound: the daemon forwarded it only to the binder (module-metadata " + + "metadata_property, fixed by repro/module-metadata.patch)" + ); } } } @@ -301,16 +346,25 @@ public async Task AnExternalToolClearingAnExportedStore_EmptiesOurConsumer() PwTools.Require(); using var cts = new CancellationTokenSource(Budget); - (PipeWireContext serverCtx, PipeWireRegistry serverReg) = await ConnectAsync("pwnet-extclear-server", cts.Token); - (PipeWireContext clientCtx, PipeWireRegistry clientReg) = await ConnectAsync("pwnet-extclear-client", cts.Token); + (PipeWireContext serverCtx, PipeWireRegistry serverReg) = await ConnectAsync( + "pwnet-extclear-server", + cts.Token + ); + (PipeWireContext clientCtx, PipeWireRegistry clientReg) = await ConnectAsync( + "pwnet-extclear-client", + cts.Token + ); await using (serverCtx) await using (serverReg) await using (clientCtx) await using (clientReg) { string storeName = Unique("pwnet-extclear"); - await using PipeWireMetadataProvider provider = - PipeWireMetadataProvider.Create(serverCtx, storeName, export: true); + await using PipeWireMetadataProvider provider = PipeWireMetadataProvider.Create( + serverCtx, + storeName, + export: true + ); // Export is a request, not a transaction: settle before treating the store as served. await provider.ReadyAsync(cts.Token); @@ -323,10 +377,12 @@ public async Task AnExternalToolClearingAnExportedStore_EmptiesOurConsumer() { await clientReg.WaitForInitialEnumerationAsync(cts.Token); consumer = clientReg.BindMetadata(storeName); - if (consumer is null) await Task.Delay(TimeSpan.FromMilliseconds(50), cts.Token); + if (consumer is null) + await Task.Delay(TimeSpan.FromMilliseconds(50), cts.Token); } - if (consumer is null) Assert.Inconclusive("the exported store never reached the other client."); + if (consumer is null) + Assert.Inconclusive("the exported store never reached the other client."); await using (consumer) { @@ -347,8 +403,10 @@ public async Task AnExternalToolClearingAnExportedStore_EmptiesOurConsumer() for (int attempt = 0; attempt < 80 && consumer.Get("a") is not null; attempt++) await Task.Delay(TimeSpan.FromMilliseconds(50), cts.Token); - Assert.IsNull(consumer.Get("a"), - "an external clear left entries in the consumer, so its subject was not understood"); + Assert.IsNull( + consumer.Get("a"), + "an external clear left entries in the consumer, so its subject was not understood" + ); Assert.IsNull(consumer.Get("b")); } } @@ -365,14 +423,19 @@ public async Task AStoreClearedLocally_EmptiesItsOwnCache() PwTools.Require(); using var cts = new CancellationTokenSource(Budget); - (PipeWireContext ctx, PipeWireRegistry registry) = await ConnectAsync("pwnet-meta-clear", cts.Token); + (PipeWireContext ctx, PipeWireRegistry registry) = await ConnectAsync( + "pwnet-meta-clear", + cts.Token + ); await using (ctx) await using (registry) { // Our own store, not the session manager's: clearing that would leave the machine's // audio unrouted. - await using PipeWireMetadataProvider provider = - PipeWireMetadataProvider.Create(ctx, Unique("pwnet-clear-store")); + await using PipeWireMetadataProvider provider = PipeWireMetadataProvider.Create( + ctx, + Unique("pwnet-clear-store") + ); // Export is a request, not a transaction: settle before treating the store as served. await provider.ReadyAsync(cts.Token); @@ -384,8 +447,11 @@ public async Task AStoreClearedLocally_EmptiesItsOwnCache() provider.Clear(); - Assert.AreEqual(0, provider.Entries.Count, - "a cleared store still reports entries, so the clear was not applied to the cache"); + Assert.AreEqual( + 0, + provider.Entries.Count, + "a cleared store still reports entries, so the clear was not applied to the cache" + ); Assert.IsNull(provider.Get("a")); Assert.IsNull(provider.Get("b")); } @@ -399,12 +465,16 @@ public async Task AWriteTheDaemonRefuses_LeavesNoValueBehindInTheCache() // exists nowhere else, and the caller has been told about a change that did not happen. RequireLinux(); using var cts = new CancellationTokenSource(Budget); - (PipeWireContext ctx, PipeWireRegistry registry) = await ConnectAsync("pwnet-meta-refused", cts.Token); + (PipeWireContext ctx, PipeWireRegistry registry) = await ConnectAsync( + "pwnet-meta-refused", + cts.Token + ); await using (ctx) await using (registry) { PipeWireMetadataProxy? store = registry.BindMetadata("default"); - if (store is null) Assert.Inconclusive("no session manager, so no default store."); + if (store is null) + Assert.Inconclusive("no session manager, so no default store."); await using (store) { @@ -418,9 +488,17 @@ public async Task AWriteTheDaemonRefuses_LeavesNoValueBehindInTheCache() Task write = store.SetAsync(key, "ghost", cancellationToken: race.Token); race.Cancel(); - try { await write; } - catch (OperationCanceledException) { /* the point of the test */ } - catch (PipeWireException e) { Assert.Inconclusive($"cannot write metadata here: {e.Message}"); } + try + { + await write; + } + catch (OperationCanceledException) + { /* the point of the test */ + } + catch (PipeWireException e) + { + Assert.Inconclusive($"cannot write metadata here: {e.Message}"); + } // Whatever happened, the store and the daemon must agree. Reading back through a // fresh barrier is the arbiter: if the write landed the value is there, and if it @@ -428,7 +506,9 @@ public async Task AWriteTheDaemonRefuses_LeavesNoValueBehindInTheCache() await store.ReadyAsync(cts.Token); string? cached = store.Get(key); - Console.Error.WriteLine($"after a cancelled write the cache holds '{cached ?? "(null)"}'"); + Console.Error.WriteLine( + $"after a cancelled write the cache holds '{cached ?? "(null)"}'" + ); // A later write must still take, which a wedged reconciler entry would prevent. await store.SetAsync(key, "real", cancellationToken: cts.Token); diff --git a/tests/PipeWire.NET.Tests/MetadataProviderTests.cs b/tests/PipeWire.NET.Tests/MetadataProviderTests.cs index 56e5bcb..a27ebad 100644 --- a/tests/PipeWire.NET.Tests/MetadataProviderTests.cs +++ b/tests/PipeWire.NET.Tests/MetadataProviderTests.cs @@ -37,12 +37,19 @@ public async Task AKeyWithAnEmbeddedNul_IsRefusedBeforeItCanDesyncTheStore() RequireLinux(); using var cts = new CancellationTokenSource(Budget); - await using var ctx = new PipeWireContext("pwnet-provider-nul", ConsoleTestLoggerFactory.Instance); + await using var ctx = new PipeWireContext( + "pwnet-provider-nul", + ConsoleTestLoggerFactory.Instance + ); await ctx.StartAsync(cts.Token); // Not exported: this one only checks argument validation, and a served store that is made and // unmade while a device is rebuilding can leave the daemon unable to answer anyone. - using PipeWireMetadataProvider provider = PipeWireMetadataProvider.Create(ctx, Unique(), export: false); + using PipeWireMetadataProvider provider = PipeWireMetadataProvider.Create( + ctx, + Unique(), + export: false + ); Assert.ThrowsExactly(() => provider.Set("a\0b", "v")); Assert.ThrowsExactly(() => provider.Set("k", "a\0b")); @@ -56,7 +63,10 @@ public async Task AStoreWeServe_LeavesTheSessionResponsive() CliTool cli = CliTool.Require("pw-cli"); using var cts = new CancellationTokenSource(Budget); - await using var ctx = new PipeWireContext("pwnet-provider", ConsoleTestLoggerFactory.Instance); + await using var ctx = new PipeWireContext( + "pwnet-provider", + ConsoleTestLoggerFactory.Instance + ); await ctx.StartAsync(cts.Token); await using var registry = new PipeWireRegistry(ctx); await registry.WaitForInitialEnumerationAsync(cts.Token); @@ -68,7 +78,11 @@ public async Task AStoreWeServe_LeavesTheSessionResponsive() provider.Set("k", "v"); // The failure this exists for: the daemon stops answering every client, not just us. - (int exit, _, _) = await cli.RunAsync(["info", "0"], cts.Token, TimeSpan.FromSeconds(5)); + (int exit, _, _) = await cli.RunAsync( + ["info", "0"], + cts.Token, + TimeSpan.FromSeconds(5) + ); Assert.AreEqual(0, exit, "the daemon stopped answering while we served a store"); await registry.WaitForInitialEnumerationAsync(cts.Token); @@ -84,15 +98,26 @@ public async Task AStoreWeServe_ReportsEveryChangeItAccepts() RequireLinux(); using var cts = new CancellationTokenSource(Budget); - await using var ctx = new PipeWireContext("pwnet-provider-events", ConsoleTestLoggerFactory.Instance); + await using var ctx = new PipeWireContext( + "pwnet-provider-events", + ConsoleTestLoggerFactory.Instance + ); await ctx.StartAsync(cts.Token); // Not exported: these assertions are all in-process, and a served store that is made and // unmade while a device is rebuilding can leave the daemon unable to answer anyone. - using PipeWireMetadataProvider provider = PipeWireMetadataProvider.Create(ctx, Unique(), export: false); + using PipeWireMetadataProvider provider = PipeWireMetadataProvider.Create( + ctx, + Unique(), + export: false + ); var seen = new List(); - provider.EntryChanged += (_, e) => { lock (seen) seen.Add(e); }; + provider.EntryChanged += (_, e) => + { + lock (seen) + seen.Add(e); + }; provider.Set("a", "1"); provider.Set("a", "2"); @@ -119,12 +144,19 @@ public async Task ClearingAStoreWeServe_EmptiesEverySubjectInOneCallEach() RequireLinux(); using var cts = new CancellationTokenSource(Budget); - await using var ctx = new PipeWireContext("pwnet-provider-clear", ConsoleTestLoggerFactory.Instance); + await using var ctx = new PipeWireContext( + "pwnet-provider-clear", + ConsoleTestLoggerFactory.Instance + ); await ctx.StartAsync(cts.Token); // Not exported: these assertions are all in-process, and a served store that is made and // unmade while a device is rebuilding can leave the daemon unable to answer anyone. - using PipeWireMetadataProvider provider = PipeWireMetadataProvider.Create(ctx, Unique(), export: false); + using PipeWireMetadataProvider provider = PipeWireMetadataProvider.Create( + ctx, + Unique(), + export: false + ); provider.Set("a", "1"); provider.Set("b", "2"); @@ -152,12 +184,19 @@ public async Task ClearingAnEmptyStore_IsNotAnError() RequireLinux(); using var cts = new CancellationTokenSource(Budget); - await using var ctx = new PipeWireContext("pwnet-provider-clear-empty", ConsoleTestLoggerFactory.Instance); + await using var ctx = new PipeWireContext( + "pwnet-provider-clear-empty", + ConsoleTestLoggerFactory.Instance + ); await ctx.StartAsync(cts.Token); // Not exported: these assertions are all in-process, and a served store that is made and // unmade while a device is rebuilding can leave the daemon unable to answer anyone. - using PipeWireMetadataProvider provider = PipeWireMetadataProvider.Create(ctx, Unique(), export: false); + using PipeWireMetadataProvider provider = PipeWireMetadataProvider.Create( + ctx, + Unique(), + export: false + ); provider.Clear(); Assert.AreEqual(0, provider.Entries.Count); @@ -172,14 +211,20 @@ public async Task AUnexportedStore_StaysInsideThisProcess() RequireLinux(); using var cts = new CancellationTokenSource(Budget); - await using var ctx = new PipeWireContext("pwnet-provider-local", ConsoleTestLoggerFactory.Instance); + await using var ctx = new PipeWireContext( + "pwnet-provider-local", + ConsoleTestLoggerFactory.Instance + ); await ctx.StartAsync(cts.Token); await using var registry = new PipeWireRegistry(ctx); await registry.WaitForInitialEnumerationAsync(cts.Token); string name = Unique(); - using PipeWireMetadataProvider provider = - PipeWireMetadataProvider.Create(ctx, name, export: false); + using PipeWireMetadataProvider provider = PipeWireMetadataProvider.Create( + ctx, + name, + export: false + ); provider.Set("k", "v"); Assert.AreEqual("v", provider.Get("k")); @@ -187,8 +232,10 @@ public async Task AUnexportedStore_StaysInsideThisProcess() await registry.WaitForInitialEnumerationAsync(cts.Token); Assert.IsNull( registry.Current.Objects.FirstOrDefault(o => - o is PipeWireMetadata metadata && metadata.MetadataName == name), - "an unexported store is visible in the graph"); + o is PipeWireMetadata metadata && metadata.MetadataName == name + ), + "an unexported store is visible in the graph" + ); provider.Dispose(); Assert.ThrowsExactly(() => provider.Set("k", "v")); @@ -204,7 +251,10 @@ public async Task ClearingAStoreWeServeThroughItsBinding_EmptiesIt() RequireLinux(); using var cts = new CancellationTokenSource(Budget); - await using var ctx = new PipeWireContext("pwnet-provider-clearbind", ConsoleTestLoggerFactory.Instance); + await using var ctx = new PipeWireContext( + "pwnet-provider-clearbind", + ConsoleTestLoggerFactory.Instance + ); await ctx.StartAsync(cts.Token); await using var registry = new PipeWireRegistry(ctx); await registry.WaitForInitialEnumerationAsync(cts.Token); @@ -212,7 +262,10 @@ public async Task ClearingAStoreWeServeThroughItsBinding_EmptiesIt() string name = Unique(); using PipeWireMetadataProvider provider = PipeWireMetadataProvider.Create(ctx, name); - await using var reader = new PipeWireContext("pwnet-provider-clearread", ConsoleTestLoggerFactory.Instance); + await using var reader = new PipeWireContext( + "pwnet-provider-clearread", + ConsoleTestLoggerFactory.Instance + ); await reader.StartAsync(cts.Token); await using var readerRegistry = new PipeWireRegistry(reader); await readerRegistry.WaitForInitialEnumerationAsync(cts.Token); @@ -260,7 +313,11 @@ public async Task ClearingAStoreWeServeThroughItsBinding_EmptiesIt() /// would sometimes lose. A value that never arrives is still a failure, just a slower one. /// private static async Task WaitForProviderValueAsync( - PipeWireMetadataProvider provider, string key, string? expected, CancellationToken cancellationToken) + PipeWireMetadataProvider provider, + string key, + string? expected, + CancellationToken cancellationToken + ) { for (int attempt = 0; attempt < 150; attempt++) { @@ -270,8 +327,11 @@ private static async Task WaitForProviderValueAsync( await Task.Delay(TimeSpan.FromMilliseconds(100), cancellationToken); } - Assert.AreEqual(expected, provider.Get(key), - $"the serving process never held '{expected ?? ""}` for '{key}'"); + Assert.AreEqual( + expected, + provider.Get(key), + $"the serving process never held '{expected ?? ""}` for '{key}'" + ); } /// @@ -286,10 +346,14 @@ private static async Task WaitForProviderValueAsync( [TestMethod] public async Task BindingAStoreThroughTheConnectionThatServesIt_IsRefusedRatherThanHanging() { - if (!OperatingSystem.IsLinux()) Assert.Inconclusive("PipeWire is a Linux daemon."); + if (!OperatingSystem.IsLinux()) + Assert.Inconclusive("PipeWire is a Linux daemon."); using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); - await using var ctx = new PipeWireContext("pwnet-provider-selfbind", ConsoleTestLoggerFactory.Instance); + await using var ctx = new PipeWireContext( + "pwnet-provider-selfbind", + ConsoleTestLoggerFactory.Instance + ); await ctx.StartAsync(cts.Token); await using var registry = new PipeWireRegistry(ctx); await registry.WaitForInitialEnumerationAsync(cts.Token); @@ -303,7 +367,8 @@ public async Task BindingAStoreThroughTheConnectionThatServesIt_IsRefusedRatherT { await registry.WaitForInitialEnumerationAsync(cts.Token); store = registry.Current.GetMetadata(name); - if (store is null) await Task.Delay(50, cts.Token); + if (store is null) + await Task.Delay(50, cts.Token); } Assert.IsNotNull(store, "the served store never reached the registry"); diff --git a/tests/PipeWire.NET.Tests/MetadataReconcilerTests.cs b/tests/PipeWire.NET.Tests/MetadataReconcilerTests.cs index 67feb1f..5ffc63d 100644 --- a/tests/PipeWire.NET.Tests/MetadataReconcilerTests.cs +++ b/tests/PipeWire.NET.Tests/MetadataReconcilerTests.cs @@ -43,7 +43,8 @@ private sealed class TestClock : TimeProvider public void Advance(TimeSpan by) => _now += (long)(TimestampFrequency * by.TotalSeconds); } - private static void Write(MetadataReconciler r, string? value) => r.NoteWrite(Subject, Key, Type, value); + private static void Write(MetadataReconciler r, string? value) => + r.NoteWrite(Subject, Key, Type, value); /// True when the echo reaches the cache at all, raised or not. private static bool Echo(MetadataReconciler r, string? value) => @@ -114,8 +115,11 @@ public void TheSameTextUnderADifferentType_IsSomebodyElsesChange() MetadataReconciler r = NewReconciler(); r.NoteWrite(Subject, Key, "Spa:String", "42"); - Assert.AreEqual(MetadataReconciler.EchoAction.ApplyAndRaise, r.Classify(Subject, Key, "Spa:Int", "42"), - "state is (subject, key, type, value); the same text under another type is a different change"); + Assert.AreEqual( + MetadataReconciler.EchoAction.ApplyAndRaise, + r.Classify(Subject, Key, "Spa:Int", "42"), + "state is (subject, key, type, value); the same text under another type is a different change" + ); } [TestMethod] @@ -141,7 +145,10 @@ public void AWriteThatFailed_StopsSuppressingOnceForgotten() r.Forget(Subject, Key, failed); - Assert.IsTrue(Echo(r, "A"), "a write that never landed must not suppress that value for ever"); + Assert.IsTrue( + Echo(r, "A"), + "a write that never landed must not suppress that value for ever" + ); } [TestMethod] @@ -197,7 +204,8 @@ public void ARemovalFollowedByALateEchoOfAnEarlierValue_StaysRemoved() // A burst then a removal, with one older echo still in flight. Applying it would bring a // deleted entry back. MetadataReconciler r = NewReconciler(); - for (int i = 0; i < 10; i++) Write(r, $"value-{i}"); + for (int i = 0; i < 10; i++) + Write(r, $"value-{i}"); Write(r, null); Assert.IsTrue(Echo(r, null), "the removal itself is the newest write"); @@ -230,7 +238,8 @@ public void AFuzzOfInterleavedWritesAndEchoes_NeverSuppressesAnUnwrittenValue() Assert.IsTrue(applied, $"step {step}: value '{value}' was never written here"); } - if (random.Next(20) == 0) Advance(TimeSpan.FromSeconds(1)); + if (random.Next(20) == 0) + Advance(TimeSpan.FromSeconds(1)); } } @@ -245,20 +254,34 @@ public void WritesRacingTheBucketBeingRemoved_AreNeverLost() Task[] workers = [ - .. Enumerable.Range(0, 8).Select(w => Task.Run(() => - { - for (int i = 0; i < 4000; i++) - { - string k = $"key-{i % 4}"; - MetadataReconciler.PendingWrite pending = reconciler.NoteWrite(Subject, k, Type, $"v{w}"); - - // The write must be visible to the classifier the instant it is recorded. - if (reconciler.Classify(Subject, k, Type, $"v{w}") == MetadataReconciler.EchoAction.ApplyAndRaise) - faults.Enqueue($"worker {w} step {i}: its own write was not recorded"); - - reconciler.Forget(Subject, k, pending); - } - })), + .. Enumerable + .Range(0, 8) + .Select(w => + Task.Run(() => + { + for (int i = 0; i < 4000; i++) + { + string k = $"key-{i % 4}"; + MetadataReconciler.PendingWrite pending = reconciler.NoteWrite( + Subject, + k, + Type, + $"v{w}" + ); + + // The write must be visible to the classifier the instant it is recorded. + if ( + reconciler.Classify(Subject, k, Type, $"v{w}") + == MetadataReconciler.EchoAction.ApplyAndRaise + ) + faults.Enqueue( + $"worker {w} step {i}: its own write was not recorded" + ); + + reconciler.Forget(Subject, k, pending); + } + }) + ), ]; Task.WaitAll(workers); @@ -279,8 +302,11 @@ public void AKeyWhoseRecordsHaveAllExpired_StopsBeingTracked() Advance(TimeSpan.FromSeconds(6)); - Assert.AreEqual(MetadataReconciler.EchoAction.ApplyAndRaise, Classify(r, "A"), - "an echo arriving after the window is somebody else's change"); + Assert.AreEqual( + MetadataReconciler.EchoAction.ApplyAndRaise, + Classify(r, "A"), + "an echo arriving after the window is somebody else's change" + ); Assert.AreEqual(0, r.TrackedKeys, "the emptied bucket was left in the dictionary"); } @@ -295,8 +321,11 @@ public void AWriteTheDaemonHasNotAcknowledged_DoesNotExpire() Write(r, "A"); Advance(TimeSpan.FromSeconds(600)); - Assert.AreEqual(MetadataReconciler.EchoAction.AlreadyKnown, Classify(r, "A"), - "an unacknowledged write was expired on elapsed time alone"); + Assert.AreEqual( + MetadataReconciler.EchoAction.AlreadyKnown, + Classify(r, "A"), + "an unacknowledged write was expired on elapsed time alone" + ); Assert.AreEqual(1, r.TrackedKeys); } @@ -310,12 +339,18 @@ public void TheWindowRunsFromAcknowledgement_NotFromIssue() r.Settle(Subject, Key); Advance(TimeSpan.FromSeconds(1)); - Assert.AreEqual(MetadataReconciler.EchoAction.AlreadyKnown, Classify(r, "A"), - "the clock started before the acknowledgement"); + Assert.AreEqual( + MetadataReconciler.EchoAction.AlreadyKnown, + Classify(r, "A"), + "the clock started before the acknowledgement" + ); Advance(TimeSpan.FromSeconds(6)); - Assert.AreEqual(MetadataReconciler.EchoAction.ApplyAndRaise, Classify(r, "A"), - "the record never expired after its acknowledgement"); + Assert.AreEqual( + MetadataReconciler.EchoAction.ApplyAndRaise, + Classify(r, "A"), + "the record never expired after its acknowledgement" + ); } [TestMethod] @@ -334,10 +369,16 @@ public void ASecondAcknowledgement_DoesNotRestartTheWindow() Advance(TimeSpan.FromSeconds(2)); - Assert.AreEqual(MetadataReconciler.EchoAction.ApplyAndRaise, Classify(r, "A"), - "A was acknowledged 6 seconds ago and the window is 5"); - Assert.AreEqual(MetadataReconciler.EchoAction.AlreadyKnown, Classify(r, "B"), - "B was acknowledged 2 seconds ago and must still be recognised"); + Assert.AreEqual( + MetadataReconciler.EchoAction.ApplyAndRaise, + Classify(r, "A"), + "A was acknowledged 6 seconds ago and the window is 5" + ); + Assert.AreEqual( + MetadataReconciler.EchoAction.AlreadyKnown, + Classify(r, "B"), + "B was acknowledged 2 seconds ago and must still be recognised" + ); } [TestMethod] diff --git a/tests/PipeWire.NET.Tests/MetadataRelay.cs b/tests/PipeWire.NET.Tests/MetadataRelay.cs index ba161cd..1968e43 100644 --- a/tests/PipeWire.NET.Tests/MetadataRelay.cs +++ b/tests/PipeWire.NET.Tests/MetadataRelay.cs @@ -40,13 +40,17 @@ internal static class MetadataRelay PipeWireMetadataProxy reader, string key, Func write, - CancellationToken cancellationToken) + CancellationToken cancellationToken + ) { - var arrived = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var arrived = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously + ); void OnChanged(PipeWireMetadataProxy _, PipeWireMetadataEntry entry) { - if (entry.Key == key) arrived.TrySetResult(entry.Value); + if (entry.Key == key) + arrived.TrySetResult(entry.Value); } reader.EntryChanged += OnChanged; @@ -56,7 +60,9 @@ void OnChanged(PipeWireMetadataProxy _, PipeWireMetadataEntry entry) try { - return await arrived.Task.WaitAsync(Budget, cancellationToken).ConfigureAwait(false); + return await arrived + .Task.WaitAsync(Budget, cancellationToken) + .ConfigureAwait(false); } catch (TimeoutException) { @@ -65,14 +71,16 @@ void OnChanged(PipeWireMetadataProxy _, PipeWireMetadataEntry entry) { Assert.Fail( $"the reader's store holds '{key}' = '{inStore}' but never raised the change, " - + "so the event path dropped it"); + + "so the event path dropped it" + ); } Assert.Inconclusive( $"the session manager did not relay '{key}' to a second client within {Budget}. " - + "The value never reached the reader's store either, so nothing about this " - + "library can be concluded from it."); - throw; // unreachable; Inconclusive throws + + "The value never reached the reader's store either, so nothing about this " + + "library can be concluded from it." + ); + throw; // unreachable; Inconclusive throws } } finally diff --git a/tests/PipeWire.NET.Tests/MultiClientHarmonyTests.cs b/tests/PipeWire.NET.Tests/MultiClientHarmonyTests.cs index 22295a4..bec2278 100644 --- a/tests/PipeWire.NET.Tests/MultiClientHarmonyTests.cs +++ b/tests/PipeWire.NET.Tests/MultiClientHarmonyTests.cs @@ -57,12 +57,16 @@ private static string Unique(string prefix) => /// Polls a condition that depends on another client's change reaching this one. private static async Task EventuallyAsync( - Func> condition, TimeSpan within, CancellationToken cancellationToken) + Func> condition, + TimeSpan within, + CancellationToken cancellationToken + ) { long deadline = Environment.TickCount64 + (long)within.TotalMilliseconds; while (Environment.TickCount64 < deadline) { - if (await condition()) return true; + if (await condition()) + return true; await Task.Delay(50, cancellationToken); } @@ -80,31 +84,40 @@ public async Task EightContextsInOneProcess_AllConvergeOnTheSameGraph() // else, and each is meant to be an independent client of the daemon. Client[] clients = [ - .. await Task.WhenAll(Enumerable.Range(0, 8) - .Select(i => ConnectAsync($"pwnet-harmony-{i}", cts.Token))), + .. await Task.WhenAll( + Enumerable.Range(0, 8).Select(i => ConnectAsync($"pwnet-harmony-{i}", cts.Token)) + ), ]; try { // A node created through one of them has to become visible to all of them, by id. - PipeWireNode node = await clients[0].Registry.CreateVirtualSink("Harmony") + PipeWireNode node = await clients[0] + .Registry.CreateVirtualSink("Harmony") .WithName(Unique("pwnet_harmony")) .ExecuteAsync(cts.Token); foreach (Client c in clients) { - bool saw = await EventuallyAsync(async () => - { - await c.Registry.WaitForInitialEnumerationAsync(cts.Token); - return c.Registry.Current.GetNode(node.NodeId) is not null; - }, TimeSpan.FromSeconds(10), cts.Token); + bool saw = await EventuallyAsync( + async () => + { + await c.Registry.WaitForInitialEnumerationAsync(cts.Token); + return c.Registry.Current.GetNode(node.NodeId) is not null; + }, + TimeSpan.FromSeconds(10), + cts.Token + ); Assert.IsTrue(saw, "a node created by one client was never seen by another"); } // And every one of them must be able to bind and drive it at the same time: eight // proxies to one global, eight independent parameter caches. - PipeWireNodeProxy[] controls = [.. clients.Select(c => c.Registry.BindNode(node.NodeId))]; + PipeWireNodeProxy[] controls = + [ + .. clients.Select(c => c.Registry.BindNode(node.NodeId)), + ]; try { await Task.WhenAll(controls.Select(c => c.ReadyAsync(cts.Token))); @@ -114,11 +127,18 @@ .. await Task.WhenAll(Enumerable.Range(0, 8) // The last write wins and everyone must agree on which that was, rather than each // believing whatever it wrote itself. - bool agreed = await EventuallyAsync(async () => - { - float?[] seen = await Task.WhenAll(controls.Select(c => c.GetVolumeAsync(cts.Token))); - return Array.TrueForAll(seen, v => v is not null) && seen.Distinct().Count() == 1; - }, TimeSpan.FromSeconds(15), cts.Token); + bool agreed = await EventuallyAsync( + async () => + { + float?[] seen = await Task.WhenAll( + controls.Select(c => c.GetVolumeAsync(cts.Token)) + ); + return Array.TrueForAll(seen, v => v is not null) + && seen.Distinct().Count() == 1; + }, + TimeSpan.FromSeconds(15), + cts.Token + ); Assert.IsTrue(agreed, "eight controls on one node did not converge on one volume"); } @@ -134,11 +154,15 @@ .. await Task.WhenAll(Enumerable.Range(0, 8) // in its snapshot hands out ids that fail on the next bind. foreach (Client c in clients) { - bool gone = await EventuallyAsync(async () => - { - await c.Registry.WaitForInitialEnumerationAsync(cts.Token); - return c.Registry.Current.GetNode(node.NodeId) is null; - }, TimeSpan.FromSeconds(10), cts.Token); + bool gone = await EventuallyAsync( + async () => + { + await c.Registry.WaitForInitialEnumerationAsync(cts.Token); + return c.Registry.Current.GetNode(node.NodeId) is null; + }, + TimeSpan.FromSeconds(10), + cts.Token + ); Assert.IsTrue(gone, "a removed node stayed in another client's snapshot"); } @@ -160,18 +184,23 @@ public async Task TwoOfOurClientsAndPwCliFightingOverOneVolume_EndUpAgreeing() await using Client a = await ConnectAsync("pwnet-fight-a", cts.Token); await using Client b = await ConnectAsync("pwnet-fight-b", cts.Token); - PipeWireNode node = await a.Registry.CreateVirtualSink("Contended") + PipeWireNode node = await a + .Registry.CreateVirtualSink("Contended") .WithName(Unique("pwnet_contended")) .ExecuteAsync(cts.Token); // b is a separate connection and learns about a's new node from the daemon, so it has to // be waited for: binding is against the caller's own snapshot, and on a loaded machine // b's has not caught up by the time the create returns on a. - bool visibleToB = await EventuallyAsync(async () => - { - await b.Registry.WaitForInitialEnumerationAsync(cts.Token); - return b.Registry.Current.GetNode(node.NodeId) is not null; - }, TimeSpan.FromSeconds(10), cts.Token); + bool visibleToB = await EventuallyAsync( + async () => + { + await b.Registry.WaitForInitialEnumerationAsync(cts.Token); + return b.Registry.Current.GetNode(node.NodeId) is not null; + }, + TimeSpan.FromSeconds(10), + cts.Token + ); Assert.IsTrue(visibleToB, "the second client never saw the contended node"); await using PipeWireNodeProxy ca = a.Registry.BindNode(node.NodeId); @@ -183,31 +212,55 @@ public async Task TwoOfOurClientsAndPwCliFightingOverOneVolume_EndUpAgreeing() // with each other or with the daemon. var faults = new ConcurrentQueue(); - Task writerA = Task.Run(async () => - { - for (int i = 0; i < 40; i++) + Task writerA = Task.Run( + async () => { - try { await ca.SetVolumeAsync(0.20f, cts.Token); } - catch (Exception ex) { faults.Enqueue($"a: {ex.GetType().Name}: {ex.Message}"); return; } - } - }, cts.Token); + for (int i = 0; i < 40; i++) + { + try + { + await ca.SetVolumeAsync(0.20f, cts.Token); + } + catch (Exception ex) + { + faults.Enqueue($"a: {ex.GetType().Name}: {ex.Message}"); + return; + } + } + }, + cts.Token + ); - Task writerB = Task.Run(async () => - { - for (int i = 0; i < 40; i++) + Task writerB = Task.Run( + async () => { - try { await cb.SetVolumeAsync(0.60f, cts.Token); } - catch (Exception ex) { faults.Enqueue($"b: {ex.GetType().Name}: {ex.Message}"); return; } - } - }, cts.Token); - - Task writerCli = Task.Run(async () => - { - for (int i = 0; i < 12; i++) - await PwTools.SetNodeVolumeAsync(node.NodeId, 0.90f, cts.Token); - }, cts.Token); + for (int i = 0; i < 40; i++) + { + try + { + await cb.SetVolumeAsync(0.60f, cts.Token); + } + catch (Exception ex) + { + faults.Enqueue($"b: {ex.GetType().Name}: {ex.Message}"); + return; + } + } + }, + cts.Token + ); - await Task.WhenAll(writerA, writerB, writerCli).WaitAsync(TimeSpan.FromSeconds(60), cts.Token); + Task writerCli = Task.Run( + async () => + { + for (int i = 0; i < 12; i++) + await PwTools.SetNodeVolumeAsync(node.NodeId, 0.90f, cts.Token); + }, + cts.Token + ); + + await Task.WhenAll(writerA, writerB, writerCli) + .WaitAsync(TimeSpan.FromSeconds(60), cts.Token); Assert.IsTrue(faults.IsEmpty, string.Join("; ", faults)); // Now nobody is writing. One more write settles the argument, and both caches have to report @@ -215,14 +268,21 @@ public async Task TwoOfOurClientsAndPwCliFightingOverOneVolume_EndUpAgreeing() // write instead. await ca.SetVolumeAsync(0.33f, cts.Token); - bool converged = await EventuallyAsync(async () => - { - float? va = await ca.GetVolumeAsync(cts.Token); - float? vb = await cb.GetVolumeAsync(cts.Token); - return va is not null && vb is not null && Math.Abs(va.Value - vb.Value) < 0.001f; - }, TimeSpan.FromSeconds(20), cts.Token); + bool converged = await EventuallyAsync( + async () => + { + float? va = await ca.GetVolumeAsync(cts.Token); + float? vb = await cb.GetVolumeAsync(cts.Token); + return va is not null && vb is not null && Math.Abs(va.Value - vb.Value) < 0.001f; + }, + TimeSpan.FromSeconds(20), + cts.Token + ); - Assert.IsTrue(converged, "two clients did not agree on the volume after the writes stopped"); + Assert.IsTrue( + converged, + "two clients did not agree on the volume after the writes stopped" + ); await a.Registry.DestroyGlobalAsync(node.NodeId, cts.Token); } @@ -251,7 +311,8 @@ public async Task MetadataWrittenByEveryKindOfClient_ReachesEveryOtherOne() var seenByB = new ConcurrentQueue(); sb.EntryChanged += (_, e) => { - if (string.Equals(e.Key, key, StringComparison.Ordinal)) seenByB.Enqueue(e.Value); + if (string.Equals(e.Key, key, StringComparison.Ordinal)) + seenByB.Enqueue(e.Value); }; // Our client writes; the other one must see it. Waited on the change event rather than @@ -261,8 +322,16 @@ public async Task MetadataWrittenByEveryKindOfClient_ReachesEveryOtherOne() string? relayed = await MetadataRelay.AwaitRelayAsync( sb, key, - () => sa.SetAsync(key, "from-a", "Spa:String", PipeWireMetadataProxy.SubjectCore, cts.Token), - cts.Token); + () => + sa.SetAsync( + key, + "from-a", + "Spa:String", + PipeWireMetadataProxy.SubjectCore, + cts.Token + ), + cts.Token + ); Assert.AreEqual("from-a", relayed, "the relayed write carried the wrong value"); @@ -272,18 +341,28 @@ public async Task MetadataWrittenByEveryKindOfClient_ReachesEveryOtherOne() await PwTools.SetMetadataAsync(key, "from-pw-metadata", cts.Token); Assert.IsTrue( - await EventuallyAsync(() => Task.FromResult(sa.Get(key) == "from-pw-metadata"), - TimeSpan.FromSeconds(10), cts.Token), - "an external write was not applied by the client that had just written the key"); + await EventuallyAsync( + () => Task.FromResult(sa.Get(key) == "from-pw-metadata"), + TimeSpan.FromSeconds(10), + cts.Token + ), + "an external write was not applied by the client that had just written the key" + ); Assert.IsTrue( - await EventuallyAsync(() => Task.FromResult(sb.Get(key) == "from-pw-metadata"), - TimeSpan.FromSeconds(10), cts.Token), - "an external write was not applied by the observing client"); + await EventuallyAsync( + () => Task.FromResult(sb.Get(key) == "from-pw-metadata"), + TimeSpan.FromSeconds(10), + cts.Token + ), + "an external write was not applied by the observing client" + ); // The event has to have fired too, not just the cache updated: an application that only // listens would otherwise never learn of the change. - Assert.IsTrue(seenByB.Contains("from-pw-metadata"), - "the external write updated the cache but raised no event"); + Assert.IsTrue( + seenByB.Contains("from-pw-metadata"), + "the external write updated the cache but raised no event" + ); // A burst from both of ours at once, then a single external write on top. Whatever the // interleaving, the external value is the last one written and must be where everyone @@ -291,26 +370,54 @@ await EventuallyAsync(() => Task.FromResult(sb.Get(key) == "from-pw-metadata"), // count instead drops values whose echoes are still in flight, and those echoes then // read as somebody else's change and put an old value back. await Task.WhenAll( - Task.Run(async () => - { - for (int i = 0; i < 30; i++) - await sa.SetAsync(key, $"a-{i}", "Spa:String", PipeWireMetadataProxy.SubjectCore, cts.Token); - }, cts.Token), - Task.Run(async () => - { - for (int i = 0; i < 30; i++) - await sb.SetAsync(key, $"b-{i}", "Spa:String", PipeWireMetadataProxy.SubjectCore, cts.Token); - }, cts.Token)); + Task.Run( + async () => + { + for (int i = 0; i < 30; i++) + await sa.SetAsync( + key, + $"a-{i}", + "Spa:String", + PipeWireMetadataProxy.SubjectCore, + cts.Token + ); + }, + cts.Token + ), + Task.Run( + async () => + { + for (int i = 0; i < 30; i++) + await sb.SetAsync( + key, + $"b-{i}", + "Spa:String", + PipeWireMetadataProxy.SubjectCore, + cts.Token + ); + }, + cts.Token + ) + ); await PwTools.SetMetadataAsync(key, "final", cts.Token); Assert.IsTrue( await EventuallyAsync( () => Task.FromResult(sa.Get(key) == "final" && sb.Get(key) == "final"), - TimeSpan.FromSeconds(25), cts.Token), - $"after a burst the clients settled on a='{sa.Get(key)}' b='{sb.Get(key)}', not the last write"); + TimeSpan.FromSeconds(25), + cts.Token + ), + $"after a burst the clients settled on a='{sa.Get(key)}' b='{sb.Get(key)}', not the last write" + ); - await sa.SetAsync(key, null, "Spa:String", PipeWireMetadataProxy.SubjectCore, cts.Token); + await sa.SetAsync( + key, + null, + "Spa:String", + PipeWireMetadataProxy.SubjectCore, + cts.Token + ); } } @@ -329,9 +436,11 @@ public async Task ARealProducerAndOurFilterAndAnObserver_AllRunAtOnce() string sourceName = Unique("pwnet_chain_src"); await using GstTestSource producer = await GstTestSource.StartAsync( - dsp.Context, sourceName, + dsp.Context, + sourceName, "audiotestsrc is-live=true wave=sine ! audio/x-raw,format=F32LE,channels=2,rate=48000", - "Audio/Source"); + "Audio/Source" + ); long cycles = 0; string dspName = Unique("pwnet_chain_dsp"); @@ -348,20 +457,26 @@ public async Task ARealProducerAndOurFilterAndAnObserver_AllRunAtOnce() // The observer - a completely separate connection - must see both of them, without either // having told it anything. - bool sawBoth = await EventuallyAsync(async () => - { - await observer.Registry.WaitForInitialEnumerationAsync(cts.Token); - PipeWireGraphSnapshot g = observer.Registry.Current; - return g.GetNode(producer.NodeId) is not null && g.GetNode(filterNodeId) is not null; - }, TimeSpan.FromSeconds(20), cts.Token); + bool sawBoth = await EventuallyAsync( + async () => + { + await observer.Registry.WaitForInitialEnumerationAsync(cts.Token); + PipeWireGraphSnapshot g = observer.Registry.Current; + return g.GetNode(producer.NodeId) is not null + && g.GetNode(filterNodeId) is not null; + }, + TimeSpan.FromSeconds(20), + cts.Token + ); if (!sawBoth) { PipeWireGraphSnapshot g = observer.Registry.Current; Assert.Fail( $"an independent observer did not see both: producer id={producer.NodeId} " - + $"seen={g.GetNode(producer.NodeId) is not null}, filter id={filterNodeId} " - + $"seen={g.GetNode(filterNodeId) is not null}, observer knows {g.Nodes.Length} nodes"); + + $"seen={g.GetNode(producer.NodeId) is not null}, filter id={filterNodeId} " + + $"seen={g.GetNode(filterNodeId) is not null}, observer knows {g.Nodes.Length} nodes" + ); } // Wired with pw-link, so the links are ones this library did not create and must still @@ -373,57 +488,77 @@ public async Task ARealProducerAndOurFilterAndAnObserver_AllRunAtOnce() ImmutableArray srcPorts = []; ImmutableArray dspPorts = []; - bool wired = await EventuallyAsync(async () => - { - await observer.Registry.WaitForInitialEnumerationAsync(cts.Token); - PipeWireGraphSnapshot g = observer.Registry.Current; - - srcPorts = - [ - .. g.GetPortsForNode(producer.NodeId) - .Where(p => p.PortDirection == PipeWirePortDirection.Out && !p.IsMonitor) - .OrderBy(p => p.PortName, StringComparer.Ordinal), - ]; - dspPorts = - [ - .. g.GetPortsForNode(filterNodeId) - .Where(p => p.PortDirection == PipeWirePortDirection.In) - .OrderBy(p => p.PortName, StringComparer.Ordinal), - ]; - - return srcPorts.Length >= 2 && dspPorts.Length >= 2; - }, TimeSpan.FromSeconds(20), cts.Token); + bool wired = await EventuallyAsync( + async () => + { + await observer.Registry.WaitForInitialEnumerationAsync(cts.Token); + PipeWireGraphSnapshot g = observer.Registry.Current; + + srcPorts = + [ + .. g.GetPortsForNode(producer.NodeId) + .Where(p => p.PortDirection == PipeWirePortDirection.Out && !p.IsMonitor) + .OrderBy(p => p.PortName, StringComparer.Ordinal), + ]; + dspPorts = + [ + .. g.GetPortsForNode(filterNodeId) + .Where(p => p.PortDirection == PipeWirePortDirection.In) + .OrderBy(p => p.PortName, StringComparer.Ordinal), + ]; + + return srcPorts.Length >= 2 && dspPorts.Length >= 2; + }, + TimeSpan.FromSeconds(20), + cts.Token + ); - Assert.IsTrue(wired, + Assert.IsTrue( + wired, $"producer id={producer.NodeId} published {srcPorts.Length} output port(s) and filter " - + $"id={filterNodeId} {dspPorts.Length} input port(s); both need two"); + + $"id={filterNodeId} {dspPorts.Length} input port(s); both need two" + ); for (int i = 0; i < 2; i++) { await PwTools.LinkAsync( srcPorts[i].PortId.ToString(System.Globalization.CultureInfo.InvariantCulture), dspPorts[i].PortId.ToString(System.Globalization.CultureInfo.InvariantCulture), - cts.Token); + cts.Token + ); } // Samples have to actually flow through the managed callback. Assert.IsTrue( - await EventuallyAsync(() => Task.FromResult(Interlocked.Read(ref cycles) > 20), - TimeSpan.FromSeconds(25), cts.Token), - $"the filter ran {Interlocked.Read(ref cycles)} cycles with a real producer linked into it"); + await EventuallyAsync( + () => Task.FromResult(Interlocked.Read(ref cycles) > 20), + TimeSpan.FromSeconds(25), + cts.Token + ), + $"the filter ran {Interlocked.Read(ref cycles)} cycles with a real producer linked into it" + ); // And the observer's view of the links has to match pw-link's exactly - a graph it played no // part in building. Assert.IsTrue( - await EventuallyAsync(async () => - { - await observer.Registry.WaitForInitialEnumerationAsync(cts.Token); - List<(uint Link, uint Output, uint Input)> theirs = await PwTools.ListLinksAsync(cts.Token); - ImmutableArray ours = observer.Registry.Current.Links; - - return theirs.Select(l => l.Link).Order().SequenceEqual(ours.Select(l => l.LinkId).Order()); - }, TimeSpan.FromSeconds(20), cts.Token), - "an observing client's link list did not match pw-link's"); + await EventuallyAsync( + async () => + { + await observer.Registry.WaitForInitialEnumerationAsync(cts.Token); + List<(uint Link, uint Output, uint Input)> theirs = + await PwTools.ListLinksAsync(cts.Token); + ImmutableArray ours = observer.Registry.Current.Links; + + return theirs + .Select(l => l.Link) + .Order() + .SequenceEqual(ours.Select(l => l.LinkId).Order()); + }, + TimeSpan.FromSeconds(20), + cts.Token + ), + "an observing client's link list did not match pw-link's" + ); } [TestMethod] @@ -440,42 +575,60 @@ public async Task ClientsJoiningAndLeavingWhileTheGraphChurns_NeverMissTheFinalS var created = new ConcurrentBag(); var faults = new ConcurrentQueue(); using var churning = new CancellationTokenSource(); - using var linked = CancellationTokenSource.CreateLinkedTokenSource(churning.Token, cts.Token); + using var linked = CancellationTokenSource.CreateLinkedTokenSource( + churning.Token, + cts.Token + ); - Task churn = Task.Run(async () => - { - try + Task churn = Task.Run( + async () => { - while (!linked.Token.IsCancellationRequested) + try { - PipeWireNode n = await churner.Registry.CreateVirtualSink("Churn") - .WithName(Unique("pwnet_churn")) - .ExecuteAsync(linked.Token); - - created.Add(n.NodeId); - try + while (!linked.Token.IsCancellationRequested) { - await Task.Delay(30, linked.Token); - } - finally - { - // Uncancellable, and in a finally: cancellation lands between the create and - // the remove often enough that a cancellable removal strands a node, which - // the assertion after the churn then reads as the graph having gone stale. - await churner.Registry.DestroyGlobalAsync(n.NodeId, CancellationToken.None); + PipeWireNode n = await churner + .Registry.CreateVirtualSink("Churn") + .WithName(Unique("pwnet_churn")) + .ExecuteAsync(linked.Token); + + created.Add(n.NodeId); + try + { + await Task.Delay(30, linked.Token); + } + finally + { + // Uncancellable, and in a finally: cancellation lands between the create and + // the remove often enough that a cancellable removal strands a node, which + // the assertion after the churn then reads as the graph having gone stale. + await churner.Registry.DestroyGlobalAsync( + n.NodeId, + CancellationToken.None + ); + } } } - } - catch (OperationCanceledException) { /* the churn is stopped by cancellation. */ } - catch (Exception ex) { faults.Enqueue($"churn: {ex.GetType().Name}: {ex.Message}"); } - }, cts.Token); + catch (OperationCanceledException) + { /* the churn is stopped by cancellation. */ + } + catch (Exception ex) + { + faults.Enqueue($"churn: {ex.GetType().Name}: {ex.Message}"); + } + }, + cts.Token + ); // Twenty short-lived clients arriving into the middle of that. for (int round = 0; round < 20; round++) { try { - await using Client transient = await ConnectAsync($"pwnet-transient-{round}", cts.Token); + await using Client transient = await ConnectAsync( + $"pwnet-transient-{round}", + cts.Token + ); Assert.IsTrue(transient.Registry.Current.Version > 0); } catch (Exception ex) @@ -492,13 +645,20 @@ public async Task ClientsJoiningAndLeavingWhileTheGraphChurns_NeverMissTheFinalS // nodes - each was removed, and a stale global would show up here as a node that is gone. await using Client after = await ConnectAsync("pwnet-after-churn", cts.Token); - bool clean = await EventuallyAsync(async () => - { - await after.Registry.WaitForInitialEnumerationAsync(cts.Token); - return !created.Any(id => after.Registry.Current.GetNode(id) is not null); - }, TimeSpan.FromSeconds(20), cts.Token); + bool clean = await EventuallyAsync( + async () => + { + await after.Registry.WaitForInitialEnumerationAsync(cts.Token); + return !created.Any(id => after.Registry.Current.GetNode(id) is not null); + }, + TimeSpan.FromSeconds(20), + cts.Token + ); - Assert.IsTrue(clean, "a client connecting after the churn still saw nodes that had been removed"); + Assert.IsTrue( + clean, + "a client connecting after the churn still saw nodes that had been removed" + ); } [TestMethod] @@ -518,7 +678,8 @@ public async Task OneClientDyingWithWorkInFlight_LeavesTheOthersHealthy() { Client doomed = await ConnectAsync($"pwnet-doomed-{round}", cts.Token); - PipeWireNode n = await doomed.Registry.CreateVirtualSink("Doomed") + PipeWireNode n = await doomed + .Registry.CreateVirtualSink("Doomed") .WithName(Unique("pwnet_doomed")) .ExecuteAsync(cts.Token); @@ -531,10 +692,19 @@ public async Task OneClientDyingWithWorkInFlight_LeavesTheOthersHealthy() Task reading = control.GetVolumeAsync(cts.Token); await doomed.DisposeAsync(); - try { await reading.WaitAsync(TimeSpan.FromSeconds(15), cts.Token); } - catch (ObjectDisposedException) { /* the expected outcome. */ } - catch (Exception e) when (e is InvalidOperationException or PipeWireException) { /* the connection went while the request was open. */ } - catch (OperationCanceledException) { /* likewise. */ } + try + { + await reading.WaitAsync(TimeSpan.FromSeconds(15), cts.Token); + } + catch (ObjectDisposedException) + { /* the expected outcome. */ + } + catch (Exception e) when (e is InvalidOperationException or PipeWireException) + { /* the connection went while the request was open. */ + } + catch (OperationCanceledException) + { /* likewise. */ + } await control.DisposeAsync(); } @@ -542,14 +712,20 @@ public async Task OneClientDyingWithWorkInFlight_LeavesTheOthersHealthy() // The survivor is still usable, and the daemon's cleanup of the dead clients' nodes has // reached it. Assert.IsTrue( - await EventuallyAsync(async () => - { - await survivor.Registry.WaitForInitialEnumerationAsync(cts.Token); - return !abandoned.Any(id => survivor.Registry.Current.GetNode(id) is not null); - }, TimeSpan.FromSeconds(25), cts.Token), - "nodes owned by clients that went away stayed in the surviving client's graph"); - - PipeWireNode fresh = await survivor.Registry.CreateVirtualSink("Survivor") + await EventuallyAsync( + async () => + { + await survivor.Registry.WaitForInitialEnumerationAsync(cts.Token); + return !abandoned.Any(id => survivor.Registry.Current.GetNode(id) is not null); + }, + TimeSpan.FromSeconds(25), + cts.Token + ), + "nodes owned by clients that went away stayed in the surviving client's graph" + ); + + PipeWireNode fresh = await survivor + .Registry.CreateVirtualSink("Survivor") .WithName(Unique("pwnet_survivor")) .ExecuteAsync(cts.Token); diff --git a/tests/PipeWire.NET.Tests/NativeAbiTests.cs b/tests/PipeWire.NET.Tests/NativeAbiTests.cs index 6d84515..cd209f9 100644 --- a/tests/PipeWire.NET.Tests/NativeAbiTests.cs +++ b/tests/PipeWire.NET.Tests/NativeAbiTests.cs @@ -26,21 +26,41 @@ public void EventTables_StartWithTheirVersionField() // Every spa events struct begins with a uint32 version the daemon reads before dispatching // anything. A field inserted ahead of it makes the daemon read a function pointer as the // version and then refuse - or call through whatever it found. - Assert.AreEqual(0, (int)Marshal.OffsetOf(nameof(pw_registry_events.version))); + Assert.AreEqual( + 0, + (int)Marshal.OffsetOf(nameof(pw_registry_events.version)) + ); Assert.AreEqual(0, (int)Marshal.OffsetOf(nameof(pw_core_events.version))); Assert.AreEqual(0, (int)Marshal.OffsetOf(nameof(pw_node_events.version))); - Assert.AreEqual(0, (int)Marshal.OffsetOf(nameof(pw_metadata_events.version))); + Assert.AreEqual( + 0, + (int)Marshal.OffsetOf(nameof(pw_metadata_events.version)) + ); Assert.AreEqual(0, (int)Marshal.OffsetOf(nameof(pw_proxy_events.version))); - Assert.AreEqual(0, (int)Marshal.OffsetOf(nameof(pw_profiler_events.version))); + Assert.AreEqual( + 0, + (int)Marshal.OffsetOf(nameof(pw_profiler_events.version)) + ); } [TestMethod] public void MethodTables_StartWithTheirVersionField() { - Assert.AreEqual(0, (int)Marshal.OffsetOf(nameof(pw_metadata_methods.version))); - Assert.AreEqual(0, (int)Marshal.OffsetOf(nameof(pw_profiler_methods.version))); - Assert.AreEqual(0, (int)Marshal.OffsetOf( - nameof(pw_security_context_methods.version))); + Assert.AreEqual( + 0, + (int)Marshal.OffsetOf(nameof(pw_metadata_methods.version)) + ); + Assert.AreEqual( + 0, + (int)Marshal.OffsetOf(nameof(pw_profiler_methods.version)) + ); + Assert.AreEqual( + 0, + (int) + Marshal.OffsetOf( + nameof(pw_security_context_methods.version) + ) + ); } [TestMethod] @@ -49,10 +69,14 @@ public void FunctionPointerFields_ArePointerSizedAndAligned() // A version field followed by pointers means the first pointer sits at the platform's // pointer alignment, not at four bytes. Getting that wrong shifts every callback by one // slot, which dispatches the wrong function rather than failing. - Assert.AreEqual(sizeof(nint), (int)Marshal.OffsetOf( - nameof(pw_metadata_methods.add_listener))); - Assert.AreEqual(sizeof(nint), (int)Marshal.OffsetOf( - nameof(pw_profiler_events.profile))); + Assert.AreEqual( + sizeof(nint), + (int)Marshal.OffsetOf(nameof(pw_metadata_methods.add_listener)) + ); + Assert.AreEqual( + sizeof(nint), + (int)Marshal.OffsetOf(nameof(pw_profiler_events.profile)) + ); } [TestMethod] @@ -61,10 +85,14 @@ public void SpaData_KeepsTheLayoutTheBufferPathReadsThrough() // Capture reads chunk->offset and chunk->size out of these to build a span over the // producer's memory. A drift here reads the wrong bytes as a frame. Assert.AreEqual(0, (int)Marshal.OffsetOf(nameof(spa_data.type))); - Assert.IsTrue((int)Marshal.OffsetOf(nameof(spa_data.maxsize)) - < (int)Marshal.OffsetOf(nameof(spa_data.data))); - Assert.IsTrue((int)Marshal.OffsetOf(nameof(spa_data.data)) - < (int)Marshal.OffsetOf(nameof(spa_data.chunk))); + Assert.IsTrue( + (int)Marshal.OffsetOf(nameof(spa_data.maxsize)) + < (int)Marshal.OffsetOf(nameof(spa_data.data)) + ); + Assert.IsTrue( + (int)Marshal.OffsetOf(nameof(spa_data.data)) + < (int)Marshal.OffsetOf(nameof(spa_data.chunk)) + ); Assert.AreEqual(0, (int)Marshal.OffsetOf(nameof(spa_chunk.offset))); Assert.AreEqual(4, (int)Marshal.OffsetOf(nameof(spa_chunk.size))); @@ -80,7 +108,10 @@ public void SpaBuffer_KeepsTheCountsAheadOfTheArraysTheyDescribe() Assert.AreEqual(0, (int)Marshal.OffsetOf(nameof(spa_buffer.n_metas))); Assert.AreEqual(4, (int)Marshal.OffsetOf(nameof(spa_buffer.n_datas))); Assert.AreEqual(8, (int)Marshal.OffsetOf(nameof(spa_buffer.metas))); - Assert.AreEqual(8 + sizeof(nint), (int)Marshal.OffsetOf(nameof(spa_buffer.datas))); + Assert.AreEqual( + 8 + sizeof(nint), + (int)Marshal.OffsetOf(nameof(spa_buffer.datas)) + ); Assert.AreEqual(8 + (2 * sizeof(nint)), sizeof(spa_buffer)); } @@ -90,7 +121,10 @@ public void PwBuffer_StartsWithTheSpaBufferAndItsUserData() // The dmabuf producer stores its pool index in user_data and reads it back on every // publish, so an offset drift hands the app a different buffer's surface. Assert.AreEqual(0, (int)Marshal.OffsetOf(nameof(pw_buffer.buffer))); - Assert.AreEqual(sizeof(nint), (int)Marshal.OffsetOf(nameof(pw_buffer.user_data))); + Assert.AreEqual( + sizeof(nint), + (int)Marshal.OffsetOf(nameof(pw_buffer.user_data)) + ); } [TestMethod] @@ -105,7 +139,10 @@ public void SpaMeta_AndItsHeader_KeepTheLayoutThePtsIsReadThrough() Assert.AreEqual(0, (int)Marshal.OffsetOf(nameof(spa_meta_header.flags))); Assert.AreEqual(4, (int)Marshal.OffsetOf(nameof(spa_meta_header.offset))); Assert.AreEqual(8, (int)Marshal.OffsetOf(nameof(spa_meta_header.pts))); - Assert.AreEqual(16, (int)Marshal.OffsetOf(nameof(spa_meta_header.dts_offset))); + Assert.AreEqual( + 16, + (int)Marshal.OffsetOf(nameof(spa_meta_header.dts_offset)) + ); } [TestMethod] @@ -128,19 +165,35 @@ public void TheInfoStructs_KeepTheFieldsTheParsersRead() // Each info struct arrives by pointer in a callback and is read field by field. The // change_mask in particular decides whether the rest is even valid. Assert.AreEqual(0, (int)Marshal.OffsetOf(nameof(pw_node_info.id))); - Assert.IsTrue((int)Marshal.OffsetOf(nameof(pw_node_info.change_mask)) - < (int)Marshal.OffsetOf(nameof(pw_node_info.props))); - Assert.IsTrue((int)Marshal.OffsetOf(nameof(pw_node_info.props)) - < (int)Marshal.OffsetOf(nameof(pw_node_info.@params))); + Assert.IsTrue( + (int)Marshal.OffsetOf(nameof(pw_node_info.change_mask)) + < (int)Marshal.OffsetOf(nameof(pw_node_info.props)) + ); + Assert.IsTrue( + (int)Marshal.OffsetOf(nameof(pw_node_info.props)) + < (int)Marshal.OffsetOf(nameof(pw_node_info.@params)) + ); Assert.AreEqual(0, (int)Marshal.OffsetOf(nameof(pw_port_info.id))); Assert.AreEqual(4, (int)Marshal.OffsetOf(nameof(pw_port_info.direction))); Assert.AreEqual(0, (int)Marshal.OffsetOf(nameof(pw_link_info.id))); - Assert.AreEqual(4, (int)Marshal.OffsetOf(nameof(pw_link_info.output_node_id))); - Assert.AreEqual(8, (int)Marshal.OffsetOf(nameof(pw_link_info.output_port_id))); - Assert.AreEqual(12, (int)Marshal.OffsetOf(nameof(pw_link_info.input_node_id))); - Assert.AreEqual(16, (int)Marshal.OffsetOf(nameof(pw_link_info.input_port_id))); + Assert.AreEqual( + 4, + (int)Marshal.OffsetOf(nameof(pw_link_info.output_node_id)) + ); + Assert.AreEqual( + 8, + (int)Marshal.OffsetOf(nameof(pw_link_info.output_port_id)) + ); + Assert.AreEqual( + 12, + (int)Marshal.OffsetOf(nameof(pw_link_info.input_node_id)) + ); + Assert.AreEqual( + 16, + (int)Marshal.OffsetOf(nameof(pw_link_info.input_port_id)) + ); } [TestMethod] @@ -160,7 +213,10 @@ public void SpaDictItem_IsAPairOfPointers() // properties read from the wrong addresses. Assert.AreEqual(2 * sizeof(nint), sizeof(spa_dict_item)); Assert.AreEqual(0, (int)Marshal.OffsetOf(nameof(spa_dict_item.key))); - Assert.AreEqual(sizeof(nint), (int)Marshal.OffsetOf(nameof(spa_dict_item.value))); + Assert.AreEqual( + sizeof(nint), + (int)Marshal.OffsetOf(nameof(spa_dict_item.value)) + ); } [TestMethod] diff --git a/tests/PipeWire.NET.Tests/NativeCallbackSignatureTests.cs b/tests/PipeWire.NET.Tests/NativeCallbackSignatureTests.cs index 72ea55a..1dd8b4b 100644 --- a/tests/PipeWire.NET.Tests/NativeCallbackSignatureTests.cs +++ b/tests/PipeWire.NET.Tests/NativeCallbackSignatureTests.cs @@ -40,9 +40,11 @@ public void EveryShippedAssembly_DisablesRuntimeMarshalling() { foreach (Assembly assembly in Shipped) { - Assert.IsNotNull(assembly.GetCustomAttribute(), + Assert.IsNotNull( + assembly.GetCustomAttribute(), $"{assembly.GetName().Name} does not disable runtime marshalling, so C's bool in the " - + "native callback signatures it shares with the core is not blittable there"); + + "native callback signatures it shares with the core is not blittable there" + ); } } @@ -53,16 +55,27 @@ public void EveryNativeCallback_HasASignatureTheRuntimeAccepts() foreach (Assembly assembly in Shipped) { - bool relaxed = assembly.GetCustomAttribute() is not null; + bool relaxed = + assembly.GetCustomAttribute() is not null; foreach (Type type in assembly.GetTypes()) { - foreach (MethodInfo method in type.GetMethods( - BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.DeclaredOnly)) + foreach ( + MethodInfo method in type.GetMethods( + BindingFlags.Static + | BindingFlags.Public + | BindingFlags.NonPublic + | BindingFlags.DeclaredOnly + ) + ) { - if (method.GetCustomAttribute() is null) continue; + if (method.GetCustomAttribute() is null) + continue; - IEnumerable types = method.GetParameters().Select(p => p.ParameterType).Append(method.ReturnType); + IEnumerable types = method + .GetParameters() + .Select(p => p.ParameterType) + .Append(method.ReturnType); foreach (Type t in types) { if (!Acceptable(t, relaxed)) @@ -72,22 +85,34 @@ public void EveryNativeCallback_HasASignatureTheRuntimeAccepts() } } - Assert.AreEqual(0, offenders.Count, + Assert.AreEqual( + 0, + offenders.Count, "these native callbacks would be refused when called, unwinding through the native frame " - + "that called them:\n" + string.Join("\n", offenders)); + + "that called them:\n" + + string.Join("\n", offenders) + ); } private static bool Acceptable(Type t, bool relaxed) { - if (t == typeof(void) || t.IsPointer || t.IsFunctionPointer || t.IsUnmanagedFunctionPointer) return true; - if (t == typeof(bool) || t == typeof(char)) return relaxed; - if (t.IsPrimitive || t.IsEnum) return true; - if (t.IsValueType) return IsUnmanagedStruct(t); + if (t == typeof(void) || t.IsPointer || t.IsFunctionPointer || t.IsUnmanagedFunctionPointer) + return true; + if (t == typeof(bool) || t == typeof(char)) + return relaxed; + if (t.IsPrimitive || t.IsEnum) + return true; + if (t.IsValueType) + return IsUnmanagedStruct(t); return false; } private static bool IsUnmanagedStruct(Type t) => t.GetFields(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic) - .All(f => f.FieldType.IsPointer || f.FieldType.IsPrimitive || f.FieldType.IsEnum - || (f.FieldType.IsValueType && IsUnmanagedStruct(f.FieldType))); + .All(f => + f.FieldType.IsPointer + || f.FieldType.IsPrimitive + || f.FieldType.IsEnum + || (f.FieldType.IsValueType && IsUnmanagedStruct(f.FieldType)) + ); } diff --git a/tests/PipeWire.NET.Tests/NativeDict.cs b/tests/PipeWire.NET.Tests/NativeDict.cs index 0825166..810ad7b 100644 --- a/tests/PipeWire.NET.Tests/NativeDict.cs +++ b/tests/PipeWire.NET.Tests/NativeDict.cs @@ -18,8 +18,8 @@ internal sealed unsafe class NativeDict : IDisposable public NativeDict(params (string Key, string? Value)[] pairs) { - _items = (spa_dict_item*)NativeMemory.AllocZeroed( - (nuint)(sizeof(spa_dict_item) * Math.Max(pairs.Length, 1))); + _items = (spa_dict_item*) + NativeMemory.AllocZeroed((nuint)(sizeof(spa_dict_item) * Math.Max(pairs.Length, 1))); for (int i = 0; i < pairs.Length; i++) { @@ -28,7 +28,12 @@ public NativeDict(params (string Key, string? Value)[] pairs) _items[i].value = pairs[i].Value is null ? null : (sbyte*)Utf8(pairs[i].Value!); } - Dict = new spa_dict { flags = 0, n_items = (uint)pairs.Length, items = _items }; + Dict = new spa_dict + { + flags = 0, + n_items = (uint)pairs.Length, + items = _items, + }; } public spa_dict Dict; @@ -45,7 +50,8 @@ private nint Utf8(string s) public void Dispose() { - foreach (nint p in _allocations) Marshal.FreeHGlobal(p); + foreach (nint p in _allocations) + Marshal.FreeHGlobal(p); NativeMemory.Free(_items); } } diff --git a/tests/PipeWire.NET.Tests/NativeHelperTests.cs b/tests/PipeWire.NET.Tests/NativeHelperTests.cs index 0e49a91..088bd55 100644 --- a/tests/PipeWire.NET.Tests/NativeHelperTests.cs +++ b/tests/PipeWire.NET.Tests/NativeHelperTests.cs @@ -1,8 +1,8 @@ -using Microsoft.Win32.SafeHandles; using System.Net.Sockets; using System.Runtime.InteropServices; using System.Runtime.Versioning; using Microsoft.VisualStudio.TestTools.UnitTesting; +using Microsoft.Win32.SafeHandles; using PipeWire.NET.Interop; using PipeWire.NET.Media; using PipeWire.NET.Spa; @@ -37,7 +37,10 @@ public void AnAsyncResult_IsRecognisedAndItsSequenceExtracted() Assert.AreEqual( NativeConstants.SPA_ASYNC_SEQ_MASK, - Native.SPA_RESULT_ASYNC_SEQ(NativeConstants.SPA_ASYNC_BIT | NativeConstants.SPA_ASYNC_SEQ_MASK)); + Native.SPA_RESULT_ASYNC_SEQ( + NativeConstants.SPA_ASYNC_BIT | NativeConstants.SPA_ASYNC_SEQ_MASK + ) + ); } [TestMethod] @@ -69,8 +72,10 @@ public void RemovingAHookFromTheMiddle_LeavesItsNeighboursJoined() Assert.IsTrue(a->link.next == &c->link, "a must now point past the removed hook"); Assert.IsTrue(c->link.prev == &a->link, "c must now point back past it"); - Assert.IsTrue(b->link.next is null && b->link.prev is null, - "the removed hook must not still reference the list"); + Assert.IsTrue( + b->link.next is null && b->link.prev is null, + "the removed hook must not still reference the list" + ); } finally { @@ -128,7 +133,8 @@ public void AHookThatWasNeverAttached_IsRemovedWithoutDereferencingAnything() [TestMethod] public void RemovingANullHook_IsANoOp() => Native.spa_hook_remove(null); - private static spa_hook* Alloc() => (spa_hook*)NativeMemory.AllocZeroed((nuint)sizeof(spa_hook)); + private static spa_hook* Alloc() => + (spa_hook*)NativeMemory.AllocZeroed((nuint)sizeof(spa_hook)); private static void Link(spa_hook* from, spa_hook* to) { @@ -159,10 +165,17 @@ public void DuplicatingAPlaneDescriptor_GivesADistinctOneThatOutlivesTheOriginal using SafeDescriptorHandle copy = plane.DuplicateFd(); Assert.IsFalse(copy.IsInvalid, "dup of a live descriptor failed"); - Assert.AreNotEqual((int)fd, copy.Descriptor, "dup returned the descriptor it was given"); + Assert.AreNotEqual( + (int)fd, + copy.Descriptor, + "dup returned the descriptor it was given" + ); file.Dispose(); - Assert.IsTrue(Fcntl(copy.Descriptor, FGetFd) >= 0, "the copy did not outlive the original"); + Assert.IsTrue( + Fcntl(copy.Descriptor, FGetFd) >= 0, + "the copy did not outlive the original" + ); } finally { @@ -225,30 +238,59 @@ public void IsListeningSocket_TellsListeningSocketsFromEverythingElse() Assert.Inconclusive("descriptors are a Linux concept here."); string path = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); - using (var listening = new Socket(AddressFamily.Unix, SocketType.Stream, ProtocolType.Unspecified)) + using ( + var listening = new Socket( + AddressFamily.Unix, + SocketType.Stream, + ProtocolType.Unspecified + ) + ) { listening.Bind(new UnixDomainSocketEndPoint(path)); listening.Listen(1); - Assert.IsTrue(FdInterop.IsListeningSocket((int)listening.SafeHandle.DangerousGetHandle()), - "a listening socket must read as one, or the constants are wrong for this architecture"); + Assert.IsTrue( + FdInterop.IsListeningSocket((int)listening.SafeHandle.DangerousGetHandle()), + "a listening socket must read as one, or the constants are wrong for this architecture" + ); } File.Delete(path); - using (var unbound = new Socket(AddressFamily.Unix, SocketType.Stream, ProtocolType.Unspecified)) + using ( + var unbound = new Socket( + AddressFamily.Unix, + SocketType.Stream, + ProtocolType.Unspecified + ) + ) { - Assert.IsFalse(FdInterop.IsListeningSocket((int)unbound.SafeHandle.DangerousGetHandle()), - "a socket that never listened is not a listening socket"); + Assert.IsFalse( + FdInterop.IsListeningSocket((int)unbound.SafeHandle.DangerousGetHandle()), + "a socket that never listened is not a listening socket" + ); } string file = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); - using (var stream = new FileStream(file, FileMode.CreateNew, FileAccess.ReadWrite, FileShare.None, 1, - FileOptions.DeleteOnClose)) + using ( + var stream = new FileStream( + file, + FileMode.CreateNew, + FileAccess.ReadWrite, + FileShare.None, + 1, + FileOptions.DeleteOnClose + ) + ) { - Assert.IsFalse(FdInterop.IsListeningSocket((int)stream.SafeFileHandle.DangerousGetHandle()), - "a regular file is not a socket at all"); + Assert.IsFalse( + FdInterop.IsListeningSocket((int)stream.SafeFileHandle.DangerousGetHandle()), + "a regular file is not a socket at all" + ); } - Assert.IsFalse(FdInterop.IsListeningSocket(-1), "a descriptor that cannot exist is not listening"); + Assert.IsFalse( + FdInterop.IsListeningSocket(-1), + "a descriptor that cannot exist is not listening" + ); } private const int FGetFd = 1; diff --git a/tests/PipeWire.NET.Tests/NativeLibcTests.cs b/tests/PipeWire.NET.Tests/NativeLibcTests.cs index efa8353..bfc685c 100644 --- a/tests/PipeWire.NET.Tests/NativeLibcTests.cs +++ b/tests/PipeWire.NET.Tests/NativeLibcTests.cs @@ -56,14 +56,23 @@ public void AnEventfdTimeline_SignalsAndIsTaken() // than blocking the test thread forever. That ordering is the producer/consumer shape // explicit sync uses. Assert.AreEqual(0, Descriptors.SignalEventfd(fd), "the signal was refused"); - Assert.AreEqual(SyncWaitOutcome.Reached, Descriptors.WaitEventfd(fd, TimeSpan.FromSeconds(1)).Outcome); + Assert.AreEqual( + SyncWaitOutcome.Reached, + Descriptors.WaitEventfd(fd, TimeSpan.FromSeconds(1)).Outcome + ); // And again, to show the counter was actually taken down rather than left set. Assert.AreEqual(0, Descriptors.SignalEventfd(fd), "the second signal was refused"); - Assert.AreEqual(SyncWaitOutcome.Reached, Descriptors.WaitEventfd(fd, TimeSpan.FromSeconds(1)).Outcome); + Assert.AreEqual( + SyncWaitOutcome.Reached, + Descriptors.WaitEventfd(fd, TimeSpan.FromSeconds(1)).Outcome + ); // Taken down to zero, so a third wait has nothing to take and must time out. - Assert.AreEqual(SyncWaitOutcome.TimedOut, Descriptors.WaitEventfd(fd, TimeSpan.FromMilliseconds(50)).Outcome); + Assert.AreEqual( + SyncWaitOutcome.TimedOut, + Descriptors.WaitEventfd(fd, TimeSpan.FromMilliseconds(50)).Outcome + ); } finally { @@ -96,7 +105,8 @@ public unsafe void AFailedLibcCall_ReportsItsErrno() Assert.AreEqual( EBADF, Marshal.GetLastPInvokeError(), - "errno was not captured - SetLastError is not in effect on the libc imports"); + "errno was not captured - SetLastError is not in effect on the libc imports" + ); } /// The EINTR the retry loops compare against is the one this system defines. @@ -118,17 +128,23 @@ public void TheEintrConstant_MatchesThisSystemsErrnoHeader() ]; string? header = candidates.FirstOrDefault(File.Exists); - if (header is null) Assert.Inconclusive("no errno header installed to check against."); + if (header is null) + Assert.Inconclusive("no errno header installed to check against."); Match match = Regex.Match( - File.ReadAllText(header!), @"^#define\s+EINTR\s+(\d+)", RegexOptions.Multiline); + File.ReadAllText(header!), + @"^#define\s+EINTR\s+(\d+)", + RegexOptions.Multiline + ); - if (!match.Success) Assert.Inconclusive($"EINTR is not defined directly in {header}."); + if (!match.Success) + Assert.Inconclusive($"EINTR is not defined directly in {header}."); Assert.AreEqual( NativeLibc.EINTR, int.Parse(match.Groups[1].Value, CultureInfo.InvariantCulture), - $"{header} defines a different EINTR than the retry loops compare against"); + $"{header} defines a different EINTR than the retry loops compare against" + ); } /// A duplicated descriptor is a new one, is usable, and carries close-on-exec. @@ -159,11 +175,15 @@ public void ADuplicatedDescriptor_IsDistinctAndCloseOnExec() Assert.AreEqual( FdCloexec, flags & FdCloexec, - "the duplicate does not carry close-on-exec, so it leaks into child processes"); + "the duplicate does not carry close-on-exec, so it leaks into child processes" + ); // Both still name the same object: signalling one is taken through the other. Assert.AreEqual(0, Descriptors.SignalEventfd(original)); - Assert.AreEqual(SyncWaitOutcome.Reached, Descriptors.WaitEventfd(copy, TimeSpan.FromSeconds(1)).Outcome); + Assert.AreEqual( + SyncWaitOutcome.Reached, + Descriptors.WaitEventfd(copy, TimeSpan.FromSeconds(1)).Outcome + ); } finally { @@ -209,7 +229,8 @@ public void TheGeneratedTimespec_IsTwoPointerWidthFields() Assert.AreEqual( 2 * IntPtr.Size, Unsafe.SizeOf(), - "timespec is not two pointer-width fields, so every time conversion is misreading it"); + "timespec is not two pointer-width fields, so every time conversion is misreading it" + ); } /// @@ -235,7 +256,10 @@ public void AnUnsignalledEventfdWait_TimesOutAfterItsDeadline() Assert.AreEqual(SyncWaitOutcome.TimedOut, wait.Outcome); Assert.AreEqual(NativeLibc.ETIME, wait.Errno); - Assert.IsTrue(elapsed >= 150, $"the wait returned after {elapsed}ms, before its 200ms deadline"); + Assert.IsTrue( + elapsed >= 150, + $"the wait returned after {elapsed}ms, before its 200ms deadline" + ); } finally { @@ -260,7 +284,11 @@ public void AnEventfdWaitOnAClosedDescriptor_Fails() Assert.AreEqual(SyncWaitOutcome.Failed, wait.Outcome); Assert.AreEqual(NativeLibc.EBADF, wait.Errno); - Assert.AreNotEqual(0, Descriptors.SignalEventfd(fd), "a signal into a closed descriptor reported success"); + Assert.AreNotEqual( + 0, + Descriptors.SignalEventfd(fd), + "a signal into a closed descriptor reported success" + ); } /// The kernel's own description separates an eventfd from any other descriptor. @@ -270,11 +298,18 @@ public void AnEventfd_IsRecognisedAndNothingElseIs() RequireLinux(); int fd = Descriptors.CreateEventfd(); - using SafeFileHandle devNull = File.OpenHandle("/dev/null", FileMode.Open, FileAccess.ReadWrite); + using SafeFileHandle devNull = File.OpenHandle( + "/dev/null", + FileMode.Open, + FileAccess.ReadWrite + ); try { Assert.IsTrue(Descriptors.IsEventfd(fd), "an eventfd was not recognised"); - Assert.IsFalse(Descriptors.IsEventfd((int)devNull.DangerousGetHandle()), "/dev/null was taken for an eventfd"); + Assert.IsFalse( + Descriptors.IsEventfd((int)devNull.DangerousGetHandle()), + "/dev/null was taken for an eventfd" + ); Assert.IsFalse(Descriptors.IsEventfd(-1)); } finally @@ -298,16 +333,30 @@ public void ASyncDescriptorOfNeitherKind_FailsTheWaitAndPromisesNoRelease() { RequireLinux(); - using SafeFileHandle devNull = File.OpenHandle("/dev/null", FileMode.Open, FileAccess.ReadWrite); + using SafeFileHandle devNull = File.OpenHandle( + "/dev/null", + FileMode.Open, + FileAccess.ReadWrite + ); int fd = (int)devNull.DangerousGetHandle(); - Assert.AreEqual(SyncTimelineKind.Unknown, SyncTimeline.Classify(fd, out uint handle, out _)); + Assert.AreEqual( + SyncTimelineKind.Unknown, + SyncTimeline.Classify(fd, out uint handle, out _) + ); Assert.AreEqual(0u, handle); SyncWait wait = SyncTimeline.Wait(fd, 1, TimeSpan.FromMilliseconds(200)); - Assert.AreEqual(SyncWaitOutcome.Failed, wait.Outcome, "a descriptor of neither kind was reported as reached"); + Assert.AreEqual( + SyncWaitOutcome.Failed, + wait.Outcome, + "a descriptor of neither kind was reported as reached" + ); Assert.AreNotEqual(0, wait.Errno, "the failure carries no errno to say why"); - Assert.IsFalse(SyncRelease.For(fd, 1).IsPending, "a release was promised on a descriptor nothing can signal"); + Assert.IsFalse( + SyncRelease.For(fd, 1).IsPending, + "a release was promised on a descriptor nothing can signal" + ); } } diff --git a/tests/PipeWire.NET.Tests/NativeLifetimeTests.cs b/tests/PipeWire.NET.Tests/NativeLifetimeTests.cs index d1b2280..49969b0 100644 --- a/tests/PipeWire.NET.Tests/NativeLifetimeTests.cs +++ b/tests/PipeWire.NET.Tests/NativeLifetimeTests.cs @@ -34,7 +34,10 @@ private static void RequireLinux() } private static async Task WaitForAsync( - PipeWireRegistry registry, Func until, CancellationToken ct) + PipeWireRegistry registry, + Func until, + CancellationToken ct + ) { await foreach (PipeWireGraphSnapshot graph in registry.WatchAsync(ct)) if (until(graph)) @@ -55,22 +58,28 @@ public async Task DestroyingTheContextWhileOwningProxies_DoesNotAbort() await reg.WaitForInitialEnumerationAsync(cts.Token); for (int i = 0; i < 4; i++) - await reg.CreateVirtualSink($"Owned {i}").WithName($"pwnet_nl_owned_{i}") - .ExecuteAsync(cts.Token); + await reg.CreateVirtualSink($"Owned {i}") + .WithName($"pwnet_nl_owned_{i}") + .ExecuteAsync(cts.Token); - await ctx.DisposeAsync(); // core disconnected, proxies still owned - await reg.DisposeAsync(); // unwinds afterwards + await ctx.DisposeAsync(); // core disconnected, proxies still owned + await reg.DisposeAsync(); // unwinds afterwards // Surviving is necessary but not sufficient: prove the process can still reach the daemon, // which a corrupted connection or a damaged loop would prevent. - Assert.IsTrue(await CanStillConnectAsync(cts.Token), - "the process survived but can no longer talk to the daemon"); + Assert.IsTrue( + await CanStillConnectAsync(cts.Token), + "the process survived but can no longer talk to the daemon" + ); } /// Opens a fresh connection and reads the graph, as evidence the process is healthy. private static async Task CanStillConnectAsync(CancellationToken ct) { - await using var probe = new PipeWireContext("pwnet-nl-probe", ConsoleTestLoggerFactory.Instance); + await using var probe = new PipeWireContext( + "pwnet-nl-probe", + ConsoleTestLoggerFactory.Instance + ); await probe.StartAsync(ct); await using var reg = new PipeWireRegistry(probe); await reg.WaitForInitialEnumerationAsync(ct); @@ -93,8 +102,10 @@ public async Task ProxiesReleasedByFinalizationAfterTheContextIsGone_DoNotAbort( await Task.Delay(500, cts.Token); - Assert.IsTrue(await CanStillConnectAsync(cts.Token), - "finalizing orphaned proxies left the process unable to reach the daemon"); + Assert.IsTrue( + await CanStillConnectAsync(cts.Token), + "finalizing orphaned proxies left the process unable to reach the daemon" + ); } [TestMethod] @@ -117,13 +128,18 @@ public async Task ALingeringNodeSurvivesItsCreatorBeingFinalized_NotJustDisposed await Task.Delay(500, cts.Token); - await using var observer = new PipeWireContext("pwnet-linger-finalize-obs", ConsoleTestLoggerFactory.Instance); + await using var observer = new PipeWireContext( + "pwnet-linger-finalize-obs", + ConsoleTestLoggerFactory.Instance + ); await observer.StartAsync(cts.Token); await using var registry = new PipeWireRegistry(observer); await registry.WaitForInitialEnumerationAsync(cts.Token); - Assert.IsNotNull(registry.Current.GetNode(lingering), - "a lingering node did not survive its creator being finalized rather than disposed"); + Assert.IsNotNull( + registry.Current.GetNode(lingering), + "a lingering node did not survive its creator being finalized rather than disposed" + ); // And it is a real object, not a stale registry entry: another client can still take it down. await registry.DestroyGlobalAsync(lingering, cts.Token); @@ -142,12 +158,16 @@ public async Task AControlDroppedWithoutDisposing_IsStillCollectable() // and a rooted object is never finalized: the release that frees the handle is waiting on // the handle being freed. A caller who forgets a using then leaks the control, its proxy and // its listener for the life of the process, and no descriptor or daemon-side count shows it. - await using var ctx = new PipeWireContext("pwnet-control-collect", ConsoleTestLoggerFactory.Instance); + await using var ctx = new PipeWireContext( + "pwnet-control-collect", + ConsoleTestLoggerFactory.Instance + ); await ctx.StartAsync(cts.Token); await using var registry = new PipeWireRegistry(ctx); await registry.WaitForInitialEnumerationAsync(cts.Token); - PipeWireNode node = await registry.CreateVirtualSink("Collect") + PipeWireNode node = await registry + .CreateVirtualSink("Collect") .WithName($"pwnet_collect_{Environment.ProcessId}_{Random.Shared.Next():x}") .ExecuteAsync(cts.Token); @@ -158,15 +178,18 @@ public async Task AControlDroppedWithoutDisposing_IsStillCollectable() GC.Collect(); GC.WaitForPendingFinalizers(); - Assert.IsFalse(weak.IsAlive, - "a control dropped without disposing is still rooted, so it and its proxy never go away"); + Assert.IsFalse( + weak.IsAlive, + "a control dropped without disposing is still rooted, so it and its proxy never go away" + ); await registry.DestroyGlobalAsync(node.NodeId, cts.Token); } /// Binds a control and returns only a weak reference to it. [System.Runtime.CompilerServices.MethodImpl( - System.Runtime.CompilerServices.MethodImplOptions.NoInlining)] + System.Runtime.CompilerServices.MethodImplOptions.NoInlining + )] private static WeakReference BindAndAbandon(PipeWireRegistry registry, uint nodeId) { PipeWireNodeProxy control = registry.BindNode(nodeId); @@ -185,7 +208,10 @@ public async Task ManyContextsCreatedAndDisposed_DoNotAccumulateProcessResources // in the same process, that is still using PipeWire. for (int i = 0; i < 10; i++) { - await using var warm = new PipeWireContext($"pwnet-init-warm-{i}", ConsoleTestLoggerFactory.Instance); + await using var warm = new PipeWireContext( + $"pwnet-init-warm-{i}", + ConsoleTestLoggerFactory.Instance + ); await warm.StartAsync(cts.Token); } @@ -200,7 +226,10 @@ public async Task ManyContextsCreatedAndDisposed_DoNotAccumulateProcessResources const int Rounds = 100; for (int i = 0; i < Rounds; i++) { - await using var ctx = new PipeWireContext($"pwnet-init-{i}", ConsoleTestLoggerFactory.Instance); + await using var ctx = new PipeWireContext( + $"pwnet-init-{i}", + ConsoleTestLoggerFactory.Instance + ); await ctx.StartAsync(cts.Token); } @@ -214,12 +243,17 @@ public async Task ManyContextsCreatedAndDisposed_DoNotAccumulateProcessResources Console.Error.WriteLine( $"{Rounds} contexts: fds {fdsBefore} -> {fdsAfter}, threads {threadsBefore} -> {threadsAfter}, " - + $"rss {rssBefore / 1024}KB -> {rssAfter / 1024}KB"); - - Assert.IsTrue(fdsAfter <= fdsBefore + 2, - $"file descriptors grew {fdsBefore} -> {fdsAfter} over {Rounds} contexts"); - Assert.IsTrue(threadsAfter <= threadsBefore + 1, - $"threads grew {threadsBefore} -> {threadsAfter}, so loop threads are being stranded"); + + $"rss {rssBefore / 1024}KB -> {rssAfter / 1024}KB" + ); + + Assert.IsTrue( + fdsAfter <= fdsBefore + 2, + $"file descriptors grew {fdsBefore} -> {fdsAfter} over {Rounds} contexts" + ); + Assert.IsTrue( + threadsAfter <= threadsBefore + 1, + $"threads grew {threadsBefore} -> {threadsAfter}, so loop threads are being stranded" + ); // Resident memory is the coarse backstop; the descriptor and thread counts above are the // precise ones, and they are exact. What this catches is a per-pw_init allocation never @@ -231,8 +265,10 @@ public async Task ManyContextsCreatedAndDisposed_DoNotAccumulateProcessResources // A megabyte still fails a leak of 10KB a round, and stops a glibc upgrade reading as one. const long RetainedBound = 1024 * 1024; - Assert.IsTrue(rssAfter - rssBefore < RetainedBound, - $"resident memory grew {(rssAfter - rssBefore) / 1024}KB over {Rounds} contexts"); + Assert.IsTrue( + rssAfter - rssBefore < RetainedBound, + $"resident memory grew {(rssAfter - rssBefore) / 1024}KB over {Rounds} contexts" + ); } private static int OpenFileDescriptors() => Directory.GetFiles("/proc/self/fd").Length; @@ -245,11 +281,13 @@ private static int ReadStatus(string key) { foreach (string line in File.ReadLines("/proc/self/status")) { - if (!line.StartsWith(key, StringComparison.Ordinal)) continue; + if (!line.StartsWith(key, StringComparison.Ordinal)) + continue; ReadOnlySpan rest = line.AsSpan(key.Length).Trim(); int space = rest.IndexOf(' '); - if (space > 0) rest = rest[..space]; + if (space > 0) + rest = rest[..space]; return int.Parse(rest, System.Globalization.CultureInfo.InvariantCulture); } @@ -259,16 +297,21 @@ private static int ReadStatus(string key) /// Creates a lingering node and drops every managed reference without disposing. [System.Runtime.CompilerServices.MethodImpl( - System.Runtime.CompilerServices.MethodImplOptions.NoInlining)] + System.Runtime.CompilerServices.MethodImplOptions.NoInlining + )] private static async Task CreateLingeringAndAbandonAsync(CancellationToken ct) { - var context = new PipeWireContext("pwnet-linger-finalize", ConsoleTestLoggerFactory.Instance); + var context = new PipeWireContext( + "pwnet-linger-finalize", + ConsoleTestLoggerFactory.Instance + ); await context.StartAsync(ct); var registry = new PipeWireRegistry(context); await registry.WaitForInitialEnumerationAsync(ct); - PipeWireNode node = await registry.CreateVirtualSink("LingerFinalize") + PipeWireNode node = await registry + .CreateVirtualSink("LingerFinalize") .WithName($"pwnet_linger_fin_{Environment.ProcessId}_{Random.Shared.Next():x}") .WithLinger() .ExecuteAsync(ct); @@ -279,7 +322,10 @@ private static async Task CreateLingeringAndAbandonAsync(CancellationToken } private static async Task WaitForRemovedAsync( - PipeWireRegistry registry, uint id, CancellationToken ct) + PipeWireRegistry registry, + uint id, + CancellationToken ct + ) { while (registry.Current.GetNode(id) is not null) { @@ -297,8 +343,9 @@ private static async Task CreateAndAbandonAsync(CancellationToken ct) await reg.WaitForInitialEnumerationAsync(ct); for (int i = 0; i < 3; i++) - await reg.CreateVirtualSink($"Abandoned {i}").WithName($"pwnet_nl_ab_{i}") - .ExecuteAsync(ct); + await reg.CreateVirtualSink($"Abandoned {i}") + .WithName($"pwnet_nl_ab_{i}") + .ExecuteAsync(ct); await ctx.DisposeAsync(); } @@ -318,7 +365,8 @@ public async Task TheLoopOutlivesEveryProxyThatReferencesIt() await reg.WaitForInitialEnumerationAsync(cts.Token); PipeWireNode node = await reg.CreateVirtualSink("LoopRef") - .WithName("pwnet_nl_loopref").ExecuteAsync(cts.Token); + .WithName("pwnet_nl_loopref") + .ExecuteAsync(cts.Token); Assert.IsNotNull(reg.Current.GetNode(node.NodeId)); await ctx.DisposeAsync(); @@ -327,8 +375,10 @@ public async Task TheLoopOutlivesEveryProxyThatReferencesIt() // would fault rather than throw. await reg.DisposeAsync(); - Assert.IsTrue(await CanStillConnectAsync(cts.Token), - "tearing down proxies after the context left the process unusable"); + Assert.IsTrue( + await CanStillConnectAsync(cts.Token), + "tearing down proxies after the context left the process unusable" + ); } [TestMethod] @@ -338,7 +388,10 @@ public async Task AForeignObjectIsNeverDestroyedThroughAProxyWeDoNotOwn() PwTools.Require(); using var cts = new CancellationTokenSource(Budget); - await using var ctx = new PipeWireContext("pwnet-nl-foreign", ConsoleTestLoggerFactory.Instance); + await using var ctx = new PipeWireContext( + "pwnet-nl-foreign", + ConsoleTestLoggerFactory.Instance + ); await ctx.StartAsync(cts.Token); await using var reg = new PipeWireRegistry(ctx); await reg.WaitForInitialEnumerationAsync(cts.Token); @@ -346,10 +399,16 @@ public async Task AForeignObjectIsNeverDestroyedThroughAProxyWeDoNotOwn() // A node published by another process. We hold no proxy for it, so our disposal must not // attempt pw_proxy_destroy against something we never created - the double-destruction // that an ownership abstraction hiding the distinction would cause. - await using PwTools.Loopback loop = await PwTools.StartLoopbackAsync("pwnet_nl_foreign", cts.Token); + await using PwTools.Loopback loop = await PwTools.StartLoopbackAsync( + "pwnet_nl_foreign", + cts.Token + ); PipeWireGraphSnapshot graph = await WaitForAsync( - reg, g => g.Nodes.Any(n => n.NodeName == "input.pwnet_nl_foreign"), cts.Token); + reg, + g => g.Nodes.Any(n => n.NodeName == "input.pwnet_nl_foreign"), + cts.Token + ); uint foreignId = graph.Nodes.First(n => n.NodeName == "input.pwnet_nl_foreign").NodeId; Assert.IsNotNull(reg.Current.GetNode(foreignId)); @@ -361,14 +420,22 @@ public async Task AForeignObjectIsNeverDestroyedThroughAProxyWeDoNotOwn() // the assertion in proxy.c and abort. await reg.DisposeAsync(); - Assert.IsTrue(await ForeignNodeStillPresentAsync("input.pwnet_nl_foreign", cts.Token), - "disposing our registry destroyed a node belonging to another process"); + Assert.IsTrue( + await ForeignNodeStillPresentAsync("input.pwnet_nl_foreign", cts.Token), + "disposing our registry destroyed a node belonging to another process" + ); } /// Re-reads the graph from a fresh connection to see whether a node survived. - private static async Task ForeignNodeStillPresentAsync(string nodeName, CancellationToken ct) + private static async Task ForeignNodeStillPresentAsync( + string nodeName, + CancellationToken ct + ) { - await using var probe = new PipeWireContext("pwnet-nl-foreign-probe", ConsoleTestLoggerFactory.Instance); + await using var probe = new PipeWireContext( + "pwnet-nl-foreign-probe", + ConsoleTestLoggerFactory.Instance + ); await probe.StartAsync(ct); await using var reg = new PipeWireRegistry(probe); await reg.WaitForInitialEnumerationAsync(ct); @@ -388,15 +455,21 @@ public async Task DestroyingTheContextWhileAStreamIsConnected_DoesNotAbandonIt() await ctx.StartAsync(cts.Token); var output = new PipeWireAudioOutput(ctx, "pwnet_nl_stream"); - output.FillSamples += (_, s, _, _, _) => { s.Clear(); return s.Length; }; + output.FillSamples += (_, s, _, _, _) => + { + s.Clear(); + return s.Length; + }; output.Connect(autoConnect: false); await Task.Delay(300, cts.Token); await ctx.DisposeAsync(); await output.DisposeAsync(); - Assert.IsTrue(await CanStillConnectAsync(cts.Token), - "tearing the context down under a live stream left the process unusable"); + Assert.IsTrue( + await CanStillConnectAsync(cts.Token), + "tearing the context down under a live stream left the process unusable" + ); } [TestMethod] @@ -416,7 +489,8 @@ public async Task ASnapshotOutlivingTheNativeObjectsItDescribes_IsStillReadable( await reg.WaitForInitialEnumerationAsync(cts.Token); PipeWireNode node = await reg.CreateVirtualSink("Outlive") - .WithName("pwnet_nl_outlive").ExecuteAsync(cts.Token); + .WithName("pwnet_nl_outlive") + .ExecuteAsync(cts.Token); nodeId = node.NodeId; held = await WaitForAsync(reg, g => g.GetPortsForNode(nodeId).Length == 4, cts.Token); @@ -441,7 +515,10 @@ public async Task EveryGraphChangedNotification_SeesTheSnapshotItDescribes() { RequireLinux(); using var cts = new CancellationTokenSource(Budget); - await using var ctx = new PipeWireContext("pwnet-nl-ordering", ConsoleTestLoggerFactory.Instance); + await using var ctx = new PipeWireContext( + "pwnet-nl-ordering", + ConsoleTestLoggerFactory.Instance + ); await ctx.StartAsync(cts.Token); await using var reg = new PipeWireRegistry(ctx); await reg.WaitForInitialEnumerationAsync(cts.Token); @@ -456,18 +533,23 @@ public async Task EveryGraphChangedNotification_SeesTheSnapshotItDescribes() PipeWireGraphSnapshot current = sender.Current; // Current is either this snapshot or a newer one, never an older one. - if (current.Version < snapshot.Version) Interlocked.Increment(ref violations); + if (current.Version < snapshot.Version) + Interlocked.Increment(ref violations); }; PipeWireNode node = await reg.CreateVirtualSink("Order") - .WithName("pwnet_nl_order").ExecuteAsync(cts.Token); + .WithName("pwnet_nl_order") + .ExecuteAsync(cts.Token); await WaitForAsync(reg, g => g.GetPortsForNode(node.NodeId).Length == 4, cts.Token); await reg.DestroyGlobalAsync(node.NodeId, cts.Token); await WaitForAsync(reg, g => g.GetNode(node.NodeId) is null, cts.Token); Assert.IsTrue(Volatile.Read(ref seen) > 0, "no GraphChanged fired, so nothing was checked"); - Assert.AreEqual(0, Volatile.Read(ref violations), - "a handler observed Current older than the snapshot it was handed"); + Assert.AreEqual( + 0, + Volatile.Read(ref violations), + "a handler observed Current older than the snapshot it was handed" + ); } [TestMethod] @@ -475,7 +557,10 @@ public async Task CreatingWhileTheInitialEnumerationIsStillArriving_Succeeds() { RequireLinux(); using var cts = new CancellationTokenSource(Budget); - await using var ctx = new PipeWireContext("pwnet-nl-burst", ConsoleTestLoggerFactory.Instance); + await using var ctx = new PipeWireContext( + "pwnet-nl-burst", + ConsoleTestLoggerFactory.Instance + ); await ctx.StartAsync(cts.Token); // Deliberately no WaitForInitialEnumerationAsync first. Creation has to work while the @@ -484,7 +569,8 @@ public async Task CreatingWhileTheInitialEnumerationIsStillArriving_Succeeds() await using var reg = new PipeWireRegistry(ctx); Task creation = reg.CreateVirtualSink("Burst") - .WithName("pwnet_nl_burst").ExecuteAsync(cts.Token); + .WithName("pwnet_nl_burst") + .ExecuteAsync(cts.Token); Task enumeration = reg.WaitForInitialEnumerationAsync(cts.Token); await Task.WhenAll(creation, enumeration); @@ -493,7 +579,11 @@ public async Task CreatingWhileTheInitialEnumerationIsStillArriving_Succeeds() // The object must be complete, not half-built by a snapshot published mid-burst. PipeWireNode? live = reg.Current.GetNode(node.NodeId); Assert.IsNotNull(live, "a node created during the burst is missing from the graph"); - Assert.AreEqual("pwnet_nl_burst", live!.NodeName, "the node arrived without its properties"); + Assert.AreEqual( + "pwnet_nl_burst", + live!.NodeName, + "the node arrived without its properties" + ); Assert.AreEqual("Audio/Sink", live.MediaClass); await reg.DestroyGlobalAsync(node.NodeId, cts.Token); @@ -504,16 +594,22 @@ public async Task CreatingBeforeTheRegistryHasSeenAnything_Succeeds() { RequireLinux(); using var cts = new CancellationTokenSource(Budget); - await using var ctx = new PipeWireContext("pwnet-nl-immediate", ConsoleTestLoggerFactory.Instance); + await using var ctx = new PipeWireContext( + "pwnet-nl-immediate", + ConsoleTestLoggerFactory.Instance + ); await ctx.StartAsync(cts.Token); // Create on the line after construction, before any global can have arrived. await using var reg = new PipeWireRegistry(ctx); PipeWireNode node = await reg.CreateVirtualSink("Immediate") - .WithName("pwnet_nl_immediate").ExecuteAsync(cts.Token); + .WithName("pwnet_nl_immediate") + .ExecuteAsync(cts.Token); - Assert.IsNotNull(reg.Current.GetNode(node.NodeId), - "creation must not depend on enumeration having finished"); + Assert.IsNotNull( + reg.Current.GetNode(node.NodeId), + "creation must not depend on enumeration having finished" + ); await reg.DestroyGlobalAsync(node.NodeId, cts.Token); } diff --git a/tests/PipeWire.NET.Tests/ObjectIdentityTests.cs b/tests/PipeWire.NET.Tests/ObjectIdentityTests.cs index ea7049b..0c4a763 100644 --- a/tests/PipeWire.NET.Tests/ObjectIdentityTests.cs +++ b/tests/PipeWire.NET.Tests/ObjectIdentityTests.cs @@ -13,14 +13,28 @@ namespace PipeWire.NET.Tests; public sealed class ObjectIdentityTests : PipeWireTestBase { private static PipeWireNode Node(uint id, ulong? serial, string? name = "n") => - new(id, name, null, null, null, PipeWirePermissions.None, 3, + new( + id, + name, + null, + null, + null, + PipeWirePermissions.None, + 3, Properties: serial is null - ? PipeWireProperties.FromItems(new Dictionary { ["node.name"] = name ?? "n" }) - : PipeWireProperties.FromItems(new Dictionary - { - ["node.name"] = name ?? "n", - [PipeWireKeys.PW_KEY_OBJECT_SERIAL] = serial.Value.ToString(CultureInfo.InvariantCulture), - })); + ? PipeWireProperties.FromItems( + new Dictionary { ["node.name"] = name ?? "n" } + ) + : PipeWireProperties.FromItems( + new Dictionary + { + ["node.name"] = name ?? "n", + [PipeWireKeys.PW_KEY_OBJECT_SERIAL] = serial.Value.ToString( + CultureInfo.InvariantCulture + ), + } + ) + ); private static PipeWireGraphSnapshot Graph(params PipeWireNode[] nodes) => new(1, nodes, [], []); @@ -31,8 +45,10 @@ public void AnObjectWhoseIdWasReused_IsNotStillInTheGraph() PipeWireNode held = Node(42, serial: 900); PipeWireGraphSnapshot after = Graph(Node(42, serial: 1500, name: "somebody else")); - Assert.IsFalse(held.IsStillIn(after), - "the id came back attached to a different object, so the held one is gone"); + Assert.IsFalse( + held.IsStillIn(after), + "the id came back attached to a different object, so the held one is gone" + ); Assert.IsTrue(Node(42, serial: 900).IsStillIn(Graph(Node(42, serial: 900)))); } diff --git a/tests/PipeWire.NET.Tests/ParameterAndMetadataTests.cs b/tests/PipeWire.NET.Tests/ParameterAndMetadataTests.cs index c2f4e64..f4a3fc7 100644 --- a/tests/PipeWire.NET.Tests/ParameterAndMetadataTests.cs +++ b/tests/PipeWire.NET.Tests/ParameterAndMetadataTests.cs @@ -25,7 +25,9 @@ private static void RequireLinux() } private static async Task<(PipeWireContext Context, PipeWireRegistry Registry)> ConnectAsync( - string name, CancellationToken cancellationToken) + string name, + CancellationToken cancellationToken + ) { var context = new PipeWireContext(name, ConsoleTestLoggerFactory.Instance); await context.StartAsync(cancellationToken); @@ -36,20 +38,26 @@ private static void RequireLinux() // Unique names per process: the session manager restores volume and mute by node.name, // so reusing a fixed name lets a previous leg's values race the assertions below. - private static string Unique(string p) => $"{p}_{Environment.ProcessId}_{Random.Shared.Next():x}"; + private static string Unique(string p) => + $"{p}_{Environment.ProcessId}_{Random.Shared.Next():x}"; [TestMethod] public async Task AVirtualSink_ReportsAVolumeAndAcceptsANewOne() { RequireLinux(); using var cts = new CancellationTokenSource(Budget); - (PipeWireContext ctx, PipeWireRegistry registry) = await ConnectAsync("pwnet-params", cts.Token); + (PipeWireContext ctx, PipeWireRegistry registry) = await ConnectAsync( + "pwnet-params", + cts.Token + ); await using (ctx) await using (registry) { // A node we create ourselves, so nothing in the session is disturbed by changing it. - PipeWireNode node = await registry.CreateVirtualSink("Params") - .WithName(Unique("pwnet_param_sink")).ExecuteAsync(cts.Token); + PipeWireNode node = await registry + .CreateVirtualSink("Params") + .WithName(Unique("pwnet_param_sink")) + .ExecuteAsync(cts.Token); await using PipeWireNodeProxy control = registry.BindNode(node.NodeId); @@ -82,12 +90,17 @@ public async Task MuteAndChannelVolumes_RoundTripThroughTheDaemon() { RequireLinux(); using var cts = new CancellationTokenSource(Budget); - (PipeWireContext ctx, PipeWireRegistry registry) = await ConnectAsync("pwnet-mute", cts.Token); + (PipeWireContext ctx, PipeWireRegistry registry) = await ConnectAsync( + "pwnet-mute", + cts.Token + ); await using (ctx) await using (registry) { - PipeWireNode node = await registry.CreateVirtualSink("Mute") - .WithName(Unique("pwnet_mute_sink")).ExecuteAsync(cts.Token); + PipeWireNode node = await registry + .CreateVirtualSink("Mute") + .WithName(Unique("pwnet_mute_sink")) + .ExecuteAsync(cts.Token); await using PipeWireNodeProxy control = registry.BindNode(node.NodeId); @@ -115,9 +128,11 @@ public async Task MuteAndChannelVolumes_RoundTripThroughTheDaemon() await control.SetChannelVolumesAsync([0.4f, 0.6f], cts.Token); after = await control.GetChannelVolumesAsync(cts.Token); - if (after.Length == 2 + if ( + after.Length == 2 && Math.Abs(after[0] - 0.4f) <= 0.01f - && Math.Abs(after[1] - 0.6f) <= 0.01f) + && Math.Abs(after[1] - 0.6f) <= 0.01f + ) { break; } @@ -138,12 +153,17 @@ public async Task PropertyInfo_NamesTheControlsTheNodeActuallyHas() { RequireLinux(); using var cts = new CancellationTokenSource(Budget); - (PipeWireContext ctx, PipeWireRegistry registry) = await ConnectAsync("pwnet-propinfo", cts.Token); + (PipeWireContext ctx, PipeWireRegistry registry) = await ConnectAsync( + "pwnet-propinfo", + cts.Token + ); await using (ctx) await using (registry) { - PipeWireNode node = await registry.CreateVirtualSink("PropInfo") - .WithName("pwnet_propinfo_sink").ExecuteAsync(cts.Token); + PipeWireNode node = await registry + .CreateVirtualSink("PropInfo") + .WithName("pwnet_propinfo_sink") + .ExecuteAsync(cts.Token); await using PipeWireNodeProxy control = registry.BindNode(node.NodeId); @@ -151,9 +171,10 @@ public async Task PropertyInfo_NamesTheControlsTheNodeActuallyHas() Assert.IsTrue(info.Length > 0, "a sink must describe the properties it supports"); // Each entry says which property it describes. - uint[] described = [.. info.Select(o => o[(uint)SpaPropInfo.Id]) - .OfType() - .Select(id => id.Value)]; + uint[] described = + [ + .. info.Select(o => o[(uint)SpaPropInfo.Id]).OfType().Select(id => id.Value), + ]; CollectionAssert.Contains(described, (uint)SpaProp.Volume); CollectionAssert.Contains(described, (uint)SpaProp.Mute); @@ -168,12 +189,17 @@ public async Task AskingForAParameterANodeDoesNotHave_IsRefusedByTheDaemon() { RequireLinux(); using var cts = new CancellationTokenSource(Budget); - (PipeWireContext ctx, PipeWireRegistry registry) = await ConnectAsync("pwnet-noparam", cts.Token); + (PipeWireContext ctx, PipeWireRegistry registry) = await ConnectAsync( + "pwnet-noparam", + cts.Token + ); await using (ctx) await using (registry) { - PipeWireNode node = await registry.CreateVirtualSink("NoParam") - .WithName("pwnet_noparam_sink").ExecuteAsync(cts.Token); + PipeWireNode node = await registry + .CreateVirtualSink("NoParam") + .WithName("pwnet_noparam_sink") + .ExecuteAsync(cts.Token); await using PipeWireNodeProxy control = registry.BindNode(node.NodeId); @@ -182,11 +208,14 @@ public async Task AskingForAParameterANodeDoesNotHave_IsRefusedByTheDaemon() // A node has no profiles - that is a device parameter - and PipeWire answers an // unsupported parameter with an error rather than an empty result. Check what the // object advertises first, which is what CanRead exists for. - Assert.IsFalse(control.CanRead(SpaParamType.EnumProfile), - "a node must not advertise a device parameter"); + Assert.IsFalse( + control.CanRead(SpaParamType.EnumProfile), + "a node must not advertise a device parameter" + ); - await Assert.ThrowsExactlyAsync( - async () => await control.EnumerateParametersAsync(SpaParamType.EnumProfile, cts.Token)); + await Assert.ThrowsExactlyAsync(async () => + await control.EnumerateParametersAsync(SpaParamType.EnumProfile, cts.Token) + ); await registry.DestroyGlobalAsync(node.NodeId, cts.Token); } @@ -197,12 +226,17 @@ public async Task BindingSomethingThatIsNotThatKind_IsRefusedBeforeTouchingTheDa { RequireLinux(); using var cts = new CancellationTokenSource(Budget); - (PipeWireContext ctx, PipeWireRegistry registry) = await ConnectAsync("pwnet-bindkind", cts.Token); + (PipeWireContext ctx, PipeWireRegistry registry) = await ConnectAsync( + "pwnet-bindkind", + cts.Token + ); await using (ctx) await using (registry) { - PipeWireNode node = await registry.CreateVirtualSink("BindKind") - .WithName("pwnet_bindkind_sink").ExecuteAsync(cts.Token); + PipeWireNode node = await registry + .CreateVirtualSink("BindKind") + .WithName("pwnet_bindkind_sink") + .ExecuteAsync(cts.Token); // Binding a node as a device would hand the daemon a proxy of the wrong interface and // misbehave later; catching it here turns that into an argument error. @@ -219,12 +253,16 @@ public async Task ADevice_ReportsTheProfilesAndRoutesItsCardOffers() { RequireLinux(); using var cts = new CancellationTokenSource(Budget); - (PipeWireContext ctx, PipeWireRegistry registry) = await ConnectAsync("pwnet-device", cts.Token); + (PipeWireContext ctx, PipeWireRegistry registry) = await ConnectAsync( + "pwnet-device", + cts.Token + ); await using (ctx) await using (registry) { - PipeWireDevice? card = registry.Current.Devices - .FirstOrDefault(d => string.Equals(d.Api, "alsa", StringComparison.Ordinal)); + PipeWireDevice? card = registry.Current.Devices.FirstOrDefault(d => + string.Equals(d.Api, "alsa", StringComparison.Ordinal) + ); if (card is null) Assert.Inconclusive("this session has no ALSA card to enumerate."); @@ -237,10 +275,14 @@ public async Task ADevice_ReportsTheProfilesAndRoutesItsCardOffers() // Every profile names itself, which is what a profile switcher shows. foreach (SpaObject profile in profiles) { - Assert.IsInstanceOfType(profile[(uint)SpaParamProfile.Index], - "a profile must carry its index"); - Assert.IsInstanceOfType(profile[(uint)SpaParamProfile.Name], - "a profile must carry its name"); + Assert.IsInstanceOfType( + profile[(uint)SpaParamProfile.Index], + "a profile must carry its index" + ); + Assert.IsInstanceOfType( + profile[(uint)SpaParamProfile.Name], + "a profile must carry its name" + ); } SpaObject? current = await control.GetProfileAsync(cts.Token); @@ -254,7 +296,10 @@ public async Task TheDefaultStore_ReportsTheSessionDefaultSink() { RequireLinux(); using var cts = new CancellationTokenSource(Budget); - (PipeWireContext ctx, PipeWireRegistry registry) = await ConnectAsync("pwnet-metadata", cts.Token); + (PipeWireContext ctx, PipeWireRegistry registry) = await ConnectAsync( + "pwnet-metadata", + cts.Token + ); await using (ctx) await using (registry) { @@ -282,18 +327,24 @@ public async Task TheDefaultStore_ReportsTheSessionDefaultSink() for (int i = 0; i < 50 && sink is null; i++) { sink = store.DefaultAudioSink; - if (sink is null) await Task.Delay(100, cts.Token); + if (sink is null) + await Task.Delay(100, cts.Token); } if (sink is null) Assert.Inconclusive("this session has no default sink set."); // The daemon stores JSON, not a bare name, which is the trap this accessor exists for. - Assert.IsTrue(sink!.Value!.Contains("name", StringComparison.Ordinal), - "the raw value is JSON"); + Assert.IsTrue( + sink!.Value!.Contains("name", StringComparison.Ordinal), + "the raw value is JSON" + ); Assert.IsNotNull(sink.NameValue, "the node name must be readable out of the JSON"); - Assert.AreEqual(PipeWireMetadataProxy.SubjectCore, sink.Subject, - "a session-wide default is about the daemon, not about one object"); + Assert.AreEqual( + PipeWireMetadataProxy.SubjectCore, + sink.Subject, + "a session-wide default is about the daemon, not about one object" + ); // And it names a node that is actually in the graph when the session is coherent. // A session whose ALSA device is held by something else keeps a default naming a @@ -302,8 +353,9 @@ public async Task TheDefaultStore_ReportsTheSessionDefaultSink() { Assert.Inconclusive( $"the session's default sink is '{sink.NameValue}', which is not a node in " - + "the graph. The session manager's state is inconsistent, which says " - + "nothing about the value this store read."); + + "the graph. The session manager's state is inconsistent, which says " + + "nothing about the value this store read." + ); } } } @@ -314,7 +366,10 @@ public async Task TheSettingsStore_ReportsTheGraphClock() { RequireLinux(); using var cts = new CancellationTokenSource(Budget); - (PipeWireContext ctx, PipeWireRegistry registry) = await ConnectAsync("pwnet-settings", cts.Token); + (PipeWireContext ctx, PipeWireRegistry registry) = await ConnectAsync( + "pwnet-settings", + cts.Token + ); await using (ctx) await using (registry) { @@ -326,8 +381,14 @@ public async Task TheSettingsStore_ReportsTheGraphClock() { await store.ReadyAsync(cts.Token); - Assert.IsTrue(store.Entries.Count > 0, "the settings store must report its entries"); - Assert.IsNotNull(store.Get("clock.rate"), "the graph clock rate is a settings entry"); + Assert.IsTrue( + store.Entries.Count > 0, + "the settings store must report its entries" + ); + Assert.IsNotNull( + store.Get("clock.rate"), + "the graph clock rate is a settings entry" + ); } } } @@ -337,29 +398,38 @@ public async Task SubscribingToProps_RaisesWhenSomethingElseChangesTheVolume() { RequireLinux(); using var cts = new CancellationTokenSource(Budget); - (PipeWireContext ctx, PipeWireRegistry registry) = await ConnectAsync("pwnet-subscribe", cts.Token); + (PipeWireContext ctx, PipeWireRegistry registry) = await ConnectAsync( + "pwnet-subscribe", + cts.Token + ); await using (ctx) await using (registry) { - PipeWireNode node = await registry.CreateVirtualSink("Subscribe") - .WithName("pwnet_subscribe_sink").ExecuteAsync(cts.Token); + PipeWireNode node = await registry + .CreateVirtualSink("Subscribe") + .WithName("pwnet_subscribe_sink") + .ExecuteAsync(cts.Token); await using PipeWireNodeProxy control = registry.BindNode(node.NodeId); var changed = new TaskCompletionSource( - TaskCreationOptions.RunContinuationsAsynchronously); + TaskCreationOptions.RunContinuationsAsynchronously + ); control.ParameterChanged += (_, value) => { - if (value.ObjectId == SpaParamType.Props) changed.TrySetResult(value); + if (value.ObjectId == SpaParamType.Props) + changed.TrySetResult(value); }; control.SubscribeParameters(SpaParamType.Props); await control.SetVolumeAsync(0.5f, cts.Token); SpaObject props = await changed.Task.WaitAsync(cts.Token); - Assert.IsNotNull(props[(uint)SpaProp.Volume], - "a subscribed Props change must carry the volume that changed"); + Assert.IsNotNull( + props[(uint)SpaProp.Volume], + "a subscribed Props change must carry the volume that changed" + ); await registry.DestroyGlobalAsync(node.NodeId, cts.Token); } @@ -370,19 +440,27 @@ public async Task DisposingABindingWhileAReadIsInFlight_DoesNotHangOrCrash() { RequireLinux(); using var cts = new CancellationTokenSource(Budget); - (PipeWireContext ctx, PipeWireRegistry registry) = await ConnectAsync("pwnet-bindrace", cts.Token); + (PipeWireContext ctx, PipeWireRegistry registry) = await ConnectAsync( + "pwnet-bindrace", + cts.Token + ); await using (ctx) await using (registry) { - PipeWireNode node = await registry.CreateVirtualSink("BindRace") - .WithName("pwnet_bindrace_sink").ExecuteAsync(cts.Token); + PipeWireNode node = await registry + .CreateVirtualSink("BindRace") + .WithName("pwnet_bindrace_sink") + .ExecuteAsync(cts.Token); PipeWireNodeProxy control = registry.BindNode(node.NodeId); Task reading = control.GetVolumeAsync(cts.Token); await control.DisposeAsync(); // Either answer is acceptable; hanging or aborting the process is not. - try { await reading.WaitAsync(TimeSpan.FromSeconds(5), cts.Token); } + try + { + await reading.WaitAsync(TimeSpan.FromSeconds(5), cts.Token); + } catch (ObjectDisposedException) { } catch (Exception e) when (e is InvalidOperationException or PipeWireException) { } @@ -395,12 +473,17 @@ public async Task ANodeDescribesItsOwnParameters_BeforeAnythingIsAskedOfIt() { RequireLinux(); using var cts = new CancellationTokenSource(Budget); - (PipeWireContext ctx, PipeWireRegistry registry) = await ConnectAsync("pwnet-info", cts.Token); + (PipeWireContext ctx, PipeWireRegistry registry) = await ConnectAsync( + "pwnet-info", + cts.Token + ); await using (ctx) await using (registry) { - PipeWireNode node = await registry.CreateVirtualSink("Info") - .WithName("pwnet_info_sink").ExecuteAsync(cts.Token); + PipeWireNode node = await registry + .CreateVirtualSink("Info") + .WithName("pwnet_info_sink") + .ExecuteAsync(cts.Token); await using PipeWireNodeProxy control = registry.BindNode(node.NodeId); @@ -415,8 +498,10 @@ public async Task ANodeDescribesItsOwnParameters_BeforeAnythingIsAskedOfIt() // Format is write-only on a node: enumerating it would be an error, so the flags have to // distinguish the two rather than reporting a single "supported". Assert.IsTrue(control.CanWrite(SpaParamType.Format)); - Assert.IsFalse(control.CanRead(SpaParamType.Format), - "Format is advertised write-only and must not be reported as readable"); + Assert.IsFalse( + control.CanRead(SpaParamType.Format), + "Format is advertised write-only and must not be reported as readable" + ); await registry.DestroyGlobalAsync(node.NodeId, cts.Token); } @@ -427,12 +512,16 @@ public async Task ADeviceDescribesItsOwnParameters_Too() { RequireLinux(); using var cts = new CancellationTokenSource(Budget); - (PipeWireContext ctx, PipeWireRegistry registry) = await ConnectAsync("pwnet-devinfo", cts.Token); + (PipeWireContext ctx, PipeWireRegistry registry) = await ConnectAsync( + "pwnet-devinfo", + cts.Token + ); await using (ctx) await using (registry) { - PipeWireDevice? card = registry.Current.Devices - .FirstOrDefault(d => string.Equals(d.Api, "alsa", StringComparison.Ordinal)); + PipeWireDevice? card = registry.Current.Devices.FirstOrDefault(d => + string.Equals(d.Api, "alsa", StringComparison.Ordinal) + ); if (card is null) Assert.Inconclusive("this session has no ALSA card."); @@ -451,14 +540,18 @@ public async Task AClientCanBeBoundAndItsPropertiesUpdated() { RequireLinux(); using var cts = new CancellationTokenSource(Budget); - (PipeWireContext ctx, PipeWireRegistry registry) = await ConnectAsync("pwnet-clientprops", cts.Token); + (PipeWireContext ctx, PipeWireRegistry registry) = await ConnectAsync( + "pwnet-clientprops", + cts.Token + ); await using (ctx) await using (registry) { // Our own client, found by the pid the daemon read off the socket rather than by any // name we could have made up. - PipeWireClient? me = registry.Current.Clients - .FirstOrDefault(c => c.ProcessId == Environment.ProcessId); + PipeWireClient? me = registry.Current.Clients.FirstOrDefault(c => + c.ProcessId == Environment.ProcessId + ); if (me is null) Assert.Inconclusive("this connection is not visible as a client object."); @@ -469,7 +562,8 @@ public async Task AClientCanBeBoundAndItsPropertiesUpdated() // exercises the whole write path without needing to be a session manager. await control.UpdatePropertiesAsync( new Dictionary { ["application.name"] = "pwnet-renamed" }, - cts.Token); + cts.Token + ); } } @@ -499,7 +593,10 @@ public async Task ConfiningAClient_LeavesItOnlyWhatItWasGrantedAndConnected() RequireLinux(); using var cts = new CancellationTokenSource(Budget); string appName = Unique("pwnet-confined"); - (PipeWireContext managerCtx, PipeWireRegistry manager) = await ConnectAsync("pwnet-confine-manager", cts.Token); + (PipeWireContext managerCtx, PipeWireRegistry manager) = await ConnectAsync( + "pwnet-confine-manager", + cts.Token + ); (PipeWireContext appCtx, PipeWireRegistry app) = await ConnectAsync(appName, cts.Token); await using (managerCtx) await using (manager) @@ -512,47 +609,76 @@ public async Task ConfiningAClient_LeavesItOnlyWhatItWasGrantedAndConnected() held.Add(app.BindNode(node.NodeId)); Assert.IsTrue(held.Count > 0, "the session has no nodes for the application to hold"); - Assert.IsTrue(app.Current.Nodes.Length > 0, "the application saw no nodes to begin with"); + Assert.IsTrue( + app.Current.Nodes.Length > 0, + "the application saw no nodes to begin with" + ); PipeWireClient? confined = null; for (var i = 0; i < 100 && confined is null; i++) { - confined = manager.Current.Clients.FirstOrDefault(c => c.ApplicationName == appName); - if (confined is null) await Task.Delay(50, cts.Token); + confined = manager.Current.Clients.FirstOrDefault(c => + c.ApplicationName == appName + ); + if (confined is null) + await Task.Delay(50, cts.Token); } - Assert.IsNotNull(confined, "the application's client never reached the manager's graph"); + Assert.IsNotNull( + confined, + "the application's client never reached the manager's graph" + ); await using PipeWireClientProxy control = manager.BindClient(confined.Id); await control.ConfineToAsync( - [new PipeWireObjectPermission(0, PipeWirePermissions.Read)], cts.Token); + [new PipeWireObjectPermission(0, PipeWirePermissions.Read)], + cts.Token + ); // Everything but the core is withdrawn from the application's view. for (var i = 0; i < 100 && app.Current.Nodes.Length > 0; i++) await Task.Delay(50, cts.Token); - Assert.AreEqual(0, app.Current.Nodes.Length, - "the confined application still sees nodes it was never granted"); + Assert.AreEqual( + 0, + app.Current.Nodes.Length, + "the confined application still sees nodes it was never granted" + ); // Read on the core is not enough to talk to it: every method needs X on its object // (protocol-native process_messages), so the application's sync is refused - and the // refusal has to reach it as one, not leave it waiting for a done that never comes. - PipeWireException refused = await Assert.ThrowsAsync( - () => Interop.CoreSync.RoundTripAsync(appCtx, cts.Token)); - Assert.IsTrue(refused.IsPermissionDenied, $"the refused sync reported {refused.Result}, not a denial"); + PipeWireException refused = await Assert.ThrowsAsync(() => + Interop.CoreSync.RoundTripAsync(appCtx, cts.Token) + ); + Assert.IsTrue( + refused.IsPermissionDenied, + $"the refused sync reported {refused.Result}, not a denial" + ); // Granted execute on the core as well, as WirePlumber's access policy grants a sandboxed // client, it can talk to the daemon again and is still connected. await control.UpdatePermissionsAsync( - new[] { new PipeWireObjectPermission(0, PipeWirePermissions.Read | PipeWirePermissions.Execute) }, - cts.Token); + new[] + { + new PipeWireObjectPermission( + 0, + PipeWirePermissions.Read | PipeWirePermissions.Execute + ), + }, + cts.Token + ); await Interop.CoreSync.RoundTripAsync(appCtx, cts.Token); // And the manager's own view, and the daemon, are untouched. await manager.WaitForInitialEnumerationAsync(cts.Token); - Assert.IsTrue(manager.Current.Nodes.Length > 0, "confining another client emptied the manager's view"); + Assert.IsTrue( + manager.Current.Nodes.Length > 0, + "confining another client emptied the manager's view" + ); - foreach (PipeWireNodeProxy c in held) await c.DisposeAsync(); + foreach (PipeWireNodeProxy c in held) + await c.DisposeAsync(); } } } diff --git a/tests/PipeWire.NET.Tests/PenHarness.cs b/tests/PipeWire.NET.Tests/PenHarness.cs index c5d6039..9374cec 100644 --- a/tests/PipeWire.NET.Tests/PenHarness.cs +++ b/tests/PipeWire.NET.Tests/PenHarness.cs @@ -58,7 +58,8 @@ private static void Report(string line) File.AppendAllText( ReportPath, $"{DateTime.UtcNow:yyyy-MM-dd HH:mm:ss}Z pid={Environment.ProcessId} {line}" - + Environment.NewLine); + + Environment.NewLine + ); } } @@ -81,8 +82,13 @@ private static CancellationTokenSource Budget() public async Task Churn() { using CancellationTokenSource cts = Budget(); - long binds = 0, reads = 0; - try { (binds, reads) = await ChurnAsync(cts.Token); } catch (OperationCanceledException) { } + long binds = 0, + reads = 0; + try + { + (binds, reads) = await ChurnAsync(cts.Token); + } + catch (OperationCanceledException) { } // Asserted here rather than inside the scenario: a budget expiring mid-iteration // otherwise skips the report and the check together, and the run passes having @@ -95,8 +101,13 @@ public async Task Churn() public async Task BindAll() { using CancellationTokenSource cts = Budget(); - long held = 0, ok = 0; - try { (held, ok) = await BindAllAsync(cts.Token); } catch (OperationCanceledException) { } + long held = 0, + ok = 0; + try + { + (held, ok) = await BindAllAsync(cts.Token); + } + catch (OperationCanceledException) { } Assert.IsTrue(held > 0, "nothing in the graph could be bound"); Assert.IsTrue(ok > 0, "every read against every held binding failed"); @@ -107,9 +118,16 @@ public async Task Meta() { using CancellationTokenSource cts = Budget(); long writes = 0; - try { writes = await MetaAsync(cts.Token); } catch (OperationCanceledException) { } + try + { + writes = await MetaAsync(cts.Token); + } + catch (OperationCanceledException) { } - Assert.IsTrue(writes > 0, "the metadata scenario wrote nothing; it never reached the store"); + Assert.IsTrue( + writes > 0, + "the metadata scenario wrote nothing; it never reached the store" + ); } [TestMethod] @@ -117,7 +135,11 @@ public async Task Contexts() { using CancellationTokenSource cts = Budget(); long made = 0; - try { made = await ContextsAsync(cts.Token); } catch (OperationCanceledException) { } + try + { + made = await ContextsAsync(cts.Token); + } + catch (OperationCanceledException) { } Assert.IsTrue(made > 0, "no context completed a full open-and-close cycle"); } @@ -129,14 +151,17 @@ public async Task Contexts() await using var reg = new PipeWireRegistry(ctx); await reg.WaitForInitialEnumerationAsync(ct); - long reads = 0, errors = 0, binds = 0; + long reads = 0, + errors = 0, + binds = 0; try { while (!ct.IsCancellationRequested) { foreach (PipeWireNode node in reg.Current.Nodes) { - if (ct.IsCancellationRequested) break; + if (ct.IsCancellationRequested) + break; PipeWireNodeProxy? control = null; try { @@ -145,9 +170,21 @@ public async Task Contexts() await control.EnumerateParametersAsync(SpaParamType.Props, ct); reads++; } - catch (OperationCanceledException) { throw; } - catch (Exception ex) { errors++; if (errors < 6) Report($"PEN churn: {ex.GetType().Name}: {ex.Message}"); } - finally { if (control is not null) await control.DisposeAsync(); } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + errors++; + if (errors < 6) + Report($"PEN churn: {ex.GetType().Name}: {ex.Message}"); + } + finally + { + if (control is not null) + await control.DisposeAsync(); + } } } } @@ -174,21 +211,37 @@ public async Task Contexts() var held = new List(); foreach (PipeWireNode node in reg.Current.Nodes) { - try { held.Add(reg.BindNode(node.NodeId)); } catch (Exception) { } + try + { + held.Add(reg.BindNode(node.NodeId)); + } + catch (Exception) { } } Report($"PEN bindall: holding {held.Count} bindings"); - long ok = 0, gone = 0; + long ok = 0, + gone = 0; try { while (!ct.IsCancellationRequested) { foreach (PipeWireNodeProxy c in held) { - if (ct.IsCancellationRequested) break; - try { await c.GetVolumeAsync(ct); ok++; } - catch (OperationCanceledException) { throw; } - catch (Exception) { gone++; } + if (ct.IsCancellationRequested) + break; + try + { + await c.GetVolumeAsync(ct); + ok++; + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception) + { + gone++; + } } } } @@ -198,7 +251,8 @@ public async Task Contexts() } finally { - foreach (PipeWireNodeProxy c in held) await c.DisposeAsync(); + foreach (PipeWireNodeProxy c in held) + await c.DisposeAsync(); Report($"PEN bindall: reads={ok} failed={gone}"); } return (held.Count, ok); @@ -213,12 +267,17 @@ private static async Task MetaAsync(CancellationToken ct) await reg.WaitForInitialEnumerationAsync(ct); PipeWireMetadataProxy? store = reg.BindMetadata("default"); - if (store is null) { Report("PEN meta: no default store"); return 0; } + if (store is null) + { + Report("PEN meta: no default store"); + return 0; + } await using (store) { await store.ReadyAsync(ct); - long writes = 0, events = 0; + long writes = 0, + events = 0; store.EntryChanged += (_, _) => Interlocked.Increment(ref events); string key = $"pen.meta.{Environment.ProcessId}"; @@ -233,8 +292,15 @@ private static async Task MetaAsync(CancellationToken ct) Report($"PEN meta: READ-AFTER-WRITE MISMATCH at {writes}"); writes++; } - catch (OperationCanceledException) { throw; } - catch (Exception ex) { Report($"PEN meta: {ex.GetType().Name}"); break; } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + Report($"PEN meta: {ex.GetType().Name}"); + break; + } } } catch (OperationCanceledException) when (ct.IsCancellationRequested) @@ -243,7 +309,11 @@ private static async Task MetaAsync(CancellationToken ct) } finally { - try { await store.SetAsync(key, null, cancellationToken: CancellationToken.None); } catch { } + try + { + await store.SetAsync(key, null, cancellationToken: CancellationToken.None); + } + catch { } Report($"PEN meta: writes={writes} events={Interlocked.Read(ref events)}"); } return writes; @@ -260,25 +330,42 @@ private static async Task ContextsAsync(CancellationToken ct) { Task[] wave = [ - .. Enumerable.Range(0, 12).Select(i => Task.Run(async () => - { - var c = new PipeWireContext($"pen-ctx-{i}"); - try - { - await c.StartAsync(ct); - var r = new PipeWireRegistry(c); - await r.WaitForInitialEnumerationAsync(ct); - await r.DisposeAsync(); - // Only a full cycle counts. Incrementing in a finally would count - // attempts that never reached the graph as successes. - Interlocked.Increment(ref made); - } - catch (OperationCanceledException) { } - catch (Exception ex) { Report($"PEN ctx: {ex.GetType().Name}: {ex.Message}"); } - finally { await c.DisposeAsync(); } - }, ct)), + .. Enumerable + .Range(0, 12) + .Select(i => + Task.Run( + async () => + { + var c = new PipeWireContext($"pen-ctx-{i}"); + try + { + await c.StartAsync(ct); + var r = new PipeWireRegistry(c); + await r.WaitForInitialEnumerationAsync(ct); + await r.DisposeAsync(); + // Only a full cycle counts. Incrementing in a finally would count + // attempts that never reached the graph as successes. + Interlocked.Increment(ref made); + } + catch (OperationCanceledException) { } + catch (Exception ex) + { + Report($"PEN ctx: {ex.GetType().Name}: {ex.Message}"); + } + finally + { + await c.DisposeAsync(); + } + }, + ct + ) + ), ]; - try { await Task.WhenAll(wave); } catch (OperationCanceledException) { } + try + { + await Task.WhenAll(wave); + } + catch (OperationCanceledException) { } } } finally diff --git a/tests/PipeWire.NET.Tests/PermissionRefusalTests.cs b/tests/PipeWire.NET.Tests/PermissionRefusalTests.cs index 7e49ca1..18e8bbf 100644 --- a/tests/PipeWire.NET.Tests/PermissionRefusalTests.cs +++ b/tests/PipeWire.NET.Tests/PermissionRefusalTests.cs @@ -60,12 +60,14 @@ private static void RequireLinux() Assert.Inconclusive("PipeWire is a Linux daemon."); } - private static string Unique(string p) => $"{p}_{Environment.ProcessId}_{Random.Shared.Next():x}"; + private static string Unique(string p) => + $"{p}_{Environment.ProcessId}_{Random.Shared.Next():x}"; /// Finds this connection's own client object, by the name it connected under. private static PipeWireClient? OurClient(PipeWireRegistry registry, string applicationName) => - registry.Current.Clients.FirstOrDefault( - c => string.Equals(c.ApplicationName, applicationName, StringComparison.Ordinal)); + registry.Current.Clients.FirstOrDefault(c => + string.Equals(c.ApplicationName, applicationName, StringComparison.Ordinal) + ); [TestMethod] public async Task AClientThatLosesWriteAccess_IsRefusedAndRollsBack() @@ -105,11 +107,15 @@ public async Task AClientThatLosesWriteAccess_IsRefusedAndRollsBack() // Writes to this store only. Everything else keeps its permissions, so the // connection stays alive to hear the refusal. await control.UpdatePermissionsAsync( - new[] { new PipeWireObjectPermission(store.Id, PipeWirePermissions.None) }, cts.Token); + new[] { new PipeWireObjectPermission(store.Id, PipeWirePermissions.None) }, + cts.Token + ); } - PipeWireException refused = await Assert.ThrowsExactlyAsync( - () => store.SetAsync(key, "v2", cancellationToken: cts.Token)); + PipeWireException refused = + await Assert.ThrowsExactlyAsync(() => + store.SetAsync(key, "v2", cancellationToken: cts.Token) + ); Assert.IsTrue(refused.Result < 0, "a refusal must carry the daemon's code"); Console.Error.WriteLine($"after losing write access: {refused.Message}"); @@ -118,8 +124,11 @@ await control.UpdatePermissionsAsync( // asserting the daemon's implementation. What is ours is the mapping: // IsPermissionDenied has to be exactly the EACCES case and nothing else, or a caller // branching on it gets a different answer than the code says. - Assert.AreEqual(refused.Result == -13, refused.IsPermissionDenied, - $"IsPermissionDenied disagrees with the result code {refused.Result}"); + Assert.AreEqual( + refused.Result == -13, + refused.IsPermissionDenied, + $"IsPermissionDenied disagrees with the result code {refused.Result}" + ); // And the property is reachable on a real refusal rather than only on a constructed // one, which is the whole reason it exists. @@ -152,12 +161,20 @@ public async Task ClientControlGuards_RefuseBadInputBeforeTheDaemon() { // A default is the method's own to write, so one in the grants contradicts the // confining it exists to do. - await Assert.ThrowsExactlyAsync( - async () => await control.ConfineToAsync( - [new PipeWireObjectPermission(PipeWireClientProxy.AnyObject, PipeWirePermissions.Read)], - cts.Token)); - await Assert.ThrowsExactlyAsync( - async () => await control.UpdatePropertiesAsync(null!, cts.Token)); + await Assert.ThrowsExactlyAsync(async () => + await control.ConfineToAsync( + [ + new PipeWireObjectPermission( + PipeWireClientProxy.AnyObject, + PipeWirePermissions.Read + ), + ], + cts.Token + ) + ); + await Assert.ThrowsExactlyAsync(async () => + await control.UpdatePropertiesAsync(null!, cts.Token) + ); } } @@ -177,8 +194,9 @@ public async Task AClientDeniedItsFactory_IsRefusedWhenCreating() await using var registry = new PipeWireRegistry(ctx); await registry.WaitForInitialEnumerationAsync(cts.Token); - PipeWireFactory? factory = registry.Current.Factories - .FirstOrDefault(f => string.Equals(f.FactoryName, "adapter", StringComparison.Ordinal)); + PipeWireFactory? factory = registry.Current.Factories.FirstOrDefault(f => + string.Equals(f.FactoryName, "adapter", StringComparison.Ordinal) + ); if (factory is null) Assert.Inconclusive("this session has no adapter factory to be denied."); @@ -188,16 +206,26 @@ public async Task AClientDeniedItsFactory_IsRefusedWhenCreating() await using (PipeWireClientProxy control = registry.BindClient(self!.Id)) { await control.UpdatePermissionsAsync( - new[] { new PipeWireObjectPermission(factory!.Id, PipeWirePermissions.None) }, cts.Token); + new[] { new PipeWireObjectPermission(factory!.Id, PipeWirePermissions.None) }, + cts.Token + ); } - PipeWireException refused = await Assert.ThrowsExactlyAsync( - () => registry.CreateVirtualSink("Denied").WithName(Unique("pwnet_denied")).ExecuteAsync(cts.Token)); + PipeWireException refused = + await Assert.ThrowsExactlyAsync(() => + registry + .CreateVirtualSink("Denied") + .WithName(Unique("pwnet_denied")) + .ExecuteAsync(cts.Token) + ); Assert.IsTrue(refused.Result < 0, "a refusal must carry the daemon's code"); Console.Error.WriteLine($"after losing the factory: {refused.Message}"); - Assert.AreEqual(refused.Result == -13, refused.IsPermissionDenied, - $"IsPermissionDenied disagrees with the result code {refused.Result}"); + Assert.AreEqual( + refused.Result == -13, + refused.IsPermissionDenied, + $"IsPermissionDenied disagrees with the result code {refused.Result}" + ); } /// @@ -210,15 +238,21 @@ public async Task ASecurityContextGivenADescriptorThatCannotListen_IsRefusedBefo RequireLinux(); using var cts = new CancellationTokenSource(Budget); - await using var ctx = new PipeWireContext(Unique("pwnet-secctx"), ConsoleTestLoggerFactory.Instance); + await using var ctx = new PipeWireContext( + Unique("pwnet-secctx"), + ConsoleTestLoggerFactory.Instance + ); await ctx.StartAsync(cts.Token); await using var registry = new PipeWireRegistry(ctx); await registry.WaitForInitialEnumerationAsync(cts.Token); PipeWireSecurityContext? available = registry.Current.SecurityContext; - if (available is null) Assert.Inconclusive("this daemon exposes no security context."); + if (available is null) + Assert.Inconclusive("this daemon exposes no security context."); - await using PipeWireSecurityContextProxy control = registry.BindSecurityContext(available!.Id); + await using PipeWireSecurityContextProxy control = registry.BindSecurityContext( + available!.Id + ); var properties = new Dictionary(StringComparer.Ordinal) { @@ -228,19 +262,24 @@ public async Task ASecurityContextGivenADescriptorThatCannotListen_IsRefusedBefo using (SafeFileHandle invalid = new(new IntPtr(-1), ownsHandle: false)) { - await Assert.ThrowsExactlyAsync( - async () => await control.CreateAsync(invalid, invalid, properties, cts.Token)); + await Assert.ThrowsExactlyAsync(async () => + await control.CreateAsync(invalid, invalid, properties, cts.Token) + ); } - string scratch = Path.Combine(Path.GetTempPath(), $"pwnet-secctx-{Environment.ProcessId}.tmp"); + string scratch = Path.Combine( + Path.GetTempPath(), + $"pwnet-secctx-{Environment.ProcessId}.tmp" + ); await File.WriteAllTextAsync(scratch, "not a socket", cts.Token); try { using var notASocket = new FileStream(scratch, FileMode.Open, FileAccess.Read); SafeHandle fd = notASocket.SafeFileHandle; - await Assert.ThrowsExactlyAsync( - async () => await control.CreateAsync(fd, fd, properties, cts.Token)); + await Assert.ThrowsExactlyAsync(async () => + await control.CreateAsync(fd, fd, properties, cts.Token) + ); } finally { @@ -248,16 +287,22 @@ await Assert.ThrowsExactlyAsync( } // A real socket, but connected rather than listening. - using (var connected = new Socket(AddressFamily.Unix, SocketType.Stream, ProtocolType.Unspecified)) + using ( + var connected = new Socket( + AddressFamily.Unix, + SocketType.Stream, + ProtocolType.Unspecified + ) + ) { SafeHandle fd = connected.SafeHandle; - await Assert.ThrowsExactlyAsync( - async () => await control.CreateAsync(fd, fd, properties, cts.Token)); + await Assert.ThrowsExactlyAsync(async () => + await control.CreateAsync(fd, fd, properties, cts.Token) + ); } // Still usable, which is what makes them refusals. await registry.WaitForInitialEnumerationAsync(cts.Token); Assert.IsTrue(registry.Current.Nodes.Length > 0); } - } diff --git a/tests/PipeWire.NET.Tests/PipeWireLogTests.cs b/tests/PipeWire.NET.Tests/PipeWireLogTests.cs index 325d884..7f709be 100644 --- a/tests/PipeWire.NET.Tests/PipeWireLogTests.cs +++ b/tests/PipeWire.NET.Tests/PipeWireLogTests.cs @@ -32,7 +32,10 @@ public async Task EveryLevel_IsAcceptedAndLeavesTheLibraryUsable() // diagnose a startup failure has nowhere else to put the call. PipeWireLog.SetLevel(PipeWireLogLevel.Warn); - await using var ctx = new PipeWireContext("pwnet-loglevel", ConsoleTestLoggerFactory.Instance); + await using var ctx = new PipeWireContext( + "pwnet-loglevel", + ConsoleTestLoggerFactory.Instance + ); await ctx.StartAsync(cts.Token); foreach (PipeWireLogLevel level in Enum.GetValues()) @@ -55,7 +58,11 @@ public void ALevelOutsideTheEnum_IsRefusedRatherThanPassedToTheLibrary() { RequireLinux(); - Assert.ThrowsExactly(() => PipeWireLog.SetLevel((PipeWireLogLevel)99)); - Assert.ThrowsExactly(() => PipeWireLog.SetLevel((PipeWireLogLevel)(-1))); + Assert.ThrowsExactly(() => + PipeWireLog.SetLevel((PipeWireLogLevel)99) + ); + Assert.ThrowsExactly(() => + PipeWireLog.SetLevel((PipeWireLogLevel)(-1)) + ); } } diff --git a/tests/PipeWire.NET.Tests/PipeWireRateControllerTests.cs b/tests/PipeWire.NET.Tests/PipeWireRateControllerTests.cs index 198e28a..1557ea4 100644 --- a/tests/PipeWire.NET.Tests/PipeWireRateControllerTests.cs +++ b/tests/PipeWire.NET.Tests/PipeWireRateControllerTests.cs @@ -11,7 +11,9 @@ namespace PipeWire.NET.Tests; [TestClass] public sealed class PipeWireRateControllerTests { - private static PipeWireRateController Tuned(double bandwidth = PipeWireRateController.MinBandwidth) + private static PipeWireRateController Tuned( + double bandwidth = PipeWireRateController.MinBandwidth + ) { var dll = new PipeWireRateController(); dll.SetBandwidth(bandwidth, period: 1024, rate: 48000); @@ -30,7 +32,12 @@ public void AQueueThatIsNeverWrong_IsNeverCorrected() { PipeWireRateController dll = Tuned(); for (int i = 0; i < 1000; i++) - Assert.AreEqual(1.0, dll.Update(0.0), 1e-12, $"drifted at cycle {i} with nothing to correct"); + Assert.AreEqual( + 1.0, + dll.Update(0.0), + 1e-12, + $"drifted at cycle {i} with nothing to correct" + ); } /// @@ -46,7 +53,7 @@ public void AQueueThatStartsShort_IsDrivenBackToTarget() const double target = 4800.0; const double period = 480.0; - double avail = 4000.0; // starts short by more than a period + double avail = 4000.0; // starts short by more than a period double correction = 1.0; for (int i = 0; i < 20000; i++) @@ -115,7 +122,8 @@ public void TheCorrectionOpposesTheError() PipeWireRateController tooFull = Tuned(); PipeWireRateController tooEmpty = Tuned(); - double full = 1.0, empty = 1.0; + double full = 1.0, + empty = 1.0; for (int i = 0; i < 500; i++) { full = tooFull.Update(64.0); @@ -125,7 +133,8 @@ public void TheCorrectionOpposesTheError() Assert.AreNotEqual(1.0, full, 1e-9, "a queue that is too long must produce a correction"); Assert.IsTrue( (full < 1.0 && empty > 1.0) || (full > 1.0 && empty < 1.0), - $"opposite errors must correct in opposite directions, got {full} and {empty}"); + $"opposite errors must correct in opposite directions, got {full} and {empty}" + ); } [TestMethod] @@ -135,27 +144,36 @@ public void AWiderBandwidth_ConvergesFaster() PipeWireRateController narrow = Tuned(PipeWireRateController.MinBandwidth); PipeWireRateController wide = Tuned(PipeWireRateController.MaxBandwidth); - double narrowMoved = 0.0, wideMoved = 0.0; + double narrowMoved = 0.0, + wideMoved = 0.0; for (int i = 0; i < 50; i++) { narrowMoved = Math.Abs(1.0 - narrow.Update(64.0)); wideMoved = Math.Abs(1.0 - wide.Update(64.0)); } - Assert.IsTrue(wideMoved > narrowMoved, - $"the wider bandwidth should have moved further after 50 cycles: {wideMoved} vs {narrowMoved}"); + Assert.IsTrue( + wideMoved > narrowMoved, + $"the wider bandwidth should have moved further after 50 cycles: {wideMoved} vs {narrowMoved}" + ); } [TestMethod] public void Reset_ForgetsWhatItLearned() { PipeWireRateController dll = Tuned(); - for (int i = 0; i < 200; i++) dll.Update(64.0); + for (int i = 0; i < 200; i++) + dll.Update(64.0); Assert.AreNotEqual(1.0, dll.Update(64.0), 1e-9); dll.Reset(); - Assert.AreEqual(1.0, dll.Update(0.0), 1e-12, "a reset controller must start from nothing again"); + Assert.AreEqual( + 1.0, + dll.Update(0.0), + 1e-12, + "a reset controller must start from nothing again" + ); } [TestMethod] diff --git a/tests/PipeWire.NET.Tests/PipeWireStreamTests.cs b/tests/PipeWire.NET.Tests/PipeWireStreamTests.cs index 51ec426..a705ffb 100644 --- a/tests/PipeWire.NET.Tests/PipeWireStreamTests.cs +++ b/tests/PipeWire.NET.Tests/PipeWireStreamTests.cs @@ -3,8 +3,8 @@ using Microsoft.VisualStudio.TestTools.UnitTesting; using PipeWire.NET.Graph; using PipeWire.NET.Interop; -using PipeWire.NET.Spa; using PipeWire.NET.Media; +using PipeWire.NET.Spa; namespace PipeWire.NET.Tests; @@ -20,10 +20,10 @@ public void Build_FormatObject_RoundtripsThroughReader() // No fluent chaining - each call mutates `builder` directly. Chaining on a // ref struct invokes subsequent calls on a returned copy. builder.PushObject(SpaType.ObjectFormat, SpaParamType.EnumFormat); - builder.AddId(SpaFormat.MediaType, SpaMediaType.Video); + builder.AddId(SpaFormat.MediaType, SpaMediaType.Video); builder.AddId(SpaFormat.MediaSubtype, SpaMediaSubtype.Raw); - builder.AddId(SpaFormat.VideoFormat, SpaVideoFormat.Bgra); - builder.AddRectangle(SpaFormat.VideoSize, 1920, 1080); + builder.AddId(SpaFormat.VideoFormat, SpaVideoFormat.Bgra); + builder.AddRectangle(SpaFormat.VideoSize, 1920, 1080); builder.AddFraction(SpaFormat.VideoFramerate, 30, 1); ReadOnlySpan pod = builder.GetPod(); @@ -34,7 +34,9 @@ public void Build_FormatObject_RoundtripsThroughReader() Assert.IsTrue(reader.EnterObject(out uint objType, out _, out _)); Assert.AreEqual(SpaType.ObjectFormat, (SpaType)objType); - bool sawMediaType = false, sawSize = false, sawFramerate = false; + bool sawMediaType = false, + sawSize = false, + sawFramerate = false; while (reader.TryReadProperty(out SpaKey key, out var value)) { @@ -63,7 +65,7 @@ public void Build_FormatObject_RoundtripsThroughReader() } Assert.IsTrue(sawMediaType, "expected MediaType property"); - Assert.IsTrue(sawSize, "expected Size property"); + Assert.IsTrue(sawSize, "expected Size property"); Assert.IsTrue(sawFramerate, "expected Framerate property"); } @@ -73,8 +75,7 @@ public void Build_ChoiceEnum_RoundtripsThroughUnwrapChoice() Span buf = stackalloc byte[256]; var builder = new SpaPodBuilder(buf); builder.PushObject(SpaType.ObjectFormat, SpaParamType.EnumFormat); - builder.AddChoiceEnum(SpaFormat.VideoFormat, - SpaVideoFormat.Bgra, SpaVideoFormat.Rgba); + builder.AddChoiceEnum(SpaFormat.VideoFormat, SpaVideoFormat.Bgra, SpaVideoFormat.Rgba); var reader = new SpaPodReader(builder.GetPod()); Assert.IsTrue(reader.EnterObject(out _, out _, out _)); @@ -103,8 +104,14 @@ public sealed class VideoFrameTests : PipeWireTestBase public void Ctor_PreservesAllFields() { ReadOnlySpan pixels = stackalloc byte[16]; - var frame = new VideoFrame(pixels, stride: 64, width: 4, height: 4, - format: PixelFormat.Bgra, sequenceNumber: 42); + var frame = new VideoFrame( + pixels, + stride: 64, + width: 4, + height: 4, + format: PixelFormat.Bgra, + sequenceNumber: 42 + ); Assert.AreEqual(64, frame.Stride); Assert.AreEqual(4, frame.Width); Assert.AreEqual(4, frame.Height); @@ -120,13 +127,26 @@ public sealed class GeneratedAbiTests : PipeWireTestBase // Hand-verified against libpipewire-0.3-dev 1.0.5 on x86_64 Linux. // If PipeWire bumps any of these struct sizes, regenerate bindings and update these. - [TestMethod] public unsafe void SpaChunk_Size() => Assert.AreEqual(16, sizeof(spa_chunk)); - [TestMethod] public unsafe void SpaData_Size() => Assert.AreEqual(40, sizeof(spa_data)); - [TestMethod] public unsafe void SpaBuffer_Size() => Assert.AreEqual(24, sizeof(spa_buffer)); - [TestMethod] public unsafe void SpaPod_Size() => Assert.AreEqual(8, sizeof(spa_pod)); - [TestMethod] public unsafe void SpaRectangle_Size() => Assert.AreEqual(8, sizeof(spa_rectangle)); - [TestMethod] public unsafe void SpaFraction_Size() => Assert.AreEqual(8, sizeof(spa_fraction)); - [TestMethod] public unsafe void SpaHook_Size() => Assert.AreEqual(48, sizeof(spa_hook)); + [TestMethod] + public unsafe void SpaChunk_Size() => Assert.AreEqual(16, sizeof(spa_chunk)); + + [TestMethod] + public unsafe void SpaData_Size() => Assert.AreEqual(40, sizeof(spa_data)); + + [TestMethod] + public unsafe void SpaBuffer_Size() => Assert.AreEqual(24, sizeof(spa_buffer)); + + [TestMethod] + public unsafe void SpaPod_Size() => Assert.AreEqual(8, sizeof(spa_pod)); + + [TestMethod] + public unsafe void SpaRectangle_Size() => Assert.AreEqual(8, sizeof(spa_rectangle)); + + [TestMethod] + public unsafe void SpaFraction_Size() => Assert.AreEqual(8, sizeof(spa_fraction)); + + [TestMethod] + public unsafe void SpaHook_Size() => Assert.AreEqual(48, sizeof(spa_hook)); [TestMethod] public unsafe void PwBuffer_HasExpectedShape() @@ -141,7 +161,7 @@ public unsafe void PwStreamEvents_VersionFieldIsAtOffsetZero() { pw_stream_events ev = default; nint baseAddr = (nint)(&ev); - nint verAddr = (nint)(&ev.version); + nint verAddr = (nint)(&ev.version); Assert.AreEqual(0, (int)(verAddr - baseAddr)); } @@ -173,24 +193,37 @@ public sealed class MetadataMappingTests : PipeWireTestBase public void ColorMappers_MapSpaValuesCorrectly() { // SPA numbering is non-contiguous with our public enums; mapping is explicit. - Assert.AreEqual(VideoColorRange.Limited_16_235, - SpaFormatPod.MapColorRange(SpaVideoColorRange.Limited)); - Assert.AreEqual(VideoColorMatrix.Bt709, - SpaFormatPod.MapColorMatrix(SpaVideoColorMatrix.Bt709)); - Assert.AreEqual(VideoColorMatrix.Bt2020, - SpaFormatPod.MapColorMatrix(SpaVideoColorMatrix.Bt2020)); - Assert.AreEqual(VideoTransferFunction.Srgb, - SpaFormatPod.MapTransfer(SpaVideoTransferFunction.Srgb)); - Assert.AreEqual(VideoColorPrimaries.Bt2020, - SpaFormatPod.MapPrimaries(SpaVideoColorPrimaries.Bt2020)); - Assert.AreEqual(VideoColorMatrix.Unknown, SpaFormatPod.MapColorMatrix((SpaVideoColorMatrix)9999)); + Assert.AreEqual( + VideoColorRange.Limited_16_235, + SpaFormatPod.MapColorRange(SpaVideoColorRange.Limited) + ); + Assert.AreEqual( + VideoColorMatrix.Bt709, + SpaFormatPod.MapColorMatrix(SpaVideoColorMatrix.Bt709) + ); + Assert.AreEqual( + VideoColorMatrix.Bt2020, + SpaFormatPod.MapColorMatrix(SpaVideoColorMatrix.Bt2020) + ); + Assert.AreEqual( + VideoTransferFunction.Srgb, + SpaFormatPod.MapTransfer(SpaVideoTransferFunction.Srgb) + ); + Assert.AreEqual( + VideoColorPrimaries.Bt2020, + SpaFormatPod.MapPrimaries(SpaVideoColorPrimaries.Bt2020) + ); + Assert.AreEqual( + VideoColorMatrix.Unknown, + SpaFormatPod.MapColorMatrix((SpaVideoColorMatrix)9999) + ); } [TestMethod] public void ToBufferType_MapsDataTypes() { Assert.AreEqual(PipeWireBufferType.MemPtr, SpaFormatPod.ToBufferType(SpaDataType.MemPtr)); - Assert.AreEqual(PipeWireBufferType.MemFd, SpaFormatPod.ToBufferType(SpaDataType.MemFd)); + Assert.AreEqual(PipeWireBufferType.MemFd, SpaFormatPod.ToBufferType(SpaDataType.MemFd)); Assert.AreEqual(PipeWireBufferType.DmaBuf, SpaFormatPod.ToBufferType(SpaDataType.DmaBuf)); Assert.AreEqual(PipeWireBufferType.Unknown, SpaFormatPod.ToBufferType((SpaDataType)9999)); } @@ -201,28 +234,30 @@ public unsafe void ParseVideoFormat_ReadsColorMetadata() Span buf = stackalloc byte[512]; var b = new SpaPodBuilder(buf); b.PushObject(SpaType.ObjectFormat, SpaParamType.Format); - b.AddId(SpaFormat.MediaType, SpaMediaType.Video); + b.AddId(SpaFormat.MediaType, SpaMediaType.Video); b.AddId(SpaFormat.MediaSubtype, SpaMediaSubtype.Raw); - b.AddId(SpaFormat.VideoFormat, SpaVideoFormat.Bgra); - b.AddRectangle(SpaFormat.VideoSize, 1920, 1080); - b.AddId(SpaFormat.VideoColorRange, SpaVideoColorRange.Limited); - b.AddId(SpaFormat.VideoColorMatrix, SpaVideoColorMatrix.Bt709); + b.AddId(SpaFormat.VideoFormat, SpaVideoFormat.Bgra); + b.AddRectangle(SpaFormat.VideoSize, 1920, 1080); + b.AddId(SpaFormat.VideoColorRange, SpaVideoColorRange.Limited); + b.AddId(SpaFormat.VideoColorMatrix, SpaVideoColorMatrix.Bt709); b.AddId(SpaFormat.VideoTransferFunction, SpaVideoTransferFunction.Srgb); - b.AddId(SpaFormat.VideoColorPrimaries, SpaVideoColorPrimaries.Bt709); + b.AddId(SpaFormat.VideoColorPrimaries, SpaVideoColorPrimaries.Bt709); ReadOnlySpan pod = b.GetPod(); SpaFormatPod.VideoFormatInfo info; fixed (byte* p = pod) - info = SpaFormatPod.ParseVideoFormat((spa_pod*)p, - new SpaFormatPod.VideoFormatInfo(PixelFormat.Bgra, 0, 0, VideoColorInfo.Unknown)); + info = SpaFormatPod.ParseVideoFormat( + (spa_pod*)p, + new SpaFormatPod.VideoFormatInfo(PixelFormat.Bgra, 0, 0, VideoColorInfo.Unknown) + ); Assert.AreEqual(PixelFormat.Bgra, info.Format); Assert.AreEqual(1920, info.Width); Assert.AreEqual(1080, info.Height); Assert.AreEqual(VideoColorRange.Limited_16_235, info.Color.Range); - Assert.AreEqual(VideoColorMatrix.Bt709, info.Color.Matrix); - Assert.AreEqual(VideoTransferFunction.Srgb, info.Color.Transfer); - Assert.AreEqual(VideoColorPrimaries.Bt709, info.Color.Primaries); + Assert.AreEqual(VideoColorMatrix.Bt709, info.Color.Matrix); + Assert.AreEqual(VideoTransferFunction.Srgb, info.Color.Transfer); + Assert.AreEqual(VideoColorPrimaries.Bt709, info.Color.Primaries); } [TestMethod] @@ -231,8 +266,12 @@ public unsafe void VideoBuffersParam_AdvertisesDmaBuf() // The capture must offer SPA_DATA_DmaBuf (plus host memory) so a GPU producer can hand // us zero-copy buffers. Verify the pod we send actually carries that flag - headless. Span buf = stackalloc byte[256]; - int len = SpaFormatPod.WriteVideoBuffersParam(buf, size: 1920 * 1080 * 4, stride: 1920 * 4, - dataTypes: SpaFormatPod.VideoCaptureDataTypeMask); + int len = SpaFormatPod.WriteVideoBuffersParam( + buf, + size: 1920 * 1080 * 4, + stride: 1920 * 4, + dataTypes: SpaFormatPod.VideoCaptureDataTypeMask + ); var reader = new SpaPodReader(buf[..len]); Assert.IsTrue(reader.EnterObject(out uint objType, out _, out _)); @@ -242,25 +281,44 @@ public unsafe void VideoBuffersParam_AdvertisesDmaBuf() while (reader.TryReadProperty(out SpaKey key, out var value)) { if (key == SpaParamBuffers.DataType) - dataTypeMask = value.TryUnwrapChoice(out var inner) ? inner.ReadInt() : value.ReadInt(); + dataTypeMask = value.TryUnwrapChoice(out var inner) + ? inner.ReadInt() + : value.ReadInt(); } Assert.IsNotNull(dataTypeMask, "buffers param must contain a dataType property"); int dmaBufBit = 1 << (int)SpaDataType.DmaBuf; int memPtrBit = 1 << (int)SpaDataType.MemPtr; Assert.AreEqual(dmaBufBit, dataTypeMask!.Value & dmaBufBit, "must advertise DMA-BUF"); - Assert.AreEqual(memPtrBit, dataTypeMask!.Value & memPtrBit, "must also advertise host memory fallback"); + Assert.AreEqual( + memPtrBit, + dataTypeMask!.Value & memPtrBit, + "must also advertise host memory fallback" + ); } [TestMethod] public void VideoFrame_CarriesMetadata() { ReadOnlySpan px = stackalloc byte[16]; - var frame = new VideoFrame(px, 64, 4, 4, PixelFormat.Bgra, 7, - bufferType: PipeWireBufferType.DmaBuf, fd: 42, mapOffset: 0, + var frame = new VideoFrame( + px, + 64, + 4, + 4, + PixelFormat.Bgra, + 7, + bufferType: PipeWireBufferType.DmaBuf, + fd: 42, + mapOffset: 0, presentationTimestampNs: 123_456, - color: new VideoColorInfo(VideoColorRange.Full_0_255, VideoColorMatrix.Rgb, - VideoTransferFunction.Srgb, VideoColorPrimaries.Bt709)); + color: new VideoColorInfo( + VideoColorRange.Full_0_255, + VideoColorMatrix.Rgb, + VideoTransferFunction.Srgb, + VideoColorPrimaries.Bt709 + ) + ); Assert.AreEqual(PipeWireBufferType.DmaBuf, frame.BufferType); Assert.IsTrue(frame.IsFdBacked); Assert.AreEqual(42, frame.Fd); @@ -275,7 +333,7 @@ public sealed class ModifierNegotiationTests : PipeWireTestBase // Two sample DRM format modifiers (values are opaque 64-bit tokens; the test only checks the wire // round-trip, not their meaning). Linear plus an AMD GFX9 tiled-ish token exercise multi-value. private const long ModLinear = 0; - private const long ModTiled = 0x0300_0000_0000_0001L; + private const long ModTiled = 0x0300_0000_0000_0001L; [TestMethod] public void WriteVideoFormat_FirstPass_OffersLongChoiceWithMandatoryDontFixate() @@ -283,7 +341,15 @@ public void WriteVideoFormat_FirstPass_OffersLongChoiceWithMandatoryDontFixate() Span buf = stackalloc byte[512]; ReadOnlySpan fmts = stackalloc[] { PixelFormat.Yuv420 }; ReadOnlySpan mods = stackalloc[] { ModTiled, ModLinear }; - int len = SpaFormatPod.WriteVideoFormat(buf, fmts, 1920, 1080, 30, fixedSize: false, modifiers: mods); + int len = SpaFormatPod.WriteVideoFormat( + buf, + fmts, + 1920, + 1080, + 30, + fixedSize: false, + modifiers: mods + ); var reader = new SpaPodReader(buf[..len]); Assert.IsTrue(reader.EnterObject(out _, out _, out _)); @@ -291,23 +357,43 @@ public void WriteVideoFormat_FirstPass_OffersLongChoiceWithMandatoryDontFixate() bool sawModifier = false; while (reader.TryReadProperty(out SpaKey key, out SpaPodPropFlags flags, out var value)) { - if (key != SpaFormat.VideoModifier) continue; + if (key != SpaFormat.VideoModifier) + continue; sawModifier = true; // First pass MUST advertise MANDATORY|DONT_FIXATE so the producer narrows the modifier // set to what it supports without collapsing it to a single value (the two-step handshake). - Assert.AreEqual(SpaPodPropFlags.Mandatory, flags & SpaPodPropFlags.Mandatory, "MANDATORY must be set"); - Assert.AreEqual(SpaPodPropFlags.DontFixate, flags & SpaPodPropFlags.DontFixate, "DONT_FIXATE must be set"); + Assert.AreEqual( + SpaPodPropFlags.Mandatory, + flags & SpaPodPropFlags.Mandatory, + "MANDATORY must be set" + ); + Assert.AreEqual( + SpaPodPropFlags.DontFixate, + flags & SpaPodPropFlags.DontFixate, + "DONT_FIXATE must be set" + ); Assert.IsTrue(value.TryReadModifier(out long first, out int count)); // SPA Choice Enum body is { default, ...allowed }: the first value is the default AND must also // appear in the allowed set, so the preferred modifier is written twice. For [Tiled, Linear] the // wire is [Tiled(default), Tiled, Linear] = 3 longs. A single modifier written once would leave // the allowed set empty and the consumer with nothing to select. - Assert.AreEqual(3, count, "default is repeated into the allowed set, then both modifiers follow"); - Assert.AreEqual(ModTiled, first, "the first offered modifier is the preferred one (the default)"); + Assert.AreEqual( + 3, + count, + "default is repeated into the allowed set, then both modifiers follow" + ); + Assert.AreEqual( + ModTiled, + first, + "the first offered modifier is the preferred one (the default)" + ); } - Assert.IsTrue(sawModifier, "EnumFormat must carry a modifier property when modifiers are offered"); + Assert.IsTrue( + sawModifier, + "EnumFormat must carry a modifier property when modifiers are offered" + ); } [TestMethod] @@ -316,18 +402,31 @@ public void WriteVideoFormat_FixatePass_ClearsDontFixate() Span buf = stackalloc byte[512]; ReadOnlySpan fmts = stackalloc[] { PixelFormat.Yuv420 }; ReadOnlySpan mods = stackalloc[] { ModTiled }; - int len = SpaFormatPod.WriteVideoFormat(buf, fmts, 1920, 1080, 30, fixedSize: false, - modifiers: mods, fixateModifier: true); + int len = SpaFormatPod.WriteVideoFormat( + buf, + fmts, + 1920, + 1080, + 30, + fixedSize: false, + modifiers: mods, + fixateModifier: true + ); var reader = new SpaPodReader(buf[..len]); Assert.IsTrue(reader.EnterObject(out _, out _, out _)); while (reader.TryReadProperty(out SpaKey key, out SpaPodPropFlags flags, out _)) { - if (key != SpaFormat.VideoModifier) continue; + if (key != SpaFormat.VideoModifier) + continue; // The fixate pass keeps MANDATORY but drops DONT_FIXATE, telling the producer to settle // on the single modifier we now offer. Assert.AreEqual(SpaPodPropFlags.Mandatory, flags & SpaPodPropFlags.Mandatory); - Assert.AreEqual(SpaPodPropFlags.None, flags & SpaPodPropFlags.DontFixate, "fixate pass must NOT set DONT_FIXATE"); + Assert.AreEqual( + SpaPodPropFlags.None, + flags & SpaPodPropFlags.DontFixate, + "fixate pass must NOT set DONT_FIXATE" + ); } } @@ -337,12 +436,22 @@ public unsafe void ParseVideoFormat_MultiModifierChoice_FlagsFixationNeeded() Span buf = stackalloc byte[512]; ReadOnlySpan fmts = stackalloc[] { PixelFormat.Yuv420 }; ReadOnlySpan mods = stackalloc[] { ModTiled, ModLinear }; - int len = SpaFormatPod.WriteVideoFormat(buf, fmts, 1920, 1080, 30, fixedSize: false, modifiers: mods); + int len = SpaFormatPod.WriteVideoFormat( + buf, + fmts, + 1920, + 1080, + 30, + fixedSize: false, + modifiers: mods + ); SpaFormatPod.VideoFormatInfo info; fixed (byte* p = buf) - info = SpaFormatPod.ParseVideoFormat((spa_pod*)p, - new SpaFormatPod.VideoFormatInfo(PixelFormat.Yuv420, 0, 0, VideoColorInfo.Unknown)); + info = SpaFormatPod.ParseVideoFormat( + (spa_pod*)p, + new SpaFormatPod.VideoFormatInfo(PixelFormat.Yuv420, 0, 0, VideoColorInfo.Unknown) + ); Assert.AreEqual((ulong)ModTiled, info.Modifier, "preferred modifier is the first offered"); Assert.IsTrue(info.ModifierNeedsFixation, "more than one modifier => still needs fixation"); @@ -354,13 +463,23 @@ public unsafe void ParseVideoFormat_SingleModifier_IsFixated() Span buf = stackalloc byte[512]; ReadOnlySpan fmts = stackalloc[] { PixelFormat.Yuv420 }; ReadOnlySpan mods = stackalloc[] { ModTiled }; - int len = SpaFormatPod.WriteVideoFormat(buf, fmts, 1920, 1080, 30, fixedSize: false, - modifiers: mods, fixateModifier: true); + int len = SpaFormatPod.WriteVideoFormat( + buf, + fmts, + 1920, + 1080, + 30, + fixedSize: false, + modifiers: mods, + fixateModifier: true + ); SpaFormatPod.VideoFormatInfo info; fixed (byte* p = buf) - info = SpaFormatPod.ParseVideoFormat((spa_pod*)p, - new SpaFormatPod.VideoFormatInfo(PixelFormat.Yuv420, 0, 0, VideoColorInfo.Unknown)); + info = SpaFormatPod.ParseVideoFormat( + (spa_pod*)p, + new SpaFormatPod.VideoFormatInfo(PixelFormat.Yuv420, 0, 0, VideoColorInfo.Unknown) + ); Assert.AreEqual((ulong)ModTiled, info.Modifier); Assert.IsFalse(info.ModifierNeedsFixation, "a single modifier is already fixated"); @@ -372,12 +491,21 @@ public void VideoFrame_CarriesModifierAndPlanes() ReadOnlySpan px = default; ReadOnlySpan planes = stackalloc VideoPlane[] { - new VideoPlane(Fd: 7, Offset: 0, Stride: 1920, Size: 1920 * 1080), - new VideoPlane(Fd: 7, Offset: 1920*1080, Stride: 1920, Size: 1920 * 1080 / 2), + new VideoPlane(Fd: 7, Offset: 0, Stride: 1920, Size: 1920 * 1080), + new VideoPlane(Fd: 7, Offset: 1920 * 1080, Stride: 1920, Size: 1920 * 1080 / 2), }; - var frame = new VideoFrame(px, 1920, 1920, 1080, PixelFormat.Yuv420, 1, - bufferType: PipeWireBufferType.DmaBuf, fd: 7, - modifier: (ulong)ModTiled, planes: planes); + var frame = new VideoFrame( + px, + 1920, + 1920, + 1080, + PixelFormat.Yuv420, + 1, + bufferType: PipeWireBufferType.DmaBuf, + fd: 7, + modifier: (ulong)ModTiled, + planes: planes + ); Assert.AreEqual((ulong)ModTiled, frame.Modifier); Assert.AreEqual(2, frame.Planes.Length); @@ -413,16 +541,20 @@ public void WriteVideoBuffersParam_DmaBufNv12_RequestsTwoBlocks() // spa_data (fd/offset/stride), and advertise the DMA-BUF data type only. int blocks = SpaFormatPod.PlaneCount(PixelFormat.Nv12); Span buf = stackalloc byte[256]; - int len = SpaFormatPod.WriteVideoBuffersParam(buf, + int len = SpaFormatPod.WriteVideoBuffersParam( + buf, size: SpaFormatPod.VideoImageSize(PixelFormat.Nv12, 1920, 1080), stride: SpaFormatPod.VideoStride(PixelFormat.Nv12, 1920), - dataTypes: 1 << (int)SpaDataType.DmaBuf, blocks: blocks); + dataTypes: 1 << (int)SpaDataType.DmaBuf, + blocks: blocks + ); var reader = new SpaPodReader(buf[..len]); Assert.IsTrue(reader.EnterObject(out uint objType, out _, out _)); Assert.AreEqual(SpaType.ObjectParamBuffers, (SpaType)objType); - int? sawBlocks = null, sawDataType = null; + int? sawBlocks = null, + sawDataType = null; while (reader.TryReadProperty(out SpaKey key, out var value)) { if (key == SpaParamBuffers.Blocks) @@ -483,13 +615,15 @@ public unsafe void PwProperties_RoundtripsKeyValue() { ReadOnlySpan kv = "media.role=Camera\0"u8; pw_properties* props; - fixed (byte* p = kv) props = Native.pw_properties_new_string((sbyte*)p); + fixed (byte* p = kv) + props = Native.pw_properties_new_string((sbyte*)p); Assert.IsTrue(props is not null); ReadOnlySpan key = "media.role\0"u8; sbyte* val; - fixed (byte* k = key) val = Native.pw_properties_get(props, (sbyte*)k); + fixed (byte* k = key) + val = Native.pw_properties_get(props, (sbyte*)k); string? gotValue = val is null ? null : Marshal.PtrToStringUTF8((nint)val); Assert.AreEqual("Camera", gotValue); @@ -534,27 +668,43 @@ public async Task AudioOutputToCapture_DeliversFrames_EndToEnd() using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); await registry.WaitForInitialEnumerationAsync(cts.Token); PipeWireNode sink = await registry.CreateVirtualSinkAsync( - "RoundTripSink", "pwnet_roundtrip_sink", cts.Token); - - const int rate = 48000, channels = 2; - await using var output = new PipeWireAudioOutput(ctx, "PipeWire.NET.Test.Source", - sampleRate: rate, channels: channels, format: AudioSampleFormat.F32Le); + "RoundTripSink", + "pwnet_roundtrip_sink", + cts.Token + ); + + const int rate = 48000, + channels = 2; + await using var output = new PipeWireAudioOutput( + ctx, + "PipeWire.NET.Test.Source", + sampleRate: rate, + channels: channels, + format: AudioSampleFormat.F32Le + ); int produced = 0; output.FillSamples += (_, samples, _, _, _) => { - samples.Clear(); // silence is fine; we only assert flow + samples.Clear(); // silence is fine; we only assert flow Interlocked.Increment(ref produced); return samples.Length; }; output.Connect(targetObjectName: sink.NodeName, autoConnect: false); // AudioFrame is a ref struct and can't be a TResult - capture scalar facts. - var captured = new TaskCompletionSource<(int Rate, int Channels)>(TaskCreationOptions.RunContinuationsAsynchronously); + var captured = new TaskCompletionSource<(int Rate, int Channels)>( + TaskCreationOptions.RunContinuationsAsynchronously + ); await using var capture = new PipeWireAudioCapture(ctx, "PipeWire.NET.Test.Sink"); - capture.FrameReady += (_, frame) => captured.TrySetResult((frame.SampleRate, frame.Channels)); - capture.Connect(sampleRate: rate, channels: channels, format: AudioSampleFormat.F32Le, - targetObjectName: "PipeWire.NET.Test.Source"); + capture.FrameReady += (_, frame) => + captured.TrySetResult((frame.SampleRate, frame.Channels)); + capture.Connect( + sampleRate: rate, + channels: channels, + format: AudioSampleFormat.F32Le, + targetObjectName: "PipeWire.NET.Test.Source" + ); (int Rate, int Channels) got = await captured.Task.WaitAsync(TimeSpan.FromSeconds(5)); @@ -588,7 +738,8 @@ public async Task VideoOutputToCapture_DeliversFrames_EndToEnd() // passes the buffer through unchanged - proving real pixel data crosses the graph, // not merely that "a buffer of the right shape arrived". const byte marker = 0xAB; - const int width = 320, height = 240; + const int width = 320, + height = 240; await using var ctx = new PipeWireContext(); await ctx.StartAsync(); @@ -596,33 +747,52 @@ public async Task VideoOutputToCapture_DeliversFrames_EndToEnd() using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); await registry.WaitForInitialEnumerationAsync(cts.Token); PipeWireNode sink = await registry.CreateVirtualSinkAsync( - "RoundTripVideoSink", "pwnet_roundtrip_vsink", cts.Token); - - await using var output = new PipeWireVideoOutput(ctx, "PipeWire.NET.Test.VideoSource", - width: width, height: height, format: PixelFormat.Bgra, frameRate: 30); + "RoundTripVideoSink", + "pwnet_roundtrip_vsink", + cts.Token + ); + + await using var output = new PipeWireVideoOutput( + ctx, + "PipeWire.NET.Test.VideoSource", + width: width, + height: height, + format: PixelFormat.Bgra, + frameRate: 30 + ); int produced = 0; output.FillFrame += (_, pixels, _, _, _, _) => { - pixels.Fill(marker); // known pattern, not zeros + pixels.Fill(marker); // known pattern, not zeros Interlocked.Increment(ref produced); return true; }; output.Connect(targetObjectName: sink.NodeName, autoConnect: false); // Copy enough captured bytes out of the ref-struct frame to verify content. - var captured = new TaskCompletionSource<(int W, int H, PixelFormat Fmt, PipeWireBufferType Buf, byte[] Head)>( - TaskCreationOptions.RunContinuationsAsynchronously); + var captured = new TaskCompletionSource<( + int W, + int H, + PixelFormat Fmt, + PipeWireBufferType Buf, + byte[] Head + )>(TaskCreationOptions.RunContinuationsAsynchronously); await using var capture = new PipeWireVideoCapture(ctx, "PipeWire.NET.Test.VideoSink"); capture.FrameReady += (_, frame) => { // Ignore the initial empty/black frames some negotiations emit; wait for real data. - if (frame.Pixels.Length < 256) return; + if (frame.Pixels.Length < 256) + return; byte[] head = frame.Pixels[..256].ToArray(); - captured.TrySetResult((frame.Width, frame.Height, frame.Format, frame.BufferType, head)); + captured.TrySetResult( + (frame.Width, frame.Height, frame.Format, frame.BufferType, head) + ); }; - capture.Connect(preferredFormats: stackalloc[] { PixelFormat.Bgra }, - targetObjectName: "PipeWire.NET.Test.VideoSource"); + capture.Connect( + preferredFormats: stackalloc[] { PixelFormat.Bgra }, + targetObjectName: "PipeWire.NET.Test.VideoSource" + ); var got = await captured.Task.WaitAsync(TimeSpan.FromSeconds(5)); @@ -633,7 +803,11 @@ public async Task VideoOutputToCapture_DeliversFrames_EndToEnd() Assert.AreNotEqual(PipeWireBufferType.Unknown, got.Buf); foreach (byte b in got.Head) - Assert.AreEqual(marker, b, "captured pixel data must match the produced marker byte-for-byte"); + Assert.AreEqual( + marker, + b, + "captured pixel data must match the produced marker byte-for-byte" + ); // Both streams are flowing here, so the streaming wait returns at once and the // control lookup goes through on a live stream (an unknown id reports nothing). @@ -668,14 +842,21 @@ public async Task VideoRoundTrip_PreservesAlphaChannel() // and assert all four channels - alpha included - arrive byte-exact. Proves the alpha // channel is carried through the graph, not silently dropped to opaque. // BGRA byte order in memory is B,G,R,A. - byte[] pixel = [0x11, 0x22, 0x33, 0x80]; // B,G,R,A=0x80 (semi-transparent) - const int width = 64, height = 64; + byte[] pixel = [0x11, 0x22, 0x33, 0x80]; // B,G,R,A=0x80 (semi-transparent) + const int width = 64, + height = 64; await using var ctx = new PipeWireContext(); await ctx.StartAsync(); - await using var output = new PipeWireVideoOutput(ctx, "PipeWire.NET.Test.AlphaSource", - width: width, height: height, format: PixelFormat.Bgra, frameRate: 30); + await using var output = new PipeWireVideoOutput( + ctx, + "PipeWire.NET.Test.AlphaSource", + width: width, + height: height, + format: PixelFormat.Bgra, + frameRate: 30 + ); output.FillFrame += (_, pixels, _, _, _, _) => { for (int i = 0; i + 4 <= pixels.Length; i += 4) @@ -689,20 +870,28 @@ public async Task VideoRoundTrip_PreservesAlphaChannel() }; output.Connect(); - var captured = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var captured = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously + ); await using var capture = new PipeWireVideoCapture(ctx, "PipeWire.NET.Test.AlphaSink"); capture.FrameReady += (_, frame) => { - if (frame.Pixels.Length < 4) return; + if (frame.Pixels.Length < 4) + return; captured.TrySetResult(frame.Pixels[..4].ToArray()); }; - capture.Connect(preferredFormats: stackalloc[] { PixelFormat.Bgra }, - targetObjectName: "PipeWire.NET.Test.AlphaSource"); + capture.Connect( + preferredFormats: stackalloc[] { PixelFormat.Bgra }, + targetObjectName: "PipeWire.NET.Test.AlphaSource" + ); byte[] firstPixel = await captured.Task.WaitAsync(TimeSpan.FromSeconds(5)); - CollectionAssert.AreEqual(pixel, firstPixel, - "BGRA pixel including the 0x80 alpha byte must survive the round-trip"); + CollectionAssert.AreEqual( + pixel, + firstPixel, + "BGRA pixel including the 0x80 alpha byte must survive the round-trip" + ); Assert.AreEqual(0x80, firstPixel[3], "alpha channel must be preserved (not forced opaque)"); } @@ -714,12 +903,18 @@ public async Task DisposingAStreamFromItsOwnCallback_IsRefused() { // The callback's frame is still on the stack: destroying the stream underneath it would // requeue a buffer onto freed memory once the handler returns. - const int rate = 48000, channels = 2; + const int rate = 48000, + channels = 2; await using var ctx = new PipeWireContext(); await ctx.StartAsync(); - await using var output = new PipeWireAudioOutput(ctx, "PipeWire.NET.Test.Suicidal", - sampleRate: rate, channels: channels, format: AudioSampleFormat.F32Le); + await using var output = new PipeWireAudioOutput( + ctx, + "PipeWire.NET.Test.Suicidal", + sampleRate: rate, + channels: channels, + format: AudioSampleFormat.F32Le + ); var refused = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); output.FillSamples += (_, samples, _, _, _) => @@ -743,8 +938,12 @@ public async Task DisposingAStreamFromItsOwnCallback_IsRefused() var flowed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); await using var capture = new PipeWireAudioCapture(ctx, "PipeWire.NET.Test.SuicidalSink"); capture.FrameReady += (_, _) => flowed.TrySetResult(); - capture.Connect(sampleRate: rate, channels: channels, format: AudioSampleFormat.F32Le, - targetObjectName: "PipeWire.NET.Test.Suicidal"); + capture.Connect( + sampleRate: rate, + channels: channels, + format: AudioSampleFormat.F32Le, + targetObjectName: "PipeWire.NET.Test.Suicidal" + ); await refused.Task.WaitAsync(TimeSpan.FromSeconds(10)); await flowed.Task.WaitAsync(TimeSpan.FromSeconds(10)); @@ -758,12 +957,18 @@ public async Task AThrowingStateChangedHandler_DoesNotStopTheStream() { // State transitions are reported, not load-bearing: a subscriber that throws is logged // and the stream keeps flowing. - const int rate = 48000, channels = 2; + const int rate = 48000, + channels = 2; await using var ctx = new PipeWireContext(); await ctx.StartAsync(); - await using var output = new PipeWireAudioOutput(ctx, "PipeWire.NET.Test.StateThrow", - sampleRate: rate, channels: channels, format: AudioSampleFormat.F32Le); + await using var output = new PipeWireAudioOutput( + ctx, + "PipeWire.NET.Test.StateThrow", + sampleRate: rate, + channels: channels, + format: AudioSampleFormat.F32Le + ); output.FillSamples += (_, samples, _, _, _) => { samples.Clear(); @@ -775,8 +980,12 @@ public async Task AThrowingStateChangedHandler_DoesNotStopTheStream() var flowed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); await using var capture = new PipeWireAudioCapture(ctx, "PipeWire.NET.Test.StateThrowSink"); capture.FrameReady += (_, _) => flowed.TrySetResult(); - capture.Connect(sampleRate: rate, channels: channels, format: AudioSampleFormat.F32Le, - targetObjectName: "PipeWire.NET.Test.StateThrow"); + capture.Connect( + sampleRate: rate, + channels: channels, + format: AudioSampleFormat.F32Le, + targetObjectName: "PipeWire.NET.Test.StateThrow" + ); await flowed.Task.WaitAsync(TimeSpan.FromSeconds(10)); } @@ -802,18 +1011,27 @@ public async Task AnUnconnectedVideoCapture_RefusesWorkWithClearErrors() Assert.AreEqual(0, capture.Controls.Length); using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); - Assert.ThrowsExactly(() => capture.WaitForStreamingAsync(cts.Token)); + Assert.ThrowsExactly(() => + capture.WaitForStreamingAsync(cts.Token) + ); Assert.ThrowsExactly(() => capture.SetControl(0, [])); Assert.ThrowsExactly(() => capture.Connect(null!)); - Assert.ThrowsExactly(() => capture.Connect( - modifiers: new long[] { 1 }, - preferredFormats: new[] { PixelFormat.Bgra, PixelFormat.Rgba })); + Assert.ThrowsExactly(() => + capture.Connect( + modifiers: new long[] { 1 }, + preferredFormats: new[] { PixelFormat.Bgra, PixelFormat.Rgba } + ) + ); // The source overload forwards to the id overload once the null check passes. It starts // a real negotiation, so the node it targets is torn down again immediately after. await using var registry = new PipeWireRegistry(ctx); await registry.WaitForInitialEnumerationAsync(cts.Token); - PipeWireNode node = await registry.CreateVirtualSinkAsync("GuardSrc", "pwnet_guard_src", cts.Token); + PipeWireNode node = await registry.CreateVirtualSinkAsync( + "GuardSrc", + "pwnet_guard_src", + cts.Token + ); await using PipeWireNodeProxy nodeControl = registry.BindNode(node.NodeId); Assert.AreEqual(2, (await nodeControl.GetChannelMapAsync(cts.Token)).Length); await registry.DestroyGlobalAsync(node.NodeId, cts.Token); @@ -833,15 +1051,22 @@ public async Task AnUnconnectedAudioCapture_RefusesWorkWithClearErrors() Assert.AreEqual(0, capture.Controls.Length); using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); - Assert.ThrowsExactly(() => capture.WaitForStreamingAsync(cts.Token)); + Assert.ThrowsExactly(() => + capture.WaitForStreamingAsync(cts.Token) + ); Assert.ThrowsExactly(() => capture.SetControl(0, [])); Assert.ThrowsExactly(() => capture.Connect(null!)); - Assert.ThrowsExactly( - () => capture.Connect(sampleRate: 0, channels: 2, format: AudioSampleFormat.F32Le)); + Assert.ThrowsExactly(() => + capture.Connect(sampleRate: 0, channels: 2, format: AudioSampleFormat.F32Le) + ); await using var registry = new PipeWireRegistry(ctx); await registry.WaitForInitialEnumerationAsync(cts.Token); - PipeWireNode node = await registry.CreateVirtualSinkAsync("GuardAudioSrc", "pwnet_guard_asrc", cts.Token); + PipeWireNode node = await registry.CreateVirtualSinkAsync( + "GuardAudioSrc", + "pwnet_guard_asrc", + cts.Token + ); await using PipeWireNodeProxy nodeControl = registry.BindNode(node.NodeId); Assert.AreEqual(2, (await nodeControl.GetChannelMapAsync(cts.Token)).Length); await registry.DestroyGlobalAsync(node.NodeId, cts.Token); @@ -856,12 +1081,20 @@ public async Task AnUnconnectedVideoOutput_RefusesWorkWithClearErrors() await using var ctx = new PipeWireContext(); await ctx.StartAsync(); - await using var output = new PipeWireVideoOutput(ctx, "PipeWire.NET.Test.GuardVideoOut", - width: 320, height: 240, format: PixelFormat.Bgra, frameRate: 30); + await using var output = new PipeWireVideoOutput( + ctx, + "PipeWire.NET.Test.GuardVideoOut", + width: 320, + height: 240, + format: PixelFormat.Bgra, + frameRate: 30 + ); Assert.AreEqual(0, output.Controls.Length); using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); - Assert.ThrowsExactly(() => output.WaitForStreamingAsync(cts.Token)); + Assert.ThrowsExactly(() => + output.WaitForStreamingAsync(cts.Token) + ); Assert.ThrowsExactly(() => output.SetControl(0, [])); } @@ -874,13 +1107,20 @@ public async Task AnUnconnectedAudioOutput_RefusesWorkWithClearErrors() await using var ctx = new PipeWireContext(); await ctx.StartAsync(); - await using var output = new PipeWireAudioOutput(ctx, "PipeWire.NET.Test.GuardAudioOut", - sampleRate: 48000, channels: 2, format: AudioSampleFormat.F32Le); + await using var output = new PipeWireAudioOutput( + ctx, + "PipeWire.NET.Test.GuardAudioOut", + sampleRate: 48000, + channels: 2, + format: AudioSampleFormat.F32Le + ); Assert.AreEqual(0, output.Controls.Length); Assert.IsNull(output.GetControl(0)); using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); - Assert.ThrowsExactly(() => output.WaitForStreamingAsync(cts.Token)); + Assert.ThrowsExactly(() => + output.WaitForStreamingAsync(cts.Token) + ); Assert.ThrowsExactly(() => output.SetControl(0, [])); } @@ -897,8 +1137,13 @@ public async Task ConnectingOnADisposedContext_IsRefusedAndLeavesNothingBehind() await ctx.StartAsync(); await ctx.DisposeAsync(); - await using var output = new PipeWireAudioOutput(ctx, "PipeWire.NET.Test.RefusedSource", - sampleRate: 48000, channels: 2, format: AudioSampleFormat.F32Le); + await using var output = new PipeWireAudioOutput( + ctx, + "PipeWire.NET.Test.RefusedSource", + sampleRate: 48000, + channels: 2, + format: AudioSampleFormat.F32Le + ); Assert.ThrowsExactly(() => output.Connect()); Assert.IsNull(output.NodeId); @@ -906,8 +1151,14 @@ public async Task ConnectingOnADisposedContext_IsRefusedAndLeavesNothingBehind() Assert.ThrowsExactly(() => capture.Connect()); Assert.IsNull(capture.NodeId); - await using var video = new PipeWireVideoOutput(ctx, "PipeWire.NET.Test.RefusedVideo", - width: 320, height: 240, format: PixelFormat.Bgra, frameRate: 30); + await using var video = new PipeWireVideoOutput( + ctx, + "PipeWire.NET.Test.RefusedVideo", + width: 320, + height: 240, + format: PixelFormat.Bgra, + frameRate: 30 + ); Assert.ThrowsExactly(() => video.Connect()); Assert.IsNull(video.NodeId); } diff --git a/tests/PipeWire.NET.Tests/PodComparisonTests.cs b/tests/PipeWire.NET.Tests/PodComparisonTests.cs index c6735e2..61abc3f 100644 --- a/tests/PipeWire.NET.Tests/PodComparisonTests.cs +++ b/tests/PipeWire.NET.Tests/PodComparisonTests.cs @@ -48,14 +48,21 @@ public void CompareValue_OrdersEachScalarByItsOwnRule() public void CompareValue_OrdersRectanglesByAreaThenWidth() { Assert.AreEqual( - -1, SpaPodCompare.CompareValue(new SpaRectangle(4, 1), new SpaRectangle(2, 4)), - "a rectangle with less area sorted higher"); + -1, + SpaPodCompare.CompareValue(new SpaRectangle(4, 1), new SpaRectangle(2, 4)), + "a rectangle with less area sorted higher" + ); Assert.AreEqual( - 1, SpaPodCompare.CompareValue(new SpaRectangle(4, 1), new SpaRectangle(1, 4)), - "equal areas did not fall through to width"); + 1, + SpaPodCompare.CompareValue(new SpaRectangle(4, 1), new SpaRectangle(1, 4)), + "equal areas did not fall through to width" + ); - Assert.AreEqual(0, SpaPodCompare.CompareValue(new SpaRectangle(64, 64), new SpaRectangle(64, 64))); + Assert.AreEqual( + 0, + SpaPodCompare.CompareValue(new SpaRectangle(64, 64), new SpaRectangle(64, 64)) + ); } /// Fractions cross-multiply, so unreduced forms of the same rate are equal. @@ -63,11 +70,19 @@ public void CompareValue_OrdersRectanglesByAreaThenWidth() public void CompareValue_ComparesFractionsByValueNotByForm() { Assert.AreEqual( - 0, SpaPodCompare.CompareValue(new SpaFraction(1, 2), new SpaFraction(2, 4)), - "the same rate written two ways did not compare equal"); + 0, + SpaPodCompare.CompareValue(new SpaFraction(1, 2), new SpaFraction(2, 4)), + "the same rate written two ways did not compare equal" + ); - Assert.AreEqual(1, SpaPodCompare.CompareValue(new SpaFraction(60, 1), new SpaFraction(30, 1))); - Assert.AreEqual(-1, SpaPodCompare.CompareValue(new SpaFraction(24000, 1001), new SpaFraction(30, 1))); + Assert.AreEqual( + 1, + SpaPodCompare.CompareValue(new SpaFraction(60, 1), new SpaFraction(30, 1)) + ); + Assert.AreEqual( + -1, + SpaPodCompare.CompareValue(new SpaFraction(24000, 1001), new SpaFraction(30, 1)) + ); } /// Two values of different types fall back to comparing their encodings. @@ -80,14 +95,20 @@ public void CompareValue_ComparesFractionsByValueNotByForm() public void CompareValue_FallsBackToBytesForTypesWithNoOrdering() { Assert.AreEqual( - 1, SpaPodCompare.CompareValue(new SpaInt(1), new SpaLong(1)), - "two different types compared equal"); + 1, + SpaPodCompare.CompareValue(new SpaInt(1), new SpaLong(1)), + "two different types compared equal" + ); var one = new SpaArray(SpaType.Int, [new SpaInt(1), new SpaInt(2)]); var same = new SpaArray(SpaType.Int, [new SpaInt(1), new SpaInt(2)]); var other = new SpaArray(SpaType.Int, [new SpaInt(1), new SpaInt(3)]); - Assert.AreEqual(0, SpaPodCompare.CompareValue(one, same), "identical arrays compared unequal"); + Assert.AreEqual( + 0, + SpaPodCompare.CompareValue(one, same), + "identical arrays compared unequal" + ); Assert.AreEqual(1, SpaPodCompare.CompareValue(one, other)); } @@ -109,21 +130,36 @@ public void IsStepOf_SeparatesAnInvalidStepFromAMissedOne() Assert.AreEqual(0, SpaPodCompare.IsStepOf(new SpaLong(1001), new SpaLong(250))); Assert.AreEqual(-1, SpaPodCompare.IsStepOf(new SpaLong(1000), new SpaLong(0))); - Assert.AreEqual(1, SpaPodCompare.IsStepOf(new SpaRectangle(64, 32), new SpaRectangle(16, 16))); - Assert.AreEqual(0, SpaPodCompare.IsStepOf(new SpaRectangle(65, 32), new SpaRectangle(16, 16))); - Assert.AreEqual(-1, SpaPodCompare.IsStepOf(new SpaRectangle(64, 32), new SpaRectangle(0, 16))); - Assert.AreEqual(-1, SpaPodCompare.IsStepOf(new SpaRectangle(64, 32), new SpaRectangle(16, 0))); + Assert.AreEqual( + 1, + SpaPodCompare.IsStepOf(new SpaRectangle(64, 32), new SpaRectangle(16, 16)) + ); + Assert.AreEqual( + 0, + SpaPodCompare.IsStepOf(new SpaRectangle(65, 32), new SpaRectangle(16, 16)) + ); + Assert.AreEqual( + -1, + SpaPodCompare.IsStepOf(new SpaRectangle(64, 32), new SpaRectangle(0, 16)) + ); + Assert.AreEqual( + -1, + SpaPodCompare.IsStepOf(new SpaRectangle(64, 32), new SpaRectangle(16, 0)) + ); Assert.AreEqual( - -1, SpaPodCompare.IsStepOf(new SpaFloat(1f), new SpaFloat(0.5f)), - "a type with no step rule reported a step"); + -1, + SpaPodCompare.IsStepOf(new SpaFloat(1f), new SpaFloat(0.5f)), + "a type with no step rule reported a step" + ); } /// A range is inclusive at both ends, and a step applies inside it. [TestMethod] public void IsInRange_IsInclusiveAndHonoursAStep() { - SpaValue min = new SpaInt(10), max = new SpaInt(20); + SpaValue min = new SpaInt(10), + max = new SpaInt(20); Assert.AreEqual(1, SpaPodCompare.IsInRange(new SpaInt(10), min, max, null)); Assert.AreEqual(1, SpaPodCompare.IsInRange(new SpaInt(20), min, max, null)); @@ -145,13 +181,22 @@ public void IsInRange_IsInclusiveAndHonoursAStep() public void IsValidChoice_FollowsTheRuleForEachKind() { ImmutableArray enumeration = - [new SpaInt(44100), new SpaInt(48000), new SpaInt(96000)]; + [ + new SpaInt(44100), + new SpaInt(48000), + new SpaInt(96000), + ]; - Assert.IsTrue(SpaPodCompare.IsValidChoice(new SpaInt(48000), enumeration, SpaChoiceType.Enum)); - Assert.IsTrue(SpaPodCompare.IsValidChoice(new SpaInt(96000), enumeration, SpaChoiceType.Enum)); + Assert.IsTrue( + SpaPodCompare.IsValidChoice(new SpaInt(48000), enumeration, SpaChoiceType.Enum) + ); + Assert.IsTrue( + SpaPodCompare.IsValidChoice(new SpaInt(96000), enumeration, SpaChoiceType.Enum) + ); Assert.IsFalse( SpaPodCompare.IsValidChoice(new SpaInt(44100), enumeration, SpaChoiceType.Enum), - "an enumeration accepted its own default, which upstream does not list as a member"); + "an enumeration accepted its own default, which upstream does not list as a member" + ); ImmutableArray none = [new SpaInt(48000)]; Assert.IsTrue(SpaPodCompare.IsValidChoice(new SpaInt(48000), none, SpaChoiceType.None)); @@ -162,18 +207,29 @@ public void IsValidChoice_FollowsTheRuleForEachKind() Assert.IsTrue(SpaPodCompare.IsValidChoice(new SpaInt(48000), range, SpaChoiceType.Range)); Assert.IsFalse(SpaPodCompare.IsValidChoice(new SpaInt(4000), range, SpaChoiceType.Range)); Assert.IsFalse( - SpaPodCompare.IsValidChoice(new SpaInt(48000), [new SpaInt(48000)], SpaChoiceType.Range), - "a range without bounds accepted a value anyway"); + SpaPodCompare.IsValidChoice( + new SpaInt(48000), + [new SpaInt(48000)], + SpaChoiceType.Range + ), + "a range without bounds accepted a value anyway" + ); ImmutableArray step = - [new SpaInt(48000), new SpaInt(8000), new SpaInt(192000), new SpaInt(8000)]; + [ + new SpaInt(48000), + new SpaInt(8000), + new SpaInt(192000), + new SpaInt(8000), + ]; Assert.IsTrue(SpaPodCompare.IsValidChoice(new SpaInt(48000), step, SpaChoiceType.Step)); Assert.IsFalse(SpaPodCompare.IsValidChoice(new SpaInt(48001), step, SpaChoiceType.Step)); Assert.IsFalse(SpaPodCompare.IsValidChoice(new SpaInt(48000), range, SpaChoiceType.Step)); Assert.IsTrue( SpaPodCompare.IsValidChoice(new SpaInt(7), [], SpaChoiceType.Flags), - "flags refused a value, but any combination of flags is allowed"); + "flags refused a value, but any combination of flags is allowed" + ); Assert.IsFalse(SpaPodCompare.IsValidChoice(new SpaInt(1), none, (SpaChoiceType)999)); } @@ -187,14 +243,21 @@ public void IsValidChoice_FollowsTheRuleForEachKind() [TestMethod] public void AndFlags_AnswersNothingWhenTheSidesShareNoBits() { - Assert.AreEqual(new SpaInt(0b0100), SpaPodCompare.AndFlags(new SpaInt(0b1100), new SpaInt(0b0110))); - Assert.AreEqual(new SpaLong(0b0100), SpaPodCompare.AndFlags(new SpaLong(0b1100), new SpaLong(0b0110))); + Assert.AreEqual( + new SpaInt(0b0100), + SpaPodCompare.AndFlags(new SpaInt(0b1100), new SpaInt(0b0110)) + ); + Assert.AreEqual( + new SpaLong(0b0100), + SpaPodCompare.AndFlags(new SpaLong(0b1100), new SpaLong(0b0110)) + ); Assert.IsNull(SpaPodCompare.AndFlags(new SpaInt(0b1000), new SpaInt(0b0001))); Assert.IsNull(SpaPodCompare.AndFlags(new SpaLong(0b1000), new SpaLong(0b0001))); Assert.IsNull( SpaPodCompare.AndFlags(new SpaInt(0b1100), new SpaLong(0b0110)), - "two different types were intersected as though they were the same one"); + "two different types were intersected as though they were the same one" + ); Assert.IsNull(SpaPodCompare.AndFlags(new SpaFloat(1f), new SpaFloat(1f))); } } diff --git a/tests/PipeWire.NET.Tests/PublicSurfaceTests.cs b/tests/PipeWire.NET.Tests/PublicSurfaceTests.cs index ede04a4..db1aeab 100644 --- a/tests/PipeWire.NET.Tests/PublicSurfaceTests.cs +++ b/tests/PipeWire.NET.Tests/PublicSurfaceTests.cs @@ -40,16 +40,25 @@ private static string RenderSurface() { foreach (Type type in assembly.GetExportedTypes()) { - if (IsCompilerGenerated(type)) continue; + if (IsCompilerGenerated(type)) + continue; lines.Add(type.FullName!); - foreach (MemberInfo member in type.GetMembers( - BindingFlags.Public | BindingFlags.Instance | BindingFlags.Static | BindingFlags.DeclaredOnly)) + foreach ( + MemberInfo member in type.GetMembers( + BindingFlags.Public + | BindingFlags.Instance + | BindingFlags.Static + | BindingFlags.DeclaredOnly + ) + ) { // Property and event accessors are already implied by the property or event. - if (member is MethodInfo { IsSpecialName: true }) continue; - if (IsCompilerGenerated(member)) continue; + if (member is MethodInfo { IsSpecialName: true }) + continue; + if (IsCompilerGenerated(member)) + continue; lines.Add($"{type.FullName}.{Signature(member)}"); } @@ -60,15 +69,16 @@ private static string RenderSurface() return string.Join('\n', lines) + '\n'; } - private static string Signature(MemberInfo member) => member switch - { - MethodBase method => - $"{method.Name}({string.Join(", ", method.GetParameters().Select(p => Render(p.ParameterType)))})", - PropertyInfo property => $"{property.Name} : {Render(property.PropertyType)}", - FieldInfo field => $"{field.Name} = {Render(field.FieldType)}", - EventInfo evt => $"{evt.Name} event", - _ => member.Name, - }; + private static string Signature(MemberInfo member) => + member switch + { + MethodBase method => + $"{method.Name}({string.Join(", ", method.GetParameters().Select(p => Render(p.ParameterType)))})", + PropertyInfo property => $"{property.Name} : {Render(property.PropertyType)}", + FieldInfo field => $"{field.Name} = {Render(field.FieldType)}", + EventInfo evt => $"{evt.Name} event", + _ => member.Name, + }; /// A type name that tells two generic instantiations apart. /// @@ -78,11 +88,13 @@ private static string RenderSurface() /// private static string Render(Type type) { - if (!type.IsGenericType) return type.Name; + if (!type.IsGenericType) + return type.Name; string name = type.Name; int tick = name.IndexOf('`', StringComparison.Ordinal); - if (tick >= 0) name = name[..tick]; + if (tick >= 0) + name = name[..tick]; return $"{name}<{string.Join(", ", type.GetGenericArguments().Select(Render))}>"; } @@ -100,7 +112,9 @@ public void ThePublicSurface_MatchesTheCheckedInBaseline() // missing baseline there means it was never committed, and quietly generating one // turns the whole check into a skip that reads as a pass. if (Environment.GetEnvironmentVariable("CI") is { Length: > 0 }) - Assert.Fail($"{baselinePath} is missing. It is a committed file, not a generated one."); + Assert.Fail( + $"{baselinePath} is missing. It is a committed file, not a generated one." + ); File.WriteAllText(baselinePath, actual, new UTF8Encoding(false)); Assert.Inconclusive($"wrote a first baseline to {baselinePath}; review and commit it."); @@ -108,18 +122,26 @@ public void ThePublicSurface_MatchesTheCheckedInBaseline() string expected = File.ReadAllText(baselinePath).ReplaceLineEndings("\n"); - if (expected == actual) return; + if (expected == actual) + return; // Show what moved rather than two thousand lines of file. var before = new HashSet(expected.Split('\n'), StringComparer.Ordinal); var after = new HashSet(actual.Split('\n'), StringComparer.Ordinal); - IEnumerable removed = before.Except(after).Order(StringComparer.Ordinal).Select(l => $" - {l}"); - IEnumerable added = after.Except(before).Order(StringComparer.Ordinal).Select(l => $" + {l}"); + IEnumerable removed = before + .Except(after) + .Order(StringComparer.Ordinal) + .Select(l => $" - {l}"); + IEnumerable added = after + .Except(before) + .Order(StringComparer.Ordinal) + .Select(l => $" + {l}"); Assert.Fail( "the public surface changed. Intentional? Regenerate PublicAPI.txt by deleting it and " - + $"re-running this test.\n{string.Join('\n', removed.Concat(added))}"); + + $"re-running this test.\n{string.Join('\n', removed.Concat(added))}" + ); } [TestMethod] @@ -133,14 +155,26 @@ public void EveryPublicControl_CanBeObtainedFromSomewhereInThePublicSurface() { foreach (Type type in assembly.GetExportedTypes()) { - foreach (MethodInfo method in type.GetMethods( - BindingFlags.Public | BindingFlags.Instance | BindingFlags.Static | BindingFlags.DeclaredOnly)) + foreach ( + MethodInfo method in type.GetMethods( + BindingFlags.Public + | BindingFlags.Instance + | BindingFlags.Static + | BindingFlags.DeclaredOnly + ) + ) { factories.Add(Unwrap(method.ReturnType).Name); } - foreach (PropertyInfo property in type.GetProperties( - BindingFlags.Public | BindingFlags.Instance | BindingFlags.Static | BindingFlags.DeclaredOnly)) + foreach ( + PropertyInfo property in type.GetProperties( + BindingFlags.Public + | BindingFlags.Instance + | BindingFlags.Static + | BindingFlags.DeclaredOnly + ) + ) { factories.Add(Unwrap(property.PropertyType).Name); } @@ -155,16 +189,21 @@ public void EveryPublicControl_CanBeObtainedFromSomewhereInThePublicSurface() { // Every bound-object type, identified by what makes it one: an internal static // Bind and no public constructor. - if (type.GetConstructors(BindingFlags.Public | BindingFlags.Instance).Length > 0) continue; - if (type.GetMethod("Bind", BindingFlags.Static | BindingFlags.NonPublic) is null) continue; + if (type.GetConstructors(BindingFlags.Public | BindingFlags.Instance).Length > 0) + continue; + if (type.GetMethod("Bind", BindingFlags.Static | BindingFlags.NonPublic) is null) + continue; - if (!factories.Contains(type.Name)) unreachable.Add(type.Name); + if (!factories.Contains(type.Name)) + unreachable.Add(type.Name); } } - Assert.IsTrue(unreachable.Count == 0, + Assert.IsTrue( + unreachable.Count == 0, "these public types cannot be obtained from anywhere in the public surface: " - + string.Join(", ", unreachable)); + + string.Join(", ", unreachable) + ); } /// Whether a type is the compiler's, not the library's. @@ -176,7 +215,10 @@ public void EveryPublicControl_CanBeObtainedFromSomewhereInThePublicSurface() private static bool IsCompilerGenerated(Type type) => type.FullName is null || type.FullName.Contains('<', StringComparison.Ordinal) - || type.IsDefined(typeof(System.Runtime.CompilerServices.CompilerGeneratedAttribute), false); + || type.IsDefined( + typeof(System.Runtime.CompilerServices.CompilerGeneratedAttribute), + false + ); /// Whether a member is the compiler's, not the library's. /// @@ -193,7 +235,11 @@ private static Type Unwrap(Type type) while (type.IsGenericType) { Type definition = type.GetGenericTypeDefinition(); - if (definition != typeof(Task<>) && definition != typeof(ValueTask<>) && definition != typeof(Nullable<>)) + if ( + definition != typeof(Task<>) + && definition != typeof(ValueTask<>) + && definition != typeof(Nullable<>) + ) break; type = type.GetGenericArguments()[0]; diff --git a/tests/PipeWire.NET.Tests/PulledVideoFrameTests.cs b/tests/PipeWire.NET.Tests/PulledVideoFrameTests.cs index 956905f..02a2d02 100644 --- a/tests/PipeWire.NET.Tests/PulledVideoFrameTests.cs +++ b/tests/PipeWire.NET.Tests/PulledVideoFrameTests.cs @@ -15,7 +15,8 @@ public sealed class PulledVideoFrameTests private static PulledVideoFrame HostFrame( ImmutableArray? pixels = null, PixelFormat format = PixelFormat.Bgra, - long? presentationTimestampNs = 1234) => + long? presentationTimestampNs = 1234 + ) => new( pixels: pixels ?? [1, 2, 3, 4], planes: ImmutableArray.Empty, @@ -34,7 +35,8 @@ private static PulledVideoFrame HostFrame( streamPositionNs: 9012, delayNs: 42, crop: new VideoRegion(1, 2, 3, 4), - transform: SpaMetaVideotransformValue.Rotate90); + transform: SpaMetaVideotransformValue.Rotate90 + ); [TestMethod] public void AHostFrameCarriesItsBytesAndNoDescriptors() @@ -100,7 +102,10 @@ public void ADmaBufFrameCarriesDescriptorsAndCopiesNoPixels() // disposes on any platform while still exercising the plane-carrying path. using PulledVideoFrame frame = new( pixels: ImmutableArray.Empty, - planes: [new PulledVideoPlane(new SafeDescriptorHandle(), 0, 1920 * 4, 1920 * 1080 * 4)], + planes: + [ + new PulledVideoPlane(new SafeDescriptorHandle(), 0, 1920 * 4, 1920 * 1080 * 4), + ], stride: 1920 * 4, width: 1920, height: 1080, @@ -116,7 +121,8 @@ public void ADmaBufFrameCarriesDescriptorsAndCopiesNoPixels() streamPositionNs: null, delayNs: 0, crop: null, - transform: SpaMetaVideotransformValue.None); + transform: SpaMetaVideotransformValue.None + ); Assert.IsTrue(frame.IsFdBacked); Assert.IsTrue(frame.Pixels.IsEmpty, "a dmabuf frame must not carry a pixel copy"); @@ -151,7 +157,8 @@ public void AMultiPlaneFrameKeepsOneDescriptorPerPlane() streamPositionNs: null, delayNs: 0, crop: null, - transform: SpaMetaVideotransformValue.None); + transform: SpaMetaVideotransformValue.None + ); Assert.AreEqual(2, frame.Planes.Length); // The UV plane sits at an offset inside the same allocation, not at zero. diff --git a/tests/PipeWire.NET.Tests/PwDump.cs b/tests/PipeWire.NET.Tests/PwDump.cs index 35aefb9..a35554f 100644 --- a/tests/PipeWire.NET.Tests/PwDump.cs +++ b/tests/PipeWire.NET.Tests/PwDump.cs @@ -32,11 +32,16 @@ public IEnumerable OfKind(string kind) => /// Runs pw-dump and parses it. public static async Task CaptureAsync( - CancellationToken cancellationToken, TimeSpan? timeout = null) + CancellationToken cancellationToken, + TimeSpan? timeout = null + ) { CliTool tool = CliTool.Require("pw-dump"); (int exit, string stdout, string stderr) = await tool.RunAsync( - [], cancellationToken, timeout ?? TimeSpan.FromSeconds(5)); + [], + cancellationToken, + timeout ?? TimeSpan.FromSeconds(5) + ); if (exit != 0) throw new InvalidOperationException($"pw-dump exited {exit}: {stderr}"); @@ -51,23 +56,30 @@ internal static PwDump Parse(string json) using JsonDocument doc = JsonDocument.Parse(json); foreach (JsonElement element in doc.RootElement.EnumerateArray()) { - if (!element.TryGetProperty("id", out JsonElement idElement)) continue; - if (!element.TryGetProperty("type", out JsonElement typeElement)) continue; + if (!element.TryGetProperty("id", out JsonElement idElement)) + continue; + if (!element.TryGetProperty("type", out JsonElement typeElement)) + continue; var props = new Dictionary(StringComparer.Ordinal); - if (element.TryGetProperty("info", out JsonElement info) + if ( + element.TryGetProperty("info", out JsonElement info) && info.TryGetProperty("props", out JsonElement propsElement) - && propsElement.ValueKind == JsonValueKind.Object) + && propsElement.ValueKind == JsonValueKind.Object + ) { foreach (JsonProperty p in propsElement.EnumerateObject()) { - props[p.Name] = p.Value.ValueKind == JsonValueKind.String - ? p.Value.GetString() ?? string.Empty - : p.Value.ToString(); + props[p.Name] = + p.Value.ValueKind == JsonValueKind.String + ? p.Value.GetString() ?? string.Empty + : p.Value.ToString(); } } - entries.Add(new Entry(idElement.GetUInt32(), typeElement.GetString() ?? string.Empty, props)); + entries.Add( + new Entry(idElement.GetUInt32(), typeElement.GetString() ?? string.Empty, props) + ); } return new PwDump(entries); diff --git a/tests/PipeWire.NET.Tests/PwToolsSupport.cs b/tests/PipeWire.NET.Tests/PwToolsSupport.cs index f2433b9..4ab7311 100644 --- a/tests/PipeWire.NET.Tests/PwToolsSupport.cs +++ b/tests/PipeWire.NET.Tests/PwToolsSupport.cs @@ -27,7 +27,8 @@ internal static class PwTools /// The tool's path, or an assertion that skips the test when it is not installed. private static string Need(string? path, string tool) { - if (path is null) Assert.Inconclusive($"{tool} not present."); + if (path is null) + Assert.Inconclusive($"{tool} not present."); return path; } @@ -37,11 +38,16 @@ private static string Need(string? path, string tool) if (Environment.GetEnvironmentVariable(envKey) is { Length: > 0 } overridden) return File.Exists(overridden) ? overridden : null; - foreach (string dir in (Environment.GetEnvironmentVariable("PATH") ?? string.Empty) - .Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries)) + foreach ( + string dir in (Environment.GetEnvironmentVariable("PATH") ?? string.Empty).Split( + Path.PathSeparator, + StringSplitOptions.RemoveEmptyEntries + ) + ) { string candidate = Path.Combine(dir, tool); - if (File.Exists(candidate)) return candidate; + if (File.Exists(candidate)) + return candidate; } return null; @@ -60,7 +66,11 @@ public static void Require() } private static async Task<(int ExitCode, string Stdout, string Stderr)> RunAsync( - string exe, string[] args, CancellationToken ct, TimeSpan? timeout = null) + string exe, + string[] args, + CancellationToken ct, + TimeSpan? timeout = null + ) { var psi = new ProcessStartInfo(exe) { @@ -68,10 +78,11 @@ public static void Require() RedirectStandardError = true, UseShellExecute = false, }; - foreach (string a in args) psi.ArgumentList.Add(a); + foreach (string a in args) + psi.ArgumentList.Add(a); - using Process proc = Process.Start(psi) - ?? throw new InvalidOperationException($"failed to start {exe}"); + using Process proc = + Process.Start(psi) ?? throw new InvalidOperationException($"failed to start {exe}"); Task stdout = proc.StandardOutput.ReadToEndAsync(ct); Task stderr = proc.StandardError.ReadToEndAsync(ct); @@ -87,9 +98,16 @@ public static void Require() // Killed on any cancellation, not only the timeout. Leaving the child alive when the // caller's token fires is how ghost gst-launch and pw-loopback processes survive a run // and poison every test after it. - try { proc.Kill(entireProcessTree: true); } catch (InvalidOperationException) { /* already gone */ } + try + { + proc.Kill(entireProcessTree: true); + } + catch (InvalidOperationException) + { /* already gone */ + } - if (ct.IsCancellationRequested) throw; + if (ct.IsCancellationRequested) + throw; throw new TimeoutException($"{exe} did not exit"); } @@ -103,8 +121,18 @@ public static void Require() /// public static async Task SetNodeVolumeAsync(uint nodeId, float volume, CancellationToken ct) { - string pod = $"Props: {{ volume: {volume.ToString(System.Globalization.CultureInfo.InvariantCulture)} }}"; - await RunAsync(Need(PwCli, "pw-cli"), ["set-param", nodeId.ToString(System.Globalization.CultureInfo.InvariantCulture), "Props", pod], ct); + string pod = + $"Props: {{ volume: {volume.ToString(System.Globalization.CultureInfo.InvariantCulture)} }}"; + await RunAsync( + Need(PwCli, "pw-cli"), + [ + "set-param", + nodeId.ToString(System.Globalization.CultureInfo.InvariantCulture), + "Props", + pod, + ], + ct + ); } /// Writes a metadata entry through pw-metadata. @@ -113,7 +141,11 @@ public static async Task SetMetadataAsync(string key, string value, Cancellation if (PwMetadata is null) Assert.Inconclusive("pw-metadata not present."); - await RunAsync(Need(PwMetadata, "pw-metadata"), ["-n", "default", "0", key, value, "Spa:String"], ct); + await RunAsync( + Need(PwMetadata, "pw-metadata"), + ["-n", "default", "0", key, value, "Spa:String"], + ct + ); } /// Clears every entry in a metadata store, from a process that is not us. @@ -126,9 +158,15 @@ public static async Task ClearMetadataAsync(string storeName, CancellationToken if (PwMetadata is null) Assert.Inconclusive("pw-metadata not present."); - (int code, _, string err) = await RunAsync(Need(PwMetadata, "pw-metadata"), ["-n", storeName, "-d"], ct); + (int code, _, string err) = await RunAsync( + Need(PwMetadata, "pw-metadata"), + ["-n", storeName, "-d"], + ct + ); if (code != 0) - throw new InvalidOperationException($"pw-metadata -n {storeName} -d failed ({code}): {err}"); + throw new InvalidOperationException( + $"pw-metadata -n {storeName} -d failed ({code}): {err}" + ); } /// Links two ports by name, the way a user would from a terminal. @@ -139,20 +177,31 @@ public static async Task ClearMetadataAsync(string storeName, CancellationToken /// public static async Task LinkAsync(string outputPort, string inputPort, CancellationToken ct) { - (int code, _, string err) = await RunAsync(Need(PwLink, "pw-link"), ["-w", outputPort, inputPort], ct); - if (code == 0) return; + (int code, _, string err) = await RunAsync( + Need(PwLink, "pw-link"), + ["-w", outputPort, inputPort], + ct + ); + if (code == 0) + return; if (err.Contains("invalid option", StringComparison.Ordinal)) (code, _, err) = await RunAsync(Need(PwLink, "pw-link"), [outputPort, inputPort], ct); if (code != 0) - throw new InvalidOperationException($"pw-link {outputPort} {inputPort} failed ({code}): {err}"); + throw new InvalidOperationException( + $"pw-link {outputPort} {inputPort} failed ({code}): {err}" + ); } /// Disconnects a link by its id. public static async Task DisconnectAsync(uint linkId, CancellationToken ct) { - (int code, _, string err) = await RunAsync(Need(PwLink, "pw-link"), ["-d", linkId.ToString()], ct); + (int code, _, string err) = await RunAsync( + Need(PwLink, "pw-link"), + ["-d", linkId.ToString()], + ct + ); if (code != 0) throw new InvalidOperationException($"pw-link -d {linkId} failed ({code}): {err}"); } @@ -162,7 +211,9 @@ public static async Task DisconnectAsync(uint linkId, CancellationToken ct) /// Parses the -l -I listing, whose shape is an output port line followed by indented /// id |-> id name lines for each link leaving it. /// - public static async Task> ListLinksAsync(CancellationToken ct) + public static async Task> ListLinksAsync( + CancellationToken ct + ) { (_, string output, _) = await RunAsync(Need(PwLink, "pw-link"), ["-l", "-I"], ct); @@ -172,42 +223,57 @@ public static async Task DisconnectAsync(uint linkId, CancellationToken ct) foreach (string raw in output.Split('\n')) { string line = raw.TrimEnd(); - if (line.Length == 0) continue; + if (line.Length == 0) + continue; string trimmed = line.TrimStart(); // By the arrow, not by the indent. pw-link right-aligns ids to four columns, so matching // by indent misreads link lines as port lines once ids reach four digits. - bool isLink = trimmed.Contains("|->", StringComparison.Ordinal) - || trimmed.Contains("|<-", StringComparison.Ordinal); + bool isLink = + trimmed.Contains("|->", StringComparison.Ordinal) + || trimmed.Contains("|<-", StringComparison.Ordinal); string[] parts = trimmed.Split(' ', StringSplitOptions.RemoveEmptyEntries); - if (parts.Length == 0) continue; + if (parts.Length == 0) + continue; if (!isLink) { // " 103 probe_a:monitor_FL" - the port whose links follow. - if (uint.TryParse(parts[0], out uint portId)) currentOutput = portId; + if (uint.TryParse(parts[0], out uint portId)) + currentOutput = portId; continue; } // " 85 |-> 92 probe_b:playback_FL" - if (!uint.TryParse(parts[0], out uint linkId)) continue; + if (!uint.TryParse(parts[0], out uint linkId)) + continue; int arrow = Array.FindIndex(parts, p => p is "|->" or "|<-"); - if (arrow < 0 || arrow + 1 >= parts.Length) continue; - if (!uint.TryParse(parts[arrow + 1], out uint peer)) continue; + if (arrow < 0 || arrow + 1 >= parts.Length) + continue; + if (!uint.TryParse(parts[arrow + 1], out uint peer)) + continue; // Only record the outgoing view so each link appears once. - if (parts[arrow] == "|->") links.Add((linkId, currentOutput, peer)); + if (parts[arrow] == "|->") + links.Add((linkId, currentOutput, peer)); } return links; } /// Port ids by full node:port name, from the -o/-i listings. - public static async Task> ListPortsAsync(bool outputs, CancellationToken ct) + public static async Task> ListPortsAsync( + bool outputs, + CancellationToken ct + ) { - (_, string text, _) = await RunAsync(Need(PwLink, "pw-link"), [outputs ? "-o" : "-i", "-I"], ct); + (_, string text, _) = await RunAsync( + Need(PwLink, "pw-link"), + [outputs ? "-o" : "-i", "-I"], + ct + ); var ports = new Dictionary(StringComparer.Ordinal); foreach (string raw in text.Split('\n')) @@ -222,7 +288,11 @@ public static async Task> ListPortsAsync(bool outputs, /// Destroys any global by id, as an outside process would. public static async Task DestroyAsync(uint id, CancellationToken ct) { - (int code, _, string err) = await RunAsync(Need(PwCli, "pw-cli"), ["destroy", id.ToString()], ct); + (int code, _, string err) = await RunAsync( + Need(PwCli, "pw-cli"), + ["destroy", id.ToString()], + ct + ); if (code != 0) throw new InvalidOperationException($"pw-cli destroy {id} failed ({code}): {err}"); } @@ -244,7 +314,8 @@ public static async Task StartLoopbackAsync(string name, CancellationT psi.ArgumentList.Add("-n"); psi.ArgumentList.Add(name); - Process proc = Process.Start(psi) + Process proc = + Process.Start(psi) ?? throw new InvalidOperationException("failed to start pw-loopback"); var loopback = new Loopback(proc, name); @@ -276,7 +347,8 @@ public static async Task StartLoopbackAsync(string name, CancellationT { string stderr = await proc.StandardError.ReadToEndAsync(ct); throw new InvalidOperationException( - $"pw-loopback '{name}' exited immediately with {proc.ExitCode}: {stderr}"); + $"pw-loopback '{name}' exited immediately with {proc.ExitCode}: {stderr}" + ); } finally { @@ -292,11 +364,17 @@ public ValueTask DisposeAsync() { try { - if (!proc.HasExited) proc.Kill(entireProcessTree: true); + if (!proc.HasExited) + proc.Kill(entireProcessTree: true); proc.WaitForExit(3000); } - catch (InvalidOperationException) { /* already gone */ } - finally { proc.Dispose(); } + catch (InvalidOperationException) + { /* already gone */ + } + finally + { + proc.Dispose(); + } return ValueTask.CompletedTask; } diff --git a/tests/PipeWire.NET.Tests/PwTop.cs b/tests/PipeWire.NET.Tests/PwTop.cs index 77cc9e1..ddf4104 100644 --- a/tests/PipeWire.NET.Tests/PwTop.cs +++ b/tests/PipeWire.NET.Tests/PwTop.cs @@ -37,11 +37,16 @@ internal sealed record Row(uint Id, string Name, int Quantum, int Rate, long Err if (Environment.GetEnvironmentVariable("PWNET_TEST_PW_TOP") is { Length: > 0 } overridden) return File.Exists(overridden) ? overridden : null; - foreach (string dir in (Environment.GetEnvironmentVariable("PATH") ?? string.Empty) - .Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries)) + foreach ( + string dir in (Environment.GetEnvironmentVariable("PATH") ?? string.Empty).Split( + Path.PathSeparator, + StringSplitOptions.RemoveEmptyEntries + ) + ) { string candidate = Path.Combine(dir, "pw-top"); - if (File.Exists(candidate)) return candidate; + if (File.Exists(candidate)) + return candidate; } return null; @@ -55,8 +60,10 @@ internal sealed record Row(uint Id, string Name, int Quantum, int Rate, long Err /// Skips the calling test when pw-top is not installed. public static void Require() { - if (!OperatingSystem.IsLinux()) Assert.Inconclusive("PipeWire is a Linux daemon."); - if (!IsAvailable) Assert.Inconclusive("pw-top not present."); + if (!OperatingSystem.IsLinux()) + Assert.Inconclusive("PipeWire is a Linux daemon."); + if (!IsAvailable) + Assert.Inconclusive("pw-top not present."); } /// @@ -81,8 +88,8 @@ public static async Task> ReadAsync(int batches, Cancellation psi.ArgumentList.Add("-n"); psi.ArgumentList.Add(Math.Max(2, batches).ToString(CultureInfo.InvariantCulture)); - using Process proc = Process.Start(psi) - ?? throw new InvalidOperationException("could not start pw-top."); + using Process proc = + Process.Start(psi) ?? throw new InvalidOperationException("could not start pw-top."); string stdout = await proc.StandardOutput.ReadToEndAsync(ct); await proc.WaitForExitAsync(ct); @@ -93,18 +100,24 @@ public static async Task> ReadAsync(int batches, Cancellation foreach (string raw in stdout.Split('\n')) { string line = raw.TrimEnd(); - if (line.Length == 0) continue; + if (line.Length == 0) + continue; // A header row starts a new batch. Recognised by its QUANT column only: its leading "S" // is also the state letter of every suspended node's row, and on a desktop session full // of idle devices that split each batch and dropped the rows before every one of them. if (line.Contains("QUANT", StringComparison.Ordinal)) { - if (current.Count > 0) { last = current; current = []; } + if (current.Count > 0) + { + last = current; + current = []; + } continue; } - if (Parse(line) is { } row) current.Add(row); + if (Parse(line) is { } row) + current.Add(row); } return current.Count > 0 ? current : last; @@ -115,13 +128,22 @@ public static async Task> ReadAsync(int batches, Cancellation // S ID QUANT RATE WAIT BUSY W/Q B/Q ERR FORMAT NAME // 0 1 2 3 4 5 6 7 8 9... rest string[] f = line.Split(' ', StringSplitOptions.RemoveEmptyEntries); - if (f.Length < 9) return null; - if (!uint.TryParse(f[1], NumberStyles.Integer, CultureInfo.InvariantCulture, out uint id)) return null; + if (f.Length < 9) + return null; + if (!uint.TryParse(f[1], NumberStyles.Integer, CultureInfo.InvariantCulture, out uint id)) + return null; _ = int.TryParse(f[2], NumberStyles.Integer, CultureInfo.InvariantCulture, out int quantum); _ = int.TryParse(f[3], NumberStyles.Integer, CultureInfo.InvariantCulture, out int rate); - if (!long.TryParse(f[8], NumberStyles.Integer, CultureInfo.InvariantCulture, out long errors)) + if ( + !long.TryParse( + f[8], + NumberStyles.Integer, + CultureInfo.InvariantCulture, + out long errors + ) + ) return null; // The name is the last field; FORMAT may be blank, so counting from the end is safer than diff --git a/tests/PipeWire.NET.Tests/RealProducerMatrixTests.cs b/tests/PipeWire.NET.Tests/RealProducerMatrixTests.cs index 04b7e00..5920908 100644 --- a/tests/PipeWire.NET.Tests/RealProducerMatrixTests.cs +++ b/tests/PipeWire.NET.Tests/RealProducerMatrixTests.cs @@ -42,18 +42,27 @@ private static void Require() [DataRow("I420", PixelFormat.Yuv420, 320, 240)] [DataRow("NV12", PixelFormat.Nv12, 320, 240)] public async Task EveryPixelFormat_NegotiatesAndDeliversWholeFrames( - string gstFormat, PixelFormat expected, int width, int height) + string gstFormat, + PixelFormat expected, + int width, + int height + ) { Require(); using var cts = new CancellationTokenSource(Budget); - await using var ctx = new PipeWireContext($"pwnet-fmt-{gstFormat}", ConsoleTestLoggerFactory.Instance); + await using var ctx = new PipeWireContext( + $"pwnet-fmt-{gstFormat}", + ConsoleTestLoggerFactory.Instance + ); await ctx.StartAsync(cts.Token); string node = $"pwnet_fmt_{gstFormat.ToLowerInvariant()}"; await using GstTestSource src = await GstTestSource.StartAsync( - ctx, node, + ctx, + node, $"videotestsrc is-live=true ! video/x-raw,format={gstFormat},width={width},height={height},framerate=30/1", - "Video/Source"); + "Video/Source" + ); (int frames, int shortFrames, PixelFormat? negotiated, int w, int h) = await CaptureVideoAsync(ctx, src.NodeId, expected, cts.Token); @@ -62,37 +71,52 @@ public async Task EveryPixelFormat_NegotiatesAndDeliversWholeFrames( Assert.AreEqual(expected, negotiated, $"{gstFormat} negotiated as {negotiated}"); Assert.AreEqual(width, w); Assert.AreEqual(height, h); - Assert.AreEqual(0, shortFrames, - $"{gstFormat}: {shortFrames} of {frames} frames were smaller than the format requires"); + Assert.AreEqual( + 0, + shortFrames, + $"{gstFormat}: {shortFrames} of {frames} frames were smaller than the format requires" + ); } [TestMethod] - [DataRow(2, 2)] // smallest sane frame - [DataRow(17, 13)] // odd on both axes - [DataRow(641, 481)] // odd, and past a page boundary - [DataRow(1920, 1080)] // the common case + [DataRow(2, 2)] // smallest sane frame + [DataRow(17, 13)] // odd on both axes + [DataRow(641, 481)] // odd, and past a page boundary + [DataRow(1920, 1080)] // the common case public async Task OddAndExtremeDimensions_StillDeliverWholeFrames(int width, int height) { Require(); using var cts = new CancellationTokenSource(Budget); - await using var ctx = new PipeWireContext($"pwnet-dim-{width}x{height}", ConsoleTestLoggerFactory.Instance); + await using var ctx = new PipeWireContext( + $"pwnet-dim-{width}x{height}", + ConsoleTestLoggerFactory.Instance + ); await ctx.StartAsync(cts.Token); // I420 is the format whose chroma planes round up, so odd dimensions are its hard case. string node = $"pwnet_dim_{width}x{height}"; await using GstTestSource src = await GstTestSource.StartAsync( - ctx, node, + ctx, + node, $"videotestsrc is-live=true ! video/x-raw,format=I420,width={width},height={height},framerate=30/1", - "Video/Source"); + "Video/Source" + ); - (int frames, int shortFrames, PixelFormat? _, int w, int h) = - await CaptureVideoAsync(ctx, src.NodeId, PixelFormat.Yuv420, cts.Token); + (int frames, int shortFrames, PixelFormat? _, int w, int h) = await CaptureVideoAsync( + ctx, + src.NodeId, + PixelFormat.Yuv420, + cts.Token + ); Assert.IsTrue(frames > 0, $"{width}x{height}: no frame arrived"); Assert.AreEqual(width, w); Assert.AreEqual(height, h); - Assert.AreEqual(0, shortFrames, - $"{width}x{height}: {shortFrames} short frames - the planar size calculation under-allocates"); + Assert.AreEqual( + 0, + shortFrames, + $"{width}x{height}: {shortFrames} short frames - the planar size calculation under-allocates" + ); } // ------------------------------------------------------------------ audio @@ -105,24 +129,33 @@ public async Task OddAndExtremeDimensions_StillDeliverWholeFrames(int width, int [DataRow("F32LE", AudioSampleFormat.F32Le, 96000, 2)] [DataRow("S16LE", AudioSampleFormat.S16Le, 8000, 1)] public async Task EveryAudioShape_NegotiatesAndDeliversWholeFrames( - string gstFormat, AudioSampleFormat expected, int rate, int channels) + string gstFormat, + AudioSampleFormat expected, + int rate, + int channels + ) { Require(); using var cts = new CancellationTokenSource(Budget); await using var ctx = new PipeWireContext( - $"pwnet-aud-{gstFormat}-{rate}-{channels}", ConsoleTestLoggerFactory.Instance); + $"pwnet-aud-{gstFormat}-{rate}-{channels}", + ConsoleTestLoggerFactory.Instance + ); await ctx.StartAsync(cts.Token); string node = $"pwnet_aud_{gstFormat.ToLowerInvariant()}_{rate}_{channels}"; await using GstTestSource src = await GstTestSource.StartAsync( - ctx, node, + ctx, + node, $"audiotestsrc is-live=true ! audio/x-raw,format={gstFormat},rate={rate},channels={channels}", - "Audio/Source"); + "Audio/Source" + ); var buffers = 0; var ragged = 0; AudioSampleFormat? negotiated = null; - int seenRate = 0, seenChannels = 0; + int seenRate = 0, + seenChannels = 0; await using var capture = new PipeWireAudioCapture(ctx, $"pwnet-aud-consumer-{node}"); capture.FrameReady += (_, frame) => @@ -160,7 +193,10 @@ public async Task AProducerThatDisappearsMidStream_LeavesTheConsumerUsable() { Require(); using var cts = new CancellationTokenSource(Budget); - await using var ctx = new PipeWireContext("pwnet-vanish", ConsoleTestLoggerFactory.Instance); + await using var ctx = new PipeWireContext( + "pwnet-vanish", + ConsoleTestLoggerFactory.Instance + ); await ctx.StartAsync(cts.Token); await using var registry = new PipeWireRegistry(ctx); await registry.WaitForInitialEnumerationAsync(cts.Token); @@ -177,19 +213,25 @@ public async Task AProducerThatDisappearsMidStream_LeavesTheConsumerUsable() ExtraProperties = new Dictionary { ["node.dont-fallback"] = "true" }, }; capture.FrameReady += (_, _) => Interlocked.Increment(ref frames); - capture.StateChanged += (_, _, s) => { lock (states) states.Add(s); }; + capture.StateChanged += (_, _, s) => + { + lock (states) + states.Add(s); + }; uint nodeId; { await using GstTestSource src = await GstTestSource.StartAsync( - ctx, "pwnet_vanish", + ctx, + "pwnet_vanish", "videotestsrc is-live=true ! video/x-raw,format=BGRA,width=160,height=120,framerate=30/1", - "Video/Source"); + "Video/Source" + ); nodeId = src.NodeId; capture.Connect(nodeId, [PixelFormat.Bgra]); await WaitForAsync(() => Volatile.Read(ref frames) >= 3, cts.Token); - } // the producer is killed here + } // the producer is killed here int atDeath = Volatile.Read(ref frames); Assert.IsTrue(atDeath > 0, "no frames arrived before the producer was killed"); @@ -198,13 +240,22 @@ public async Task AProducerThatDisappearsMidStream_LeavesTheConsumerUsable() // The consumer must survive its producer, and must be told rather than left hanging: with // no fallback allowed, the session manager's answer to the lost target is a stream error, // and that has to reach this stream's state. - await WaitForAsync(() => { lock (states) return states.Contains(PipeWireStreamState.Error); }, cts.Token); + await WaitForAsync( + () => + { + lock (states) + return states.Contains(PipeWireStreamState.Error); + }, + cts.Token + ); lock (states) { int streaming = states.IndexOf(PipeWireStreamState.Streaming); int error = states.LastIndexOf(PipeWireStreamState.Error); - Assert.IsTrue(streaming >= 0 && error > streaming, - $"the consumer did not go from streaming to an error when its producer went: {string.Join(" -> ", states)}"); + Assert.IsTrue( + streaming >= 0 && error > streaming, + $"the consumer did not go from streaming to an error when its producer went: {string.Join(" -> ", states)}" + ); } // And it is still an object that can be torn down, which the await using below does. @@ -215,13 +266,18 @@ public async Task TwoConsumersOnOneProducer_BothReceive() { Require(); using var cts = new CancellationTokenSource(Budget); - await using var ctx = new PipeWireContext("pwnet-fanout", ConsoleTestLoggerFactory.Instance); + await using var ctx = new PipeWireContext( + "pwnet-fanout", + ConsoleTestLoggerFactory.Instance + ); await ctx.StartAsync(cts.Token); await using GstTestSource src = await GstTestSource.StartAsync( - ctx, "pwnet_fanout", + ctx, + "pwnet_fanout", "videotestsrc is-live=true ! video/x-raw,format=BGRA,width=160,height=120,framerate=30/1", - "Video/Source"); + "Video/Source" + ); var a = 0; var b = 0; @@ -244,13 +300,18 @@ public async Task AConsumerThatThrowsOnEveryFrame_DoesNotStopTheStream() { Require(); using var cts = new CancellationTokenSource(Budget); - await using var ctx = new PipeWireContext("pwnet-badconsumer", ConsoleTestLoggerFactory.Instance); + await using var ctx = new PipeWireContext( + "pwnet-badconsumer", + ConsoleTestLoggerFactory.Instance + ); await ctx.StartAsync(cts.Token); await using GstTestSource src = await GstTestSource.StartAsync( - ctx, "pwnet_badconsumer", + ctx, + "pwnet_badconsumer", "videotestsrc is-live=true ! video/x-raw,format=BGRA,width=160,height=120,framerate=30/1", - "Video/Source"); + "Video/Source" + ); // FrameReady is raised from the data thread; an escaping exception there aborts the process. var seen = 0; @@ -264,19 +325,27 @@ public async Task AConsumerThatThrowsOnEveryFrame_DoesNotStopTheStream() capture.Connect(src.NodeId, [PixelFormat.Bgra]); await WaitForAsync(() => Volatile.Read(ref seen) >= 5, cts.Token); - Assert.IsTrue(seen >= 5, - $"the stream stopped after {seen} frames; a throwing handler must not end delivery"); + Assert.IsTrue( + seen >= 5, + $"the stream stopped after {seen} frames; a throwing handler must not end delivery" + ); } // ------------------------------------------------------------------ helpers - private static async Task<(int Frames, int Short, PixelFormat? Format, int Width, int Height)> - CaptureVideoAsync(PipeWireContext ctx, uint nodeId, PixelFormat want, CancellationToken ct) + private static async Task<( + int Frames, + int Short, + PixelFormat? Format, + int Width, + int Height + )> CaptureVideoAsync(PipeWireContext ctx, uint nodeId, PixelFormat want, CancellationToken ct) { var frames = 0; var shortFrames = 0; PixelFormat? negotiated = null; - int width = 0, height = 0; + int width = 0, + height = 0; await using var capture = new PipeWireVideoCapture(ctx, $"pwnet-consumer-{nodeId}"); capture.FrameReady += (_, frame) => diff --git a/tests/PipeWire.NET.Tests/RealWorldScenarioTests.cs b/tests/PipeWire.NET.Tests/RealWorldScenarioTests.cs index d2433a4..6fc2f6b 100644 --- a/tests/PipeWire.NET.Tests/RealWorldScenarioTests.cs +++ b/tests/PipeWire.NET.Tests/RealWorldScenarioTests.cs @@ -30,7 +30,9 @@ private static void RequireLinux() } private static async Task<(PipeWireContext Context, PipeWireRegistry Registry)> ConnectAsync( - string name, CancellationToken cancellationToken) + string name, + CancellationToken cancellationToken + ) { var context = new PipeWireContext(name, ConsoleTestLoggerFactory.Instance); await context.StartAsync(cancellationToken); @@ -44,7 +46,11 @@ private static string Unique(string prefix) => /// Waits until a node has the ports it is expected to have. private static async Task> PortsOfAsync( - PipeWireRegistry registry, uint nodeId, int expected, CancellationToken cancellationToken) + PipeWireRegistry registry, + uint nodeId, + int expected, + CancellationToken cancellationToken + ) { while (true) { @@ -52,7 +58,8 @@ private static async Task> PortsOfAsync( await registry.WaitForInitialEnumerationAsync(cancellationToken); ImmutableArray ports = registry.Current.GetPortsForNode(nodeId); - if (ports.Length >= expected) return ports; + if (ports.Length >= expected) + return ports; await Task.Delay(TimeSpan.FromMilliseconds(25), cancellationToken); } @@ -63,39 +70,65 @@ public async Task AStreamingMixer_BuildsItsGraphSetsLevelsAndTearsItDownCleanly( { RequireLinux(); using var cts = new CancellationTokenSource(Budget); - (PipeWireContext ctx, PipeWireRegistry registry) = await ConnectAsync("pwnet-scenario-mixer", cts.Token); + (PipeWireContext ctx, PipeWireRegistry registry) = await ConnectAsync( + "pwnet-scenario-mixer", + cts.Token + ); await using (ctx) await using (registry) { // The shape a streaming app builds: several sources feeding one submix, each at its own // level, the submix at a master level. string submixName = Unique("pwnet_submix"); - PipeWireNode submix = await registry.CreateVirtualSink("Submix") - .WithName(submixName).ExecuteAsync(cts.Token); + PipeWireNode submix = await registry + .CreateVirtualSink("Submix") + .WithName(submixName) + .ExecuteAsync(cts.Token); var sources = new List(); for (int i = 0; i < 3; i++) { - sources.Add(await registry.CreateVirtualSink($"Source{i}") - .WithName(Unique($"pwnet_src{i}")).ExecuteAsync(cts.Token)); + sources.Add( + await registry + .CreateVirtualSink($"Source{i}") + .WithName(Unique($"pwnet_src{i}")) + .ExecuteAsync(cts.Token) + ); } // Every source's monitor ports feed the submix's playback ports. A sink's monitor is how // its output is tapped, which is the part that is easy to get backwards. - ImmutableArray submixIn = await PortsOfAsync(registry, submix.NodeId, 4, cts.Token); - PipeWirePort[] submixInputs = [.. submixIn.Where(p => p.IsDataInput).OrderBy(p => p.PortName)]; + ImmutableArray submixIn = await PortsOfAsync( + registry, + submix.NodeId, + 4, + cts.Token + ); + PipeWirePort[] submixInputs = + [ + .. submixIn.Where(p => p.IsDataInput).OrderBy(p => p.PortName), + ]; Assert.AreEqual(2, submixInputs.Length, "a stereo sink has two playback ports"); var links = new List(); foreach (PipeWireNode source in sources) { - ImmutableArray ports = await PortsOfAsync(registry, source.NodeId, 4, cts.Token); - PipeWirePort[] monitors = [.. ports.Where(p => p.IsDataOutput).OrderBy(p => p.PortName)]; + ImmutableArray ports = await PortsOfAsync( + registry, + source.NodeId, + 4, + cts.Token + ); + PipeWirePort[] monitors = + [ + .. ports.Where(p => p.IsDataOutput).OrderBy(p => p.PortName), + ]; Assert.AreEqual(2, monitors.Length, "a stereo sink exposes two monitor ports"); for (int channel = 0; channel < 2; channel++) { - PipeWireLink link = await registry.CreateLink(monitors[channel], submixInputs[channel]) + PipeWireLink link = await registry + .CreateLink(monitors[channel], submixInputs[channel]) .ExecuteAsync(cts.Token); links.Add(link.LinkId); } @@ -103,13 +136,19 @@ public async Task AStreamingMixer_BuildsItsGraphSetsLevelsAndTearsItDownCleanly( // The graph now says what was built, from both ends. PipeWireGraphSnapshot graph = registry.Current; - Assert.AreEqual(6, graph.GetLinksForNode(submix.NodeId).Count(), - "three stereo sources make six links into the submix"); + Assert.AreEqual( + 6, + graph.GetLinksForNode(submix.NodeId).Count(), + "three stereo sources make six links into the submix" + ); foreach (PipeWireNode source in sources) { - Assert.AreEqual(2, graph.GetLinksForNode(source.NodeId).Count(), - $"source {source.NodeId} should have two links out"); + Assert.AreEqual( + 2, + graph.GetLinksForNode(source.NodeId).Count(), + $"source {source.NodeId} should have two links out" + ); } // Levels: each source quieter than the last, the submix at master level. @@ -140,8 +179,11 @@ public async Task AStreamingMixer_BuildsItsGraphSetsLevelsAndTearsItDownCleanly( foreach (uint link in links) await registry.DestroyGlobalAsync(link, cts.Token); - Assert.AreEqual(0, registry.Current.GetLinksForNode(submix.NodeId).Count(), - "every link must be gone"); + Assert.AreEqual( + 0, + registry.Current.GetLinksForNode(submix.NodeId).Count(), + "every link must be gone" + ); foreach (PipeWireNode source in sources) await registry.DestroyGlobalAsync(source.NodeId, cts.Token); @@ -159,19 +201,37 @@ public async Task DestroyingANodeMidGraph_TakesItsLinksWithItAndLeavesNothingDan { RequireLinux(); using var cts = new CancellationTokenSource(Budget); - (PipeWireContext ctx, PipeWireRegistry registry) = await ConnectAsync("pwnet-scenario-cascade", cts.Token); + (PipeWireContext ctx, PipeWireRegistry registry) = await ConnectAsync( + "pwnet-scenario-cascade", + cts.Token + ); await using (ctx) await using (registry) { - PipeWireNode a = await registry.CreateVirtualSink("CascadeA") - .WithName(Unique("pwnet_casc_a")).ExecuteAsync(cts.Token); - PipeWireNode b = await registry.CreateVirtualSink("CascadeB") - .WithName(Unique("pwnet_casc_b")).ExecuteAsync(cts.Token); - - ImmutableArray aPorts = await PortsOfAsync(registry, a.NodeId, 4, cts.Token); - ImmutableArray bPorts = await PortsOfAsync(registry, b.NodeId, 4, cts.Token); + PipeWireNode a = await registry + .CreateVirtualSink("CascadeA") + .WithName(Unique("pwnet_casc_a")) + .ExecuteAsync(cts.Token); + PipeWireNode b = await registry + .CreateVirtualSink("CascadeB") + .WithName(Unique("pwnet_casc_b")) + .ExecuteAsync(cts.Token); - await registry.CreateLink(aPorts.First(p => p.IsDataOutput), bPorts.First(p => p.IsDataInput)) + ImmutableArray aPorts = await PortsOfAsync( + registry, + a.NodeId, + 4, + cts.Token + ); + ImmutableArray bPorts = await PortsOfAsync( + registry, + b.NodeId, + 4, + cts.Token + ); + + await registry + .CreateLink(aPorts.First(p => p.IsDataOutput), bPorts.First(p => p.IsDataInput)) .ExecuteAsync(cts.Token); Assert.AreEqual(1, registry.Current.GetLinksForNode(b.NodeId).Count()); @@ -183,15 +243,27 @@ await registry.CreateLink(aPorts.First(p => p.IsDataOutput), bPorts.First(p => p PipeWireGraphSnapshot graph = registry.Current; Assert.IsNull(graph.GetNode(a.NodeId)); - Assert.AreEqual(0, graph.GetPortsForNode(a.NodeId).Length, "its ports must be gone too"); - Assert.AreEqual(0, graph.GetLinksForNode(b.NodeId).Count(), "the link must have gone with it"); + Assert.AreEqual( + 0, + graph.GetPortsForNode(a.NodeId).Length, + "its ports must be gone too" + ); + Assert.AreEqual( + 0, + graph.GetLinksForNode(b.NodeId).Count(), + "the link must have gone with it" + ); foreach (PipeWireLink link in graph.Links) { - Assert.IsNotNull(graph.GetNode(link.OutputNodeId), - $"link {link.LinkId} points at output node {link.OutputNodeId}, which is not in the graph"); - Assert.IsNotNull(graph.GetNode(link.InputNodeId), - $"link {link.LinkId} points at input node {link.InputNodeId}, which is not in the graph"); + Assert.IsNotNull( + graph.GetNode(link.OutputNodeId), + $"link {link.LinkId} points at output node {link.OutputNodeId}, which is not in the graph" + ); + Assert.IsNotNull( + graph.GetNode(link.InputNodeId), + $"link {link.LinkId} points at input node {link.InputNodeId}, which is not in the graph" + ); } await registry.DestroyGlobalAsync(b.NodeId, cts.Token); @@ -203,18 +275,28 @@ public async Task AFilterInsertedBetweenTwoNodes_ProcessesWhatPassesThrough() { RequireLinux(); using var cts = new CancellationTokenSource(Budget); - (PipeWireContext ctx, PipeWireRegistry registry) = await ConnectAsync("pwnet-scenario-insert", cts.Token); + (PipeWireContext ctx, PipeWireRegistry registry) = await ConnectAsync( + "pwnet-scenario-insert", + cts.Token + ); await using (ctx) await using (registry) { // The classic insert: source -> filter -> sink, with the filter doing the work. This is // the arrangement an equaliser or a noise gate lives in. - PipeWireNode source = await registry.CreateVirtualSink("InsertSrc") - .WithName(Unique("pwnet_insert_src")).ExecuteAsync(cts.Token); - PipeWireNode sink = await registry.CreateVirtualSink("InsertSink") - .WithName(Unique("pwnet_insert_sink")).ExecuteAsync(cts.Token); + PipeWireNode source = await registry + .CreateVirtualSink("InsertSrc") + .WithName(Unique("pwnet_insert_src")) + .ExecuteAsync(cts.Token); + PipeWireNode sink = await registry + .CreateVirtualSink("InsertSink") + .WithName(Unique("pwnet_insert_sink")) + .ExecuteAsync(cts.Token); - await using PipeWireFilter filter = PipeWireFilter.Create(ctx, Unique("pwnet_insert_filter")); + await using PipeWireFilter filter = PipeWireFilter.Create( + ctx, + Unique("pwnet_insert_filter") + ); PipeWireFilterPort input = filter.AddAudioPort(PipeWirePortDirection.In, "input_FL"); PipeWireFilterPort output = filter.AddAudioPort(PipeWirePortDirection.Out, "output_FL"); @@ -233,8 +315,13 @@ public async Task AFilterInsertedBetweenTwoNodes_ProcessesWhatPassesThrough() // may legitimately be empty on a cycle the graph did not give this filter a buffer. if (!to.IsEmpty) { - if (from.IsEmpty) to.Clear(); - else { from.CopyTo(to); Interlocked.Increment(ref copied); } + if (from.IsEmpty) + to.Clear(); + else + { + from.CopyTo(to); + Interlocked.Increment(ref copied); + } ran.TrySetResult(); } }; @@ -242,17 +329,38 @@ public async Task AFilterInsertedBetweenTwoNodes_ProcessesWhatPassesThrough() await filter.ConnectAsync(cancellationToken: cts.Token); uint filterNode = await filter.WaitForNodeIdAsync(cts.Token); - ImmutableArray filterPorts = await PortsOfAsync(registry, filterNode, 2, cts.Token); - ImmutableArray sourcePorts = await PortsOfAsync(registry, source.NodeId, 4, cts.Token); - ImmutableArray sinkPorts = await PortsOfAsync(registry, sink.NodeId, 4, cts.Token); - - await registry.CreateLink( - sourcePorts.First(p => p.IsDataOutput), - filterPorts.First(p => p.IsDataInput)).ExecuteAsync(cts.Token); + ImmutableArray filterPorts = await PortsOfAsync( + registry, + filterNode, + 2, + cts.Token + ); + ImmutableArray sourcePorts = await PortsOfAsync( + registry, + source.NodeId, + 4, + cts.Token + ); + ImmutableArray sinkPorts = await PortsOfAsync( + registry, + sink.NodeId, + 4, + cts.Token + ); + + await registry + .CreateLink( + sourcePorts.First(p => p.IsDataOutput), + filterPorts.First(p => p.IsDataInput) + ) + .ExecuteAsync(cts.Token); - await registry.CreateLink( - filterPorts.First(p => p.IsDataOutput), - sinkPorts.First(p => p.IsDataInput)).ExecuteAsync(cts.Token); + await registry + .CreateLink( + filterPorts.First(p => p.IsDataOutput), + sinkPorts.First(p => p.IsDataInput) + ) + .ExecuteAsync(cts.Token); await ran.Task.WaitAsync(TimeSpan.FromSeconds(15), cts.Token); @@ -260,8 +368,11 @@ await registry.CreateLink( // And the graph shows the filter sitting between the two, with a link on each side. PipeWireGraphSnapshot graph = registry.Current; - Assert.AreEqual(2, graph.GetLinksForNode(filterNode).Count(), - "the filter must be linked on both sides"); + Assert.AreEqual( + 2, + graph.GetLinksForNode(filterNode).Count(), + "the filter must be linked on both sides" + ); await registry.DestroyGlobalAsync(source.NodeId, cts.Token); await registry.DestroyGlobalAsync(sink.NodeId, cts.Token); @@ -275,7 +386,10 @@ public async Task ARealGStreamerProducer_IsDiscoveredNamedAndControllable() GstTestSource.RequireGStreamer(); using var cts = new CancellationTokenSource(Budget); - (PipeWireContext ctx, PipeWireRegistry registry) = await ConnectAsync("pwnet-scenario-gst", cts.Token); + (PipeWireContext ctx, PipeWireRegistry registry) = await ConnectAsync( + "pwnet-scenario-gst", + cts.Token + ); await using (ctx) await using (registry) { @@ -283,9 +397,11 @@ public async Task ARealGStreamerProducer_IsDiscoveredNamedAndControllable() // choose. Everything below is what a mixer would do on discovering one. string name = Unique("pwnet_gst_audio"); await using GstTestSource gst = await GstTestSource.StartAsync( - ctx, name, + ctx, + name, "audiotestsrc is-live=true ! audioconvert ! audio/x-raw,format=F32LE,channels=2,rate=48000", - "Stream/Output/Audio"); + "Stream/Output/Audio" + ); await registry.WaitForInitialEnumerationAsync(cts.Token); @@ -305,27 +421,44 @@ public async Task ARealGStreamerProducer_IsDiscoveredNamedAndControllable() // the node, and capability is answered from them - so waiting for them comes first. Assert.AreEqual(PipeWireMediaKind.Audio, produced!.Media); - ImmutableArray ports = await PortsOfAsync(registry, produced.NodeId, 1, cts.Token); + ImmutableArray ports = await PortsOfAsync( + registry, + produced.NodeId, + 1, + cts.Token + ); Assert.IsTrue(ports.Any(p => p.IsDataOutput), "a producer must expose an output port"); - Assert.IsTrue(registry.Current.CanCaptureFrom(produced), - "a producer with an output port must be something the graph can capture from"); + Assert.IsTrue( + registry.Current.CanCaptureFrom(produced), + "a producer with an output port must be something the graph can capture from" + ); // And its parameters are readable, which is the part that needs the binding to work // against a node this library never created. await using PipeWireNodeProxy control = registry.BindNode(produced.NodeId); await control.ReadyAsync(cts.Token); - Assert.IsTrue(control.Parameters.Length > 0, "a real producer must describe its parameters"); - Assert.IsTrue(control.CanRead(SpaParamType.Format) || control.CanRead(SpaParamType.EnumFormat), - "a producer must describe the format it is producing"); + Assert.IsTrue( + control.Parameters.Length > 0, + "a real producer must describe its parameters" + ); + Assert.IsTrue( + control.CanRead(SpaParamType.Format) || control.CanRead(SpaParamType.EnumFormat), + "a producer must describe the format it is producing" + ); - ImmutableArray formats = - await control.EnumerateParametersAsync(SpaParamType.EnumFormat, cts.Token); + ImmutableArray formats = await control.EnumerateParametersAsync( + SpaParamType.EnumFormat, + cts.Token + ); Assert.IsTrue(formats.Length > 0, "the producer must offer at least one format"); Assert.IsTrue( - formats.Any(f => f[SpaFormat.MediaType] is SpaId id && id.Value == (uint)SpaMediaType.Audio), - "the offered formats must be audio"); + formats.Any(f => + f[SpaFormat.MediaType] is SpaId id && id.Value == (uint)SpaMediaType.Audio + ), + "the offered formats must be audio" + ); } } @@ -334,14 +467,18 @@ public async Task ADeviceItsNodesAndTheDefaultSink_AgreeWithEachOther() { RequireLinux(); using var cts = new CancellationTokenSource(Budget); - (PipeWireContext ctx, PipeWireRegistry registry) = await ConnectAsync("pwnet-scenario-device", cts.Token); + (PipeWireContext ctx, PipeWireRegistry registry) = await ConnectAsync( + "pwnet-scenario-device", + cts.Token + ); await using (ctx) await using (registry) { PipeWireGraphSnapshot graph = registry.Current; - PipeWireDevice? card = graph.Devices - .FirstOrDefault(d => string.Equals(d.Api, "alsa", StringComparison.Ordinal)); + PipeWireDevice? card = graph.Devices.FirstOrDefault(d => + string.Equals(d.Api, "alsa", StringComparison.Ordinal) + ); if (card is null) Assert.Inconclusive("this session has no ALSA card."); @@ -370,14 +507,16 @@ public async Task ADeviceItsNodesAndTheDefaultSink_AgreeWithEachOther() for (int i = 0; i < 50 && sinkName is null; i++) { sinkName = defaults.DefaultAudioSink?.NameValue; - if (sinkName is null) await Task.Delay(100, cts.Token); + if (sinkName is null) + await Task.Delay(100, cts.Token); } if (sinkName is null) Assert.Inconclusive("this session has no default sink set."); - PipeWireNode? defaultSink = registry.Current.Nodes - .FirstOrDefault(n => n.NodeName == sinkName); + PipeWireNode? defaultSink = registry.Current.Nodes.FirstOrDefault(n => + n.NodeName == sinkName + ); // When the session is coherent. Whether the session manager's default points at // a live node is the session manager's business, not this library's: a session @@ -386,17 +525,22 @@ public async Task ADeviceItsNodesAndTheDefaultSink_AgreeWithEachOther() if (defaultSink is null) Assert.Inconclusive( $"the session's default sink is '{sinkName}', which is not a node in " - + "the graph. The session manager's state is inconsistent, which says " - + "nothing about the value this store read."); + + "the graph. The session manager's state is inconsistent, which says " + + "nothing about the value this store read." + ); Assert.AreEqual(PipeWireMediaKind.Audio, defaultSink!.Media); - Assert.IsTrue(registry.Current.CanSendTo(defaultSink), - "the default sink must be something audio can be sent to"); + Assert.IsTrue( + registry.Current.CanSendTo(defaultSink), + "the default sink must be something audio can be sent to" + ); // And it is controllable, which is what a volume slider needs. await using PipeWireNodeProxy control = registry.BindNode(defaultSink.NodeId); await control.ReadyAsync(cts.Token); - Assert.IsTrue(control.CanWrite(SpaParamType.Props), - "the default sink must accept a volume change"); + Assert.IsTrue( + control.CanWrite(SpaParamType.Props), + "the default sink must accept a volume change" + ); } } } @@ -406,7 +550,10 @@ public async Task AUiHoldingASnapshotWhileTheGraphChanges_KeepsAConsistentView() { RequireLinux(); using var cts = new CancellationTokenSource(Budget); - (PipeWireContext ctx, PipeWireRegistry registry) = await ConnectAsync("pwnet-scenario-ui", cts.Token); + (PipeWireContext ctx, PipeWireRegistry registry) = await ConnectAsync( + "pwnet-scenario-ui", + cts.Token + ); await using (ctx) await using (registry) { @@ -417,8 +564,10 @@ public async Task AUiHoldingASnapshotWhileTheGraphChanges_KeepsAConsistentView() var created = new List(); for (int i = 0; i < 4; i++) { - PipeWireNode node = await registry.CreateVirtualSink($"Ui{i}") - .WithName(Unique($"pwnet_ui{i}")).ExecuteAsync(cts.Token); + PipeWireNode node = await registry + .CreateVirtualSink($"Ui{i}") + .WithName(Unique($"pwnet_ui{i}")) + .ExecuteAsync(cts.Token); created.Add(node.NodeId); } @@ -427,7 +576,10 @@ public async Task AUiHoldingASnapshotWhileTheGraphChanges_KeepsAConsistentView() foreach (uint id in created) Assert.IsNull(held.GetNode(id), "a snapshot must not gain nodes created after it"); - Assert.IsTrue(registry.Current.Nodes.Length > nodesWhenTaken, "the live graph did move on"); + Assert.IsTrue( + registry.Current.Nodes.Length > nodesWhenTaken, + "the live graph did move on" + ); Assert.IsTrue(registry.Current.Version > held.Version, "and its version advanced"); foreach (uint id in created) @@ -437,7 +589,9 @@ public async Task AUiHoldingASnapshotWhileTheGraphChanges_KeepsAConsistentView() // handle on anything native. Assert.AreEqual(nodesWhenTaken, held.Nodes.Length); foreach (PipeWireNode node in held.Nodes) - Assert.IsNotNull(node.NodeId.ToString(System.Globalization.CultureInfo.InvariantCulture)); + Assert.IsNotNull( + node.NodeId.ToString(System.Globalization.CultureInfo.InvariantCulture) + ); } } @@ -448,7 +602,10 @@ public async Task AUiHoldingASnapshotWhileTheGraphChanges_KeepsAConsistentView() /// session and reliable on a busy one. /// private static async Task SettledVolumeAsync( - PipeWireNodeProxy node, float want, CancellationToken cancellationToken) + PipeWireNodeProxy node, + float want, + CancellationToken cancellationToken + ) { // Generous on purpose: a write returns when the daemon has processed it, not when the // node has applied it, and on a session busy retrying dead ALSA devices that lag is @@ -456,8 +613,10 @@ private static async Task SettledVolumeAsync( float last = float.NaN; for (int attempt = 0; attempt < 80; attempt++) { - last = (await node.GetVolumeAsync(cancellationToken).ConfigureAwait(false)) ?? float.NaN; - if (Math.Abs(last - want) <= 0.01f) return last; + last = + (await node.GetVolumeAsync(cancellationToken).ConfigureAwait(false)) ?? float.NaN; + if (Math.Abs(last - want) <= 0.01f) + return last; await Task.Delay(50, cancellationToken).ConfigureAwait(false); } @@ -466,16 +625,20 @@ private static async Task SettledVolumeAsync( /// private static async Task SettledChannelVolumeAsync( - PipeWireNodeProxy node, float want, CancellationToken cancellationToken) + PipeWireNodeProxy node, + float want, + CancellationToken cancellationToken + ) { float last = float.NaN; for (int attempt = 0; attempt < 80; attempt++) { - ImmutableArray volumes = - await node.GetChannelVolumesAsync(cancellationToken).ConfigureAwait(false); + ImmutableArray volumes = await node.GetChannelVolumesAsync(cancellationToken) + .ConfigureAwait(false); last = volumes.IsDefaultOrEmpty ? float.NaN : volumes[0]; - if (Math.Abs(last - want) <= 0.01f) return last; + if (Math.Abs(last - want) <= 0.01f) + return last; await Task.Delay(50, cancellationToken).ConfigureAwait(false); } diff --git a/tests/PipeWire.NET.Tests/ReentrancyTests.cs b/tests/PipeWire.NET.Tests/ReentrancyTests.cs index 40f37ba..9b0d0ac 100644 --- a/tests/PipeWire.NET.Tests/ReentrancyTests.cs +++ b/tests/PipeWire.NET.Tests/ReentrancyTests.cs @@ -29,7 +29,9 @@ private static void RequireLinux() } private static async Task<(PipeWireContext Context, PipeWireRegistry Registry)> ConnectAsync( - string name, CancellationToken cancellationToken) + string name, + CancellationToken cancellationToken + ) { var context = new PipeWireContext(name, ConsoleTestLoggerFactory.Instance); await context.StartAsync(cancellationToken); @@ -38,14 +40,18 @@ private static void RequireLinux() return (context, registry); } - private static string Unique(string p) => $"{p}_{Environment.ProcessId}_{Random.Shared.Next():x}"; + private static string Unique(string p) => + $"{p}_{Environment.ProcessId}_{Random.Shared.Next():x}"; [TestMethod] public async Task ReadingAndBindingFromInsideAGraphCallback_Works() { RequireLinux(); using var cts = new CancellationTokenSource(Budget); - (PipeWireContext ctx, PipeWireRegistry registry) = await ConnectAsync("pwnet-reentrant-read", cts.Token); + (PipeWireContext ctx, PipeWireRegistry registry) = await ConnectAsync( + "pwnet-reentrant-read", + cts.Token + ); await using (ctx) await using (registry) { @@ -79,8 +85,10 @@ void OnAdded(PipeWireNode n) { for (int i = 0; i < 10; i++) { - PipeWireNode node = await registry.CreateVirtualSink("Reentrant") - .WithName(Unique("pwnet_reentrant")).ExecuteAsync(cts.Token); + PipeWireNode node = await registry + .CreateVirtualSink("Reentrant") + .WithName(Unique("pwnet_reentrant")) + .ExecuteAsync(cts.Token); await registry.DestroyGlobalAsync(node.NodeId, cts.Token); } @@ -101,7 +109,10 @@ public async Task SubscribingAndUnsubscribingFromInsideAHandler_DoesNotDisturbTh { RequireLinux(); using var cts = new CancellationTokenSource(Budget); - (PipeWireContext ctx, PipeWireRegistry registry) = await ConnectAsync("pwnet-reentrant-sub", cts.Token); + (PipeWireContext ctx, PipeWireRegistry registry) = await ConnectAsync( + "pwnet-reentrant-sub", + cts.Token + ); await using (ctx) await using (registry) { @@ -141,8 +152,10 @@ void Mutating(PipeWireNode _) { for (int i = 0; i < 10; i++) { - PipeWireNode node = await registry.CreateVirtualSink("Mutate") - .WithName(Unique("pwnet_mutate")).ExecuteAsync(cts.Token); + PipeWireNode node = await registry + .CreateVirtualSink("Mutate") + .WithName(Unique("pwnet_mutate")) + .ExecuteAsync(cts.Token); await registry.DestroyGlobalAsync(node.NodeId, cts.Token); } @@ -152,12 +165,15 @@ void Mutating(PipeWireNode _) { registry.NodeAdded -= Stable; registry.NodeAdded -= Mutating; - if (transient is not null) registry.NodeAdded -= transient; + if (transient is not null) + registry.NodeAdded -= transient; } Assert.IsTrue(faults.IsEmpty, string.Join("; ", faults)); - Assert.IsTrue(Volatile.Read(ref stable) > 0, - "the handler that never moved stopped being invoked"); + Assert.IsTrue( + Volatile.Read(ref stable) > 0, + "the handler that never moved stopped being invoked" + ); } } @@ -166,12 +182,16 @@ public async Task AMetadataHandlerWritingBackToTheStore_DoesNotDeadlock() { RequireLinux(); using var cts = new CancellationTokenSource(Budget); - (PipeWireContext ctx, PipeWireRegistry registry) = await ConnectAsync("pwnet-reentrant-meta", cts.Token); + (PipeWireContext ctx, PipeWireRegistry registry) = await ConnectAsync( + "pwnet-reentrant-meta", + cts.Token + ); await using (ctx) await using (registry) { PipeWireMetadataProxy? store = registry.BindMetadata("default"); - if (store is null) Assert.Inconclusive("no session manager, so no default store."); + if (store is null) + Assert.Inconclusive("no session manager, so no default store."); await using (store) { @@ -179,24 +199,33 @@ public async Task AMetadataHandlerWritingBackToTheStore_DoesNotDeadlock() string trigger = Unique("pwnet.reentrant.trigger"); string echo = Unique("pwnet.reentrant.echo"); - var wrote = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var wrote = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously + ); var faults = new ConcurrentQueue(); void OnChanged(PipeWireMetadataProxy s, PipeWireMetadataEntry e) { - if (e.Key != trigger || e.Value is null) return; + if (e.Key != trigger || e.Value is null) + return; // A write from inside the handler. It must not be awaited here: this is the // loop thread, and the round-trip it waits on is answered by this same thread. - _ = Task.Run(async () => - { - try + _ = Task.Run( + async () => { - await s.SetAsync(echo, "seen", cancellationToken: cts.Token); - wrote.TrySetResult(); - } - catch (Exception ex) { faults.Enqueue($"{ex.GetType().Name}: {ex.Message}"); } - }, cts.Token); + try + { + await s.SetAsync(echo, "seen", cancellationToken: cts.Token); + wrote.TrySetResult(); + } + catch (Exception ex) + { + faults.Enqueue($"{ex.GetType().Name}: {ex.Message}"); + } + }, + cts.Token + ); } store.EntryChanged += OnChanged; @@ -223,7 +252,10 @@ public async Task DisposingAControlFromInsideItsOwnCallback_TearsDownWithoutCras { RequireLinux(); using var cts = new CancellationTokenSource(Budget); - (PipeWireContext ctx, PipeWireRegistry registry) = await ConnectAsync("pwnet-reentrant-dispose", cts.Token); + (PipeWireContext ctx, PipeWireRegistry registry) = await ConnectAsync( + "pwnet-reentrant-dispose", + cts.Token + ); await using (ctx) await using (registry) { @@ -233,11 +265,15 @@ public async Task DisposingAControlFromInsideItsOwnCallback_TearsDownWithoutCras for (int round = 0; round < 6; round++) { - PipeWireNode node = await registry.CreateVirtualSink("SelfDispose") - .WithName(Unique("pwnet_selfdispose")).ExecuteAsync(cts.Token); + PipeWireNode node = await registry + .CreateVirtualSink("SelfDispose") + .WithName(Unique("pwnet_selfdispose")) + .ExecuteAsync(cts.Token); PipeWireNodeProxy control = registry.BindNode(node.NodeId); - var attempted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var attempted = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously + ); control.InfoChanged += _ => { @@ -280,13 +316,19 @@ public async Task DisposingTheContextFromItsOwnCallback_SaysSoRatherThanHanging( // regresses to a join, the test times out instead of hanging the run for ever. RequireLinux(); using var cts = new CancellationTokenSource(Budget); - (PipeWireContext ctx, PipeWireRegistry registry) = await ConnectAsync("pwnet-dispose-self", cts.Token); + (PipeWireContext ctx, PipeWireRegistry registry) = await ConnectAsync( + "pwnet-dispose-self", + cts.Token + ); - var attempted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var attempted = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously + ); void OnAdded(PipeWireNode _) { - if (attempted.Task.IsCompleted) return; + if (attempted.Task.IsCompleted) + return; try { @@ -302,13 +344,17 @@ void OnAdded(PipeWireNode _) registry.NodeAdded += OnAdded; try { - PipeWireNode node = await registry.CreateVirtualSink("DisposeSelf") - .WithName(Unique("pwnet_dispose_self")).ExecuteAsync(cts.Token); + PipeWireNode node = await registry + .CreateVirtualSink("DisposeSelf") + .WithName(Unique("pwnet_dispose_self")) + .ExecuteAsync(cts.Token); Exception? thrown = await attempted.Task.WaitAsync(TimeSpan.FromSeconds(20), cts.Token); - Assert.IsInstanceOfType(thrown, - "disposing from the loop thread has to be refused, not attempted"); + Assert.IsInstanceOfType( + thrown, + "disposing from the loop thread has to be refused, not attempted" + ); Assert.Contains("loop thread", thrown!.Message, StringComparison.Ordinal); // Refused, so the context is untouched and still works. diff --git a/tests/PipeWire.NET.Tests/RegistryObjectKindLiveTests.cs b/tests/PipeWire.NET.Tests/RegistryObjectKindLiveTests.cs index 8adf468..4b34206 100644 --- a/tests/PipeWire.NET.Tests/RegistryObjectKindLiveTests.cs +++ b/tests/PipeWire.NET.Tests/RegistryObjectKindLiveTests.cs @@ -29,7 +29,10 @@ public async Task ARealSession_ReportsTheObjectKindsItsGraphIsBuiltFrom() RequireLinux(); using var cts = new CancellationTokenSource(Budget); - await using var context = new PipeWireContext("pwnet-kinds", ConsoleTestLoggerFactory.Instance); + await using var context = new PipeWireContext( + "pwnet-kinds", + ConsoleTestLoggerFactory.Instance + ); await context.StartAsync(cts.Token); await using var registry = new PipeWireRegistry(context); await registry.WaitForInitialEnumerationAsync(cts.Token); @@ -41,7 +44,10 @@ public async Task ARealSession_ReportsTheObjectKindsItsGraphIsBuiltFrom() // factory. Devices and metadata stores depend on the hardware and the session manager. Assert.IsNotNull(graph.Core, "the daemon must report its core object"); Assert.IsTrue(graph.Clients.Length > 0, "this connection is itself a client"); - Assert.IsTrue(graph.Factories.Length > 0, "the daemon must expose the factories it creates with"); + Assert.IsTrue( + graph.Factories.Length > 0, + "the daemon must expose the factories it creates with" + ); Assert.IsTrue(graph.Modules.Length > 0, "protocol-native alone is a module"); // The factory names the library hardcodes when creating objects have to be among them, or @@ -57,7 +63,10 @@ public async Task EveryObjectTheRegistryReports_ResolvesBackToItselfById() RequireLinux(); using var cts = new CancellationTokenSource(Budget); - await using var context = new PipeWireContext("pwnet-kinds-ids", ConsoleTestLoggerFactory.Instance); + await using var context = new PipeWireContext( + "pwnet-kinds-ids", + ConsoleTestLoggerFactory.Instance + ); await context.StartAsync(cts.Token); await using var registry = new PipeWireRegistry(context); await registry.WaitForInitialEnumerationAsync(cts.Token); @@ -68,12 +77,23 @@ public async Task EveryObjectTheRegistryReports_ResolvesBackToItselfById() // over a live graph is what catches an index built from the wrong collection. foreach (IPipeWireObject expected in graph.Objects) { - Assert.IsTrue(graph.TryGetObject(expected.Id, out IPipeWireObject? found), - $"{expected.Kind} {expected.Id} is in the graph but does not resolve by id"); + Assert.IsTrue( + graph.TryGetObject(expected.Id, out IPipeWireObject? found), + $"{expected.Kind} {expected.Id} is in the graph but does not resolve by id" + ); Assert.AreSame(expected, found); - Assert.IsNull(graph.GetNode(expected.Id), $"{expected.Kind} {expected.Id} resolved as a node"); - Assert.IsNull(graph.GetPort(expected.Id), $"{expected.Kind} {expected.Id} resolved as a port"); - Assert.IsNull(graph.GetLink(expected.Id), $"{expected.Kind} {expected.Id} resolved as a link"); + Assert.IsNull( + graph.GetNode(expected.Id), + $"{expected.Kind} {expected.Id} resolved as a node" + ); + Assert.IsNull( + graph.GetPort(expected.Id), + $"{expected.Kind} {expected.Id} resolved as a port" + ); + Assert.IsNull( + graph.GetLink(expected.Id), + $"{expected.Kind} {expected.Id} resolved as a link" + ); } foreach (PipeWireNode node in graph.Nodes) @@ -86,7 +106,10 @@ public async Task AModulesDetails_ArriveOnlyOnceItIsBound() RequireLinux(); using var cts = new CancellationTokenSource(Budget); - await using var context = new PipeWireContext("pwnet-module-details", ConsoleTestLoggerFactory.Instance); + await using var context = new PipeWireContext( + "pwnet-module-details", + ConsoleTestLoggerFactory.Instance + ); await context.StartAsync(cts.Token); await using var registry = new PipeWireRegistry(context); await registry.WaitForInitialEnumerationAsync(cts.Token); @@ -103,13 +126,20 @@ public async Task AModulesDetails_ArriveOnlyOnceItIsBound() Assert.AreEqual(bare.ModuleName, full.ModuleName, "it must still be the same module"); Assert.IsNotNull(full.Description, "binding must have filled the description in"); - Assert.IsNotNull(full.Properties.GetValueOrDefault(PipeWireKeys.MODULE_FILENAME), - "the module's filename is on the info event and nowhere else"); - - Assert.AreSame(full, registry.Current.GetModule(bare.Id), - "the graph must hold the enriched module, not just the caller"); - Assert.IsTrue(bare.IsStillIn(registry.Current), - "enriching an object must not read as the id having been reused"); + Assert.IsNotNull( + full.Properties.GetValueOrDefault(PipeWireKeys.MODULE_FILENAME), + "the module's filename is on the info event and nowhere else" + ); + + Assert.AreSame( + full, + registry.Current.GetModule(bare.Id), + "the graph must hold the enriched module, not just the caller" + ); + Assert.IsTrue( + bare.IsStillIn(registry.Current), + "enriching an object must not read as the id having been reused" + ); } [TestMethod] @@ -118,7 +148,10 @@ public async Task EveryObjectInTheGraph_CarriesASerialThatIsUniqueToIt() RequireLinux(); using var cts = new CancellationTokenSource(Budget); - await using var context = new PipeWireContext("pwnet-serials", ConsoleTestLoggerFactory.Instance); + await using var context = new PipeWireContext( + "pwnet-serials", + ConsoleTestLoggerFactory.Instance + ); await context.StartAsync(cts.Token); await using var registry = new PipeWireRegistry(context); await registry.WaitForInitialEnumerationAsync(cts.Token); @@ -126,21 +159,32 @@ public async Task EveryObjectInTheGraph_CarriesASerialThatIsUniqueToIt() PipeWireGraphSnapshot graph = registry.Current; IPipeWireObject[] all = [ - .. graph.Nodes, .. graph.Ports, .. graph.Links, - .. graph.Devices, .. graph.Clients, .. graph.Modules, .. graph.Factories, + .. graph.Nodes, + .. graph.Ports, + .. graph.Links, + .. graph.Devices, + .. graph.Clients, + .. graph.Modules, + .. graph.Factories, ]; Assert.IsGreaterThan(0, all.Length, "an empty graph proves nothing"); List without = [.. all.Where(o => o.ObjectSerial is null)]; - Assert.AreEqual(0, without.Count, + Assert.AreEqual( + 0, + without.Count, "every global carries object.serial: " - + string.Join(", ", without.Select(o => $"{o.Kind} {o.Id}"))); + + string.Join(", ", without.Select(o => $"{o.Kind} {o.Id}")) + ); // The point of the serial. Ids are unique among live objects too, so this only shows the // serial is usable as an identity; that it is not reused is what the id cannot promise. - Assert.AreEqual(all.Length, all.Select(o => o.ObjectSerial).Distinct().Count(), - "two live objects reported the same serial"); + Assert.AreEqual( + all.Length, + all.Select(o => o.ObjectSerial).Distinct().Count(), + "two live objects reported the same serial" + ); } [TestMethod] @@ -149,7 +193,10 @@ public async Task ADeviceBackedNode_NamesADeviceThatIsInTheGraph() RequireLinux(); using var cts = new CancellationTokenSource(Budget); - await using var context = new PipeWireContext("pwnet-kinds-dev", ConsoleTestLoggerFactory.Instance); + await using var context = new PipeWireContext( + "pwnet-kinds-dev", + ConsoleTestLoggerFactory.Instance + ); await context.StartAsync(cts.Token); await using var registry = new PipeWireRegistry(context); await registry.WaitForInitialEnumerationAsync(cts.Token); @@ -175,16 +222,22 @@ public async Task TheProfiler_CanBeBoundAndReportsTheGraphsTimings() RequireLinux(); using var cts = new CancellationTokenSource(Budget); - await using var context = new PipeWireContext("pwnet-profiler", ConsoleTestLoggerFactory.Instance); + await using var context = new PipeWireContext( + "pwnet-profiler", + ConsoleTestLoggerFactory.Instance + ); await context.StartAsync(cts.Token); await using var registry = new PipeWireRegistry(context); await registry.WaitForInitialEnumerationAsync(cts.Token); PipeWireProfiler? profiler = registry.Current.Profiler; - if (profiler is null) Assert.Inconclusive("this daemon was built without the profiler."); + if (profiler is null) + Assert.Inconclusive("this daemon was built without the profiler."); - Assert.ThrowsExactly(() => registry.BindProfiler(uint.MaxValue), - "an id that is not the profiler must be refused rather than bound"); + Assert.ThrowsExactly( + () => registry.BindProfiler(uint.MaxValue), + "an id that is not the profiler must be refused rather than bound" + ); var reports = new System.Collections.Concurrent.ConcurrentQueue(); var arrived = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); @@ -201,48 +254,58 @@ public async Task TheProfiler_CanBeBoundAndReportsTheGraphsTimings() // library's contract. Assert.Inconclusive( $"the daemon refused to bind its profiler (permissions {profiler!.Permissions}): " - + $"{e.GetType().Name}: {e.Message}"); + + $"{e.GetType().Name}: {e.Message}" + ); return; } await using (reader) { - reader.ProfileReceived += (_, report) => - { - reports.Enqueue(report); - arrived.TrySetResult(); - }; - - Assert.AreEqual(profiler.Id, reader.Id); - - // The daemon only profiles a graph that is running, and an idle session drives nothing - - // which is why waiting here used to end in a skip rather than an answer. Give it something - // to drive: a producer and a consumer linked to each other keep the graph cycling for as - // long as they are connected. - await using var producer = new PipeWireAudioOutput(context, $"pwnet_profiler_drive_{Environment.ProcessId}"); - producer.FillSamples += (_, _, _, _, _) => 0; - producer.Connect(autoConnect: false); - - for (int i = 0; i < 100 && producer.NodeId is null; i++) - { - await Task.Delay(50, cts.Token); - await registry.WaitForInitialEnumerationAsync(cts.Token); - } - - Assert.IsNotNull(producer.NodeId, "the driving producer never reached the graph"); - - await using var consumer = new PipeWireAudioCapture(context, $"pwnet_profiler_sink_{Environment.ProcessId}"); - consumer.FrameReady += (_, _) => { }; - consumer.Connect((await producer.WaitForNodeIdAsync(cts.Token))); - - // Shorter than the class budget, so running out of patience is reported as such rather - // than arriving as the budget's own cancellation. - await arrived.Task.WaitAsync(TimeSpan.FromSeconds(10), cts.Token); - - Assert.IsTrue(reports.TryDequeue(out Spa.SpaObject? first)); - Assert.AreEqual(Spa.SpaType.ObjectProfiler, first!.ObjectType, - "a profiler report is a Profiler object"); - Assert.IsTrue(first.Properties.Length > 0, "a report with no properties says nothing"); + reader.ProfileReceived += (_, report) => + { + reports.Enqueue(report); + arrived.TrySetResult(); + }; + + Assert.AreEqual(profiler.Id, reader.Id); + + // The daemon only profiles a graph that is running, and an idle session drives nothing - + // which is why waiting here used to end in a skip rather than an answer. Give it something + // to drive: a producer and a consumer linked to each other keep the graph cycling for as + // long as they are connected. + await using var producer = new PipeWireAudioOutput( + context, + $"pwnet_profiler_drive_{Environment.ProcessId}" + ); + producer.FillSamples += (_, _, _, _, _) => 0; + producer.Connect(autoConnect: false); + + for (int i = 0; i < 100 && producer.NodeId is null; i++) + { + await Task.Delay(50, cts.Token); + await registry.WaitForInitialEnumerationAsync(cts.Token); + } + + Assert.IsNotNull(producer.NodeId, "the driving producer never reached the graph"); + + await using var consumer = new PipeWireAudioCapture( + context, + $"pwnet_profiler_sink_{Environment.ProcessId}" + ); + consumer.FrameReady += (_, _) => { }; + consumer.Connect((await producer.WaitForNodeIdAsync(cts.Token))); + + // Shorter than the class budget, so running out of patience is reported as such rather + // than arriving as the budget's own cancellation. + await arrived.Task.WaitAsync(TimeSpan.FromSeconds(10), cts.Token); + + Assert.IsTrue(reports.TryDequeue(out Spa.SpaObject? first)); + Assert.AreEqual( + Spa.SpaType.ObjectProfiler, + first!.ObjectType, + "a profiler report is a Profiler object" + ); + Assert.IsTrue(first.Properties.Length > 0, "a report with no properties says nothing"); } } } diff --git a/tests/PipeWire.NET.Tests/RegistryObjectKindTests.cs b/tests/PipeWire.NET.Tests/RegistryObjectKindTests.cs index e28e530..a40bc5d 100644 --- a/tests/PipeWire.NET.Tests/RegistryObjectKindTests.cs +++ b/tests/PipeWire.NET.Tests/RegistryObjectKindTests.cs @@ -17,17 +17,27 @@ public sealed unsafe class RegistryObjectKindTests : PipeWireTestBase // carry device.name and media.class, and a Module global really does carry only module.name. private static readonly (string Key, string? Value)[] DeviceProps = [ - ("object.serial", "50"), ("factory.id", "15"), ("client.id", "49"), - ("device.api", "alsa"), ("device.description", "Radeon High Definition Audio Controller"), - ("device.name", "alsa_card.pci-0000_e4_00.1"), ("device.nick", "HD-Audio Generic"), - ("media.class", "Audio/Device"), ("object.path", "alsa:acp:Generic"), + ("object.serial", "50"), + ("factory.id", "15"), + ("client.id", "49"), + ("device.api", "alsa"), + ("device.description", "Radeon High Definition Audio Controller"), + ("device.name", "alsa_card.pci-0000_e4_00.1"), + ("device.nick", "HD-Audio Generic"), + ("media.class", "Audio/Device"), + ("object.path", "alsa:acp:Generic"), ]; private static readonly (string Key, string? Value)[] ClientProps = [ - ("object.serial", "32"), ("module.id", "2"), ("pipewire.protocol", "protocol-native"), - ("pipewire.sec.pid", "3046"), ("pipewire.sec.uid", "1000"), ("pipewire.sec.gid", "1000"), - ("application.name", "xdg-desktop-portal"), ("pipewire.access", "portal"), + ("object.serial", "32"), + ("module.id", "2"), + ("pipewire.protocol", "protocol-native"), + ("pipewire.sec.pid", "3046"), + ("pipewire.sec.uid", "1000"), + ("pipewire.sec.gid", "1000"), + ("application.name", "xdg-desktop-portal"), + ("pipewire.access", "portal"), ]; [TestMethod] @@ -88,7 +98,8 @@ public void ANumericPropertyThatIsNotANumber_CostsThatFieldAndNothingElse() using var dict = new NativeDict( ("device.name", "alsa_card.junk"), ("factory.id", "not-a-number"), - ("client.id", "-1")); + ("client.id", "-1") + ); PipeWireDevice device = ParseDevice(dict, id: 7); @@ -100,19 +111,63 @@ public void ANumericPropertyThatIsNotANumber_CostsThatFieldAndNothingElse() [TestMethod] public void TheSnapshotSortsObjectsByKind_AndFindsAStoreByName() { - var device = new PipeWireDevice(10, PipeWirePermissions.None, 3, - "card", null, null, "alsa", "Audio/Device", null, null, null); - var client = new PipeWireClient(11, PipeWirePermissions.None, 3, - "firefox", 99, null, null, null, null, null); + var device = new PipeWireDevice( + 10, + PipeWirePermissions.None, + 3, + "card", + null, + null, + "alsa", + "Audio/Device", + null, + null, + null + ); + var client = new PipeWireClient( + 11, + PipeWirePermissions.None, + 3, + "firefox", + 99, + null, + null, + null, + null, + null + ); var settings = new PipeWireMetadata(12, PipeWirePermissions.None, 3, "settings"); var defaults = new PipeWireMetadata(13, PipeWirePermissions.None, 3, "default"); - var core = new PipeWireCore(0, PipeWirePermissions.None, 4, "pipewire-0", "1.6.8", null, null); + var core = new PipeWireCore( + 0, + PipeWirePermissions.None, + 4, + "pipewire-0", + "1.6.8", + null, + null + ); - var graph = new PipeWireGraphSnapshot(1, [], [], [], [device, client, settings, defaults, core]); - - CollectionAssert.AreEquivalent(new uint[] { 10 }, graph.Devices.Select(d => d.Id).ToArray()); - CollectionAssert.AreEquivalent(new uint[] { 11 }, graph.Clients.Select(c => c.Id).ToArray()); - CollectionAssert.AreEquivalent(new uint[] { 12, 13 }, graph.Metadata.Select(m => m.Id).ToArray()); + var graph = new PipeWireGraphSnapshot( + 1, + [], + [], + [], + [device, client, settings, defaults, core] + ); + + CollectionAssert.AreEquivalent( + new uint[] { 10 }, + graph.Devices.Select(d => d.Id).ToArray() + ); + CollectionAssert.AreEquivalent( + new uint[] { 11 }, + graph.Clients.Select(c => c.Id).ToArray() + ); + CollectionAssert.AreEquivalent( + new uint[] { 12, 13 }, + graph.Metadata.Select(m => m.Id).ToArray() + ); Assert.AreSame(core, graph.Core); Assert.IsNull(graph.Profiler, "the daemon in this graph has no profiler"); @@ -129,10 +184,20 @@ public void TheSnapshotSortsObjectsByKind_AndFindsAStoreByName() public void TheseObjectsDoNotDisplaceNodesPortsOrLinks_InLookups() { // Ids are unique across all kinds, so one index must not shadow another. - var device = new PipeWireDevice(1, PipeWirePermissions.None, 3, - "card", null, null, null, null, null, null, null); - var graph = new PipeWireGraphSnapshot( - 1, [new(2, "node", null, null)], [], [], [device]); + var device = new PipeWireDevice( + 1, + PipeWirePermissions.None, + 3, + "card", + null, + null, + null, + null, + null, + null, + null + ); + var graph = new PipeWireGraphSnapshot(1, [new(2, "node", null, null)], [], [], [device]); Assert.IsNull(graph.GetNode(1), "id 1 is a device, not a node"); Assert.IsNotNull(graph.GetDevice(1)); @@ -143,12 +208,22 @@ public void TheseObjectsDoNotDisplaceNodesPortsOrLinks_InLookups() private static PipeWireDevice ParseDevice(NativeDict dict, uint id) { fixed (spa_dict* d = &dict.Dict) - return PipeWireGlobalParser.ParseDevice(id, PipeWirePermissions.None, 3, PipeWireProperties.From(d)); + return PipeWireGlobalParser.ParseDevice( + id, + PipeWirePermissions.None, + 3, + PipeWireProperties.From(d) + ); } private static PipeWireClient ParseClient(NativeDict dict, uint id) { fixed (spa_dict* d = &dict.Dict) - return PipeWireGlobalParser.ParseClient(id, PipeWirePermissions.None, 3, PipeWireProperties.From(d)); + return PipeWireGlobalParser.ParseClient( + id, + PipeWirePermissions.None, + 3, + PipeWireProperties.From(d) + ); } } diff --git a/tests/PipeWire.NET.Tests/RegistryParsingTests.cs b/tests/PipeWire.NET.Tests/RegistryParsingTests.cs index 71da1a7..88e8764 100644 --- a/tests/PipeWire.NET.Tests/RegistryParsingTests.cs +++ b/tests/PipeWire.NET.Tests/RegistryParsingTests.cs @@ -2,9 +2,9 @@ using System.Runtime.Versioning; using System.Text; using Microsoft.VisualStudio.TestTools.UnitTesting; +using PipeWire.NET.Graph; using PipeWire.NET.Interop; using PipeWire.NET.Spa; -using PipeWire.NET.Graph; namespace PipeWire.NET.Tests; @@ -21,7 +21,6 @@ namespace PipeWire.NET.Tests; [SupportedOSPlatform("linux")] public sealed class RegistryParsingTests : PipeWireTestBase { - // ------------------------------------------------------------------ ports [TestMethod] @@ -30,10 +29,18 @@ public unsafe void APortWithNoNodeId_IsSkippedWithAReason() using var dict = new NativeDict(("port.direction", "out"), ("port.name", "orphan")); fixed (spa_dict* d = &dict.Dict) { - Assert.IsFalse(PipeWireGlobalParser.TryParsePort( - 42, PipeWirePermissions.Read, 3, PipeWireProperties.From(d), - out PipeWirePort? port, out string reason, out _), - "a port with no owning node cannot be filed anywhere"); + Assert.IsFalse( + PipeWireGlobalParser.TryParsePort( + 42, + PipeWirePermissions.Read, + 3, + PipeWireProperties.From(d), + out PipeWirePort? port, + out string reason, + out _ + ), + "a port with no owning node cannot be filed anywhere" + ); Assert.IsNull(port, "a refused parse must not produce a half-built port"); StringAssert.Contains(reason, "node id", "the reason must name what was wrong"); @@ -44,15 +51,24 @@ public unsafe void APortWithNoNodeId_IsSkippedWithAReason() [DataRow("not-a-number")] [DataRow("")] [DataRow("-1")] - [DataRow("99999999999999999999")] // overflows uint + [DataRow("99999999999999999999")] // overflows uint [DataRow("12abc")] public unsafe void APortWithAnUnparseableNodeId_IsSkipped(string nodeId) { using var dict = new NativeDict(("node.id", nodeId), ("port.direction", "out")); fixed (spa_dict* d = &dict.Dict) Assert.IsFalse( - PipeWireGlobalParser.TryParsePort(42, PipeWirePermissions.Read, 3, PipeWireProperties.From(d), out _, out _, out _), - $"node.id '{nodeId}' should not have parsed"); + PipeWireGlobalParser.TryParsePort( + 42, + PipeWirePermissions.Read, + 3, + PipeWireProperties.From(d), + out _, + out _, + out _ + ), + $"node.id '{nodeId}' should not have parsed" + ); } [TestMethod] @@ -67,9 +83,18 @@ public unsafe void ANodeIdWithSurroundingWhitespace_IsAcceptedDeliberately(strin using var dict = new NativeDict(("node.id", nodeId), ("port.direction", "out")); PipeWirePort? port; fixed (spa_dict* d = &dict.Dict) - Assert.IsTrue(PipeWireGlobalParser.TryParsePort( - 42, PipeWirePermissions.Read, 3, PipeWireProperties.From(d), out port, out _, out _), - $"node.id '{nodeId}' states 7 clearly enough"); + Assert.IsTrue( + PipeWireGlobalParser.TryParsePort( + 42, + PipeWirePermissions.Read, + 3, + PipeWireProperties.From(d), + out port, + out _, + out _ + ), + $"node.id '{nodeId}' states 7 clearly enough" + ); Assert.AreEqual(7u, port!.NodeId); } @@ -78,9 +103,9 @@ public unsafe void ANodeIdWithSurroundingWhitespace_IsAcceptedDeliberately(strin [DataRow(null)] [DataRow("")] [DataRow("sideways")] - [DataRow("In")] // casing matters - [DataRow("0")] // Enum.TryParse would have taken this as In - [DataRow("out ")] // trailing space is a different value + [DataRow("In")] // casing matters + [DataRow("0")] // Enum.TryParse would have taken this as In + [DataRow("out ")] // trailing space is a different value public unsafe void APortWithAnUnusableDirection_IsSkipped(string? direction) { (string, string?)[] pairs = direction is null @@ -90,9 +115,18 @@ public unsafe void APortWithAnUnusableDirection_IsSkipped(string? direction) using var dict = new NativeDict(pairs); fixed (spa_dict* d = &dict.Dict) { - Assert.IsFalse(PipeWireGlobalParser.TryParsePort( - 42, PipeWirePermissions.Read, 3, PipeWireProperties.From(d), out _, out string reason, out _), - $"direction '{direction}' should not have parsed"); + Assert.IsFalse( + PipeWireGlobalParser.TryParsePort( + 42, + PipeWirePermissions.Read, + 3, + PipeWireProperties.From(d), + out _, + out string reason, + out _ + ), + $"direction '{direction}' should not have parsed" + ); StringAssert.Contains(reason, "direction"); } } @@ -102,18 +136,37 @@ public unsafe void APortWithAnUnusableDirection_IsSkipped(string? direction) [DataRow("out", PipeWirePortDirection.Out)] [DataRow("control", PipeWirePortDirection.Control)] [DataRow("notify", PipeWirePortDirection.Notify)] - public unsafe void AWellFormedPort_ParsesEveryField(string direction, PipeWirePortDirection expected) + public unsafe void AWellFormedPort_ParsesEveryField( + string direction, + PipeWirePortDirection expected + ) { using var dict = new NativeDict( - ("node.id", "7"), ("port.direction", direction), - ("port.name", "capture_FL"), ("port.monitor", "true"), ("port.id", "3"), - ("port.control", "true"), ("format.dsp", "32 bit float mono audio"), - ("audio.channel", "FL"), ("port.alias", "alsa:capture_FL"), ("port.group", "stream.0")); + ("node.id", "7"), + ("port.direction", direction), + ("port.name", "capture_FL"), + ("port.monitor", "true"), + ("port.id", "3"), + ("port.control", "true"), + ("format.dsp", "32 bit float mono audio"), + ("audio.channel", "FL"), + ("port.alias", "alsa:capture_FL"), + ("port.group", "stream.0") + ); PipeWirePort? port; fixed (spa_dict* d = &dict.Dict) - Assert.IsTrue(PipeWireGlobalParser.TryParsePort( - 42, PipeWirePermissions.Read | PipeWirePermissions.Write, 3, PipeWireProperties.From(d), out port, out _, out _)); + Assert.IsTrue( + PipeWireGlobalParser.TryParsePort( + 42, + PipeWirePermissions.Read | PipeWirePermissions.Write, + 3, + PipeWireProperties.From(d), + out port, + out _, + out _ + ) + ); Assert.IsNotNull(port); Assert.AreEqual(42u, port!.PortId); @@ -137,23 +190,56 @@ public unsafe void AControlPortSpeltEitherWay_ReadsAsAControlPort() // PipeWire's own adapter control ports set port.control and keep the direction as in or // out, so reading the direction alone misses exactly the ports that matter. The JACK-style // direction is the other spelling and has to count too. - using var flagged = new NativeDict(("node.id", "7"), ("port.direction", "in"), ("port.control", "true")); + using var flagged = new NativeDict( + ("node.id", "7"), + ("port.direction", "in"), + ("port.control", "true") + ); using var directed = new NativeDict(("node.id", "7"), ("port.direction", "control")); using var neither = new NativeDict(("node.id", "7"), ("port.direction", "in")); - PipeWirePort? a, b, c; + PipeWirePort? a, + b, + c; fixed (spa_dict* d = &flagged.Dict) - PipeWireGlobalParser.TryParsePort(1, PipeWirePermissions.None, 3, PipeWireProperties.From(d), out a, out _, out _); + PipeWireGlobalParser.TryParsePort( + 1, + PipeWirePermissions.None, + 3, + PipeWireProperties.From(d), + out a, + out _, + out _ + ); fixed (spa_dict* d = &directed.Dict) - PipeWireGlobalParser.TryParsePort(2, PipeWirePermissions.None, 3, PipeWireProperties.From(d), out b, out _, out _); + PipeWireGlobalParser.TryParsePort( + 2, + PipeWirePermissions.None, + 3, + PipeWireProperties.From(d), + out b, + out _, + out _ + ); fixed (spa_dict* d = &neither.Dict) - PipeWireGlobalParser.TryParsePort(3, PipeWirePermissions.None, 3, PipeWireProperties.From(d), out c, out _, out _); + PipeWireGlobalParser.TryParsePort( + 3, + PipeWirePermissions.None, + 3, + PipeWireProperties.From(d), + out c, + out _, + out _ + ); Assert.IsTrue(a!.IsControl, "port.control must count"); Assert.IsTrue(b!.IsControl, "port.direction=control must count"); Assert.IsFalse(c!.IsControl); - Assert.AreEqual(PipeWirePortDirection.In, a.PortDirection, - "a control port keeps the direction it reported"); + Assert.AreEqual( + PipeWirePortDirection.In, + a.PortDirection, + "a control port keeps the direction it reported" + ); } [TestMethod] @@ -162,16 +248,27 @@ public unsafe void EveryObjectCarriesItsSerialAndItsRawProperties() // object.serial is on every global and is never reused, unlike the id. Anything the // library does not model has to stay reachable, or a caller is stuck until a release. using var dict = new NativeDict( - ("node.name", "n"), ("object.serial", "8814"), ("some.module.key", "kept")); + ("node.name", "n"), + ("object.serial", "8814"), + ("some.module.key", "kept") + ); PipeWireNode node; fixed (spa_dict* d = &dict.Dict) - node = PipeWireGlobalParser.ParseNode(5, PipeWirePermissions.None, 3, PipeWireProperties.From(d)); + node = PipeWireGlobalParser.ParseNode( + 5, + PipeWirePermissions.None, + 3, + PipeWireProperties.From(d) + ); Assert.AreEqual(8814ul, node.ObjectSerial); Assert.AreEqual("kept", node.Properties["some.module.key"]); - Assert.AreEqual("n", node.Properties[PipeWireKeys.PW_KEY_NODE_NAME], - "a modelled property stays readable through the dictionary too"); + Assert.AreEqual( + "n", + node.Properties[PipeWireKeys.PW_KEY_NODE_NAME], + "a modelled property stays readable through the dictionary too" + ); Assert.IsNull(node.Properties.GetValueOrDefault("not.sent")); } @@ -181,8 +278,17 @@ public unsafe void APortWithOnlyItsMandatoryProperties_StillParses() using var dict = new NativeDict(("node.id", "7"), ("port.direction", "in")); PipeWirePort? port; fixed (spa_dict* d = &dict.Dict) - Assert.IsTrue(PipeWireGlobalParser.TryParsePort( - 42, PipeWirePermissions.None, 3, PipeWireProperties.From(d), out port, out _, out _)); + Assert.IsTrue( + PipeWireGlobalParser.TryParsePort( + 42, + PipeWirePermissions.None, + 3, + PipeWireProperties.From(d), + out port, + out _, + out _ + ) + ); Assert.IsNotNull(port); Assert.IsNull(port!.PortName); @@ -196,17 +302,29 @@ public unsafe void APortWithOnlyItsMandatoryProperties_StillParses() [DataRow("1", true)] [DataRow("false", false)] [DataRow("0", false)] - [DataRow("True", false)] // spa_atob is ordinal + [DataRow("True", false)] // spa_atob is ordinal [DataRow("yes", false)] public unsafe void PortBooleans_FollowSpaAtobExactly(string raw, bool expected) { using var dict = new NativeDict( - ("node.id", "7"), ("port.direction", "in"), ("port.monitor", raw)); + ("node.id", "7"), + ("port.direction", "in"), + ("port.monitor", raw) + ); PipeWirePort? port; fixed (spa_dict* d = &dict.Dict) - Assert.IsTrue(PipeWireGlobalParser.TryParsePort( - 42, PipeWirePermissions.None, 3, PipeWireProperties.From(d), out port, out _, out _)); + Assert.IsTrue( + PipeWireGlobalParser.TryParsePort( + 42, + PipeWirePermissions.None, + 3, + PipeWireProperties.From(d), + out port, + out _, + out _ + ) + ); Assert.AreEqual(expected, port!.IsMonitor, $"port.monitor '{raw}'"); } @@ -232,10 +350,18 @@ public unsafe void ALinkMissingAnyEndpoint_IsSkippedNamingThatEndpoint(string om using var dict = new NativeDict([.. all.Select(kv => (kv.Key, kv.Value))]); fixed (spa_dict* d = &dict.Dict) { - Assert.IsFalse(PipeWireGlobalParser.TryParseLink( - 99, PipeWirePermissions.Read, 3, PipeWireProperties.From(d), - out PipeWireLink? link, out string reason, out _), - $"a link without {omit} describes no route"); + Assert.IsFalse( + PipeWireGlobalParser.TryParseLink( + 99, + PipeWirePermissions.Read, + 3, + PipeWireProperties.From(d), + out PipeWireLink? link, + out string reason, + out _ + ), + $"a link without {omit} describes no route" + ); Assert.IsNull(link); StringAssert.Contains(reason, omit, "the reason must name the missing key"); @@ -246,25 +372,49 @@ public unsafe void ALinkMissingAnyEndpoint_IsSkippedNamingThatEndpoint(string om public unsafe void ALinkWithAnUnparseableEndpoint_IsSkipped() { using var dict = new NativeDict( - ("link.output.node", "1"), ("link.output.port", "not-a-number"), - ("link.input.node", "3"), ("link.input.port", "4")); + ("link.output.node", "1"), + ("link.output.port", "not-a-number"), + ("link.input.node", "3"), + ("link.input.port", "4") + ); fixed (spa_dict* d = &dict.Dict) - Assert.IsFalse(PipeWireGlobalParser.TryParseLink( - 99, PipeWirePermissions.Read, 3, PipeWireProperties.From(d), out _, out _, out string? offending)); + Assert.IsFalse( + PipeWireGlobalParser.TryParseLink( + 99, + PipeWirePermissions.Read, + 3, + PipeWireProperties.From(d), + out _, + out _, + out string? offending + ) + ); } [TestMethod] public unsafe void AWellFormedLink_KeepsItsEndpointsInTheRightSlots() { using var dict = new NativeDict( - ("link.output.node", "1"), ("link.output.port", "2"), - ("link.input.node", "3"), ("link.input.port", "4")); + ("link.output.node", "1"), + ("link.output.port", "2"), + ("link.input.node", "3"), + ("link.input.port", "4") + ); PipeWireLink? link; fixed (spa_dict* d = &dict.Dict) - Assert.IsTrue(PipeWireGlobalParser.TryParseLink( - 99, PipeWirePermissions.Read, 3, PipeWireProperties.From(d), out link, out _, out _)); + Assert.IsTrue( + PipeWireGlobalParser.TryParseLink( + 99, + PipeWirePermissions.Read, + 3, + PipeWireProperties.From(d), + out link, + out _, + out _ + ) + ); Assert.IsNotNull(link); Assert.AreEqual(99u, link!.LinkId); @@ -285,7 +435,12 @@ public unsafe void ANodeWithNoPropertiesAtAll_StillParses() using var dict = new NativeDict(); PipeWireNode node; fixed (spa_dict* d = &dict.Dict) - node = PipeWireGlobalParser.ParseNode(5, PipeWirePermissions.Read, 3, PipeWireProperties.From(d)); + node = PipeWireGlobalParser.ParseNode( + 5, + PipeWirePermissions.Read, + 3, + PipeWireProperties.From(d) + ); Assert.AreEqual(5u, node.NodeId); Assert.IsNull(node.NodeName); @@ -298,12 +453,20 @@ public unsafe void ANodeWithNoPropertiesAtAll_StillParses() public unsafe void ANodeCarriesEveryPropertyItWasGiven() { using var dict = new NativeDict( - ("node.name", "alsa_output.pci"), ("node.description", "Speakers"), - ("node.nick", "Spk"), ("media.class", "Audio/Sink")); + ("node.name", "alsa_output.pci"), + ("node.description", "Speakers"), + ("node.nick", "Spk"), + ("media.class", "Audio/Sink") + ); PipeWireNode node; fixed (spa_dict* d = &dict.Dict) - node = PipeWireGlobalParser.ParseNode(5, PipeWirePermissions.Read, 3, PipeWireProperties.From(d)); + node = PipeWireGlobalParser.ParseNode( + 5, + PipeWirePermissions.Read, + 3, + PipeWireProperties.From(d) + ); Assert.AreEqual("alsa_output.pci", node.NodeName); Assert.AreEqual("Speakers", node.Description); @@ -319,7 +482,12 @@ public unsafe void ANodeADeviceProvides_NamesThatDevice() using var dict = new NativeDict(("device.id", "42"), ("media.class", "Audio/Sink")); PipeWireNode node; fixed (spa_dict* d = &dict.Dict) - node = PipeWireGlobalParser.ParseNode(5, PipeWirePermissions.Read, 3, PipeWireProperties.From(d)); + node = PipeWireGlobalParser.ParseNode( + 5, + PipeWirePermissions.Read, + 3, + PipeWireProperties.From(d) + ); Assert.AreEqual(42u, node.DeviceId); } @@ -332,12 +500,22 @@ public unsafe void ANodeNoDeviceProvides_ReportsNoDevice() using var dict = new NativeDict(("media.class", "Stream/Output/Audio")); PipeWireNode stream; fixed (spa_dict* d = &dict.Dict) - stream = PipeWireGlobalParser.ParseNode(5, PipeWirePermissions.Read, 3, PipeWireProperties.From(d)); + stream = PipeWireGlobalParser.ParseNode( + 5, + PipeWirePermissions.Read, + 3, + PipeWireProperties.From(d) + ); using var garbage = new NativeDict(("device.id", "not-a-number")); PipeWireNode unparsable; fixed (spa_dict* d = &garbage.Dict) - unparsable = PipeWireGlobalParser.ParseNode(6, PipeWirePermissions.Read, 3, PipeWireProperties.From(d)); + unparsable = PipeWireGlobalParser.ParseNode( + 6, + PipeWirePermissions.Read, + 3, + PipeWireProperties.From(d) + ); Assert.IsNull(stream.DeviceId); Assert.IsNull(unparsable.DeviceId); @@ -351,7 +529,12 @@ public unsafe void APropertyWithANullValue_ReadsAsAbsentAndDoesNotStopTheRest() using var dict = new NativeDict(("node.name", null), ("media.class", "Audio/Sink")); PipeWireNode node; fixed (spa_dict* d = &dict.Dict) - node = PipeWireGlobalParser.ParseNode(5, PipeWirePermissions.Read, 3, PipeWireProperties.From(d)); + node = PipeWireGlobalParser.ParseNode( + 5, + PipeWirePermissions.Read, + 3, + PipeWireProperties.From(d) + ); Assert.IsNull(node.NodeName); Assert.AreEqual("Audio/Sink", node.MediaClass, "later properties must still be read"); @@ -364,7 +547,12 @@ public unsafe void AKeyThatIsAPrefixOfAnother_DoesNotMatchIt() using var dict = new NativeDict(("node.n", "short"), ("node.name", "full")); PipeWireNode node; fixed (spa_dict* d = &dict.Dict) - node = PipeWireGlobalParser.ParseNode(5, PipeWirePermissions.Read, 3, PipeWireProperties.From(d)); + node = PipeWireGlobalParser.ParseNode( + 5, + PipeWirePermissions.Read, + 3, + PipeWireProperties.From(d) + ); Assert.AreEqual("full", node.NodeName, "a prefix key must not satisfy the lookup"); Assert.IsNull(node.NodeNick); @@ -377,7 +565,12 @@ public unsafe void DuplicateKeys_ResolveToTheFirstMatch() using var dict = new NativeDict(("node.name", "first"), ("node.name", "second")); PipeWireNode node; fixed (spa_dict* d = &dict.Dict) - node = PipeWireGlobalParser.ParseNode(5, PipeWirePermissions.Read, 3, PipeWireProperties.From(d)); + node = PipeWireGlobalParser.ParseNode( + 5, + PipeWirePermissions.Read, + 3, + PipeWireProperties.From(d) + ); Assert.AreEqual("first", node.NodeName); } @@ -387,19 +580,42 @@ public unsafe void DuplicateKeys_ResolveToTheFirstMatch() [TestMethod] public unsafe void AnEmptyDictAndALyingItemCount_AreBothTolerated() { - var empty = new spa_dict { flags = 0, n_items = 0, items = null }; - Assert.IsNull(PipeWireGlobalParser.ParseNode(1, PipeWirePermissions.None, 3, PipeWireProperties.From(&empty)).NodeName); + var empty = new spa_dict + { + flags = 0, + n_items = 0, + items = null, + }; + Assert.IsNull( + PipeWireGlobalParser + .ParseNode(1, PipeWirePermissions.None, 3, PipeWireProperties.From(&empty)) + .NodeName + ); // n_items claiming four entries behind a null pointer is the shape a corrupted message takes. - var lying = new spa_dict { flags = 0, n_items = 4, items = null }; - Assert.IsNull(PipeWireGlobalParser.ParseNode(2, PipeWirePermissions.None, 3, PipeWireProperties.From(&lying)).NodeName, - "a null items array must read as no properties, not be dereferenced"); + var lying = new spa_dict + { + flags = 0, + n_items = 4, + items = null, + }; + Assert.IsNull( + PipeWireGlobalParser + .ParseNode(2, PipeWirePermissions.None, 3, PipeWireProperties.From(&lying)) + .NodeName, + "a null items array must read as no properties, not be dereferenced" + ); } [TestMethod] public unsafe void ANullPropertyDictionary_IsTolerated() { - PipeWireNode node = PipeWireGlobalParser.ParseNode(1, PipeWirePermissions.None, 3, PipeWireProperties.From(null)); + PipeWireNode node = PipeWireGlobalParser.ParseNode( + 1, + PipeWirePermissions.None, + 3, + PipeWireProperties.From(null) + ); Assert.AreEqual(1u, node.NodeId, "props may be null; the node still exists"); Assert.IsNull(node.NodeName); } @@ -407,7 +623,8 @@ public unsafe void ANullPropertyDictionary_IsTolerated() [TestMethod] public unsafe void AnItemWithANullKey_IsSkippedRatherThanDereferenced() { - spa_dict_item* items = (spa_dict_item*)NativeMemory.AllocZeroed((nuint)(sizeof(spa_dict_item) * 2)); + spa_dict_item* items = (spa_dict_item*) + NativeMemory.AllocZeroed((nuint)(sizeof(spa_dict_item) * 2)); try { byte[] key = Encoding.UTF8.GetBytes("node.name\0"); @@ -415,14 +632,28 @@ public unsafe void AnItemWithANullKey_IsSkippedRatherThanDereferenced() fixed (byte* pk = key) fixed (byte* pv = val) { - items[0].key = null; // the hostile entry + items[0].key = null; // the hostile entry items[0].value = (sbyte*)pv; items[1].key = (sbyte*)pk; items[1].value = (sbyte*)pv; - var dict = new spa_dict { flags = 0, n_items = 2, items = items }; - PipeWireNode node = PipeWireGlobalParser.ParseNode(1, PipeWirePermissions.None, 3, PipeWireProperties.From(&dict)); - Assert.AreEqual("kept", node.NodeName, "a null key must be skipped, not crash the walk"); + var dict = new spa_dict + { + flags = 0, + n_items = 2, + items = items, + }; + PipeWireNode node = PipeWireGlobalParser.ParseNode( + 1, + PipeWirePermissions.None, + 3, + PipeWireProperties.From(&dict) + ); + Assert.AreEqual( + "kept", + node.NodeName, + "a null key must be skipped, not crash the walk" + ); } } finally @@ -436,19 +667,47 @@ public unsafe void ParsingNeverThrows_ForAnyCombinationOfMissingProperties() { // Every callback runs inside a reverse P/Invoke, where an escaping exception aborts the // process. Nothing below may throw, whatever the dictionary looks like. - string[] keys = ["node.id", "port.direction", "port.monitor", "link.output.node", "media.class"]; + string[] keys = + [ + "node.id", + "port.direction", + "port.monitor", + "link.output.node", + "media.class", + ]; string?[] values = [null, "", "x", "0", "true", "4294967296"]; foreach (string k in keys) - foreach (string? v in values) + foreach (string? v in values) + { + using var dict = new NativeDict((k, v)); + fixed (spa_dict* d = &dict.Dict) { - using var dict = new NativeDict((k, v)); - fixed (spa_dict* d = &dict.Dict) - { - _ = PipeWireGlobalParser.ParseNode(1, PipeWirePermissions.None, 3, PipeWireProperties.From(d)); - _ = PipeWireGlobalParser.TryParsePort(1, PipeWirePermissions.None, 3, PipeWireProperties.From(d), out _, out _, out _); - _ = PipeWireGlobalParser.TryParseLink(1, PipeWirePermissions.None, 3, PipeWireProperties.From(d), out _, out _, out _); - } + _ = PipeWireGlobalParser.ParseNode( + 1, + PipeWirePermissions.None, + 3, + PipeWireProperties.From(d) + ); + _ = PipeWireGlobalParser.TryParsePort( + 1, + PipeWirePermissions.None, + 3, + PipeWireProperties.From(d), + out _, + out _, + out _ + ); + _ = PipeWireGlobalParser.TryParseLink( + 1, + PipeWirePermissions.None, + 3, + PipeWireProperties.From(d), + out _, + out _, + out _ + ); } + } } } diff --git a/tests/PipeWire.NET.Tests/RegistryRefusalTests.cs b/tests/PipeWire.NET.Tests/RegistryRefusalTests.cs index f359ae3..3841fa3 100644 --- a/tests/PipeWire.NET.Tests/RegistryRefusalTests.cs +++ b/tests/PipeWire.NET.Tests/RegistryRefusalTests.cs @@ -46,7 +46,10 @@ public async Task EveryLookupByGlobalId_RefusesAnIdTheGraphDoesNotHave() RequireLinux(); using var cts = new CancellationTokenSource(Budget); - await using var ctx = new PipeWireContext("pwnet-registry-refusal", ConsoleTestLoggerFactory.Instance); + await using var ctx = new PipeWireContext( + "pwnet-registry-refusal", + ConsoleTestLoggerFactory.Instance + ); await ctx.StartAsync(cts.Token); await using var reg = new PipeWireRegistry(ctx); await reg.WaitForInitialEnumerationAsync(cts.Token); @@ -62,18 +65,21 @@ public async Task EveryLookupByGlobalId_RefusesAnIdTheGraphDoesNotHave() Assert.ThrowsExactly(() => reg.BindProfiler(Nothing)); Assert.ThrowsExactly(() => reg.BindSecurityContext(Nothing)); - await Assert.ThrowsExactlyAsync( - async () => await reg.ReadModuleDetailsAsync(Nothing, cts.Token)); + await Assert.ThrowsExactlyAsync(async () => + await reg.ReadModuleDetailsAsync(Nothing, cts.Token) + ); // A link needs two ports, and neither being real is refused before anything is sent. - await Assert.ThrowsExactlyAsync( - async () => await reg.CreateLinkAsync(Nothing, Nothing, cts.Token)); + await Assert.ThrowsExactlyAsync(async () => + await reg.CreateLinkAsync(Nothing, Nothing, cts.Token) + ); // By name rather than by id: absent is null, because asking for a store that is simply not // running is a question with an answer, not a mistake. Assert.IsNull( reg.BindMetadata("pwnet-no-such-store"), - "a metadata store that does not exist was reported as bound"); + "a metadata store that does not exist was reported as bound" + ); Assert.ThrowsExactly(() => reg.BindMetadata(string.Empty)); Assert.ThrowsExactly(() => reg.BindMetadata((string)null!)); @@ -91,7 +97,10 @@ public async Task ADisposedRegistry_RefusesEveryCallAndEndsItsWatch() RequireLinux(); using var cts = new CancellationTokenSource(Budget); - await using var ctx = new PipeWireContext("pwnet-registry-disposed", ConsoleTestLoggerFactory.Instance); + await using var ctx = new PipeWireContext( + "pwnet-registry-disposed", + ConsoleTestLoggerFactory.Instance + ); await ctx.StartAsync(cts.Token); var reg = new PipeWireRegistry(ctx); @@ -103,21 +112,26 @@ public async Task ADisposedRegistry_RefusesEveryCallAndEndsItsWatch() Assert.ThrowsExactly(() => reg.BindNode(1)); Assert.ThrowsExactly(() => reg.BindMetadata("settings")); - await Assert.ThrowsExactlyAsync( - async () => await reg.WaitForInitialEnumerationAsync(cts.Token)); + await Assert.ThrowsExactlyAsync(async () => + await reg.WaitForInitialEnumerationAsync(cts.Token) + ); - await Assert.ThrowsExactlyAsync( - async () => await reg.CreateVirtualSinkAsync("pwnet gone", cancellationToken: cts.Token)); + await Assert.ThrowsExactlyAsync(async () => + await reg.CreateVirtualSinkAsync("pwnet gone", cancellationToken: cts.Token) + ); var seen = 0; await foreach (PipeWireGraphSnapshot _ in reg.WatchAsync(cts.Token)) { seen++; - if (seen > 2) break; + if (seen > 2) + break; } Assert.AreEqual( - 0, seen, - "a disposed registry kept yielding snapshots instead of ending its stream"); + 0, + seen, + "a disposed registry kept yielding snapshots instead of ending its stream" + ); } } diff --git a/tests/PipeWire.NET.Tests/RegistryRobustnessTests.cs b/tests/PipeWire.NET.Tests/RegistryRobustnessTests.cs index b29ebee..86994a1 100644 --- a/tests/PipeWire.NET.Tests/RegistryRobustnessTests.cs +++ b/tests/PipeWire.NET.Tests/RegistryRobustnessTests.cs @@ -30,7 +30,9 @@ private static void RequireLinux() } private static async Task<(PipeWireContext Context, PipeWireRegistry Registry)> ConnectAsync( - string name, CancellationToken cancellationToken) + string name, + CancellationToken cancellationToken + ) { var context = new PipeWireContext(name, ConsoleTestLoggerFactory.Instance); await context.StartAsync(cancellationToken); @@ -40,7 +42,10 @@ private static void RequireLinux() } private static async Task WaitForAsync( - PipeWireRegistry registry, Func until, CancellationToken cancellationToken) + PipeWireRegistry registry, + Func until, + CancellationToken cancellationToken + ) { await foreach (PipeWireGraphSnapshot graph in registry.WatchAsync(cancellationToken)) if (until(graph)) @@ -54,7 +59,10 @@ public async Task EveryEventHandlerThrowing_DoesNotStopTheGraph() { RequireLinux(); using var cts = new CancellationTokenSource(Budget); - (PipeWireContext context, PipeWireRegistry registry) = await ConnectAsync("pwnet-throwall", cts.Token); + (PipeWireContext context, PipeWireRegistry registry) = await ConnectAsync( + "pwnet-throwall", + cts.Token + ); await using (context) await using (registry) @@ -62,14 +70,19 @@ public async Task EveryEventHandlerThrowing_DoesNotStopTheGraph() // If any of them escapes, this process is gone. var raised = new Dictionary { - ["NodeAdded"] = 0, ["PortAdded"] = 0, ["LinkAdded"] = 0, - ["NodeRemoved"] = 0, ["PortRemoved"] = 0, ["LinkRemoved"] = 0, + ["NodeAdded"] = 0, + ["PortAdded"] = 0, + ["LinkAdded"] = 0, + ["NodeRemoved"] = 0, + ["PortRemoved"] = 0, + ["LinkRemoved"] = 0, ["GraphChanged"] = 0, }; void Bang(string which) { - lock (raised) raised[which]++; + lock (raised) + raised[which]++; throw new InvalidOperationException($"{which} handler is hostile"); } @@ -81,18 +94,36 @@ void Bang(string which) registry.LinkRemoved += _ => Bang("LinkRemoved"); registry.GraphChanged += (_, _) => Bang("GraphChanged"); - PipeWireNode a = await registry.CreateVirtualSinkAsync("HA", "pwnet_hostile_a", cts.Token); - PipeWireNode b = await registry.CreateVirtualSinkAsync("HB", "pwnet_hostile_b", cts.Token); + PipeWireNode a = await registry.CreateVirtualSinkAsync( + "HA", + "pwnet_hostile_a", + cts.Token + ); + PipeWireNode b = await registry.CreateVirtualSinkAsync( + "HB", + "pwnet_hostile_b", + cts.Token + ); PipeWireGraphSnapshot ready = await WaitForAsync( registry, - g => g.GetPortsForNode(a.NodeId).Length == 4 && g.GetPortsForNode(b.NodeId).Length == 4, - cts.Token); + g => + g.GetPortsForNode(a.NodeId).Length == 4 + && g.GetPortsForNode(b.NodeId).Length == 4, + cts.Token + ); PipeWireLink link = await registry.CreateLinkAsync( - ready.GetPortsForNode(a.NodeId, PipeWirePortDirection.Out).OrderBy(p => p.PortId).First(), - ready.GetPortsForNode(b.NodeId, PipeWirePortDirection.In).OrderBy(p => p.PortId).First(), - cts.Token); + ready + .GetPortsForNode(a.NodeId, PipeWirePortDirection.Out) + .OrderBy(p => p.PortId) + .First(), + ready + .GetPortsForNode(b.NodeId, PipeWirePortDirection.In) + .OrderBy(p => p.PortId) + .First(), + cts.Token + ); await registry.DestroyGlobalAsync(link.LinkId, cts.Token); await registry.DestroyGlobalAsync(a.NodeId, cts.Token); @@ -100,16 +131,26 @@ void Bang(string which) PipeWireGraphSnapshot end = await WaitForAsync( registry, - g => g.GetNode(a.NodeId) is null && g.GetNode(b.NodeId) is null && g.GetLink(link.LinkId) is null, - cts.Token); + g => + g.GetNode(a.NodeId) is null + && g.GetNode(b.NodeId) is null + && g.GetLink(link.LinkId) is null, + cts.Token + ); lock (raised) { foreach ((string which, int count) in raised) - Assert.IsTrue(count > 0, $"{which} never fired, so its throwing path was not exercised"); + Assert.IsTrue( + count > 0, + $"{which} never fired, so its throwing path was not exercised" + ); } - Assert.IsNull(end.GetNode(a.NodeId), "removals must still land despite hostile handlers"); + Assert.IsNull( + end.GetNode(a.NodeId), + "removals must still land despite hostile handlers" + ); Assert.IsNull(end.GetLink(link.LinkId)); } } @@ -119,7 +160,10 @@ public async Task AHandlerThatThrowsOnEveryEvent_StillLeavesTheGraphConsistent() { RequireLinux(); using var cts = new CancellationTokenSource(Budget); - (PipeWireContext context, PipeWireRegistry registry) = await ConnectAsync("pwnet-consistent", cts.Token); + (PipeWireContext context, PipeWireRegistry registry) = await ConnectAsync( + "pwnet-consistent", + cts.Token + ); await using (context) await using (registry) @@ -127,9 +171,16 @@ public async Task AHandlerThatThrowsOnEveryEvent_StillLeavesTheGraphConsistent() registry.GraphChanged += (_, _) => throw new InvalidOperationException("no"); registry.PortAdded += _ => throw new InvalidOperationException("no"); - PipeWireNode node = await registry.CreateVirtualSinkAsync("C", "pwnet_consistent", cts.Token); + PipeWireNode node = await registry.CreateVirtualSinkAsync( + "C", + "pwnet_consistent", + cts.Token + ); PipeWireGraphSnapshot graph = await WaitForAsync( - registry, g => g.GetPortsForNode(node.NodeId).Length == 4, cts.Token); + registry, + g => g.GetPortsForNode(node.NodeId).Length == 4, + cts.Token + ); // A handler failing must not leave a partially indexed snapshot behind. foreach (PipeWirePort port in graph.GetPortsForNode(node.NodeId)) @@ -146,7 +197,10 @@ public async Task AThrowingSubscriber_DoesNotStarveTheOnesRegisteredAfterIt() { RequireLinux(); using var cts = new CancellationTokenSource(Budget); - (PipeWireContext context, PipeWireRegistry registry) = await ConnectAsync("pwnet-starve", cts.Token); + (PipeWireContext context, PipeWireRegistry registry) = await ConnectAsync( + "pwnet-starve", + cts.Token + ); await using (context) await using (registry) @@ -163,13 +217,26 @@ public async Task AThrowingSubscriber_DoesNotStarveTheOnesRegisteredAfterIt() registry.PortAdded += _ => throw new InvalidOperationException("hostile"); registry.PortAdded += port => seenPorts.Add(port.NodeId); - PipeWireNode node = await registry.CreateVirtualSinkAsync("SV", "pwnet_starve", cts.Token); - await WaitForAsync(registry, g => g.GetPortsForNode(node.NodeId).Length == 4, cts.Token); - - Assert.IsTrue(Volatile.Read(ref good) > 0, - "a subscriber registered after a throwing one still has to be called"); - Assert.AreEqual(4, seenPorts.Count(id => id == node.NodeId), - "every port event for this node must reach the well-behaved subscriber"); + PipeWireNode node = await registry.CreateVirtualSinkAsync( + "SV", + "pwnet_starve", + cts.Token + ); + await WaitForAsync( + registry, + g => g.GetPortsForNode(node.NodeId).Length == 4, + cts.Token + ); + + Assert.IsTrue( + Volatile.Read(ref good) > 0, + "a subscriber registered after a throwing one still has to be called" + ); + Assert.AreEqual( + 4, + seenPorts.Count(id => id == node.NodeId), + "every port event for this node must reach the well-behaved subscriber" + ); } } @@ -178,7 +245,10 @@ public async Task AThrowingSubscriber_DoesNotBreakWatchAsync() { RequireLinux(); using var cts = new CancellationTokenSource(Budget); - (PipeWireContext context, PipeWireRegistry registry) = await ConnectAsync("pwnet-watchstarve", cts.Token); + (PipeWireContext context, PipeWireRegistry registry) = await ConnectAsync( + "pwnet-watchstarve", + cts.Token + ); await using (context) await using (registry) @@ -187,9 +257,16 @@ public async Task AThrowingSubscriber_DoesNotBreakWatchAsync() // hostile handler registered first must not stop the stream. registry.GraphChanged += (_, _) => throw new InvalidOperationException("hostile"); - PipeWireNode node = await registry.CreateVirtualSinkAsync("WS", "pwnet_watchstarve", cts.Token); + PipeWireNode node = await registry.CreateVirtualSinkAsync( + "WS", + "pwnet_watchstarve", + cts.Token + ); PipeWireGraphSnapshot graph = await WaitForAsync( - registry, g => g.GetPortsForNode(node.NodeId).Length == 4, cts.Token); + registry, + g => g.GetPortsForNode(node.NodeId).Length == 4, + cts.Token + ); Assert.AreEqual(4, graph.GetPortsForNode(node.NodeId).Length); } @@ -200,26 +277,43 @@ public async Task AHandlerThatDisposesTheRegistry_DoesNotDeadlockOrAbort() { RequireLinux(); using var cts = new CancellationTokenSource(Budget); - (PipeWireContext context, PipeWireRegistry registry) = await ConnectAsync("pwnet-suicidal", cts.Token); + (PipeWireContext context, PipeWireRegistry registry) = await ConnectAsync( + "pwnet-suicidal", + cts.Token + ); await using (context) { // Disposal from a callback means taking the loop lock while already holding it. The // mutex is recursive, so this is legal; the point is that nothing hangs or aborts. - var disposedFromHandler = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var disposedFromHandler = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously + ); var once = 0; registry.NodeAdded += node => { - if (node.NodeName != "pwnet_suicidal") return; - if (Interlocked.Exchange(ref once, 1) != 0) return; + if (node.NodeName != "pwnet_suicidal") + return; + if (Interlocked.Exchange(ref once, 1) != 0) + return; // Fire and forget: awaiting from the loop thread would be the deadlock. - _ = Task.Run(async () => - { - try { await registry.DisposeAsync(); disposedFromHandler.TrySetResult(); } - catch (Exception ex) { disposedFromHandler.TrySetException(ex); } - }, CancellationToken.None); + _ = Task.Run( + async () => + { + try + { + await registry.DisposeAsync(); + disposedFromHandler.TrySetResult(); + } + catch (Exception ex) + { + disposedFromHandler.TrySetException(ex); + } + }, + CancellationToken.None + ); }; try @@ -235,7 +329,8 @@ public async Task AHandlerThatDisposesTheRegistry_DoesNotDeadlockOrAbort() Assert.ThrowsExactly( () => registry.DestroyGlobalAsync(1, CancellationToken.None), - "the registry really is disposed afterwards"); + "the registry really is disposed afterwards" + ); } } @@ -244,26 +339,38 @@ public async Task CreateLink_RejectsBothWrongDirectionsIndependently() { RequireLinux(); using var cts = new CancellationTokenSource(Budget); - (PipeWireContext context, PipeWireRegistry registry) = await ConnectAsync("pwnet-dirs", cts.Token); + (PipeWireContext context, PipeWireRegistry registry) = await ConnectAsync( + "pwnet-dirs", + cts.Token + ); await using (context) await using (registry) { PipeWireNode node = await registry.CreateVirtualSinkAsync("D", "pwnet_dirs", cts.Token); PipeWireGraphSnapshot graph = await WaitForAsync( - registry, g => g.GetPortsForNode(node.NodeId).Length == 4, cts.Token); + registry, + g => g.GetPortsForNode(node.NodeId).Length == 4, + cts.Token + ); - PipeWirePort output = graph.GetPortsForNode(node.NodeId, PipeWirePortDirection.Out).First(); - PipeWirePort input = graph.GetPortsForNode(node.NodeId, PipeWirePortDirection.In).First(); + PipeWirePort output = graph + .GetPortsForNode(node.NodeId, PipeWirePortDirection.Out) + .First(); + PipeWirePort input = graph + .GetPortsForNode(node.NodeId, PipeWirePortDirection.In) + .First(); // An input in the output slot, and an output in the input slot, are separate checks and // each must name the argument at fault rather than failing generically. - ArgumentException swapped = Assert.ThrowsExactly( - () => registry.CreateLink(input, output)); + ArgumentException swapped = Assert.ThrowsExactly(() => + registry.CreateLink(input, output) + ); Assert.AreEqual("output", swapped.ParamName); - ArgumentException badInput = Assert.ThrowsExactly( - () => registry.CreateLink(output, output)); + ArgumentException badInput = Assert.ThrowsExactly(() => + registry.CreateLink(output, output) + ); Assert.AreEqual("input", badInput.ParamName); } } @@ -273,15 +380,27 @@ public async Task CreateLink_RejectsNullPortsBeforeTouchingTheDaemon() { RequireLinux(); using var cts = new CancellationTokenSource(Budget); - (PipeWireContext context, PipeWireRegistry registry) = await ConnectAsync("pwnet-nullports", cts.Token); + (PipeWireContext context, PipeWireRegistry registry) = await ConnectAsync( + "pwnet-nullports", + cts.Token + ); await using (context) await using (registry) { - PipeWireNode node = await registry.CreateVirtualSinkAsync("N", "pwnet_nullports", cts.Token); + PipeWireNode node = await registry.CreateVirtualSinkAsync( + "N", + "pwnet_nullports", + cts.Token + ); PipeWireGraphSnapshot graph = await WaitForAsync( - registry, g => g.GetPortsForNode(node.NodeId).Length == 4, cts.Token); - PipeWirePort real = graph.GetPortsForNode(node.NodeId, PipeWirePortDirection.Out).First(); + registry, + g => g.GetPortsForNode(node.NodeId).Length == 4, + cts.Token + ); + PipeWirePort real = graph + .GetPortsForNode(node.NodeId, PipeWirePortDirection.Out) + .First(); Assert.ThrowsExactly(() => registry.CreateLink(null!, real)); Assert.ThrowsExactly(() => registry.CreateLink(real, null!)); @@ -293,7 +412,10 @@ public async Task UsingADisposedRegistry_ThrowsRatherThanMisbehaving() { RequireLinux(); using var cts = new CancellationTokenSource(Budget); - (PipeWireContext context, PipeWireRegistry registry) = await ConnectAsync("pwnet-afterdispose", cts.Token); + (PipeWireContext context, PipeWireRegistry registry) = await ConnectAsync( + "pwnet-afterdispose", + cts.Token + ); await using (context) { @@ -301,14 +423,19 @@ public async Task UsingADisposedRegistry_ThrowsRatherThanMisbehaving() await registry.DisposeAsync(); // Reads keep working: the snapshot is immutable and holding it is legal after disposal. - Assert.AreEqual(last.Version, registry.Current.Version, - "the last published snapshot survives disposal"); + Assert.AreEqual( + last.Version, + registry.Current.Version, + "the last published snapshot survives disposal" + ); // Mutations do not. - Assert.ThrowsExactly( - () => registry.DestroyGlobalAsync(1, CancellationToken.None)); - await Assert.ThrowsExactlyAsync( - () => registry.CreateVirtualSinkAsync("X", "pwnet_after", CancellationToken.None)); + Assert.ThrowsExactly(() => + registry.DestroyGlobalAsync(1, CancellationToken.None) + ); + await Assert.ThrowsExactlyAsync(() => + registry.CreateVirtualSinkAsync("X", "pwnet_after", CancellationToken.None) + ); } } } diff --git a/tests/PipeWire.NET.Tests/RoutingAndSettingsTests.cs b/tests/PipeWire.NET.Tests/RoutingAndSettingsTests.cs index 078661c..cb0afd5 100644 --- a/tests/PipeWire.NET.Tests/RoutingAndSettingsTests.cs +++ b/tests/PipeWire.NET.Tests/RoutingAndSettingsTests.cs @@ -35,16 +35,22 @@ public async Task ANodeCreatedWithATarget_CarriesTargetObjectAndNotTheDeprecated { RequireLinux(); using var cts = new CancellationTokenSource(Budget); - await using var ctx = new PipeWireContext("pwnet-target", ConsoleTestLoggerFactory.Instance); + await using var ctx = new PipeWireContext( + "pwnet-target", + ConsoleTestLoggerFactory.Instance + ); await ctx.StartAsync(cts.Token); await using var registry = new PipeWireRegistry(ctx); await registry.WaitForInitialEnumerationAsync(cts.Token); string sinkName = Unique("pwnet_target_sink"); - PipeWireNode sink = await registry.CreateVirtualSink("Target sink") - .WithName(sinkName).ExecuteAsync(cts.Token); + PipeWireNode sink = await registry + .CreateVirtualSink("Target sink") + .WithName(sinkName) + .ExecuteAsync(cts.Token); - PipeWireNode source = await registry.CreateVirtualSource("Targeting source") + PipeWireNode source = await registry + .CreateVirtualSource("Targeting source") .WithName(Unique("pwnet_target_src")) .WithTarget(sink) .WithAutoConnect(false) @@ -56,22 +62,29 @@ public async Task ANodeCreatedWithATarget_CarriesTargetObjectAndNotTheDeprecated // the daemon received, and PipeWireNode carries a fixed set of properties rather than // the whole dictionary, so it cannot answer it. PwDump dump = await PwDump.CaptureAsync(cts.Token); - PwDump.Entry? seen = dump.OfKind("Node") - .FirstOrDefault(e => e.Id == source.NodeId); + PwDump.Entry? seen = dump.OfKind("Node").FirstOrDefault(e => e.Id == source.NodeId); Assert.IsNotNull(seen, "the node this test made is not in pw-dump's graph"); - Assert.AreEqual(sinkName, seen!.Prop("target.object"), - "target.object did not reach the daemon"); + Assert.AreEqual( + sinkName, + seen!.Prop("target.object"), + "target.object did not reach the daemon" + ); // Compared case-insensitively because the daemon stores this one as a JSON boolean // rather than a string, and the pw-dump oracle renders that through // JsonElement.ToString(), which spells it "False". What is under test is that the key // arrived and means false, not how the oracle prints it. - Assert.AreEqual("false", seen.Prop("node.autoconnect")?.ToLowerInvariant(), - "node.autoconnect did not reach the daemon"); + Assert.AreEqual( + "false", + seen.Prop("node.autoconnect")?.ToLowerInvariant(), + "node.autoconnect did not reach the daemon" + ); // Deprecated since 0.3.64. Sending both leaves which one wins to the session manager. - Assert.IsNull(seen.Prop("node.target"), - "the deprecated node.target key was sent as well"); + Assert.IsNull( + seen.Prop("node.target"), + "the deprecated node.target key was sent as well" + ); } finally { @@ -85,13 +98,18 @@ public async Task AVirtualSource_IsCreatedAsSomethingToCaptureFrom() { RequireLinux(); using var cts = new CancellationTokenSource(Budget); - await using var ctx = new PipeWireContext("pwnet-vsource", ConsoleTestLoggerFactory.Instance); + await using var ctx = new PipeWireContext( + "pwnet-vsource", + ConsoleTestLoggerFactory.Instance + ); await ctx.StartAsync(cts.Token); await using var registry = new PipeWireRegistry(ctx); await registry.WaitForInitialEnumerationAsync(cts.Token); - PipeWireNode node = await registry.CreateVirtualSource("A virtual microphone") - .WithName(Unique("pwnet_vsource")).ExecuteAsync(cts.Token); + PipeWireNode node = await registry + .CreateVirtualSource("A virtual microphone") + .WithName(Unique("pwnet_vsource")) + .ExecuteAsync(cts.Token); try { @@ -114,7 +132,10 @@ public async Task TheSettingsStore_ReportsTheClockAsIntegersRatherThanJson() { RequireLinux(); using var cts = new CancellationTokenSource(Budget); - await using var ctx = new PipeWireContext("pwnet-settings", ConsoleTestLoggerFactory.Instance); + await using var ctx = new PipeWireContext( + "pwnet-settings", + ConsoleTestLoggerFactory.Instance + ); await ctx.StartAsync(cts.Token); await using var registry = new PipeWireRegistry(ctx); await registry.WaitForInitialEnumerationAsync(cts.Token); @@ -137,8 +158,10 @@ public async Task TheSettingsStore_ReportsTheClockAsIntegersRatherThanJson() Assert.IsNotNull(settings.ClockMinQuantum); Assert.IsNotNull(settings.ClockMaxQuantum); - Assert.IsTrue(settings.ClockMinQuantum <= settings.ClockMaxQuantum, - "the quantum range is inverted"); + Assert.IsTrue( + settings.ClockMinQuantum <= settings.ClockMaxQuantum, + "the quantum range is inverted" + ); // Present and readable even when nothing is pinned, where they read 0 rather than being // absent - which is why they are int? for absence and 0 for "not pinned". @@ -147,10 +170,12 @@ public async Task TheSettingsStore_ReportsTheClockAsIntegersRatherThanJson() // Negative is not a value the daemon has a meaning for, and atoi would take it and // store it, so it is refused here rather than written. - Assert.ThrowsExactly( - () => _ = settings.SetForcedQuantumAsync(-1, cts.Token)); - Assert.ThrowsExactly( - () => _ = settings.SetForcedRateAsync(-1, cts.Token)); + Assert.ThrowsExactly(() => + _ = settings.SetForcedQuantumAsync(-1, cts.Token) + ); + Assert.ThrowsExactly(() => + _ = settings.SetForcedRateAsync(-1, cts.Token) + ); } } @@ -159,7 +184,10 @@ public async Task PinningTheQuantumAndReleasingIt_LeavesTheGraphAsItWasFound() { RequireLinux(); using var cts = new CancellationTokenSource(Budget); - await using var ctx = new PipeWireContext("pwnet-quantum", ConsoleTestLoggerFactory.Instance); + await using var ctx = new PipeWireContext( + "pwnet-quantum", + ConsoleTestLoggerFactory.Instance + ); await ctx.StartAsync(cts.Token); await using var registry = new PipeWireRegistry(ctx); await registry.WaitForInitialEnumerationAsync(cts.Token); @@ -242,7 +270,10 @@ public async Task AQuantumTheDaemonRejects_IsAcceptedAndIgnoredRatherThanRefused { RequireLinux(); using var cts = new CancellationTokenSource(Budget); - await using var ctx = new PipeWireContext("pwnet-badquantum", ConsoleTestLoggerFactory.Instance); + await using var ctx = new PipeWireContext( + "pwnet-badquantum", + ConsoleTestLoggerFactory.Instance + ); await ctx.StartAsync(cts.Token); await using var registry = new PipeWireRegistry(ctx); await registry.WaitForInitialEnumerationAsync(cts.Token); @@ -265,18 +296,24 @@ public async Task AQuantumTheDaemonRejects_IsAcceptedAndIgnoredRatherThanRefused // settings.check-quantum is on, and it is off by default. Either way the write reports // success, so reading it back is the only way to know what happened - which is the // point being pinned here. - bool checks = string.Equals(settings.Get("settings.check-quantum"), "true", - StringComparison.OrdinalIgnoreCase); + bool checks = string.Equals( + settings.Get("settings.check-quantum"), + "true", + StringComparison.OrdinalIgnoreCase + ); int absurd = max * 100; try { await settings.SetForcedQuantumAsync(absurd, cts.Token); - Assert.AreEqual(checks ? 0 : absurd, settings.ClockForcedQuantum, + Assert.AreEqual( + checks ? 0 : absurd, + settings.ClockForcedQuantum, checks ? "check-quantum is on, so an out-of-range quantum should have been dropped" - : "check-quantum is off, so the value should have been applied as written"); + : "check-quantum is off, so the value should have been applied as written" + ); } finally { @@ -286,5 +323,4 @@ public async Task AQuantumTheDaemonRejects_IsAcceptedAndIgnoredRatherThanRefused Assert.AreEqual(0, settings.ClockForcedQuantum, "the quantum was left pinned"); } } - } diff --git a/tests/PipeWire.NET.Tests/SandboxInteropTests.cs b/tests/PipeWire.NET.Tests/SandboxInteropTests.cs index edff340..d1ecef1 100644 --- a/tests/PipeWire.NET.Tests/SandboxInteropTests.cs +++ b/tests/PipeWire.NET.Tests/SandboxInteropTests.cs @@ -54,16 +54,26 @@ public async Task StartAsync_OverASocketConnectedToAThirdPartySandbox_ReachesThe await using Sandbox sandbox = await Sandbox.StartAsync(cts.Token); - using var socket = new Socket(AddressFamily.Unix, SocketType.Stream, ProtocolType.Unspecified); + using var socket = new Socket( + AddressFamily.Unix, + SocketType.Stream, + ProtocolType.Unspecified + ); await socket.ConnectAsync(new UnixDomainSocketEndPoint(sandbox.SocketPath), cts.Token); - await using var context = new PipeWireContext("pwnet-sandboxed", ConsoleTestLoggerFactory.Instance); + await using var context = new PipeWireContext( + "pwnet-sandboxed", + ConsoleTestLoggerFactory.Instance + ); await context.StartAsync(socket.SafeHandle, cts.Token); await using var registry = new PipeWireRegistry(context); await registry.WaitForInitialEnumerationAsync(cts.Token); - Assert.IsTrue(registry.Current.Nodes.Length >= 0, "the sandboxed connection enumerated nothing at all"); + Assert.IsTrue( + registry.Current.Nodes.Length >= 0, + "the sandboxed connection enumerated nothing at all" + ); // Borrowed, as everywhere else: the connection took a duplicate. Assert.IsFalse(socket.SafeHandle.IsClosed); @@ -102,26 +112,39 @@ public async Task ASandboxWeCreate_AcceptsAThirdPartyClient() CliTool.Require("pw-cli"); using var cts = new CancellationTokenSource(Budget); - await using var context = new PipeWireContext("pwnet-sandbox-host", ConsoleTestLoggerFactory.Instance); + await using var context = new PipeWireContext( + "pwnet-sandbox-host", + ConsoleTestLoggerFactory.Instance + ); await context.StartAsync(cts.Token); await using var registry = new PipeWireRegistry(context); await registry.WaitForInitialEnumerationAsync(cts.Token); PipeWireSecurityContext? available = registry.Current.SecurityContext; - if (available is null) Assert.Inconclusive("this daemon exposes no security context."); + if (available is null) + Assert.Inconclusive("this daemon exposes no security context."); - await using PipeWireSecurityContextProxy control = registry.BindSecurityContext(available!.Id); + await using PipeWireSecurityContextProxy control = registry.BindSecurityContext( + available!.Id + ); string path = Path.Combine(Path.GetTempPath(), $"pwnet-sandbox-{Environment.ProcessId}"); File.Delete(path); - using var listening = new Socket(AddressFamily.Unix, SocketType.Stream, ProtocolType.Unspecified); + using var listening = new Socket( + AddressFamily.Unix, + SocketType.Stream, + ProtocolType.Unspecified + ); listening.Bind(new UnixDomainSocketEndPoint(path)); listening.Listen(4); // A pipe, as pw-container uses: the daemon watches this for hangup to learn the sandbox is // gone, and holding the other end is what keeps it alive. - using var closeSide = new AnonymousPipeServerStream(PipeDirection.Out, HandleInheritability.None); + using var closeSide = new AnonymousPipeServerStream( + PipeDirection.Out, + HandleInheritability.None + ); try { @@ -133,14 +156,20 @@ await control.CreateAsync( ["pipewire.access"] = "restricted", ["pipewire.sec.engine"] = "org.pipewire.Test", }, - cts.Token); + cts.Token + ); string sandboxed = await CoreInfoAsync(path, cts.Token); - StringAssert.Contains(sandboxed, "permissions", - "a third-party client could not read the core through our sandbox"); - Assert.IsFalse(HasMetadata(sandboxed), - $"our sandbox did not restrict the client, got: {sandboxed}"); + StringAssert.Contains( + sandboxed, + "permissions", + "a third-party client could not read the core through our sandbox" + ); + Assert.IsFalse( + HasMetadata(sandboxed), + $"our sandbox did not restrict the client, got: {sandboxed}" + ); } finally { @@ -156,14 +185,18 @@ private static bool HasMetadata(string permissionLine) } /// The core's permission line as pw-cli reports it, optionally through a socket. - private static async Task CoreInfoAsync(string? remote, CancellationToken cancellationToken) + private static async Task CoreInfoAsync( + string? remote, + CancellationToken cancellationToken + ) { var psi = new ProcessStartInfo("/usr/bin/pw-cli", "info 0") { RedirectStandardOutput = true, RedirectStandardError = true, }; - if (remote is not null) psi.Environment["PIPEWIRE_REMOTE"] = remote; + if (remote is not null) + psi.Environment["PIPEWIRE_REMOTE"] = remote; using Process process = Process.Start(psi)!; @@ -194,8 +227,8 @@ private sealed class Sandbox : IAsyncDisposable { private readonly Process _process; - private Sandbox(Process process, string socketPath) - => (_process, SocketPath) = (process, socketPath); + private Sandbox(Process process, string socketPath) => + (_process, SocketPath) = (process, socketPath); public string SocketPath { get; } @@ -217,7 +250,9 @@ public static async Task StartAsync(CancellationToken cancellationToken Process process = Process.Start(psi)!; string? path = null; - using (var deadline = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken)) + using ( + var deadline = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken) + ) { deadline.CancelAfter(TimeSpan.FromSeconds(15)); try @@ -227,10 +262,12 @@ public static async Task StartAsync(CancellationToken cancellationToken if (File.Exists(marker)) { string text = File.ReadAllText(marker).Trim(); - if (text.Length > 0) path = text; + if (text.Length > 0) + path = text; } - if (path is null) await Task.Delay(100, deadline.Token).ConfigureAwait(false); + if (path is null) + await Task.Delay(100, deadline.Token).ConfigureAwait(false); } } catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) @@ -247,8 +284,13 @@ public static async Task StartAsync(CancellationToken cancellationToken private static void Kill(Process process) { - try { process.Kill(entireProcessTree: true); } - catch (InvalidOperationException) { /* already gone */ } + try + { + process.Kill(entireProcessTree: true); + } + catch (InvalidOperationException) + { /* already gone */ + } process.Dispose(); } @@ -259,7 +301,9 @@ public async ValueTask DisposeAsync() _process.Kill(entireProcessTree: true); await _process.WaitForExitAsync().ConfigureAwait(false); } - catch (InvalidOperationException) { /* already gone */ } + catch (InvalidOperationException) + { /* already gone */ + } finally { _process.Dispose(); diff --git a/tests/PipeWire.NET.Tests/SequenceAndPortConfigTests.cs b/tests/PipeWire.NET.Tests/SequenceAndPortConfigTests.cs index 971d92e..ef48ecf 100644 --- a/tests/PipeWire.NET.Tests/SequenceAndPortConfigTests.cs +++ b/tests/PipeWire.NET.Tests/SequenceAndPortConfigTests.cs @@ -23,8 +23,10 @@ public sealed class SequenceAndPortConfigTests : PipeWireTestBase private static SpaValue Parse(ReadOnlySpan pod) { - Assert.IsTrue(SpaPod.TryParse(pod, out SpaValue? value), - "the writer produced bytes the reader refuses"); + Assert.IsTrue( + SpaPod.TryParse(pod, out SpaValue? value), + "the writer produced bytes the reader refuses" + ); return value!; } @@ -88,8 +90,11 @@ public void AControlWhosePayloadIsNotAMultipleOfEight_IsStillReadBack() var value = (SpaSequence)Parse(builder.GetPod()); Assert.HasCount(2, value.Controls, $"a {length} byte payload lost a control"); - CollectionAssert.AreEqual(payload, ((SpaBytes)value.Controls[1].Value).Value.ToArray(), - $"a {length} byte payload came back wrong"); + CollectionAssert.AreEqual( + payload, + ((SpaBytes)value.Controls[1].Value).Value.ToArray(), + $"a {length} byte payload came back wrong" + ); } } @@ -133,7 +138,11 @@ public void PoppingWithNothingOpen_IsRefused() public void APortConfig_SurvivesTheRoundTrip() { var written = new PipeWirePortConfig( - SpaDirection.Input, SpaParamPortConfigMode.Dsp, Monitor: true, Control: true); + SpaDirection.Input, + SpaParamPortConfigMode.Dsp, + Monitor: true, + Control: true + ); PipeWirePortConfig? read = PipeWirePortConfig.From(written.ToParameter()); @@ -146,20 +155,30 @@ public void APortConfigWithoutAFormatFilter_DoesNotWriteAnEmptyOne() // An empty Object property is not the same as an absent one: the adapter reads it as a // filter matching nothing, so a config with no filter must omit the property entirely. SpaObject param = new PipeWirePortConfig( - SpaDirection.Output, SpaParamPortConfigMode.Convert).ToParameter(); - - Assert.IsNull(param[(uint)SpaParamPortConfig.Format], - "an absent format filter was written as an empty object"); + SpaDirection.Output, + SpaParamPortConfigMode.Convert + ).ToParameter(); + + Assert.IsNull( + param[(uint)SpaParamPortConfig.Format], + "an absent format filter was written as an empty object" + ); } [TestMethod] public void APortConfigCarryingAFormatFilter_KeepsIt() { - var filter = new SpaObject(SpaType.ObjectFormat, SpaParamType.EnumFormat, - [new SpaPodProperty((uint)SpaFormat.AudioChannels, 0, new SpaInt(2))]); + var filter = new SpaObject( + SpaType.ObjectFormat, + SpaParamType.EnumFormat, + [new SpaPodProperty((uint)SpaFormat.AudioChannels, 0, new SpaInt(2))] + ); var written = new PipeWirePortConfig( - SpaDirection.Input, SpaParamPortConfigMode.Dsp, Format: filter); + SpaDirection.Input, + SpaParamPortConfigMode.Dsp, + Format: filter + ); PipeWirePortConfig? read = PipeWirePortConfig.From(written.ToParameter()); @@ -181,8 +200,13 @@ public void AnObjectOfAnotherType_IsNotReadAsAPortConfig() [TestMethod] public void TheUnscheduledReleaseFlag_IsReadOffTheTimeline() { - Assert.IsTrue(new VideoSyncTimeline(SpaMetaSyncTimelineFlags.UnscheduledRelease, 1, 2) - .ReleaseIsUnscheduled); + Assert.IsTrue( + new VideoSyncTimeline( + SpaMetaSyncTimelineFlags.UnscheduledRelease, + 1, + 2 + ).ReleaseIsUnscheduled + ); Assert.IsFalse(new VideoSyncTimeline(0, 1, 2).ReleaseIsUnscheduled); } @@ -202,12 +226,16 @@ public async Task APortConfigWrite_ReachesTheDaemon() using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(40)); - await using var ctx = new PipeWireContext("pwnet-portconfig-live", ConsoleTestLoggerFactory.Instance); + await using var ctx = new PipeWireContext( + "pwnet-portconfig-live", + ConsoleTestLoggerFactory.Instance + ); await ctx.StartAsync(cts.Token); await using var registry = new PipeWireRegistry(ctx); await registry.WaitForInitialEnumerationAsync(cts.Token); - PipeWireNode node = await registry.CreateVirtualSink("PortConfigLive") + PipeWireNode node = await registry + .CreateVirtualSink("PortConfigLive") .WithName($"pwnet_pconf_{Environment.ProcessId}_{Random.Shared.Next():x}") .ExecuteAsync(cts.Token); @@ -216,8 +244,9 @@ public async Task APortConfigWrite_ReachesTheDaemon() await using PipeWireNodeProxy control = registry.BindNode(node.NodeId); await control.ReadyAsync(cts.Token); - ImmutableArray configs = - await control.EnumeratePortConfigsAsync(cts.Token); + ImmutableArray configs = await control.EnumeratePortConfigsAsync( + cts.Token + ); Console.Error.WriteLine($"adapter reports {configs.Length} port configs"); var dsp = new PipeWirePortConfig(SpaDirection.Output, SpaParamPortConfigMode.Dsp); diff --git a/tests/PipeWire.NET.Tests/SessionGates.cs b/tests/PipeWire.NET.Tests/SessionGates.cs index f4d25cb..e214b76 100644 --- a/tests/PipeWire.NET.Tests/SessionGates.cs +++ b/tests/PipeWire.NET.Tests/SessionGates.cs @@ -41,11 +41,13 @@ internal static bool DaemonAtLeast(int major, int minor, int patch) internal static void RequireDaemonAtLeast(int major, int minor, int patch) { string? reported = Environment.GetEnvironmentVariable("PWNET_DAEMON_VERSION"); - if (reported is null) return; + if (reported is null) + return; if (CompareVersions(reported, major, minor, patch) < 0) Assert.Inconclusive( - $"needs a PipeWire daemon at least {major}.{minor}.{patch}, " + - $"this session reports {reported}."); + $"needs a PipeWire daemon at least {major}.{minor}.{patch}, " + + $"this session reports {reported}." + ); } /// @@ -55,22 +57,27 @@ internal static void RequireDaemonAtLeast(int major, int minor, int patch) /// the same as no card. /// internal static async Task RequireAudioRouteAsync( - PipeWireRegistry registry, CancellationToken cancellationToken) + PipeWireRegistry registry, + CancellationToken cancellationToken + ) { - if (HasAudioRoute(registry.Current)) return; + if (HasAudioRoute(registry.Current)) + return; // Bounded by its own clock, not the caller's: eating the whole test budget here would // leave nothing for the waits the test itself still needs afterwards. using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(15)); using var linked = CancellationTokenSource.CreateLinkedTokenSource( - cancellationToken, timeout.Token); + cancellationToken, + timeout.Token + ); try { while (!HasAudioRoute(registry.Current)) await Task.Delay(250, linked.Token).ConfigureAwait(false); } - catch (OperationCanceledException) when (timeout.IsCancellationRequested - && !cancellationToken.IsCancellationRequested) + catch (OperationCanceledException) + when (timeout.IsCancellationRequested && !cancellationToken.IsCancellationRequested) { // Our own timeout and nobody else's: fall through to Inconclusive below. // Anything else (notably the caller's own cancellation) keeps propagating. @@ -78,7 +85,8 @@ internal static async Task RequireAudioRouteAsync( if (!HasAudioRoute(registry.Current)) Assert.Inconclusive( - "this session offers no audio route for loopback streams (headless, no devices)."); + "this session offers no audio route for loopback streams (headless, no devices)." + ); } /// Whether the session has an audio sink and an audio source to route through. @@ -88,9 +96,12 @@ internal static bool HasAudioRoute(PipeWireGraphSnapshot graph) bool source = false; foreach (PipeWireNode node in graph.Nodes) { - if (node.Media != PipeWireMediaKind.Audio) continue; - if (node.Flow == PipeWireMediaFlow.Sink) sink = true; - if (node.Flow == PipeWireMediaFlow.Source) source = true; + if (node.Media != PipeWireMediaKind.Audio) + continue; + if (node.Flow == PipeWireMediaFlow.Sink) + sink = true; + if (node.Flow == PipeWireMediaFlow.Source) + source = true; } return sink && source; @@ -112,7 +123,8 @@ private static int CompareVersions(string version, int major, int minor, int pat } else if (c == '.' && digits) { - if (part < 3) got[part] = value; + if (part < 3) + got[part] = value; part++; value = 0; digits = false; @@ -123,11 +135,13 @@ private static int CompareVersions(string version, int major, int minor, int pat } } - if (digits && part < 3) got[part] = value; + if (digits && part < 3) + got[part] = value; for (int i = 0; i < 3; i++) { - if (got[i] != want[i]) return got[i].CompareTo(want[i]); + if (got[i] != want[i]) + return got[i].CompareTo(want[i]); } return 0; diff --git a/tests/PipeWire.NET.Tests/SessionHygiene.cs b/tests/PipeWire.NET.Tests/SessionHygiene.cs index 90b24d2..410f44e 100644 --- a/tests/PipeWire.NET.Tests/SessionHygiene.cs +++ b/tests/PipeWire.NET.Tests/SessionHygiene.cs @@ -22,7 +22,8 @@ private static bool IsOurs(string key) { foreach (string prefix in TestKeyPrefixes) { - if (key.StartsWith(prefix, StringComparison.Ordinal)) return true; + if (key.StartsWith(prefix, StringComparison.Ordinal)) + return true; } return false; @@ -31,12 +32,15 @@ private static bool IsOurs(string key) [AssemblyCleanup] public static async Task RemoveOurMetadataKeysAsync() { - if (!OperatingSystem.IsLinux()) return; + if (!OperatingSystem.IsLinux()) + return; // Nothing to clean after a run that never had a daemon, and connecting only to find that // out costs the whole 30-second budget on a host that has none. The unit leg runs here too. - if (Environment.GetEnvironmentVariable("XDG_RUNTIME_DIR") is not { Length: > 0 } runtimeDir - || !Directory.EnumerateFiles(runtimeDir, "pipewire-*").Any()) + if ( + Environment.GetEnvironmentVariable("XDG_RUNTIME_DIR") is not { Length: > 0 } runtimeDir + || !Directory.EnumerateFiles(runtimeDir, "pipewire-*").Any() + ) { return; } @@ -44,13 +48,17 @@ public static async Task RemoveOurMetadataKeysAsync() using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); try { - await using var ctx = new PipeWireContext("pwnet-hygiene", ConsoleTestLoggerFactory.Instance); + await using var ctx = new PipeWireContext( + "pwnet-hygiene", + ConsoleTestLoggerFactory.Instance + ); await ctx.StartAsync(cts.Token); await using var registry = new PipeWireRegistry(ctx); await registry.WaitForInitialEnumerationAsync(cts.Token); PipeWireMetadataProxy? store = registry.BindMetadata("default"); - if (store is null) return; + if (store is null) + return; await using (store) { @@ -58,13 +66,18 @@ public static async Task RemoveOurMetadataKeysAsync() foreach (PipeWireMetadataEntry entry in store.Entries) { - if (!IsOurs(entry.Key)) continue; + if (!IsOurs(entry.Key)) + continue; // A null value is the removal. One key failing to clear must not stop the rest. try { - await store.SetAsync(entry.Key, null, subject: entry.Subject, - cancellationToken: cts.Token); + await store.SetAsync( + entry.Key, + null, + subject: entry.Subject, + cancellationToken: cts.Token + ); } catch (PipeWireException) { diff --git a/tests/PipeWire.NET.Tests/SpaDictBuilderTests.cs b/tests/PipeWire.NET.Tests/SpaDictBuilderTests.cs index a89c221..e1666e2 100644 --- a/tests/PipeWire.NET.Tests/SpaDictBuilderTests.cs +++ b/tests/PipeWire.NET.Tests/SpaDictBuilderTests.cs @@ -72,8 +72,11 @@ public void AStringKey_IsEncodedIntoTheScratchLikeAnyOther() spa_dict dict = builder.Build(); Assert.AreEqual("value", Read(ref dict, "plain"u8)); - Assert.AreEqual("vålue", Read(ref dict, "nøn-ascii"u8), - "a key with a multi-byte character did not round trip"); + Assert.AreEqual( + "vålue", + Read(ref dict, "nøn-ascii"u8), + "a key with a multi-byte character did not round trip" + ); Assert.AreEqual("4294967295", Read(ref dict, "id"u8)); } @@ -169,8 +172,11 @@ public void AnEmptyValue_IsStillAValueRatherThanAnAbsentProperty() builder.Add("empty"u8, ""u8); spa_dict dict = builder.Build(); - Assert.AreEqual(string.Empty, Read(ref dict, "empty"u8), - "the key must be present with an empty value, not absent"); + Assert.AreEqual( + string.Empty, + Read(ref dict, "empty"u8), + "the key must be present with an empty value, not absent" + ); } [TestMethod] @@ -201,8 +207,11 @@ public void ValuesAreNulTerminated_BecauseTheDaemonReadsThemAsCStrings() spa_dict dict = builder.Build(); - Assert.AreEqual("one", Read(ref dict, "first"u8), - "the first value must stop at its own terminator, not run into the next key"); + Assert.AreEqual( + "one", + Read(ref dict, "first"u8), + "the first value must stop at its own terminator, not run into the next key" + ); Assert.AreEqual("two", Read(ref dict, "second"u8)); } diff --git a/tests/PipeWire.NET.Tests/SpaFormatTests.cs b/tests/PipeWire.NET.Tests/SpaFormatTests.cs index ad96d36..f422cbe 100644 --- a/tests/PipeWire.NET.Tests/SpaFormatTests.cs +++ b/tests/PipeWire.NET.Tests/SpaFormatTests.cs @@ -1,8 +1,8 @@ using System.Runtime.Versioning; using Microsoft.VisualStudio.TestTools.UnitTesting; using PipeWire.NET.Interop; -using PipeWire.NET.Spa; using PipeWire.NET.Media; +using PipeWire.NET.Spa; namespace PipeWire.NET.Tests; @@ -25,14 +25,19 @@ public void EveryPixelFormat_SurvivesARoundTripThroughSpa() var broken = new List(); foreach (PixelFormat fmt in Enum.GetValues()) { - if (fmt == PixelFormat.Unknown) continue; + if (fmt == PixelFormat.Unknown) + continue; PixelFormat back = SpaFormatPod.FromSpaVideoFormat(SpaFormatPod.ToSpaVideoFormat(fmt)); - if (back != fmt) broken.Add($"{fmt} -> {back}"); + if (back != fmt) + broken.Add($"{fmt} -> {back}"); } - CollectionAssert.AreEqual(Array.Empty(), broken, - "these formats do not survive the round trip and would silently be treated as another format"); + CollectionAssert.AreEqual( + Array.Empty(), + broken, + "these formats do not survive the round trip and would silently be treated as another format" + ); } [TestMethod] @@ -42,11 +47,14 @@ public void EveryPixelFormat_MapsToADistinctSpaFormat() var seen = new Dictionary(); foreach (PixelFormat fmt in Enum.GetValues()) { - if (fmt == PixelFormat.Unknown) continue; + if (fmt == PixelFormat.Unknown) + continue; SpaVideoFormat spa = SpaFormatPod.ToSpaVideoFormat(fmt); - Assert.IsFalse(seen.TryGetValue(spa, out PixelFormat other), - $"{fmt} and {other} both map to spa format {spa}"); + Assert.IsFalse( + seen.TryGetValue(spa, out PixelFormat other), + $"{fmt} and {other} both map to spa format {spa}" + ); seen[spa] = fmt; } } @@ -57,7 +65,10 @@ public void AnUnknownSpaVideoFormat_IsReportedAsUnknownRatherThanGuessed() // Reinterpreting an unrecognised layout as a known one produces a plausible-looking but // wrong image, which is far harder to notice than an unsupported format, so the sentinel // is the answer, and it still does not throw. - Assert.AreEqual(PixelFormat.Unknown, SpaFormatPod.FromSpaVideoFormat((SpaVideoFormat)0xDEADBEEF)); + Assert.AreEqual( + PixelFormat.Unknown, + SpaFormatPod.FromSpaVideoFormat((SpaVideoFormat)0xDEADBEEF) + ); } [TestMethod] @@ -65,11 +76,20 @@ public void AnUnknownSpaAudioFormat_IsReportedAsUnknownRatherThanGuessed() { // An unrecognised format must not read as a real one, or a consumer reads four-byte // floats out of whatever was actually negotiated. - Assert.AreEqual(AudioSampleFormat.Unknown, SpaFormatPod.FromSpaAudioFormat((SpaAudioFormat)0xDEADBEEF)); + Assert.AreEqual( + AudioSampleFormat.Unknown, + SpaFormatPod.FromSpaAudioFormat((SpaAudioFormat)0xDEADBEEF) + ); // Two the daemon really does negotiate. - Assert.AreEqual(AudioSampleFormat.S24_32Le, SpaFormatPod.FromSpaAudioFormat(SpaAudioFormat.S24_32Le)); - Assert.AreEqual(AudioSampleFormat.F64Le, SpaFormatPod.FromSpaAudioFormat(SpaAudioFormat.F64Le)); + Assert.AreEqual( + AudioSampleFormat.S24_32Le, + SpaFormatPod.FromSpaAudioFormat(SpaAudioFormat.S24_32Le) + ); + Assert.AreEqual( + AudioSampleFormat.F64Le, + SpaFormatPod.FromSpaAudioFormat(SpaAudioFormat.F64Le) + ); } [TestMethod] @@ -79,14 +99,19 @@ public void EveryAudioSampleFormat_SurvivesARoundTrip() { // Unknown is an answer, never a request: there is nothing to ask the daemon for, so // offering it is a caller mistake rather than a round trip. - if (fmt is AudioSampleFormat.Unknown) continue; - - Assert.AreEqual(fmt, SpaFormatPod.FromSpaAudioFormat(SpaFormatPod.ToSpaAudioFormat(fmt)), - $"{fmt} does not round-trip"); + if (fmt is AudioSampleFormat.Unknown) + continue; + + Assert.AreEqual( + fmt, + SpaFormatPod.FromSpaAudioFormat(SpaFormatPod.ToSpaAudioFormat(fmt)), + $"{fmt} does not round-trip" + ); } - Assert.ThrowsExactly( - () => SpaFormatPod.ToSpaAudioFormat(AudioSampleFormat.Unknown)); + Assert.ThrowsExactly(() => + SpaFormatPod.ToSpaAudioFormat(AudioSampleFormat.Unknown) + ); } // ---------------------------------------------------------------- buffer arithmetic @@ -108,14 +133,18 @@ public void TheArithmeticRefusesAFormatItDoesNotKnow() // Guessing four bytes per pixel for an unmodelled format sizes every buffer and stride // derived from it wrongly, and the frame that comes back renders as a tinted, sheared image // rather than failing anywhere a caller can see. - Assert.ThrowsExactly( - () => SpaFormatPod.BytesPerPixel(PixelFormat.Unknown)); - Assert.ThrowsExactly( - () => SpaFormatPod.VideoStride(PixelFormat.Unknown, 640)); - Assert.ThrowsExactly( - () => SpaFormatPod.VideoImageSize(PixelFormat.Unknown, 640, 480)); - Assert.ThrowsExactly( - () => SpaFormatPod.ToSpaVideoFormat(PixelFormat.Unknown)); + Assert.ThrowsExactly(() => + SpaFormatPod.BytesPerPixel(PixelFormat.Unknown) + ); + Assert.ThrowsExactly(() => + SpaFormatPod.VideoStride(PixelFormat.Unknown, 640) + ); + Assert.ThrowsExactly(() => + SpaFormatPod.VideoImageSize(PixelFormat.Unknown, 640, 480) + ); + Assert.ThrowsExactly(() => + SpaFormatPod.ToSpaVideoFormat(PixelFormat.Unknown) + ); } [TestMethod] @@ -124,9 +153,12 @@ public void StrideAlwaysEqualsWidthTimesBytesPerPixel() // Two functions computing the same thing independently; if they drift, a consumer walks // rows at the wrong pitch and every frame shears. foreach (PixelFormat fmt in KnownFormats) - foreach (int w in (int[])[1, 2, 3, 640, 1920, 4096]) - Assert.AreEqual(w * SpaFormatPod.BytesPerPixel(fmt), SpaFormatPod.VideoStride(fmt, w), - $"{fmt} at width {w}: stride and bytes-per-pixel disagree"); + foreach (int w in (int[])[1, 2, 3, 640, 1920, 4096]) + Assert.AreEqual( + w * SpaFormatPod.BytesPerPixel(fmt), + SpaFormatPod.VideoStride(fmt, w), + $"{fmt} at width {w}: stride and bytes-per-pixel disagree" + ); } [TestMethod] @@ -134,8 +166,11 @@ public void TheTwoPlaneCountFunctionsAgree() { // VideoPlaneCount and PlaneCount are separate implementations of the same fact. foreach (PixelFormat fmt in Enum.GetValues()) - Assert.AreEqual(SpaFormatPod.PlaneCount(fmt), SpaFormatPod.VideoPlaneCount(fmt), - $"{fmt}: PlaneCount and VideoPlaneCount disagree"); + Assert.AreEqual( + SpaFormatPod.PlaneCount(fmt), + SpaFormatPod.VideoPlaneCount(fmt), + $"{fmt}: PlaneCount and VideoPlaneCount disagree" + ); } [TestMethod] @@ -153,13 +188,18 @@ public void ImageSize_IsNeverSmallerThanOneFullPlaneOfRows() // The absolute floor for any layout: the primary plane must fit. An allocation below this // is an overflow waiting to happen in whoever writes the frame. foreach (PixelFormat fmt in KnownFormats) - foreach ((int w, int h) in (( int, int )[])[(1, 1), (2, 2), (3, 3), (17, 13), (640, 480), (1920, 1080)]) - { - int size = SpaFormatPod.VideoImageSize(fmt, w, h); - int primaryPlane = SpaFormatPod.VideoStride(fmt, w) * h; - Assert.IsTrue(size >= primaryPlane, - $"{fmt} {w}x{h}: size {size} is smaller than the primary plane {primaryPlane}"); - } + foreach ( + (int w, int h) in ((int, int)[]) + [(1, 1), (2, 2), (3, 3), (17, 13), (640, 480), (1920, 1080)] + ) + { + int size = SpaFormatPod.VideoImageSize(fmt, w, h); + int primaryPlane = SpaFormatPod.VideoStride(fmt, w) * h; + Assert.IsTrue( + size >= primaryPlane, + $"{fmt} {w}x{h}: size {size} is smaller than the primary plane {primaryPlane}" + ); + } } [TestMethod] @@ -176,8 +216,10 @@ public void PlanarImageSize_DoesNotTruncateBelowWhatI420Needs(int w, int h) foreach (PixelFormat fmt in (PixelFormat[])[PixelFormat.Yuv420, PixelFormat.Nv12]) { int size = SpaFormatPod.VideoImageSize(fmt, w, h); - Assert.IsTrue(size >= required, - $"{fmt} {w}x{h}: allocates {size} but the layout needs {required}"); + Assert.IsTrue( + size >= required, + $"{fmt} {w}x{h}: allocates {size} but the layout needs {required}" + ); } } @@ -186,37 +228,47 @@ public void ImageSize_RefusesAFrameItCannotAddressRatherThanWrapping() { // Realistic sizes must still compute. foreach (PixelFormat fmt in KnownFormats) - foreach ((int w, int h) in (( int, int )[])[(7680, 4320), (16384, 16384)]) - Assert.IsTrue(SpaFormatPod.VideoImageSize(fmt, w, h) > 0, $"{fmt} {w}x{h} should compute"); + foreach ((int w, int h) in ((int, int)[])[(7680, 4320), (16384, 16384)]) + Assert.IsTrue( + SpaFormatPod.VideoImageSize(fmt, w, h) > 0, + $"{fmt} {w}x{h} should compute" + ); // 32768 square at 32bpp is exactly 2^32 bytes, which must be refused rather than // truncating to a zero-sized buffer. - Assert.ThrowsExactly( - () => SpaFormatPod.VideoImageSize(PixelFormat.Rgba, 32768, 32768)); + Assert.ThrowsExactly(() => + SpaFormatPod.VideoImageSize(PixelFormat.Rgba, 32768, 32768) + ); } [TestMethod] public void ImageSize_RejectsNegativeDimensions() { // Dimensions come off a negotiated param, so a nonsense value must not reach the arithmetic. - Assert.ThrowsExactly( - () => SpaFormatPod.VideoImageSize(PixelFormat.Bgra, -1, 480)); - Assert.ThrowsExactly( - () => SpaFormatPod.VideoImageSize(PixelFormat.Bgra, 640, -1)); + Assert.ThrowsExactly(() => + SpaFormatPod.VideoImageSize(PixelFormat.Bgra, -1, 480) + ); + Assert.ThrowsExactly(() => + SpaFormatPod.VideoImageSize(PixelFormat.Bgra, 640, -1) + ); } [TestMethod] public void Stride_ComputesForLargeWidthsAndRefusesImpossibleOnes() { foreach (PixelFormat fmt in KnownFormats) - foreach (int w in (int[])[16384, 65536, 268_435_456]) - Assert.IsTrue(SpaFormatPod.VideoStride(fmt, w) > 0, - $"{fmt} at width {w}: stride computed as {SpaFormatPod.VideoStride(fmt, w)}"); - - Assert.ThrowsExactly( - () => SpaFormatPod.VideoStride(PixelFormat.Bgra, int.MaxValue)); - Assert.ThrowsExactly( - () => SpaFormatPod.VideoStride(PixelFormat.Bgra, -1)); + foreach (int w in (int[])[16384, 65536, 268_435_456]) + Assert.IsTrue( + SpaFormatPod.VideoStride(fmt, w) > 0, + $"{fmt} at width {w}: stride computed as {SpaFormatPod.VideoStride(fmt, w)}" + ); + + Assert.ThrowsExactly(() => + SpaFormatPod.VideoStride(PixelFormat.Bgra, int.MaxValue) + ); + Assert.ThrowsExactly(() => + SpaFormatPod.VideoStride(PixelFormat.Bgra, -1) + ); } // ---------------------------------------------------------------- buffer types @@ -228,7 +280,10 @@ public void BufferTypeMapping_CoversTheThreeRealTypesAndRejectsTheRest() Assert.AreEqual(PipeWireBufferType.MemFd, SpaFormatPod.ToBufferType(SpaDataType.MemFd)); Assert.AreEqual(PipeWireBufferType.DmaBuf, SpaFormatPod.ToBufferType(SpaDataType.DmaBuf)); Assert.AreEqual(PipeWireBufferType.Unknown, SpaFormatPod.ToBufferType((SpaDataType)0)); - Assert.AreEqual(PipeWireBufferType.Unknown, SpaFormatPod.ToBufferType((SpaDataType)uint.MaxValue)); + Assert.AreEqual( + PipeWireBufferType.Unknown, + SpaFormatPod.ToBufferType((SpaDataType)uint.MaxValue) + ); } [TestMethod] @@ -237,11 +292,17 @@ public void TheCaptureDataTypeMask_AdvertisesExactlyTheTypesWeCanDecode() int mask = SpaFormatPod.VideoCaptureDataTypeMask; // Advertising a type we cannot handle makes the producer hand us buffers we then drop. - foreach (SpaDataType t in (SpaDataType[])[SpaDataType.MemPtr, SpaDataType.MemFd, SpaDataType.DmaBuf]) + foreach ( + SpaDataType t in (SpaDataType[]) + [SpaDataType.MemPtr, SpaDataType.MemFd, SpaDataType.DmaBuf] + ) { Assert.AreNotEqual(0, mask & (1 << (int)t), $"data type {t} must be advertised"); - Assert.AreNotEqual(PipeWireBufferType.Unknown, SpaFormatPod.ToBufferType(t), - $"data type {t} is advertised but does not map to a known buffer type"); + Assert.AreNotEqual( + PipeWireBufferType.Unknown, + SpaFormatPod.ToBufferType(t), + $"data type {t} is advertised but does not map to a known buffer type" + ); } } @@ -251,51 +312,73 @@ public void TheCaptureDataTypeMask_AdvertisesExactlyTheTypesWeCanDecode() public void UnknownColourMetadata_MapsToUnknownRatherThanAPlausibleDefault() { // Guessing BT.709 for an unknown matrix would silently mis-convert colour. - Assert.AreEqual(VideoColorRange.Unknown, SpaFormatPod.MapColorRange((SpaVideoColorRange)0xDEADBEEF)); - Assert.AreEqual(VideoColorMatrix.Unknown, SpaFormatPod.MapColorMatrix((SpaVideoColorMatrix)0xDEADBEEF)); - Assert.AreEqual(VideoTransferFunction.Unknown, SpaFormatPod.MapTransfer((SpaVideoTransferFunction)0xDEADBEEF)); - Assert.AreEqual(VideoColorPrimaries.Unknown, SpaFormatPod.MapPrimaries((SpaVideoColorPrimaries)0xDEADBEEF)); + Assert.AreEqual( + VideoColorRange.Unknown, + SpaFormatPod.MapColorRange((SpaVideoColorRange)0xDEADBEEF) + ); + Assert.AreEqual( + VideoColorMatrix.Unknown, + SpaFormatPod.MapColorMatrix((SpaVideoColorMatrix)0xDEADBEEF) + ); + Assert.AreEqual( + VideoTransferFunction.Unknown, + SpaFormatPod.MapTransfer((SpaVideoTransferFunction)0xDEADBEEF) + ); + Assert.AreEqual( + VideoColorPrimaries.Unknown, + SpaFormatPod.MapPrimaries((SpaVideoColorPrimaries)0xDEADBEEF) + ); } [TestMethod] public void KnownColourMetadata_MapsToTheMatchingMember() { - Assert.AreEqual(VideoColorRange.Full_0_255, - SpaFormatPod.MapColorRange(SpaVideoColorRange.Full)); - Assert.AreEqual(VideoColorRange.Limited_16_235, - SpaFormatPod.MapColorRange(SpaVideoColorRange.Limited)); + Assert.AreEqual( + VideoColorRange.Full_0_255, + SpaFormatPod.MapColorRange(SpaVideoColorRange.Full) + ); + Assert.AreEqual( + VideoColorRange.Limited_16_235, + SpaFormatPod.MapColorRange(SpaVideoColorRange.Limited) + ); // Every member is mapped or explicitly unknown: a quiet default here mistranslates // colour, so the map is total by test rather than by inspection. - foreach ((SpaVideoColorMatrix spa, VideoColorMatrix expected) in new[] - { - (SpaVideoColorMatrix.Unknown, VideoColorMatrix.Unknown), - (SpaVideoColorMatrix.Rgb, VideoColorMatrix.Rgb), - (SpaVideoColorMatrix.Fcc, VideoColorMatrix.Unknown), - (SpaVideoColorMatrix.Bt709, VideoColorMatrix.Bt709), - (SpaVideoColorMatrix.Bt601, VideoColorMatrix.Bt601), - (SpaVideoColorMatrix.Smpte240M, VideoColorMatrix.Unknown), - (SpaVideoColorMatrix.Bt2020, VideoColorMatrix.Bt2020), - }) + foreach ( + (SpaVideoColorMatrix spa, VideoColorMatrix expected) in new[] + { + (SpaVideoColorMatrix.Unknown, VideoColorMatrix.Unknown), + (SpaVideoColorMatrix.Rgb, VideoColorMatrix.Rgb), + (SpaVideoColorMatrix.Fcc, VideoColorMatrix.Unknown), + (SpaVideoColorMatrix.Bt709, VideoColorMatrix.Bt709), + (SpaVideoColorMatrix.Bt601, VideoColorMatrix.Bt601), + (SpaVideoColorMatrix.Smpte240M, VideoColorMatrix.Unknown), + (SpaVideoColorMatrix.Bt2020, VideoColorMatrix.Bt2020), + } + ) Assert.AreEqual(expected, SpaFormatPod.MapColorMatrix(spa), $"matrix {spa}"); - foreach ((SpaVideoTransferFunction spa, VideoTransferFunction expected) in new[] - { - (SpaVideoTransferFunction.Unknown, VideoTransferFunction.Unknown), - (SpaVideoTransferFunction.Gamma22, VideoTransferFunction.Gamma22), - (SpaVideoTransferFunction.Bt709, VideoTransferFunction.Bt709), - (SpaVideoTransferFunction.Srgb, VideoTransferFunction.Srgb), - (SpaVideoTransferFunction.Bt2020_12, VideoTransferFunction.Bt2020_12), - (SpaVideoTransferFunction.Gamma10, VideoTransferFunction.Unknown), - (SpaVideoTransferFunction.Smpte2084, VideoTransferFunction.Unknown), - }) + foreach ( + (SpaVideoTransferFunction spa, VideoTransferFunction expected) in new[] + { + (SpaVideoTransferFunction.Unknown, VideoTransferFunction.Unknown), + (SpaVideoTransferFunction.Gamma22, VideoTransferFunction.Gamma22), + (SpaVideoTransferFunction.Bt709, VideoTransferFunction.Bt709), + (SpaVideoTransferFunction.Srgb, VideoTransferFunction.Srgb), + (SpaVideoTransferFunction.Bt2020_12, VideoTransferFunction.Bt2020_12), + (SpaVideoTransferFunction.Gamma10, VideoTransferFunction.Unknown), + (SpaVideoTransferFunction.Smpte2084, VideoTransferFunction.Unknown), + } + ) Assert.AreEqual(expected, SpaFormatPod.MapTransfer(spa), $"transfer {spa}"); - foreach ((SpaVideoColorPrimaries spa, VideoColorPrimaries expected) in new[] - { - (SpaVideoColorPrimaries.Bt709, VideoColorPrimaries.Bt709), - (SpaVideoColorPrimaries.Bt2020, VideoColorPrimaries.Bt2020), - }) + foreach ( + (SpaVideoColorPrimaries spa, VideoColorPrimaries expected) in new[] + { + (SpaVideoColorPrimaries.Bt709, VideoColorPrimaries.Bt709), + (SpaVideoColorPrimaries.Bt2020, VideoColorPrimaries.Bt2020), + } + ) Assert.AreEqual(expected, SpaFormatPod.MapPrimaries(spa), $"primaries {spa}"); } @@ -308,25 +391,41 @@ public void WriteVideoFormat_RefusesABufferTooSmallRatherThanTruncating() Span tiny = stackalloc byte[8]; try { - _ = SpaFormatPod.WriteVideoFormat(tiny, [PixelFormat.Bgra], 640, 480, 30, fixedSize: true); + _ = SpaFormatPod.WriteVideoFormat( + tiny, + [PixelFormat.Bgra], + 640, + 480, + 30, + fixedSize: true + ); Assert.Fail("writing a format into 8 bytes must not appear to succeed"); } - catch (Exception e) when (e is InvalidOperationException or ArgumentException or IndexOutOfRangeException) - { - } + catch (Exception e) + when (e is InvalidOperationException or ArgumentException or IndexOutOfRangeException) + { } } [TestMethod] public void WriteVideoFormat_ProducesAPodThatParsesBackToWhatWasWritten() { Span buf = stackalloc byte[1024]; - int written = SpaFormatPod.WriteVideoFormat(buf, [PixelFormat.Bgra], 1280, 720, 60, fixedSize: true); + int written = SpaFormatPod.WriteVideoFormat( + buf, + [PixelFormat.Bgra], + 1280, + 720, + 60, + fixedSize: true + ); Assert.IsTrue(written > 0 && written <= buf.Length); var reader = new SpaPodReader(buf[..written]); - Assert.IsTrue(reader.EnterObject(out uint objType, out _, out _), - "what we write must be a well-formed object pod"); + Assert.IsTrue( + reader.EnterObject(out uint objType, out _, out _), + "what we write must be a well-formed object pod" + ); Assert.AreEqual(SpaType.ObjectFormat, (SpaType)objType); } @@ -339,7 +438,13 @@ public void WriteHeaderMetaParam_AndBuffersParam_ProduceParseablePods() Assert.IsTrue(new SpaPodReader(meta[..m]).EnterObject(out _, out _, out _)); Span buffers = stackalloc byte[256]; - int b = SpaFormatPod.WriteVideoBuffersParam(buffers, size: 1024, stride: 64, dataTypes: 1, blocks: 1); + int b = SpaFormatPod.WriteVideoBuffersParam( + buffers, + size: 1024, + stride: 64, + dataTypes: 1, + blocks: 1 + ); Assert.IsTrue(b > 0); Assert.IsTrue(new SpaPodReader(buffers[..b]).EnterObject(out _, out _, out _)); } @@ -352,7 +457,10 @@ public void PlanarFormats_SizeTheWholeFrameNotJustTheLumaPlane() foreach (PixelFormat fmt in (PixelFormat[])[PixelFormat.Nv12, PixelFormat.Yuv420]) { Assert.AreEqual(320 * 240 * 3 / 2, SpaFormatPod.VideoImageSize(fmt, 320, 240)); - Assert.IsTrue(SpaFormatPod.VideoImageSize(fmt, 320, 240) > SpaFormatPod.VideoStride(fmt, 320) * 240); + Assert.IsTrue( + SpaFormatPod.VideoImageSize(fmt, 320, 240) + > SpaFormatPod.VideoStride(fmt, 320) * 240 + ); } } @@ -368,16 +476,19 @@ public void TheDefaultVideoOffer_NamesEveryFormatThisLibrarySupports() var format = (SpaObject)value!; var choice = (SpaChoice)format[SpaFormat.VideoFormat]!; - var offered = choice.Alternatives - .OfType() + var offered = choice + .Alternatives.OfType() .Select(id => SpaFormatPod.FromSpaVideoFormat((SpaVideoFormat)id.Value)) .ToHashSet(); var supported = Enum.GetValues().Where(f => f != PixelFormat.Unknown).ToList(); var missing = supported.Where(f => !offered.Contains(f)).ToList(); - CollectionAssert.AreEqual(Array.Empty(), missing, - $"these supported formats are not offered by default: {string.Join(", ", missing)}"); + CollectionAssert.AreEqual( + Array.Empty(), + missing, + $"these supported formats are not offered by default: {string.Join(", ", missing)}" + ); } // ---------------------------------------------------------------- explicit sync and hostile formats @@ -395,8 +506,7 @@ public void WriteSyncTimelineMetaParam_NamesTheTimeline() var obj = (SpaObject)value!; Assert.AreEqual(SpaType.ObjectParamMeta, obj.ObjectType); Assert.AreEqual(SpaParamType.Meta, obj.ObjectId); - Assert.AreEqual( - (uint)SpaMetaType.SyncTimeline, ((SpaId)obj[SpaParamMeta.Type]!).Value); + Assert.AreEqual((uint)SpaMetaType.SyncTimeline, ((SpaId)obj[SpaParamMeta.Type]!).Value); } [TestMethod] @@ -404,7 +514,9 @@ public unsafe void TryFindSyncTimeline_ReadsThePointsOffTheBuffer() { spa_meta_sync_timeline native = new() { - flags = 1, acquire_point = 10, release_point = 20, + flags = 1, + acquire_point = 10, + release_point = 20, }; spa_meta meta = new() { diff --git a/tests/PipeWire.NET.Tests/SpaKeyTests.cs b/tests/PipeWire.NET.Tests/SpaKeyTests.cs index 042b0f9..d766e63 100644 --- a/tests/PipeWire.NET.Tests/SpaKeyTests.cs +++ b/tests/PipeWire.NET.Tests/SpaKeyTests.cs @@ -34,7 +34,8 @@ public void EveryKeyEnum_ConvertsToItsOwnNumericValue() AssertKey(SpaParamIo.Id); AssertKey(SpaProfiler.Info); - static void AssertKey(TEnum value) where TEnum : unmanaged, Enum + static void AssertKey(TEnum value) + where TEnum : unmanaged, Enum { SpaKey key = value switch { @@ -54,8 +55,11 @@ static void AssertKey(TEnum value) where TEnum : unmanaged, Enum _ => throw new InvalidOperationException($"unmapped enum {typeof(TEnum).Name}"), }; - Assert.AreEqual(Convert.ToUInt32(value, System.Globalization.CultureInfo.InvariantCulture), key.Value, - $"{typeof(TEnum).Name}.{value} converted to the wrong key"); + Assert.AreEqual( + Convert.ToUInt32(value, System.Globalization.CultureInfo.InvariantCulture), + key.Value, + $"{typeof(TEnum).Name}.{value} converted to the wrong key" + ); Assert.AreEqual(value, key.As(), "As must undo the conversion"); } } @@ -100,21 +104,41 @@ public void EveryIdEnum_ConvertsToItsOwnNumericValue() Check(SpaVideoColorRange.Full, SpaVideoColorRange.Full); Check(SpaVideoColorMatrix.Bt709, SpaVideoColorMatrix.Bt709); Check(SpaVideoColorPrimaries.Bt709, SpaVideoColorPrimaries.Bt709); - Check(SpaVideoTransferFunction.Bt709, SpaVideoTransferFunction.Bt709); - Check(SpaVideoInterlaceMode.Progressive, SpaVideoInterlaceMode.Progressive); + Check( + SpaVideoTransferFunction.Bt709, + SpaVideoTransferFunction.Bt709 + ); + Check( + SpaVideoInterlaceMode.Progressive, + SpaVideoInterlaceMode.Progressive + ); Check(SpaVideoChromaSite.Cosited, SpaVideoChromaSite.Cosited); Check(SpaParamAvailability.Yes, SpaParamAvailability.Yes); Check(SpaParamPortConfigMode.Dsp, SpaParamPortConfigMode.Dsp); Check(SpaParamBitorder.Lsb, SpaParamBitorder.Lsb); - Check(SpaAudioVolumeRampScale.Cubic, SpaAudioVolumeRampScale.Cubic); + Check( + SpaAudioVolumeRampScale.Cubic, + SpaAudioVolumeRampScale.Cubic + ); Check(SpaIoType.Clock, SpaIoType.Clock); - Check(SpaMetaVideotransformValue.None, SpaMetaVideotransformValue.None); + Check( + SpaMetaVideotransformValue.None, + SpaMetaVideotransformValue.None + ); - static void Check(SpaIdValue converted, TEnum original) where TEnum : unmanaged, Enum + static void Check(SpaIdValue converted, TEnum original) + where TEnum : unmanaged, Enum { - uint expected = Convert.ToUInt32(original, System.Globalization.CultureInfo.InvariantCulture); + uint expected = Convert.ToUInt32( + original, + System.Globalization.CultureInfo.InvariantCulture + ); - Assert.AreEqual(expected, converted.Value, $"{typeof(TEnum).Name} converted to the wrong id"); + Assert.AreEqual( + expected, + converted.Value, + $"{typeof(TEnum).Name} converted to the wrong id" + ); Assert.AreEqual(expected, (uint)converted); Assert.AreEqual(original, converted.As(), "As must undo the conversion"); } @@ -129,14 +153,21 @@ public void AnIdReadAsAnEnumOfTheWrongWidth_SaysSoRatherThanReinterpreting() Assert.ThrowsExactly(() => SpaKey.FromRaw(1).As()); } - private enum ByteWide : byte { Zero } + private enum ByteWide : byte + { + Zero, + } [TestMethod] public void KeysCompareByValue_SoTheyCanIndexALookup() { // They are the key type of the parser's property lookups; identity equality would make // every lookup miss. - var lookup = new Dictionary { [SpaProp.Volume] = "volume", [SpaProp.Mute] = "mute" }; + var lookup = new Dictionary + { + [SpaProp.Volume] = "volume", + [SpaProp.Mute] = "mute", + }; Assert.AreEqual("volume", lookup[SpaProp.Volume]); Assert.AreEqual("mute", lookup[SpaProp.Mute]); diff --git a/tests/PipeWire.NET.Tests/SpaPodChoiceRoundTripTests.cs b/tests/PipeWire.NET.Tests/SpaPodChoiceRoundTripTests.cs index e95c8dd..64525cf 100644 --- a/tests/PipeWire.NET.Tests/SpaPodChoiceRoundTripTests.cs +++ b/tests/PipeWire.NET.Tests/SpaPodChoiceRoundTripTests.cs @@ -28,12 +28,18 @@ private static SpaPodProperty RoundTrip(BuildOne build) build(ref builder); builder.Pop(); - Assert.IsTrue(SpaPod.TryParse(builder.GetPod(), out SpaValue? parsed), - "the builder wrote a pod the parser rejects"); + Assert.IsTrue( + SpaPod.TryParse(builder.GetPod(), out SpaValue? parsed), + "the builder wrote a pod the parser rejects" + ); var obj = parsed as SpaObject; Assert.IsNotNull(obj, "the pod did not read back as an object"); - Assert.AreEqual(1, obj!.Properties.Length, "expected exactly the one property that was written"); + Assert.AreEqual( + 1, + obj!.Properties.Length, + "expected exactly the one property that was written" + ); return obj.Properties[0]; } @@ -41,7 +47,10 @@ private static SpaPodProperty RoundTrip(BuildOne build) private static SpaChoice AsChoice(SpaPodProperty property) { var choice = property.Value as SpaChoice; - Assert.IsNotNull(choice, $"the property read back as {property.Value.GetType().Name}, not a choice"); + Assert.IsNotNull( + choice, + $"the property read back as {property.Value.GetType().Name}, not a choice" + ); return choice!; } @@ -52,8 +61,15 @@ public void ChoiceEnumOverIds_LeadsWithTheDefaultAndThenEveryValue() // rest are what may be selected, so the default appears twice. Writing it once leaves a // default with no alternatives, which is a negotiation that fails with "no more input // formats" rather than an error anybody can read. - SpaPodProperty p = RoundTrip((ref SpaPodBuilder b) => - b.AddChoiceEnum(SpaFormat.VideoFormat, SpaVideoFormat.Bgra, SpaVideoFormat.Rgba, SpaVideoFormat.Nv12)); + SpaPodProperty p = RoundTrip( + (ref SpaPodBuilder b) => + b.AddChoiceEnum( + SpaFormat.VideoFormat, + SpaVideoFormat.Bgra, + SpaVideoFormat.Rgba, + SpaVideoFormat.Nv12 + ) + ); SpaChoice choice = AsChoice(p); Assert.AreEqual(SpaChoiceType.Enum, choice.Kind); @@ -63,10 +79,13 @@ public void ChoiceEnumOverIds_LeadsWithTheDefaultAndThenEveryValue() new[] { (uint)SpaVideoFormat.Bgra, - (uint)SpaVideoFormat.Bgra, (uint)SpaVideoFormat.Rgba, (uint)SpaVideoFormat.Nv12, + (uint)SpaVideoFormat.Bgra, + (uint)SpaVideoFormat.Rgba, + (uint)SpaVideoFormat.Nv12, }, (uint[])[.. choice.Alternatives.Cast().Select(v => v.Value)], - "the default must lead, and every offered value must follow it in order"); + "the default must lead, and every offered value must follow it in order" + ); } [TestMethod] @@ -74,8 +93,9 @@ public void ChoiceEnumOverLongs_LeadsWithTheDefaultAndThenEveryModifier() { long[] modifiers = [0x0100000000000001L, 0x0100000000000002L, 0L]; - SpaPodProperty p = RoundTrip((ref SpaPodBuilder b) => - b.AddChoiceEnumLong(SpaFormat.VideoModifier, modifiers)); + SpaPodProperty p = RoundTrip( + (ref SpaPodBuilder b) => b.AddChoiceEnumLong(SpaFormat.VideoModifier, modifiers) + ); SpaChoice choice = AsChoice(p); Assert.AreEqual(SpaChoiceType.Enum, choice.Kind); @@ -83,7 +103,8 @@ public void ChoiceEnumOverLongs_LeadsWithTheDefaultAndThenEveryModifier() CollectionAssert.AreEqual( (long[])[modifiers[0], .. modifiers], (long[])[.. choice.Alternatives.Cast().Select(v => v.Value)], - "a single modifier written once would be a default with nothing to select"); + "a single modifier written once would be a default with nothing to select" + ); } [TestMethod] @@ -94,8 +115,9 @@ public void ChoiceEnumOverLongs_CarriesThePropFlagsItWasGiven() // it survives. SpaPodPropFlags flags = SpaPodPropFlags.Mandatory | SpaPodPropFlags.DontFixate; - SpaPodProperty p = RoundTrip((ref SpaPodBuilder b) => - b.AddChoiceEnumLong(SpaFormat.VideoModifier, [1L, 2L], flags)); + SpaPodProperty p = RoundTrip( + (ref SpaPodBuilder b) => b.AddChoiceEnumLong(SpaFormat.VideoModifier, [1L, 2L], flags) + ); Assert.AreEqual(flags, p.Flags, "the property flags did not survive the round trip"); } @@ -103,8 +125,10 @@ public void ChoiceEnumOverLongs_CarriesThePropFlagsItWasGiven() [TestMethod] public void ChoiceRangeOverInts_ReadsBackAsDefaultMinMax() { - SpaPodProperty p = RoundTrip((ref SpaPodBuilder b) => - b.AddChoiceRangeInt(SpaFormat.VideoFormat, def: 44100, min: 8000, max: 192000)); + SpaPodProperty p = RoundTrip( + (ref SpaPodBuilder b) => + b.AddChoiceRangeInt(SpaFormat.VideoFormat, def: 44100, min: 8000, max: 192000) + ); SpaChoice choice = AsChoice(p); Assert.AreEqual(SpaChoiceType.Range, choice.Kind); @@ -112,7 +136,8 @@ public void ChoiceRangeOverInts_ReadsBackAsDefaultMinMax() CollectionAssert.AreEqual( new[] { 44100, 8000, 192000 }, (int[])[.. choice.Alternatives.Cast().Select(v => v.Value)], - "Range is three values in the order default, min, max"); + "Range is three values in the order default, min, max" + ); } [TestMethod] @@ -120,21 +145,28 @@ public void ChoiceFlagsOverInt_CarriesExactlyOneValue() { // The header documents Flags as carrying its flags in the first value, singular, unlike // Range and Enum. - SpaPodProperty p = RoundTrip((ref SpaPodBuilder b) => - b.AddChoiceFlagsInt(SpaFormat.VideoFormat, 0b1011)); + SpaPodProperty p = RoundTrip( + (ref SpaPodBuilder b) => b.AddChoiceFlagsInt(SpaFormat.VideoFormat, 0b1011) + ); SpaChoice choice = AsChoice(p); Assert.AreEqual(SpaChoiceType.Flags, choice.Kind); Assert.AreEqual(SpaType.Int, choice.ChildType); - Assert.AreEqual(1, choice.Alternatives.Length, "Flags carries one value, not a default and a mask"); + Assert.AreEqual( + 1, + choice.Alternatives.Length, + "Flags carries one value, not a default and a mask" + ); Assert.AreEqual(0b1011, ((SpaInt)choice.Alternatives[0]).Value); } [TestMethod] public void ChoiceRangeOverRectangles_ReadsBackAsDefaultMinMax() { - SpaPodProperty p = RoundTrip((ref SpaPodBuilder b) => - b.AddChoiceRangeRectangle(SpaFormat.VideoSize, 1920, 1080, 320, 240, 3840, 2160)); + SpaPodProperty p = RoundTrip( + (ref SpaPodBuilder b) => + b.AddChoiceRangeRectangle(SpaFormat.VideoSize, 1920, 1080, 320, 240, 3840, 2160) + ); SpaChoice choice = AsChoice(p); Assert.AreEqual(SpaChoiceType.Range, choice.Kind); @@ -150,8 +182,10 @@ public void ChoiceRangeOverRectangles_ReadsBackAsDefaultMinMax() [TestMethod] public void ChoiceRangeOverFractions_ReadsBackAsDefaultMinMax() { - SpaPodProperty p = RoundTrip((ref SpaPodBuilder b) => - b.AddChoiceRangeFraction(SpaFormat.VideoFramerate, 30, 1, 1, 1, 240, 1)); + SpaPodProperty p = RoundTrip( + (ref SpaPodBuilder b) => + b.AddChoiceRangeFraction(SpaFormat.VideoFramerate, 30, 1, 1, 1, 240, 1) + ); SpaChoice choice = AsChoice(p); Assert.AreEqual(SpaChoiceType.Range, choice.Kind); @@ -176,13 +210,15 @@ public void AnyNumberOfModifiers_SurvivesTheRoundTrip() { long[] values = [.. Enumerable.Range(0, count).Select(_ => (long)random.Next())]; - SpaPodProperty p = RoundTrip((ref SpaPodBuilder b) => - b.AddChoiceEnumLong(SpaFormat.VideoModifier, values)); + SpaPodProperty p = RoundTrip( + (ref SpaPodBuilder b) => b.AddChoiceEnumLong(SpaFormat.VideoModifier, values) + ); CollectionAssert.AreEqual( (long[])[values[0], .. values], (long[])[.. AsChoice(p).Alternatives.Cast().Select(v => v.Value)], - $"a choice of {count} values did not survive"); + $"a choice of {count} values did not survive" + ); } } @@ -206,14 +242,21 @@ public void AChoicePropertyAmongOthers_LeavesTheOthersReadable() Assert.IsTrue(SpaPod.TryParse(builder.GetPod(), out SpaValue? parsed)); var obj = (SpaObject)parsed!; - Assert.AreEqual(5, obj.Properties.Length, "a property was lost, so a choice reported the wrong size"); + Assert.AreEqual( + 5, + obj.Properties.Length, + "a property was lost, so a choice reported the wrong size" + ); var size = obj.Properties[2].Value as SpaRectangle; Assert.IsNotNull(size, "the property after a choice did not read back as itself"); Assert.AreEqual((1920u, 1080u), (size!.Width, size.Height)); var subtype = obj.Properties[4].Value as SpaId; - Assert.IsNotNull(subtype, "the property after the second choice did not read back as itself"); + Assert.IsNotNull( + subtype, + "the property after the second choice did not read back as itself" + ); Assert.AreEqual((uint)SpaMediaSubtype.Raw, subtype!.Value); } } diff --git a/tests/PipeWire.NET.Tests/SpaPodChoiceTests.cs b/tests/PipeWire.NET.Tests/SpaPodChoiceTests.cs index d2989e3..781ad89 100644 --- a/tests/PipeWire.NET.Tests/SpaPodChoiceTests.cs +++ b/tests/PipeWire.NET.Tests/SpaPodChoiceTests.cs @@ -31,11 +31,12 @@ private static byte[] Pod(SpaType type, ReadOnlySpan body) private static byte[] ChoicePod(SpaType childType, uint childSize, params byte[][] values) { var body = new List(); - body.AddRange(BitConverter.GetBytes(0u)); // choiceType (Enum) - body.AddRange(BitConverter.GetBytes(0u)); // flags + body.AddRange(BitConverter.GetBytes(0u)); // choiceType (Enum) + body.AddRange(BitConverter.GetBytes(0u)); // flags body.AddRange(BitConverter.GetBytes(childSize)); body.AddRange(BitConverter.GetBytes((uint)childType)); - foreach (byte[] v in values) body.AddRange(v); + foreach (byte[] v in values) + body.AddRange(v); return Pod(SpaType.Choice, System.Runtime.InteropServices.CollectionsMarshal.AsSpan(body)); } @@ -66,10 +67,13 @@ public void AChoiceOfOneModifier_ReportsCountOne() public void AChoiceOfSeveralModifiers_ReturnsThePreferredOneAndTheCount() { // The first value is the preferred modifier; more than one means fixation is still needed. - byte[] pod = ChoicePod(SpaType.Long, 8, + byte[] pod = ChoicePod( + SpaType.Long, + 8, BitConverter.GetBytes(0x0100000000000002L), BitConverter.GetBytes(0x0100000000000003L), - BitConverter.GetBytes(0L)); + BitConverter.GetBytes(0L) + ); var reader = new SpaPodReader(pod); Assert.IsTrue(reader.TryReadModifier(out long first, out int count)); @@ -103,7 +107,9 @@ public void AChoiceWithTheWrongChildSize_IsDeclined() [TestMethod] public void ANonChoiceNonLongPod_IsDeclinedWithoutConsuming() { - foreach (SpaType type in (SpaType[])[SpaType.Int, SpaType.Id, SpaType.Rectangle, SpaType.Float]) + foreach ( + SpaType type in (SpaType[])[SpaType.Int, SpaType.Id, SpaType.Rectangle, SpaType.Float] + ) { var reader = new SpaPodReader(Pod(type, new byte[8])); Assert.IsFalse(reader.TryReadModifier(out _, out _), $"type {type} is not a modifier"); @@ -126,20 +132,25 @@ public void AChoiceClaimingMoreValuesThanItCarries_IsDeclined() { // Hand-build a choice whose declared size promises values that are not there. byte[] pod = ChoicePod(SpaType.Long, 8, BitConverter.GetBytes(1L)); - BitConverter.TryWriteBytes(pod.AsSpan(0, 4), 16u + 80u); // claim ten values + BitConverter.TryWriteBytes(pod.AsSpan(0, 4), 16u + 80u); // claim ten values var reader = new SpaPodReader(pod); - Assert.IsFalse(reader.TryReadModifier(out _, out _), - "a choice may not promise more values than the buffer holds"); + Assert.IsFalse( + reader.TryReadModifier(out _, out _), + "a choice may not promise more values than the buffer holds" + ); Assert.AreEqual(0, reader.Position); } [TestMethod] public void AChoiceWithNoValuesAtAll_IsDeclined() { - byte[] pod = ChoicePod(SpaType.Long, 8); // header only, zero values + byte[] pod = ChoicePod(SpaType.Long, 8); // header only, zero values var reader = new SpaPodReader(pod); - Assert.IsFalse(reader.TryReadModifier(out _, out _), "a choice of nothing offers no modifier"); + Assert.IsFalse( + reader.TryReadModifier(out _, out _), + "a choice of nothing offers no modifier" + ); } [TestMethod] @@ -182,8 +193,11 @@ public void UnwrappingAPlainValue_IsDeclinedAndLeavesItReadable() var reader = new SpaPodReader(pod); Assert.IsFalse(reader.TryUnwrapChoice(out _), "a plain value is not a choice"); - Assert.AreEqual((640u, 480u), reader.ReadRectangle(), - "declining must leave the pod fully readable"); + Assert.AreEqual( + (640u, 480u), + reader.ReadRectangle(), + "declining must leave the pod fully readable" + ); } [TestMethod] @@ -193,7 +207,7 @@ public void UnwrappingATruncatedChoice_IsDeclinedRatherThanOverrunning() for (int cut = 8; cut < full.Length; cut++) { var reader = new SpaPodReader(full.AsSpan(0, cut).ToArray()); - _ = reader.TryUnwrapChoice(out _); // must not throw at any cut point + _ = reader.TryUnwrapChoice(out _); // must not throw at any cut point } } @@ -205,15 +219,16 @@ public void UnwrappingAChoiceWhoseChildSizeExceedsTheBuffer_IsDeclined() BitConverter.TryWriteBytes(pod.AsSpan(16, 4), 4096u); var reader = new SpaPodReader(pod); - Assert.IsFalse(reader.TryUnwrapChoice(out _), - "a child larger than the buffer must not produce a reader over it"); + Assert.IsFalse( + reader.TryUnwrapChoice(out _), + "a child larger than the buffer must not produce a reader over it" + ); } [TestMethod] public void AChoiceOfIds_UnwrapsToTheFirstId() { - byte[] pod = ChoicePod(SpaType.Id, 4, - BitConverter.GetBytes(7u), BitConverter.GetBytes(9u)); + byte[] pod = ChoicePod(SpaType.Id, 4, BitConverter.GetBytes(7u), BitConverter.GetBytes(9u)); var reader = new SpaPodReader(pod); Assert.IsTrue(reader.TryUnwrapChoice(out SpaPodReader inner)); diff --git a/tests/PipeWire.NET.Tests/SpaPodFilterTests.cs b/tests/PipeWire.NET.Tests/SpaPodFilterTests.cs index 40c6a19..719a3f4 100644 --- a/tests/PipeWire.NET.Tests/SpaPodFilterTests.cs +++ b/tests/PipeWire.NET.Tests/SpaPodFilterTests.cs @@ -16,8 +16,11 @@ public sealed class SpaPodFilterTests : PipeWireTestBase private static SpaObject Obj(params SpaPodProperty[] props) => new(SpaType.ObjectProps, SpaParamType.Props, [.. props]); - private static SpaPodProperty Prop(uint key, SpaValue value, SpaPodPropFlags flags = SpaPodPropFlags.None) => - new(key, flags, value); + private static SpaPodProperty Prop( + uint key, + SpaValue value, + SpaPodPropFlags flags = SpaPodPropFlags.None + ) => new(key, flags, value); private static SpaChoice Enum(params SpaValue[] alts) => new(SpaChoiceType.Enum, alts[0].Type, [.. alts]); @@ -28,16 +31,20 @@ private static SpaChoice Range(SpaValue def, SpaValue min, SpaValue max) => private static SpaChoice Step(SpaValue def, SpaValue min, SpaValue max, SpaValue step) => new(SpaChoiceType.Step, def.Type, [def, min, max, step]); - private static SpaChoice Flags(SpaValue value) => - new(SpaChoiceType.Flags, value.Type, [value]); + private static SpaChoice Flags(SpaValue value) => new(SpaChoiceType.Flags, value.Type, [value]); [TestMethod] public void ScalarEquality_MatchesAndMismatches() { - Assert.IsTrue(SpaPodFilter.Matches(Obj(Prop(1, new SpaInt(7))), Obj(Prop(1, new SpaInt(7))))); - Assert.IsFalse(SpaPodFilter.Matches(Obj(Prop(1, new SpaInt(7))), Obj(Prop(1, new SpaInt(8))))); - Assert.IsFalse(SpaPodFilter.Matches( - Obj(Prop(1, new SpaInt(7))), Obj(Prop(1, new SpaString("7"))))); + Assert.IsTrue( + SpaPodFilter.Matches(Obj(Prop(1, new SpaInt(7))), Obj(Prop(1, new SpaInt(7)))) + ); + Assert.IsFalse( + SpaPodFilter.Matches(Obj(Prop(1, new SpaInt(7))), Obj(Prop(1, new SpaInt(8)))) + ); + Assert.IsFalse( + SpaPodFilter.Matches(Obj(Prop(1, new SpaInt(7))), Obj(Prop(1, new SpaString("7")))) + ); } [TestMethod] @@ -46,10 +53,12 @@ public void EnumChoices_IntersectOnAnySharedAlternative() SpaObject candidate = Obj(Prop(1, Enum(new SpaInt(1), new SpaInt(2)))); Assert.IsTrue(SpaPodFilter.Matches(candidate, Obj(Prop(1, new SpaInt(2))))); - Assert.IsTrue(SpaPodFilter.Matches( - candidate, Obj(Prop(1, Enum(new SpaInt(2), new SpaInt(3)))))); - Assert.IsFalse(SpaPodFilter.Matches( - candidate, Obj(Prop(1, Enum(new SpaInt(3), new SpaInt(4)))))); + Assert.IsTrue( + SpaPodFilter.Matches(candidate, Obj(Prop(1, Enum(new SpaInt(2), new SpaInt(3))))) + ); + Assert.IsFalse( + SpaPodFilter.Matches(candidate, Obj(Prop(1, Enum(new SpaInt(3), new SpaInt(4))))) + ); } [TestMethod] @@ -57,10 +66,18 @@ public void RangeFilters_ContainOrReject() { SpaObject candidate = Obj(Prop(1, new SpaInt(48000))); - Assert.IsTrue(SpaPodFilter.Matches( - candidate, Obj(Prop(1, Range(new SpaInt(44100), new SpaInt(8000), new SpaInt(96000)))))); - Assert.IsFalse(SpaPodFilter.Matches( - candidate, Obj(Prop(1, Range(new SpaInt(44100), new SpaInt(8000), new SpaInt(44100)))))); + Assert.IsTrue( + SpaPodFilter.Matches( + candidate, + Obj(Prop(1, Range(new SpaInt(44100), new SpaInt(8000), new SpaInt(96000)))) + ) + ); + Assert.IsFalse( + SpaPodFilter.Matches( + candidate, + Obj(Prop(1, Range(new SpaInt(44100), new SpaInt(8000), new SpaInt(44100)))) + ) + ); } [TestMethod] @@ -70,14 +87,30 @@ public void StepFilters_ApplyUpstreamModuloSemantics() // value inside the range still fails when it is not a multiple of the step. SpaObject candidate = Obj(Prop(1, new SpaInt(48000))); - Assert.IsTrue(SpaPodFilter.Matches(candidate, - Obj(Prop(1, Step(new SpaInt(0), new SpaInt(0), new SpaInt(96000), new SpaInt(3000)))))); - Assert.IsFalse(SpaPodFilter.Matches(candidate, - Obj(Prop(1, Step(new SpaInt(0), new SpaInt(0), new SpaInt(96000), new SpaInt(7000)))))); + Assert.IsTrue( + SpaPodFilter.Matches( + candidate, + Obj( + Prop(1, Step(new SpaInt(0), new SpaInt(0), new SpaInt(96000), new SpaInt(3000))) + ) + ) + ); + Assert.IsFalse( + SpaPodFilter.Matches( + candidate, + Obj( + Prop(1, Step(new SpaInt(0), new SpaInt(0), new SpaInt(96000), new SpaInt(7000))) + ) + ) + ); // A zero step is invalid upstream: no match rather than a divide by zero. - Assert.IsFalse(SpaPodFilter.Matches(candidate, - Obj(Prop(1, Step(new SpaInt(0), new SpaInt(0), new SpaInt(96000), new SpaInt(0)))))); + Assert.IsFalse( + SpaPodFilter.Matches( + candidate, + Obj(Prop(1, Step(new SpaInt(0), new SpaInt(0), new SpaInt(96000), new SpaInt(0)))) + ) + ); } [TestMethod] @@ -85,10 +118,18 @@ public void RangeAgainstRange_OverlapsOrNot() { SpaObject candidate = Obj(Prop(1, Range(new SpaInt(0), new SpaInt(100), new SpaInt(200)))); - Assert.IsTrue(SpaPodFilter.Matches(candidate, - Obj(Prop(1, Range(new SpaInt(0), new SpaInt(150), new SpaInt(300)))))); - Assert.IsFalse(SpaPodFilter.Matches(candidate, - Obj(Prop(1, Range(new SpaInt(0), new SpaInt(201), new SpaInt(300)))))); + Assert.IsTrue( + SpaPodFilter.Matches( + candidate, + Obj(Prop(1, Range(new SpaInt(0), new SpaInt(150), new SpaInt(300)))) + ) + ); + Assert.IsFalse( + SpaPodFilter.Matches( + candidate, + Obj(Prop(1, Range(new SpaInt(0), new SpaInt(201), new SpaInt(300)))) + ) + ); } [TestMethod] @@ -101,8 +142,12 @@ public void FlagsFilters_AndThePair() Assert.IsFalse(SpaPodFilter.Matches(candidate, Obj(Prop(1, Flags(new SpaInt(0b0011)))))); // Anything else against Flags is ENOTSUP upstream: no match, not an error. - Assert.IsFalse(SpaPodFilter.Matches(candidate, - Obj(Prop(1, Range(new SpaInt(0), new SpaInt(0), new SpaInt(9999)))))); + Assert.IsFalse( + SpaPodFilter.Matches( + candidate, + Obj(Prop(1, Range(new SpaInt(0), new SpaInt(0), new SpaInt(9999)))) + ) + ); } [TestMethod] @@ -115,10 +160,17 @@ public void AnObjectAsAPropertyValue_MustBeEqual_NotIntersected() SpaObject nested = Obj(Prop(2, new SpaInt(5)), Prop(3, new SpaInt(6))); SpaObject candidate = Obj(Prop(1, nested)); - Assert.IsTrue(SpaPodFilter.Matches(candidate, Obj(Prop(1, Obj(Prop(2, new SpaInt(5)), Prop(3, new SpaInt(6)))))), - "an identical nested object is equal and should match"); - Assert.IsFalse(SpaPodFilter.Matches(candidate, Obj(Prop(1, Obj(Prop(2, new SpaInt(5)))))), - "a partial nested object is not equal, and upstream does not narrow into it"); + Assert.IsTrue( + SpaPodFilter.Matches( + candidate, + Obj(Prop(1, Obj(Prop(2, new SpaInt(5)), Prop(3, new SpaInt(6))))) + ), + "an identical nested object is equal and should match" + ); + Assert.IsFalse( + SpaPodFilter.Matches(candidate, Obj(Prop(1, Obj(Prop(2, new SpaInt(5)))))), + "a partial nested object is not equal, and upstream does not narrow into it" + ); } [TestMethod] @@ -127,28 +179,42 @@ public void MandatoryProperties_MustExistOnBothSides() SpaObject candidate = Obj(Prop(1, new SpaInt(5))); // A mandatory filter constraint the candidate lacks fails. - Assert.IsFalse(SpaPodFilter.Matches(candidate, - Obj(Prop(1, new SpaInt(5)), Prop(2, new SpaInt(6), SpaPodPropFlags.Mandatory)))); + Assert.IsFalse( + SpaPodFilter.Matches( + candidate, + Obj(Prop(1, new SpaInt(5)), Prop(2, new SpaInt(6), SpaPodPropFlags.Mandatory)) + ) + ); // And a mandatory candidate property the filter does not constrain fails too. SpaObject demanding = Obj( Prop(1, new SpaInt(5)), - Prop(2, new SpaInt(6), SpaPodPropFlags.Mandatory)); + Prop(2, new SpaInt(6), SpaPodPropFlags.Mandatory) + ); Assert.IsFalse(SpaPodFilter.Matches(demanding, Obj(Prop(1, new SpaInt(5))))); // Non-mandatory extras on either side are fine. - Assert.IsTrue(SpaPodFilter.Matches(demanding, - Obj(Prop(1, new SpaInt(5)), Prop(2, new SpaInt(6))))); + Assert.IsTrue( + SpaPodFilter.Matches(demanding, Obj(Prop(1, new SpaInt(5)), Prop(2, new SpaInt(6)))) + ); } [TestMethod] public void Fractions_CompareByValueNotRepresentation() { // 1/2 equals 2/4 upstream (cross-multiplied): representation must not decide the match. - Assert.IsTrue(SpaPodFilter.Matches( - Obj(Prop(1, new SpaFraction(1, 2))), Obj(Prop(1, new SpaFraction(2, 4))))); - Assert.IsFalse(SpaPodFilter.Matches( - Obj(Prop(1, new SpaFraction(1, 2))), Obj(Prop(1, new SpaFraction(1, 3))))); + Assert.IsTrue( + SpaPodFilter.Matches( + Obj(Prop(1, new SpaFraction(1, 2))), + Obj(Prop(1, new SpaFraction(2, 4))) + ) + ); + Assert.IsFalse( + SpaPodFilter.Matches( + Obj(Prop(1, new SpaFraction(1, 2))), + Obj(Prop(1, new SpaFraction(1, 3))) + ) + ); } [TestMethod] @@ -157,13 +223,18 @@ public void MalformedChoices_MatchNothingRatherThanThrowing() SpaObject candidate = Obj(Prop(1, new SpaInt(5))); // An empty choice carries no alternatives to intersect or bound: no match. - Assert.IsFalse(SpaPodFilter.Matches(candidate, - Obj(Prop(1, new SpaChoice(SpaChoiceType.Range, SpaType.Int, []))))); + Assert.IsFalse( + SpaPodFilter.Matches( + candidate, + Obj(Prop(1, new SpaChoice(SpaChoiceType.Range, SpaType.Int, []))) + ) + ); // And a wrongly-sized one cannot even be built: the arity is checked where the caller is // still in scope, so the matcher never has to guess what positions mean. - Assert.ThrowsExactly(() => new SpaChoice( - SpaChoiceType.Range, SpaType.Int, [new SpaInt(5), new SpaInt(0)])); + Assert.ThrowsExactly(() => + new SpaChoice(SpaChoiceType.Range, SpaType.Int, [new SpaInt(5), new SpaInt(0)]) + ); } [TestMethod] @@ -171,18 +242,30 @@ public void StructValues_PairFieldsInOrder() { static SpaStruct Pair(SpaValue a, SpaValue b) => new([a, b]); - Assert.IsTrue(SpaPodFilter.Matches( - Obj(Prop(1, Pair(new SpaInt(1), new SpaInt(2)))), - Obj(Prop(1, Pair(new SpaInt(1), new SpaInt(2)))))); - Assert.IsFalse(SpaPodFilter.Matches( - Obj(Prop(1, Pair(new SpaInt(1), new SpaInt(2)))), - Obj(Prop(1, Pair(new SpaInt(1), new SpaInt(3)))))); - Assert.IsFalse(SpaPodFilter.Matches( - Obj(Prop(1, Pair(new SpaInt(1), new SpaInt(2)))), - Obj(Prop(1, new SpaStruct([new SpaInt(1)]))))); - Assert.IsFalse(SpaPodFilter.Matches( - Obj(Prop(1, new SpaInt(1))), - Obj(Prop(1, Pair(new SpaInt(1), new SpaInt(1)))))); + Assert.IsTrue( + SpaPodFilter.Matches( + Obj(Prop(1, Pair(new SpaInt(1), new SpaInt(2)))), + Obj(Prop(1, Pair(new SpaInt(1), new SpaInt(2)))) + ) + ); + Assert.IsFalse( + SpaPodFilter.Matches( + Obj(Prop(1, Pair(new SpaInt(1), new SpaInt(2)))), + Obj(Prop(1, Pair(new SpaInt(1), new SpaInt(3)))) + ) + ); + Assert.IsFalse( + SpaPodFilter.Matches( + Obj(Prop(1, Pair(new SpaInt(1), new SpaInt(2)))), + Obj(Prop(1, new SpaStruct([new SpaInt(1)]))) + ) + ); + Assert.IsFalse( + SpaPodFilter.Matches( + Obj(Prop(1, new SpaInt(1))), + Obj(Prop(1, Pair(new SpaInt(1), new SpaInt(1)))) + ) + ); } [TestMethod] @@ -210,12 +293,26 @@ public void StepFilters_SupportLongSteps() // step matches nothing rather than dividing by zero. SpaObject candidate = Obj(Prop(1, new SpaLong(20))); - Assert.IsTrue(SpaPodFilter.Matches(candidate, Obj(Prop(1, Step( - new SpaLong(0), new SpaLong(0), new SpaLong(100), new SpaLong(10)))))); - Assert.IsFalse(SpaPodFilter.Matches(candidate, Obj(Prop(1, Step( - new SpaLong(0), new SpaLong(0), new SpaLong(100), new SpaLong(7)))))); - Assert.IsFalse(SpaPodFilter.Matches(candidate, Obj(Prop(1, Step( - new SpaLong(0), new SpaLong(0), new SpaLong(100), new SpaLong(0)))))); + Assert.IsTrue( + SpaPodFilter.Matches( + candidate, + Obj( + Prop(1, Step(new SpaLong(0), new SpaLong(0), new SpaLong(100), new SpaLong(10))) + ) + ) + ); + Assert.IsFalse( + SpaPodFilter.Matches( + candidate, + Obj(Prop(1, Step(new SpaLong(0), new SpaLong(0), new SpaLong(100), new SpaLong(7)))) + ) + ); + Assert.IsFalse( + SpaPodFilter.Matches( + candidate, + Obj(Prop(1, Step(new SpaLong(0), new SpaLong(0), new SpaLong(100), new SpaLong(0)))) + ) + ); } [TestMethod] @@ -224,12 +321,38 @@ public void StepFilters_SupportRectangleSteps() // Both dimensions must be multiples of the step, as upstream requires. SpaObject candidate = Obj(Prop(1, new SpaRectangle(640, 480))); - Assert.IsTrue(SpaPodFilter.Matches(candidate, Obj(Prop(1, Step( - new SpaRectangle(0, 0), new SpaRectangle(0, 0), new SpaRectangle(1920, 1080), - new SpaRectangle(320, 240)))))); - Assert.IsFalse(SpaPodFilter.Matches(candidate, Obj(Prop(1, Step( - new SpaRectangle(0, 0), new SpaRectangle(0, 0), new SpaRectangle(1920, 1080), - new SpaRectangle(320, 241)))))); + Assert.IsTrue( + SpaPodFilter.Matches( + candidate, + Obj( + Prop( + 1, + Step( + new SpaRectangle(0, 0), + new SpaRectangle(0, 0), + new SpaRectangle(1920, 1080), + new SpaRectangle(320, 240) + ) + ) + ) + ) + ); + Assert.IsFalse( + SpaPodFilter.Matches( + candidate, + Obj( + Prop( + 1, + Step( + new SpaRectangle(0, 0), + new SpaRectangle(0, 0), + new SpaRectangle(1920, 1080), + new SpaRectangle(320, 241) + ) + ) + ) + ) + ); } [TestMethod] @@ -238,25 +361,51 @@ public void StepFilters_RejectStepsOverNonSteppableTypes() // Doubles have no step semantics upstream: the bounds admit, the step refuses. SpaObject candidate = Obj(Prop(1, new SpaDouble(4.0))); - Assert.IsFalse(SpaPodFilter.Matches(candidate, Obj(Prop(1, Step( - new SpaDouble(0), new SpaDouble(0), new SpaDouble(10), new SpaDouble(2)))))); + Assert.IsFalse( + SpaPodFilter.Matches( + candidate, + Obj( + Prop( + 1, + Step( + new SpaDouble(0), + new SpaDouble(0), + new SpaDouble(10), + new SpaDouble(2) + ) + ) + ) + ) + ); } [TestMethod] public void RangeFilters_CompareLongsAndFloatsByValue() { - Assert.IsTrue(SpaPodFilter.Matches( - Obj(Prop(1, new SpaLong(50))), - Obj(Prop(1, Range(new SpaLong(0), new SpaLong(0), new SpaLong(100)))))); - Assert.IsFalse(SpaPodFilter.Matches( - Obj(Prop(1, new SpaLong(150))), - Obj(Prop(1, Range(new SpaLong(0), new SpaLong(0), new SpaLong(100)))))); - Assert.IsTrue(SpaPodFilter.Matches( - Obj(Prop(1, new SpaFloat(1.5f))), - Obj(Prop(1, Range(new SpaFloat(0), new SpaFloat(0), new SpaFloat(2)))))); - Assert.IsFalse(SpaPodFilter.Matches( - Obj(Prop(1, new SpaFloat(2.5f))), - Obj(Prop(1, Range(new SpaFloat(0), new SpaFloat(0), new SpaFloat(2)))))); + Assert.IsTrue( + SpaPodFilter.Matches( + Obj(Prop(1, new SpaLong(50))), + Obj(Prop(1, Range(new SpaLong(0), new SpaLong(0), new SpaLong(100)))) + ) + ); + Assert.IsFalse( + SpaPodFilter.Matches( + Obj(Prop(1, new SpaLong(150))), + Obj(Prop(1, Range(new SpaLong(0), new SpaLong(0), new SpaLong(100)))) + ) + ); + Assert.IsTrue( + SpaPodFilter.Matches( + Obj(Prop(1, new SpaFloat(1.5f))), + Obj(Prop(1, Range(new SpaFloat(0), new SpaFloat(0), new SpaFloat(2)))) + ) + ); + Assert.IsFalse( + SpaPodFilter.Matches( + Obj(Prop(1, new SpaFloat(2.5f))), + Obj(Prop(1, Range(new SpaFloat(0), new SpaFloat(0), new SpaFloat(2)))) + ) + ); } [TestMethod] @@ -265,11 +414,18 @@ public void RangeFilters_CompareStringsOrdinally() // Ordinal, like upstream's memcmp-flavoured ordering: "b" sits between, "z" does not. SpaObject candidate = Obj(Prop(1, new SpaString("b"))); - Assert.IsTrue(SpaPodFilter.Matches(candidate, Obj(Prop(1, Range( - new SpaString("a"), new SpaString("a"), new SpaString("c")))))); - Assert.IsFalse(SpaPodFilter.Matches( - Obj(Prop(1, new SpaString("z"))), Obj(Prop(1, Range( - new SpaString("a"), new SpaString("a"), new SpaString("c")))))); + Assert.IsTrue( + SpaPodFilter.Matches( + candidate, + Obj(Prop(1, Range(new SpaString("a"), new SpaString("a"), new SpaString("c")))) + ) + ); + Assert.IsFalse( + SpaPodFilter.Matches( + Obj(Prop(1, new SpaString("z"))), + Obj(Prop(1, Range(new SpaString("a"), new SpaString("a"), new SpaString("c")))) + ) + ); } [TestMethod] @@ -285,13 +441,40 @@ public void FlagsFilters_RejectNonIntegerFlagTypes() public void RangeAgainstRange_ComparesRectanglesByArea() { // Same-area rectangles tie on area and fall back to width, as upstream does. - SpaObject candidate = Obj(Prop(1, Range( - new SpaRectangle(4, 6), new SpaRectangle(2, 12), new SpaRectangle(8, 3)))); - - Assert.IsTrue(SpaPodFilter.Matches(candidate, Obj(Prop(1, Range( - new SpaRectangle(4, 6), new SpaRectangle(3, 8), new SpaRectangle(6, 4)))))); - Assert.IsFalse(SpaPodFilter.Matches(candidate, Obj(Prop(1, Range( - new SpaRectangle(4, 6), new SpaRectangle(9, 9), new SpaRectangle(10, 10)))))); + SpaObject candidate = Obj( + Prop(1, Range(new SpaRectangle(4, 6), new SpaRectangle(2, 12), new SpaRectangle(8, 3))) + ); + + Assert.IsTrue( + SpaPodFilter.Matches( + candidate, + Obj( + Prop( + 1, + Range( + new SpaRectangle(4, 6), + new SpaRectangle(3, 8), + new SpaRectangle(6, 4) + ) + ) + ) + ) + ); + Assert.IsFalse( + SpaPodFilter.Matches( + candidate, + Obj( + Prop( + 1, + Range( + new SpaRectangle(4, 6), + new SpaRectangle(9, 9), + new SpaRectangle(10, 10) + ) + ) + ) + ) + ); } [TestMethod] @@ -300,20 +483,27 @@ public void MixedTypeRanges_CannotEvenBeBuilt() // A range over mixed types is meaningless, and it never reaches the matcher: children // must cohere with the declared child type at construction, so the matcher's mixed-type // arm is unreachable defense rather than a live path. - Assert.ThrowsExactly(() => new SpaChoice(SpaChoiceType.Range, SpaType.Int, - [new SpaInt(5), new SpaInt(0), new SpaString("x")])); + Assert.ThrowsExactly(() => + new SpaChoice( + SpaChoiceType.Range, + SpaType.Int, + [new SpaInt(5), new SpaInt(0), new SpaString("x")] + ) + ); } [TestMethod] public void BoolAndIdScalars_CompareByValue() { - Assert.IsTrue(SpaPodFilter.Matches( - Obj(Prop(1, new SpaBool(true))), Obj(Prop(1, new SpaBool(true))))); - Assert.IsFalse(SpaPodFilter.Matches( - Obj(Prop(1, new SpaBool(true))), Obj(Prop(1, new SpaBool(false))))); - Assert.IsTrue(SpaPodFilter.Matches( - Obj(Prop(2, new SpaId(7))), Obj(Prop(2, new SpaId(7))))); - Assert.IsFalse(SpaPodFilter.Matches( - Obj(Prop(2, new SpaId(7))), Obj(Prop(2, new SpaId(8))))); + Assert.IsTrue( + SpaPodFilter.Matches(Obj(Prop(1, new SpaBool(true))), Obj(Prop(1, new SpaBool(true)))) + ); + Assert.IsFalse( + SpaPodFilter.Matches(Obj(Prop(1, new SpaBool(true))), Obj(Prop(1, new SpaBool(false)))) + ); + Assert.IsTrue(SpaPodFilter.Matches(Obj(Prop(2, new SpaId(7))), Obj(Prop(2, new SpaId(7))))); + Assert.IsFalse( + SpaPodFilter.Matches(Obj(Prop(2, new SpaId(7))), Obj(Prop(2, new SpaId(8)))) + ); } } diff --git a/tests/PipeWire.NET.Tests/SpaPodFuzzTests.cs b/tests/PipeWire.NET.Tests/SpaPodFuzzTests.cs index 9b6bc59..a0a3a9c 100644 --- a/tests/PipeWire.NET.Tests/SpaPodFuzzTests.cs +++ b/tests/PipeWire.NET.Tests/SpaPodFuzzTests.cs @@ -29,14 +29,35 @@ public sealed class SpaPodFuzzTests : PipeWireTestBase private static readonly SpaType[] Types = [ - SpaType.None, SpaType.Bool, SpaType.Id, SpaType.Int, SpaType.Long, SpaType.Float, - SpaType.Double, SpaType.String, SpaType.Bytes, SpaType.Rectangle, SpaType.Fraction, - SpaType.Bitmap, SpaType.Array, SpaType.Struct, SpaType.Object, SpaType.Sequence, - SpaType.Pointer, SpaType.Fd, SpaType.Choice, SpaType.Pod, + SpaType.None, + SpaType.Bool, + SpaType.Id, + SpaType.Int, + SpaType.Long, + SpaType.Float, + SpaType.Double, + SpaType.String, + SpaType.Bytes, + SpaType.Rectangle, + SpaType.Fraction, + SpaType.Bitmap, + SpaType.Array, + SpaType.Struct, + SpaType.Object, + SpaType.Sequence, + SpaType.Pointer, + SpaType.Fd, + SpaType.Choice, + SpaType.Pod, ]; /// A pod header over a body of the caller's choosing, with the size deliberately loose. - private static byte[] Framed(Random random, SpaType type, ReadOnlySpan body, bool honestSize) + private static byte[] Framed( + Random random, + SpaType type, + ReadOnlySpan body, + bool honestSize + ) { var pod = new byte[8 + body.Length]; @@ -45,8 +66,8 @@ private static byte[] Framed(Random random, SpaType type, ReadOnlySpan bod : random.Next(4) switch { 0 => 0u, - 1 => (uint)body.Length + (uint)random.Next(1, 64), // claims more than it has - 2 => uint.MaxValue, // the overflow case + 1 => (uint)body.Length + (uint)random.Next(1, 64), // claims more than it has + 2 => uint.MaxValue, // the overflow case _ => (uint)Math.Max(0, body.Length - random.Next(1, 8)), }; @@ -80,7 +101,8 @@ private static byte[] Body(Random random, int depth) { body.AddRange(BitConverter.GetBytes((uint)random.Next(0, 32))); body.AddRange(BitConverter.GetBytes((uint)random.Next(0, 4))); - if (depth > 0) body.AddRange(Nested(random, depth - 1)); + if (depth > 0) + body.AddRange(Nested(random, depth - 1)); } break; @@ -107,7 +129,12 @@ private static byte[] Body(Random random, int depth) } private static byte[] Nested(Random random, int depth) => - Framed(random, Types[random.Next(Types.Length)], Body(random, depth), honestSize: random.Next(4) != 0); + Framed( + random, + Types[random.Next(Types.Length)], + Body(random, depth), + honestSize: random.Next(4) != 0 + ); private static void MustNotMisbehave(byte[] pod, string origin) { @@ -119,13 +146,15 @@ private static void MustNotMisbehave(byte[] pod, string origin) // A parser that says yes has to hand back something, or a caller dereferences null on // an input an attacker chose. - if (value is null) return; + if (value is null) + return; } catch (Exception ex) { Assert.Fail( $"{origin} threw {ex.GetType().Name}: {ex.Message}\n" - + $"pod: {Convert.ToHexString(pod)}"); + + $"pod: {Convert.ToHexString(pod)}" + ); } } @@ -166,7 +195,10 @@ public void EveryTruncationOfAValidPod_IsRefusedWithoutThrowing() builder.Pop(); byte[] whole = builder.GetPod().ToArray(); - Assert.IsTrue(SpaPod.TryParse(whole, out _), "the intact pod must parse, or this proves nothing"); + Assert.IsTrue( + SpaPod.TryParse(whole, out _), + "the intact pod must parse, or this proves nothing" + ); for (int length = 0; length < whole.Length; length++) MustNotMisbehave(whole[..length], $"a pod truncated to {length} bytes"); diff --git a/tests/PipeWire.NET.Tests/SpaPodHardeningTests.cs b/tests/PipeWire.NET.Tests/SpaPodHardeningTests.cs index aaaf382..bd6024f 100644 --- a/tests/PipeWire.NET.Tests/SpaPodHardeningTests.cs +++ b/tests/PipeWire.NET.Tests/SpaPodHardeningTests.cs @@ -67,7 +67,7 @@ public void PaddingWrittenIntoADirtyBuffer_IsZeroedRatherThanLeftAsItWas() buffer.Fill(0xAB); var b = new SpaPodBuilder(buffer); - b.AddInt(1); // 8-byte header + 4-byte body, 4 bytes of pad + b.AddInt(1); // 8-byte header + 4-byte body, 4 bytes of pad ReadOnlySpan pod = b.GetPod(); Assert.AreEqual(16, pod.Length, "an Int pod occupies a header, four bytes and four of pad"); @@ -80,11 +80,14 @@ public void APodLargerThanItsBuffer_FailsAsTheBuilderRatherThanAsASpanOffset() { // ArgumentOutOfRangeException from inside a Slice names an offset nobody can act on. The // caller's actual problem is the buffer size, so that is what the message has to say. - InvalidOperationException ex = - Assert.ThrowsExactly(WriteTwoLongsIntoRoomForOne); + InvalidOperationException ex = Assert.ThrowsExactly( + WriteTwoLongsIntoRoomForOne + ); - Assert.IsTrue(ex.Message.Contains("SpaPodBuilder", StringComparison.Ordinal), - $"the message must name the builder: {ex.Message}"); + Assert.IsTrue( + ex.Message.Contains("SpaPodBuilder", StringComparison.Ordinal), + $"the message must name the builder: {ex.Message}" + ); static void WriteTwoLongsIntoRoomForOne() { @@ -181,7 +184,9 @@ public void AWellFormedStructAndSequence_AreStillAccepted() Assert.AreEqual(1, ((SpaStruct)parsedStruct!).Fields.Length); byte[] sequenceBody = Concat(U32(0, 0), U32(0, 0), Pod(SpaType.Int, U32(1))); - Assert.IsTrue(SpaPod.TryParse(Pod(SpaType.Sequence, sequenceBody), out SpaValue? parsedSequence)); + Assert.IsTrue( + SpaPod.TryParse(Pod(SpaType.Sequence, sequenceBody), out SpaValue? parsedSequence) + ); Assert.AreEqual(1, ((SpaSequence)parsedSequence!).Controls.Length); } @@ -255,7 +260,8 @@ public void PropertiesAreBoundedByTheObject_NotByTheBuffer() byte[] objectBody = Concat( U32((uint)SpaType.ObjectFormat, (uint)SpaParamType.Format), U32((uint)SpaFormat.VideoFormat, 0), - Pod(SpaType.Id, U32(1))); + Pod(SpaType.Id, U32(1)) + ); byte[] sibling = Pod(SpaType.Int, U32(0xDEAD)); byte[] buffer = Concat(Pod(SpaType.Object, objectBody), sibling); @@ -309,5 +315,8 @@ public void AnIdReadAsAWronglySizedEnum_NamesBothTypesRatherThanTheReinterpret() Assert.ThrowsExactly(() => _ = SpaIdValue.FromRaw(1).As()); } - private enum ByteWide : byte { Zero } + private enum ByteWide : byte + { + Zero, + } } diff --git a/tests/PipeWire.NET.Tests/SpaPodMalformedTests.cs b/tests/PipeWire.NET.Tests/SpaPodMalformedTests.cs index 5519a0e..16d82a3 100644 --- a/tests/PipeWire.NET.Tests/SpaPodMalformedTests.cs +++ b/tests/PipeWire.NET.Tests/SpaPodMalformedTests.cs @@ -35,9 +35,15 @@ public void EveryFixedSizeTypeWithABodyTooShortForIt_IsRefused() // body for a type that needs eight is the shape that reads into the next pod. (SpaType Type, int Needs)[] cases = [ - (SpaType.Bool, 4), (SpaType.Id, 4), (SpaType.Int, 4), (SpaType.Float, 4), - (SpaType.Long, 8), (SpaType.Double, 8), (SpaType.Fd, 8), - (SpaType.Rectangle, 8), (SpaType.Fraction, 8), + (SpaType.Bool, 4), + (SpaType.Id, 4), + (SpaType.Int, 4), + (SpaType.Float, 4), + (SpaType.Long, 8), + (SpaType.Double, 8), + (SpaType.Fd, 8), + (SpaType.Rectangle, 8), + (SpaType.Fraction, 8), ]; foreach ((SpaType type, int needs) in cases) @@ -45,8 +51,10 @@ public void EveryFixedSizeTypeWithABodyTooShortForIt_IsRefused() int truncated = needs - 1; byte[] pod = Pod(type, (uint)truncated, truncated); - Assert.IsFalse(SpaPod.TryParse(pod, out SpaValue? value), - $"{type} with {truncated} body bytes must be refused; it needs {needs}"); + Assert.IsFalse( + SpaPod.TryParse(pod, out SpaValue? value), + $"{type} with {truncated} body bytes must be refused; it needs {needs}" + ); Assert.IsNull(value); } } @@ -57,15 +65,24 @@ public void EveryFixedSizeTypeWithExactlyEnoughBody_IsAccepted() // The other side of the same boundary, so the check above cannot pass by refusing everything. (SpaType Type, int Needs)[] cases = [ - (SpaType.Bool, 4), (SpaType.Id, 4), (SpaType.Int, 4), (SpaType.Float, 4), - (SpaType.Long, 8), (SpaType.Double, 8), (SpaType.Fd, 8), - (SpaType.Rectangle, 8), (SpaType.Fraction, 8), + (SpaType.Bool, 4), + (SpaType.Id, 4), + (SpaType.Int, 4), + (SpaType.Float, 4), + (SpaType.Long, 8), + (SpaType.Double, 8), + (SpaType.Fd, 8), + (SpaType.Rectangle, 8), + (SpaType.Fraction, 8), ]; foreach ((SpaType type, int needs) in cases) { byte[] pod = Pod(type, (uint)needs, needs); - Assert.IsTrue(SpaPod.TryParse(pod, out SpaValue? value), $"{type} with {needs} bytes is valid"); + Assert.IsTrue( + SpaPod.TryParse(pod, out SpaValue? value), + $"{type} with {needs} bytes is valid" + ); Assert.AreEqual(type, value!.Type); } } @@ -78,7 +95,10 @@ public void AnObjectBodyTooShortForItsOwnTypeAndId_IsRefused() foreach (uint declared in (uint[])[0, 1, 4, 7]) { byte[] pod = Pod(SpaType.Object, declared, (int)declared); - Assert.IsFalse(SpaPod.TryParse(pod, out _), $"an object with a {declared}-byte body is malformed"); + Assert.IsFalse( + SpaPod.TryParse(pod, out _), + $"an object with a {declared}-byte body is malformed" + ); } // Exactly eight is a valid object with no properties. @@ -96,7 +116,7 @@ public void AChoiceBodyTooShortForItsHeader_IsRefused() // Sixteen exactly is an empty choice, which is legal. byte[] pod = Pod(SpaType.Choice, 16, 16); BinaryPrimitives.WriteUInt32LittleEndian(pod.AsSpan(8, 4), (uint)SpaChoiceType.Enum); - BinaryPrimitives.WriteUInt32LittleEndian(pod.AsSpan(16, 4), 0); // child size + BinaryPrimitives.WriteUInt32LittleEndian(pod.AsSpan(16, 4), 0); // child size BinaryPrimitives.WriteUInt32LittleEndian(pod.AsSpan(20, 4), (uint)SpaType.Int); Assert.IsTrue(SpaPod.TryParse(pod, out SpaValue? value)); @@ -109,7 +129,7 @@ public void AChoiceWhoseChildSizeIsZeroButCarriesValues_IsRefused() // Zero-sized children with a non-empty body describes infinitely many of them. byte[] pod = Pod(SpaType.Choice, 24, 24); BinaryPrimitives.WriteUInt32LittleEndian(pod.AsSpan(8, 4), (uint)SpaChoiceType.Enum); - BinaryPrimitives.WriteUInt32LittleEndian(pod.AsSpan(16, 4), 0); // child size + BinaryPrimitives.WriteUInt32LittleEndian(pod.AsSpan(16, 4), 0); // child size BinaryPrimitives.WriteUInt32LittleEndian(pod.AsSpan(20, 4), (uint)SpaType.Int); Assert.IsFalse(SpaPod.TryParse(pod, out _)); @@ -122,7 +142,7 @@ public void AChoiceChildTooShortForItsDeclaredType_IsRefused() // One claiming to hold Longs in four bytes each is describing something that cannot exist. byte[] pod = Pod(SpaType.Choice, 24, 24); BinaryPrimitives.WriteUInt32LittleEndian(pod.AsSpan(8, 4), (uint)SpaChoiceType.Enum); - BinaryPrimitives.WriteUInt32LittleEndian(pod.AsSpan(16, 4), 4); // child size: 4 + BinaryPrimitives.WriteUInt32LittleEndian(pod.AsSpan(16, 4), 4); // child size: 4 BinaryPrimitives.WriteUInt32LittleEndian(pod.AsSpan(20, 4), (uint)SpaType.Long); // needs 8 Assert.IsFalse(SpaPod.TryParse(pod, out _)); @@ -142,10 +162,14 @@ public void ASequenceBodyTooShortForItsUnit_IsRefused() public void APointerBodyTooShortForAPointer_IsRefused() { // Type, padding, then a native-word pointer. - Assert.IsFalse(SpaPod.TryParse(Pod(SpaType.Pointer, 8, 8), out _), - "eight bytes holds the type and padding but no pointer"); - - Assert.IsTrue(SpaPod.TryParse(Pod(SpaType.Pointer, (uint)(8 + IntPtr.Size), 8 + IntPtr.Size), out _)); + Assert.IsFalse( + SpaPod.TryParse(Pod(SpaType.Pointer, 8, 8), out _), + "eight bytes holds the type and padding but no pointer" + ); + + Assert.IsTrue( + SpaPod.TryParse(Pod(SpaType.Pointer, (uint)(8 + IntPtr.Size), 8 + IntPtr.Size), out _) + ); } [TestMethod] @@ -159,8 +183,8 @@ public void AnObjectPropertyRunningPastTheEndOfItsObject_IsRefused() BinaryPrimitives.WriteUInt32LittleEndian(pod.AsSpan(8, 4), (uint)SpaType.ObjectProps); BinaryPrimitives.WriteUInt32LittleEndian(pod.AsSpan(12, 4), (uint)SpaParamType.Props); BinaryPrimitives.WriteUInt32LittleEndian(pod.AsSpan(16, 4), (uint)SpaProp.Volume); // key - BinaryPrimitives.WriteUInt32LittleEndian(pod.AsSpan(20, 4), 0); // flags - BinaryPrimitives.WriteUInt32LittleEndian(pod.AsSpan(24, 4), 0xFFFF); // value size + BinaryPrimitives.WriteUInt32LittleEndian(pod.AsSpan(20, 4), 0); // flags + BinaryPrimitives.WriteUInt32LittleEndian(pod.AsSpan(24, 4), 0xFFFF); // value size BinaryPrimitives.WriteUInt32LittleEndian(pod.AsSpan(28, 4), (uint)SpaType.Float); Assert.IsFalse(SpaPod.TryParse(pod, out _)); @@ -172,7 +196,7 @@ public void AStructWhoseFieldRunsPastItsEnd_IsRefused() byte[] pod = new byte[8 + 16]; BinaryPrimitives.WriteUInt32LittleEndian(pod.AsSpan(0, 4), 16); BinaryPrimitives.WriteUInt32LittleEndian(pod.AsSpan(4, 4), (uint)SpaType.Struct); - BinaryPrimitives.WriteUInt32LittleEndian(pod.AsSpan(8, 4), 0xFFFF); // field claims 64KB + BinaryPrimitives.WriteUInt32LittleEndian(pod.AsSpan(8, 4), 0xFFFF); // field claims 64KB BinaryPrimitives.WriteUInt32LittleEndian(pod.AsSpan(12, 4), (uint)SpaType.Int); Assert.IsFalse(SpaPod.TryParse(pod, out _)); @@ -182,7 +206,7 @@ public void AStructWhoseFieldRunsPastItsEnd_IsRefused() public void AnArrayWhoseChildTypeCannotFitItsChildSize_IsRefused() { byte[] pod = Pod(SpaType.Array, 16, 16); - BinaryPrimitives.WriteUInt32LittleEndian(pod.AsSpan(8, 4), 4); // child size + BinaryPrimitives.WriteUInt32LittleEndian(pod.AsSpan(8, 4), 4); // child size BinaryPrimitives.WriteUInt32LittleEndian(pod.AsSpan(12, 4), (uint)SpaType.Double); // needs 8 Assert.IsFalse(SpaPod.TryParse(pod, out _)); @@ -195,7 +219,9 @@ public void AStringWithNoTerminator_IsStillReadRatherThanRefused() byte[] pod = new byte[8 + 8]; BinaryPrimitives.WriteUInt32LittleEndian(pod.AsSpan(0, 4), 3); BinaryPrimitives.WriteUInt32LittleEndian(pod.AsSpan(4, 4), (uint)SpaType.String); - pod[8] = (byte)'a'; pod[9] = (byte)'b'; pod[10] = (byte)'c'; + pod[8] = (byte)'a'; + pod[9] = (byte)'b'; + pod[10] = (byte)'c'; Assert.IsTrue(SpaPod.TryParse(pod, out SpaValue? value)); Assert.AreEqual("abc", ((SpaString)value!).Value); @@ -219,7 +245,8 @@ public void BytesAndBitmapsOfAnyLength_AreAcceptedIncludingEmpty() foreach (int length in (int[])[0, 1, 7, 64]) { byte[] bytes = Pod(SpaType.Bytes, (uint)length, length); - for (int i = 0; i < length; i++) bytes[8 + i] = (byte)i; + for (int i = 0; i < length; i++) + bytes[8 + i] = (byte)i; Assert.IsTrue(SpaPod.TryParse(bytes, out SpaValue? value)); Assert.AreEqual(length, ((SpaBytes)value!).Value.Length); @@ -272,7 +299,10 @@ public void AnArrayOfEveryFixedSizeType_RoundTripsAtItsOwnChildSize() foreach ((SpaType type, SpaValue item) in cases) { var array = new SpaArray(type, [item, item, item]); - Assert.IsTrue(SpaPod.TryParse(SpaPod.ToBytes(array), out SpaValue? read), $"{type} array"); + Assert.IsTrue( + SpaPod.TryParse(SpaPod.ToBytes(array), out SpaValue? read), + $"{type} array" + ); Assert.AreEqual(array, read, $"{type} array changed on the way through"); } } @@ -293,7 +323,10 @@ public void ATailShorterThanItsOwnDeclaredSize_IsRefusedRatherThanReadShort() // Ten bytes clear the eight-byte header, so the size field is known, but an int needs // twelve: neither the padded nor the unpadded extent fits, and there is no partial value. byte[] full = SpaPod.ToBytes(new SpaInt(7)); - Assert.IsTrue(SpaPod.TryParse(full, out _), "the intact pod must parse, or this proves nothing"); + Assert.IsTrue( + SpaPod.TryParse(full, out _), + "the intact pod must parse, or this proves nothing" + ); Assert.IsFalse(SpaPod.TryParse(full.AsSpan(0, 10), out _)); } @@ -317,9 +350,14 @@ public void APropertySizeThatOverflowsAnInt_IsRefusedRatherThanThrowing() { // uint.MaxValue casts to -1, making "8 + (int)size" seven and small enough to pass a // bounds check; a value near int.MaxValue overflows the same sum to negative. - foreach (uint size in (uint[])[uint.MaxValue, uint.MaxValue - 7, int.MaxValue, (uint)int.MaxValue + 1]) + foreach ( + uint size in (uint[]) + [uint.MaxValue, uint.MaxValue - 7, int.MaxValue, (uint)int.MaxValue + 1] + ) { - if (SpaPod.TryParse(PropertyOfSize(size), out SpaValue? value) && value is SpaObject obj) + if ( + SpaPod.TryParse(PropertyOfSize(size), out SpaValue? value) && value is SpaObject obj + ) Assert.AreEqual(0, obj.Properties.Length, $"size {size} produced a property"); } } @@ -373,7 +411,10 @@ public void AnObjectBodyEndingMidProperty_IsDeclinedRatherThanTruncated() builder.Pop(); byte[] whole = builder.GetPod().ToArray(); - Assert.IsTrue(SpaPod.TryParse(whole, out _), "the intact pod must parse, or this proves nothing"); + Assert.IsTrue( + SpaPod.TryParse(whole, out _), + "the intact pod must parse, or this proves nothing" + ); // Cut inside the trailing property, leaving too little for another header but more than // nothing. The pod's own size field is corrected so only the truncation is under test. @@ -382,8 +423,10 @@ public void AnObjectBodyEndingMidProperty_IsDeclinedRatherThanTruncated() byte[] cut = whole[..^lost]; BitConverter.TryWriteBytes(cut.AsSpan(0, 4), (uint)(cut.Length - 8)); - Assert.IsFalse(SpaPod.TryParse(cut, out _), - $"an object body {lost} byte(s) short parsed as though it were whole"); + Assert.IsFalse( + SpaPod.TryParse(cut, out _), + $"an object body {lost} byte(s) short parsed as though it were whole" + ); } } @@ -397,9 +440,9 @@ public void AMalformedChoiceDeclined_LeavesTheReaderWhereItFoundIt() foreach (int truncateTo in (int[])[8, 12, 16, 20]) { var body = new List(); - body.AddRange(BitConverter.GetBytes(3u)); // choiceType (Enum) - body.AddRange(BitConverter.GetBytes(0u)); // flags - body.AddRange(BitConverter.GetBytes(8u)); // childSize + body.AddRange(BitConverter.GetBytes(3u)); // choiceType (Enum) + body.AddRange(BitConverter.GetBytes(0u)); // flags + body.AddRange(BitConverter.GetBytes(8u)); // childSize body.AddRange(BitConverter.GetBytes((uint)SpaType.Long)); // Header plus body, written directly: the point is a pod whose declared size is honest @@ -412,14 +455,19 @@ public void AMalformedChoiceDeclined_LeavesTheReaderWhereItFoundIt() byte[] cut = pod[..Math.Min(truncateTo, pod.Length)]; var reader = new SpaPodReader(cut); - Assert.IsFalse(reader.TryUnwrapChoice(out _), - $"a choice truncated to {truncateTo} bytes was accepted"); + Assert.IsFalse( + reader.TryUnwrapChoice(out _), + $"a choice truncated to {truncateTo} bytes was accepted" + ); // The proof that the position came back: the same reader now reads the header it would // have read had nothing been attempted. var again = new SpaPodReader(cut); - Assert.AreEqual(again.TryUnwrapChoice(out _), reader.TryUnwrapChoice(out _), - $"a second attempt at {truncateTo} bytes behaved differently, so the position moved"); + Assert.AreEqual( + again.TryUnwrapChoice(out _), + reader.TryUnwrapChoice(out _), + $"a second attempt at {truncateTo} bytes behaved differently, so the position moved" + ); } } } diff --git a/tests/PipeWire.NET.Tests/SpaPodProjectionTests.cs b/tests/PipeWire.NET.Tests/SpaPodProjectionTests.cs index 7f11c83..0025007 100644 --- a/tests/PipeWire.NET.Tests/SpaPodProjectionTests.cs +++ b/tests/PipeWire.NET.Tests/SpaPodProjectionTests.cs @@ -27,10 +27,15 @@ private static SpaObject Format(params (SpaKey Key, SpaValue Value)[] properties new( SpaType.ObjectFormat, SpaParamType.EnumFormat, - [.. properties.Select(p => new SpaPodProperty(p.Key, 0, p.Value))]); + [.. properties.Select(p => new SpaPodProperty(p.Key, 0, p.Value))] + ); private static SpaChoice Enum(params int[] values) => - new(SpaChoiceType.Enum, SpaType.Int, [new SpaInt(values[0]), .. values.Select(v => (SpaValue)new SpaInt(v))]); + new( + SpaChoiceType.Enum, + SpaType.Int, + [new SpaInt(values[0]), .. values.Select(v => (SpaValue)new SpaInt(v))] + ); private static SpaChoice Range(int def, int min, int max) => new(SpaChoiceType.Range, SpaType.Int, [new SpaInt(def), new SpaInt(min), new SpaInt(max)]); @@ -60,7 +65,11 @@ public void TwoEnumerations_NarrowToTheirIntersection() var choice = ValueOf(got, SpaFormat.AudioRate) as SpaChoice; Assert.IsNotNull(choice, "the narrowed rate is not a choice"); - CollectionAssert.AreEquivalent(new[] { 48000, 96000 }, Members(choice!), "the intersection kept the wrong members"); + CollectionAssert.AreEquivalent( + new[] { 48000, 96000 }, + Members(choice!), + "the intersection kept the wrong members" + ); } /// When several values survive, the peer's preference leads. @@ -76,10 +85,15 @@ public void AmongSeveralSurvivors_TheFiltersPreferenceLeads() SpaObject mine = Format((SpaFormat.AudioRate, Enum(96000, 48000, 96000))); SpaObject theirs = Format((SpaFormat.AudioRate, Enum(48000, 48000, 96000))); - var choice = ValueOf(SpaPodProjection.Project(mine, theirs), SpaFormat.AudioRate) as SpaChoice; + var choice = + ValueOf(SpaPodProjection.Project(mine, theirs), SpaFormat.AudioRate) as SpaChoice; Assert.IsNotNull(choice); - Assert.AreEqual(new SpaInt(48000), choice!.Alternatives[0], "the candidate's preference led instead of the filter's"); + Assert.AreEqual( + new SpaInt(48000), + choice!.Alternatives[0], + "the candidate's preference led instead of the filter's" + ); } /// A value copied exactly once collapses to a fixed value; one copied twice does not. @@ -98,14 +112,23 @@ public void ASurvivorCopiedOnce_CollapsesToAFixedValue_AndOneCopiedTwiceDoesNot( Assert.AreEqual( new SpaInt(48000), ValueOf(SpaPodProjection.Project(fixedMine, theirs), SpaFormat.AudioRate), - "one survivor copied once should be a fixed value"); + "one survivor copied once should be a fixed value" + ); SpaObject mine = Format((SpaFormat.AudioRate, Enum(48000, 44100, 48000))); SpaObject theirsToo = Format((SpaFormat.AudioRate, Enum(48000, 48000, 192000))); - var choice = ValueOf(SpaPodProjection.Project(mine, theirsToo), SpaFormat.AudioRate) as SpaChoice; - Assert.IsNotNull(choice, "a survivor copied more than once stays an enumeration, as upstream leaves it"); - CollectionAssert.AreEquivalent(new[] { 48000 }, Members(choice!), "the only surviving value should be 48000"); + var choice = + ValueOf(SpaPodProjection.Project(mine, theirsToo), SpaFormat.AudioRate) as SpaChoice; + Assert.IsNotNull( + choice, + "a survivor copied more than once stays an enumeration, as upstream leaves it" + ); + CollectionAssert.AreEquivalent( + new[] { 48000 }, + Members(choice!), + "the only surviving value should be 48000" + ); } /// An enumeration against a range keeps only the members inside it. @@ -140,8 +163,16 @@ public void TwoRanges_NarrowToTheOverlap() var choice = ValueOf(got, SpaFormat.AudioRate) as SpaChoice; Assert.IsNotNull(choice, "two ranges should narrow to a range"); - Assert.AreEqual(new SpaInt(44100), choice!.Alternatives[1], "the low end should be the higher minimum"); - Assert.AreEqual(new SpaInt(48000), choice.Alternatives[2], "the high end should be the lower maximum"); + Assert.AreEqual( + new SpaInt(44100), + choice!.Alternatives[1], + "the low end should be the higher minimum" + ); + Assert.AreEqual( + new SpaInt(48000), + choice.Alternatives[2], + "the high end should be the lower maximum" + ); } /// Disjoint values produce nothing, which is a refusal rather than an empty answer. @@ -157,7 +188,8 @@ public void DisjointValues_ProduceNothing() Assert.IsNull( SpaPodProjection.Project(mine, theirs), - "disjoint enumerations must not produce a format"); + "disjoint enumerations must not produce a format" + ); } /// A property the filter never mentions survives untouched. @@ -166,7 +198,8 @@ public void AnUnconstrainedProperty_SurvivesUnchanged() { SpaObject mine = Format( (SpaFormat.AudioRate, Enum(44100, 48000)), - (SpaFormat.AudioChannels, new SpaInt(2))); + (SpaFormat.AudioChannels, new SpaInt(2)) + ); SpaObject theirs = Format((SpaFormat.AudioRate, Enum(48000))); @@ -176,7 +209,8 @@ public void AnUnconstrainedProperty_SurvivesUnchanged() Assert.AreEqual( new SpaInt(2), ValueOf(got, SpaFormat.AudioChannels), - "a property the filter does not mention should be carried through as it was"); + "a property the filter does not mention should be carried through as it was" + ); } /// The projection never contradicts the matcher. @@ -210,7 +244,8 @@ public void TheProjectionAndTheMatcher_NeverDisagree() Assert.AreEqual( matches, projects, - $"matcher said {matches} and projection said {projects} for {a.Kind} vs {b.Kind}"); + $"matcher said {matches} and projection said {projects} for {a.Kind} vs {b.Kind}" + ); } } } @@ -221,27 +256,55 @@ public void TheProjectionAndTheMatcher_NeverDisagree() public void TwoRanges_DefaultToTheFiltersValue_ThenTheCandidates_ThenTheMinimum() { SpaChoice Overlap(SpaChoice mine, SpaChoice theirs) => - (SpaChoice)ValueOf(SpaPodProjection.Project(Format((SpaFormat.AudioRate, mine)), Format((SpaFormat.AudioRate, theirs))), SpaFormat.AudioRate)!; - - Assert.AreEqual(new SpaInt(48000), Overlap(Range(44100, 8000, 96000), Range(48000, 22050, 192000)).Alternatives[0], - "the filter's default is in the overlap and should lead"); - Assert.AreEqual(new SpaInt(44100), Overlap(Range(44100, 8000, 96000), Range(4000, 22050, 192000)).Alternatives[0], - "the filter's default is outside the overlap, so the candidate's should lead"); - Assert.AreEqual(new SpaInt(22050), Overlap(Range(8000, 8000, 96000), Range(4000, 22050, 192000)).Alternatives[0], - "neither default is in the overlap, so its minimum should lead"); + (SpaChoice) + ValueOf( + SpaPodProjection.Project( + Format((SpaFormat.AudioRate, mine)), + Format((SpaFormat.AudioRate, theirs)) + ), + SpaFormat.AudioRate + )!; + + Assert.AreEqual( + new SpaInt(48000), + Overlap(Range(44100, 8000, 96000), Range(48000, 22050, 192000)).Alternatives[0], + "the filter's default is in the overlap and should lead" + ); + Assert.AreEqual( + new SpaInt(44100), + Overlap(Range(44100, 8000, 96000), Range(4000, 22050, 192000)).Alternatives[0], + "the filter's default is outside the overlap, so the candidate's should lead" + ); + Assert.AreEqual( + new SpaInt(22050), + Overlap(Range(8000, 8000, 96000), Range(4000, 22050, 192000)).Alternatives[0], + "neither default is in the overlap, so its minimum should lead" + ); } /// Range against a stepped range yields a plain range, as upstream writes it. [TestMethod] public void ARangeAgainstAStep_YieldsAPlainRange() { - var step = new SpaChoice(SpaChoiceType.Step, SpaType.Int, [new SpaInt(64), new SpaInt(16), new SpaInt(1024), new SpaInt(16)]); + var step = new SpaChoice( + SpaChoiceType.Step, + SpaType.Int, + [new SpaInt(64), new SpaInt(16), new SpaInt(1024), new SpaInt(16)] + ); var got = (SpaChoice?)ValueOf( - SpaPodProjection.Project(Format((SpaFormat.AudioRate, Range(256, 32, 512))), Format((SpaFormat.AudioRate, step))), - SpaFormat.AudioRate); + SpaPodProjection.Project( + Format((SpaFormat.AudioRate, Range(256, 32, 512))), + Format((SpaFormat.AudioRate, step)) + ), + SpaFormat.AudioRate + ); Assert.IsNotNull(got); - Assert.AreEqual(SpaChoiceType.Range, got!.Kind, "upstream marks the overlap of range and step as a plain range (filter.h 223)"); + Assert.AreEqual( + SpaChoiceType.Range, + got!.Kind, + "upstream marks the overlap of range and step as a plain range (filter.h 223)" + ); Assert.AreEqual(new SpaInt(32), got.Alternatives[1]); Assert.AreEqual(new SpaInt(512), got.Alternatives[2]); } @@ -252,11 +315,21 @@ public void ARangeAgainstAStep_YieldsAPlainRange() public void FlagsAgainstAnEnumeration_IsRefused() { var flags = new SpaChoice(SpaChoiceType.Flags, SpaType.Int, [new SpaInt(0b0110)]); - Assert.IsNull(SpaPodProjection.Project(Format((SpaFormat.AudioRate, flags)), Format((SpaFormat.AudioRate, Enum(2, 4))))); + Assert.IsNull( + SpaPodProjection.Project( + Format((SpaFormat.AudioRate, flags)), + Format((SpaFormat.AudioRate, Enum(2, 4))) + ) + ); var theirs = new SpaChoice(SpaChoiceType.Flags, SpaType.Int, [new SpaInt(0b0011)]); var and = (SpaChoice?)ValueOf( - SpaPodProjection.Project(Format((SpaFormat.AudioRate, flags)), Format((SpaFormat.AudioRate, theirs))), SpaFormat.AudioRate); + SpaPodProjection.Project( + Format((SpaFormat.AudioRate, flags)), + Format((SpaFormat.AudioRate, theirs)) + ), + SpaFormat.AudioRate + ); Assert.IsNotNull(and, "flags against flags is the supported pairing"); Assert.AreEqual(new SpaInt(0b0010), and!.Alternatives[0], "flags intersect by AND"); } @@ -267,31 +340,56 @@ public void FlagsAgainstAnEnumeration_IsRefused() public void AFilterOnlyProperty_IsCarriedIntoTheAnswer_UnlessMarkedDrop() { SpaObject mine = Format((SpaFormat.AudioRate, new SpaInt(48000))); - SpaObject theirs = new(SpaType.ObjectFormat, SpaParamType.EnumFormat, - [ - new SpaPodProperty(SpaFormat.AudioRate, SpaPodPropFlags.None, new SpaInt(48000)), - new SpaPodProperty(SpaFormat.AudioChannels, SpaPodPropFlags.None, new SpaInt(2)), - new SpaPodProperty(SpaFormat.AudioFormat, SpaPodPropFlags.Drop, new SpaId(283)), - ]); + SpaObject theirs = new( + SpaType.ObjectFormat, + SpaParamType.EnumFormat, + [ + new SpaPodProperty(SpaFormat.AudioRate, SpaPodPropFlags.None, new SpaInt(48000)), + new SpaPodProperty(SpaFormat.AudioChannels, SpaPodPropFlags.None, new SpaInt(2)), + new SpaPodProperty(SpaFormat.AudioFormat, SpaPodPropFlags.Drop, new SpaId(283)), + ] + ); SpaObject? got = SpaPodProjection.Project(mine, theirs); Assert.IsNotNull(got); - Assert.AreEqual(new SpaInt(2), ValueOf(got, SpaFormat.AudioChannels), "the filter-only property should be carried in"); - Assert.IsNull(got!.Find(SpaFormat.AudioFormat), "a filter-only property marked drop should be left out"); + Assert.AreEqual( + new SpaInt(2), + ValueOf(got, SpaFormat.AudioChannels), + "the filter-only property should be carried in" + ); + Assert.IsNull( + got!.Find(SpaFormat.AudioFormat), + "a filter-only property marked drop should be left out" + ); } /// The answer's property flags are the AND of both sides'. [TestMethod] public void ThePropertyFlags_AreTheAndOfBothSides() { - SpaObject mine = new(SpaType.ObjectFormat, SpaParamType.EnumFormat, - [new SpaPodProperty(SpaFormat.AudioRate, SpaPodPropFlags.Mandatory | SpaPodPropFlags.DontFixate, new SpaInt(48000))]); - SpaObject theirs = new(SpaType.ObjectFormat, SpaParamType.EnumFormat, - [new SpaPodProperty(SpaFormat.AudioRate, SpaPodPropFlags.Mandatory, new SpaInt(48000))]); + SpaObject mine = new( + SpaType.ObjectFormat, + SpaParamType.EnumFormat, + [ + new SpaPodProperty( + SpaFormat.AudioRate, + SpaPodPropFlags.Mandatory | SpaPodPropFlags.DontFixate, + new SpaInt(48000) + ), + ] + ); + SpaObject theirs = new( + SpaType.ObjectFormat, + SpaParamType.EnumFormat, + [new SpaPodProperty(SpaFormat.AudioRate, SpaPodPropFlags.Mandatory, new SpaInt(48000))] + ); - Assert.AreEqual(SpaPodPropFlags.Mandatory, SpaPodProjection.Project(mine, theirs)!.Find(SpaFormat.AudioRate)!.Flags, - "filter.h 98: flags are p1->flags & p2->flags"); + Assert.AreEqual( + SpaPodPropFlags.Mandatory, + SpaPodProjection.Project(mine, theirs)!.Find(SpaFormat.AudioRate)!.Flags, + "filter.h 98: flags are p1->flags & p2->flags" + ); } /// When the filter's own default is not valid for its own choice, the candidate leads. @@ -302,14 +400,34 @@ public void AFilterWhoseDefaultIsInvalid_LetsTheCandidateLead() // An enumeration whose default is not among its own members is not a valid choice // (compare.h checks members from index 1). Without the swap the filter's order would lead // and 44100 would win; with it, the candidate's order leads and 96000 wins. - SpaObject mine = Format((SpaFormat.AudioRate, new SpaChoice(SpaChoiceType.Enum, SpaType.Int, - [new SpaInt(96000), new SpaInt(96000), new SpaInt(44100), new SpaInt(48000)]))); - SpaObject theirs = Format((SpaFormat.AudioRate, new SpaChoice(SpaChoiceType.Enum, SpaType.Int, - [new SpaInt(44100), new SpaInt(48000), new SpaInt(96000)]))); + SpaObject mine = Format( + ( + SpaFormat.AudioRate, + new SpaChoice( + SpaChoiceType.Enum, + SpaType.Int, + [new SpaInt(96000), new SpaInt(96000), new SpaInt(44100), new SpaInt(48000)] + ) + ) + ); + SpaObject theirs = Format( + ( + SpaFormat.AudioRate, + new SpaChoice( + SpaChoiceType.Enum, + SpaType.Int, + [new SpaInt(44100), new SpaInt(48000), new SpaInt(96000)] + ) + ) + ); var got = ValueOf(SpaPodProjection.Project(mine, theirs), SpaFormat.AudioRate); Assert.IsNotNull(got, "the swap still intersects; it only changes which side leads"); SpaValue lead = got is SpaChoice c ? c.Alternatives[0] : got!; - Assert.AreEqual(new SpaInt(96000), lead, "with the filter's default invalid, the candidate's order should lead"); + Assert.AreEqual( + new SpaInt(96000), + lead, + "with the filter's default invalid, the candidate's order should lead" + ); } } diff --git a/tests/PipeWire.NET.Tests/SpaPodReaderTests.cs b/tests/PipeWire.NET.Tests/SpaPodReaderTests.cs index be95e30..ca65d54 100644 --- a/tests/PipeWire.NET.Tests/SpaPodReaderTests.cs +++ b/tests/PipeWire.NET.Tests/SpaPodReaderTests.cs @@ -28,10 +28,15 @@ private static byte[] Pod(SpaType type, ReadOnlySpan body) } private static byte[] IntPod(int v) => Pod(SpaType.Int, BitConverter.GetBytes(v)); + private static byte[] LongPod(long v) => Pod(SpaType.Long, BitConverter.GetBytes(v)); + private static byte[] FloatPod(float v) => Pod(SpaType.Float, BitConverter.GetBytes(v)); + private static byte[] DoublePod(double v) => Pod(SpaType.Double, BitConverter.GetBytes(v)); + private static byte[] BoolPod(bool v) => Pod(SpaType.Bool, BitConverter.GetBytes(v ? 1 : 0)); + private static byte[] IdPod(uint v) => Pod(SpaType.Id, BitConverter.GetBytes(v)); private static byte[] RectanglePod(uint w, uint h) @@ -80,14 +85,17 @@ public void ReadFloatAndDouble_RoundTripSpecialValues() Assert.AreEqual(0f, new SpaPodReader(FloatPod(0f)).ReadFloat()); Assert.AreEqual(float.MaxValue, new SpaPodReader(FloatPod(float.MaxValue)).ReadFloat()); Assert.IsTrue(float.IsNaN(new SpaPodReader(FloatPod(float.NaN)).ReadFloat())); - Assert.IsTrue(double.IsNegativeInfinity( - new SpaPodReader(DoublePod(double.NegativeInfinity)).ReadDouble())); + Assert.IsTrue( + double.IsNegativeInfinity( + new SpaPodReader(DoublePod(double.NegativeInfinity)).ReadDouble() + ) + ); } [TestMethod] [DataRow(0, false)] [DataRow(1, true)] - [DataRow(2, true)] // SPA writes 0/1, but anything non-zero must not read as false + [DataRow(2, true)] // SPA writes 0/1, but anything non-zero must not read as false [DataRow(-1, true)] public void ReadBool_TreatsAnyNonZeroAsTrue(int raw, bool expected) { @@ -118,10 +126,18 @@ public void ReadId_AcceptsTheFullUnsignedRange() public void ReadingTheWrongType_Throws() { // A Long pod read as an Int would otherwise silently return the low four bytes. - Assert.ThrowsExactly(() => new SpaPodReader(LongPod(1)).ReadInt()); - Assert.ThrowsExactly(() => new SpaPodReader(IntPod(1)).ReadLong()); - Assert.ThrowsExactly(() => new SpaPodReader(IntPod(1)).ReadFloat()); - Assert.ThrowsExactly(() => new SpaPodReader(IntPod(1)).ReadRectangle()); + Assert.ThrowsExactly(() => + new SpaPodReader(LongPod(1)).ReadInt() + ); + Assert.ThrowsExactly(() => + new SpaPodReader(IntPod(1)).ReadLong() + ); + Assert.ThrowsExactly(() => + new SpaPodReader(IntPod(1)).ReadFloat() + ); + Assert.ThrowsExactly(() => + new SpaPodReader(IntPod(1)).ReadRectangle() + ); } [TestMethod] @@ -140,8 +156,10 @@ public void ATruncatedHeader_IsRejectedRatherThanReadPastTheBuffer() for (int len = 0; len < 8; len++) { var reader = new SpaPodReader(new byte[len]); - Assert.IsFalse(reader.EnterObject(out _, out _, out _), - $"a {len}-byte buffer cannot contain a pod header"); + Assert.IsFalse( + reader.EnterObject(out _, out _, out _), + $"a {len}-byte buffer cannot contain a pod header" + ); } } @@ -153,8 +171,10 @@ public void APodHeaderPromisingMoreBodyThanExists_IsRejected() BitConverter.TryWriteBytes(pod.AsSpan(4, 4), (uint)SpaType.Object); var reader = new SpaPodReader(pod); - Assert.IsFalse(reader.EnterObject(out _, out _, out _), - "a pod declaring more body than the buffer holds must be refused, not reported"); + Assert.IsFalse( + reader.EnterObject(out _, out _, out _), + "a pod declaring more body than the buffer holds must be refused, not reported" + ); } [TestMethod] @@ -169,8 +189,10 @@ public void AnObjectPodDeclaringLessThanItsOwnHeader_IsRejected(uint declaredSiz BitConverter.TryWriteBytes(pod.AsSpan(4, 4), (uint)SpaType.Object); var reader = new SpaPodReader(pod); - Assert.IsFalse(reader.EnterObject(out _, out _, out uint bodySize), - $"an object declaring {declaredSize} bytes cannot hold its own type and id"); + Assert.IsFalse( + reader.EnterObject(out _, out _, out uint bodySize), + $"an object declaring {declaredSize} bytes cannot hold its own type and id" + ); Assert.AreEqual(0u, bodySize, "a refused read must not report a body size"); } @@ -189,7 +211,8 @@ public void TryReadProperty_WalksEveryPropertyThenStops() byte[] body = BuildObjectBody( (key: 1u, flags: 0u, value: IntPod(42)), (key: 2u, flags: 0u, value: LongPod(7)), - (key: 3u, flags: 0u, value: RectanglePod(640, 480))); + (key: 3u, flags: 0u, value: RectanglePod(640, 480)) + ); var reader = new SpaPodReader(body); Assert.IsTrue(reader.EnterObject(out _, out _, out _)); @@ -206,20 +229,28 @@ public void TryReadProperty_WalksEveryPropertyThenStops() Assert.AreEqual(3u, k3.Value); Assert.AreEqual((640u, 480u), v3.ReadRectangle()); - Assert.IsFalse(reader.TryReadProperty(out _, out _), "the object had exactly three properties"); + Assert.IsFalse( + reader.TryReadProperty(out _, out _), + "the object had exactly three properties" + ); } [TestMethod] public void TryReadProperty_SurfacesTheFlagsThatGateFixation() { - byte[] body = BuildObjectBody((key: 9u, flags: (uint)SpaPodPropFlags.DontFixate, value: IntPod(1))); + byte[] body = BuildObjectBody( + (key: 9u, flags: (uint)SpaPodPropFlags.DontFixate, value: IntPod(1)) + ); var reader = new SpaPodReader(body); Assert.IsTrue(reader.EnterObject(out _, out _, out _)); Assert.IsTrue(reader.TryReadProperty(out SpaKey key, out SpaPodPropFlags flags, out _)); Assert.AreEqual(9u, key.Value); - Assert.AreEqual(SpaPodPropFlags.DontFixate, flags & SpaPodPropFlags.DontFixate, - "DontFixate must survive; it is what says a modifier list is still a choice"); + Assert.AreEqual( + SpaPodPropFlags.DontFixate, + flags & SpaPodPropFlags.DontFixate, + "DontFixate must survive; it is what says a modifier list is still a choice" + ); } [TestMethod] @@ -230,11 +261,17 @@ public void TryReadProperty_StopsOnATruncatedPropertyRatherThanOverrunning() for (int cut = 8; cut < full.Length; cut++) { var reader = new SpaPodReader(full.AsSpan(0, cut).ToArray()); - if (!reader.EnterObject(out _, out _, out _)) continue; + if (!reader.EnterObject(out _, out _, out _)) + continue; // Whatever it decides, it must not throw an out-of-range: it either reads or declines. - try { _ = reader.TryReadProperty(out _, out _); } - catch (InvalidOperationException) { /* a declared type/size mismatch is a fair refusal */ } + try + { + _ = reader.TryReadProperty(out _, out _); + } + catch (InvalidOperationException) + { /* a declared type/size mismatch is a fair refusal */ + } } } @@ -264,14 +301,15 @@ public void AnEmptyBuffer_IsAtEndAndReadsNothing() private static byte[] BuildObjectBody(params (uint key, uint flags, byte[] value)[] props) { var body = new List(); - body.AddRange(BitConverter.GetBytes(0u)); // object type - body.AddRange(BitConverter.GetBytes(0u)); // object id + body.AddRange(BitConverter.GetBytes(0u)); // object type + body.AddRange(BitConverter.GetBytes(0u)); // object id foreach ((uint key, uint flags, byte[] value) in props) { body.AddRange(BitConverter.GetBytes(key)); body.AddRange(BitConverter.GetBytes(flags)); body.AddRange(value); - while (body.Count % 8 != 0) body.Add(0); + while (body.Count % 8 != 0) + body.Add(0); } return Pod(SpaType.Object, CollectionsMarshal.AsSpan(body)); } diff --git a/tests/PipeWire.NET.Tests/SpaPodValueTests.cs b/tests/PipeWire.NET.Tests/SpaPodValueTests.cs index 1d73e52..3982fda 100644 --- a/tests/PipeWire.NET.Tests/SpaPodValueTests.cs +++ b/tests/PipeWire.NET.Tests/SpaPodValueTests.cs @@ -41,7 +41,10 @@ public void EveryPrimitive_SurvivesAWriteAndAReadBack() foreach (SpaValue value in values) { byte[] bytes = SpaPod.ToBytes(value); - Assert.IsTrue(SpaPod.TryParse(bytes, out SpaValue? read), $"{value} did not parse back"); + Assert.IsTrue( + SpaPod.TryParse(bytes, out SpaValue? read), + $"{value} did not parse back" + ); Assert.AreEqual(value, read, $"{value} changed on the way through"); } } @@ -52,8 +55,12 @@ public void AnObjectOfPropertiesRoundTrips_KeepingOrderAndFlags() SpaObject original = Props( new SpaPodProperty((uint)SpaProp.Volume, 0, new SpaFloat(0.5f)), new SpaPodProperty((uint)SpaProp.Mute, SpaPodPropFlags.Mandatory, new SpaBool(true)), - new SpaPodProperty((uint)SpaProp.ChannelVolumes, 0, - new SpaArray(SpaType.Float, [new SpaFloat(0.25f), new SpaFloat(0.75f)]))); + new SpaPodProperty( + (uint)SpaProp.ChannelVolumes, + 0, + new SpaArray(SpaType.Float, [new SpaFloat(0.25f), new SpaFloat(0.75f)]) + ) + ); Assert.IsTrue(SpaPod.TryParse(SpaPod.ToBytes(original), out SpaValue? read)); var parsed = (SpaObject)read!; @@ -68,21 +75,28 @@ public void AnObjectOfPropertiesRoundTrips_KeepingOrderAndFlags() Assert.AreEqual(SpaType.Float, channels.ChildType); CollectionAssert.AreEqual( new[] { 0.25f, 0.75f }, - channels.Items.Cast().Select(f => f.Value).ToArray()); + channels.Items.Cast().Select(f => f.Value).ToArray() + ); } [TestMethod] public void AChoiceRoundTrips_AndItsFirstAlternativeIsTheDefault() { - var choice = new SpaChoice(SpaChoiceType.Range, SpaType.Int, - [new SpaInt(48000), new SpaInt(8000), new SpaInt(192000)]); + var choice = new SpaChoice( + SpaChoiceType.Range, + SpaType.Int, + [new SpaInt(48000), new SpaInt(8000), new SpaInt(192000)] + ); Assert.IsTrue(SpaPod.TryParse(SpaPod.ToBytes(choice), out SpaValue? read)); var parsed = (SpaChoice)read!; Assert.AreEqual(SpaChoiceType.Range, parsed.Kind); - Assert.AreEqual(new SpaInt(48000), parsed.Default, - "whatever the kind, the first alternative is the default or current value"); + Assert.AreEqual( + new SpaInt(48000), + parsed.Default, + "whatever the kind, the first alternative is the default or current value" + ); Assert.AreEqual(3, parsed.Alternatives.Length); } @@ -91,13 +105,19 @@ public void NestedObjectsRoundTrip_WhichIsWhatARouteNeeds() { // A device route carries its volume as a Props object nested inside the Route object, so // nesting is not a curiosity here - it is how the hardware mixer is written. - var route = new SpaObject(SpaType.ObjectParamRoute, SpaParamType.Route, - [ - new SpaPodProperty((uint)SpaParamRoute.Index, 0, new SpaInt(3)), - new SpaPodProperty((uint)SpaParamRoute.Device, 0, new SpaInt(1)), - new SpaPodProperty((uint)SpaParamRoute.Props, 0, - Props(new SpaPodProperty((uint)SpaProp.Mute, 0, new SpaBool(false)))), - ]); + var route = new SpaObject( + SpaType.ObjectParamRoute, + SpaParamType.Route, + [ + new SpaPodProperty((uint)SpaParamRoute.Index, 0, new SpaInt(3)), + new SpaPodProperty((uint)SpaParamRoute.Device, 0, new SpaInt(1)), + new SpaPodProperty( + (uint)SpaParamRoute.Props, + 0, + Props(new SpaPodProperty((uint)SpaProp.Mute, 0, new SpaBool(false))) + ), + ] + ); Assert.IsTrue(SpaPod.TryParse(SpaPod.ToBytes(route), out SpaValue? read)); var parsed = (SpaObject)read!; @@ -147,7 +167,10 @@ public void APodDeclaringMoreBodyThanTheBufferHolds_IsRefused() public void ATruncatedHeader_IsRefusedRatherThanRead() { foreach (int length in (int[])[0, 1, 7]) - Assert.IsFalse(SpaPod.TryParse(new byte[length], out _), $"{length} bytes is not a pod"); + Assert.IsFalse( + SpaPod.TryParse(new byte[length], out _), + $"{length} bytes is not a pod" + ); } [TestMethod] @@ -158,8 +181,8 @@ public void AnArrayWhoseChildSizeIsZero_DoesNotLoopForever() byte[] pod = new byte[8 + 8 + 8]; BitConverter.TryWriteBytes(pod.AsSpan(0, 4), 16u); BitConverter.TryWriteBytes(pod.AsSpan(4, 4), (uint)SpaType.Array); - BitConverter.TryWriteBytes(pod.AsSpan(8, 4), 0u); // child size - BitConverter.TryWriteBytes(pod.AsSpan(12, 4), (uint)SpaType.Int); // child type + BitConverter.TryWriteBytes(pod.AsSpan(8, 4), 0u); // child size + BitConverter.TryWriteBytes(pod.AsSpan(12, 4), (uint)SpaType.Int); // child type Assert.IsFalse(SpaPod.TryParse(pod, out _)); } @@ -189,8 +212,11 @@ public void AnUnknownPodType_IsKeptRatherThanDiscardingTheObjectAroundIt() Assert.IsTrue(SpaPod.TryParse(pod, out SpaValue? value)); var unknown = (SpaUnknown)value!; Assert.AreEqual((SpaType)0xDEAD, unknown.UnknownType); - CollectionAssert.AreEqual(new byte[] { 0x34, 0x12, 0, 0 }, unknown.Body.ToArray(), - "the bytes are kept so the value can be written back unchanged"); + CollectionAssert.AreEqual( + new byte[] { 0x34, 0x12, 0, 0 }, + unknown.Body.ToArray(), + "the bytes are kept so the value can be written back unchanged" + ); CollectionAssert.AreEqual(pod, SpaPod.ToBytes(unknown)); } @@ -200,7 +226,8 @@ public void AnObjectWithARepeatedKey_ResolvesToTheFirstAsSpaDoes() { SpaObject duplicated = Props( new SpaPodProperty((uint)SpaProp.Volume, 0, new SpaFloat(0.1f)), - new SpaPodProperty((uint)SpaProp.Volume, 0, new SpaFloat(0.9f))); + new SpaPodProperty((uint)SpaProp.Volume, 0, new SpaFloat(0.9f)) + ); Assert.IsTrue(SpaPod.TryParse(SpaPod.ToBytes(duplicated), out SpaValue? read)); Assert.AreEqual(new SpaFloat(0.1f), ((SpaObject)read!)[(uint)SpaProp.Volume]); diff --git a/tests/PipeWire.NET.Tests/SpaValueModelTests.cs b/tests/PipeWire.NET.Tests/SpaValueModelTests.cs index 777821d..06e7bad 100644 --- a/tests/PipeWire.NET.Tests/SpaValueModelTests.cs +++ b/tests/PipeWire.NET.Tests/SpaValueModelTests.cs @@ -24,24 +24,33 @@ public void TwoValuesWithTheSameContents_AreEqualEvenThoughTheirArraysAreNot() Assert.AreEqual(new SpaBitmap([9, 8]), new SpaBitmap([9, 8])); Assert.AreEqual( new SpaArray(SpaType.Int, [new SpaInt(1), new SpaInt(2)]), - new SpaArray(SpaType.Int, [new SpaInt(1), new SpaInt(2)])); + new SpaArray(SpaType.Int, [new SpaInt(1), new SpaInt(2)]) + ); Assert.AreEqual( new SpaStruct([new SpaInt(1), new SpaString("x")]), - new SpaStruct([new SpaInt(1), new SpaString("x")])); + new SpaStruct([new SpaInt(1), new SpaString("x")]) + ); Assert.AreEqual( new SpaChoice(SpaChoiceType.Enum, SpaType.Id, [new SpaId(7)]), - new SpaChoice(SpaChoiceType.Enum, SpaType.Id, [new SpaId(7)])); + new SpaChoice(SpaChoiceType.Enum, SpaType.Id, [new SpaId(7)]) + ); Assert.AreEqual( new SpaSequence(1, [new SpaControl(0, 2, new SpaFloat(0.5f))]), - new SpaSequence(1, [new SpaControl(0, 2, new SpaFloat(0.5f))])); + new SpaSequence(1, [new SpaControl(0, 2, new SpaFloat(0.5f))]) + ); + Assert.AreEqual(new SpaUnknown((SpaType)999, [4, 5]), new SpaUnknown((SpaType)999, [4, 5])); Assert.AreEqual( - new SpaUnknown((SpaType)999, [4, 5]), - new SpaUnknown((SpaType)999, [4, 5])); - Assert.AreEqual( - new SpaObject(SpaType.ObjectProps, SpaParamType.Props, - [new SpaPodProperty(1, 0, new SpaFloat(1f))]), - new SpaObject(SpaType.ObjectProps, SpaParamType.Props, - [new SpaPodProperty(1, 0, new SpaFloat(1f))])); + new SpaObject( + SpaType.ObjectProps, + SpaParamType.Props, + [new SpaPodProperty(1, 0, new SpaFloat(1f))] + ), + new SpaObject( + SpaType.ObjectProps, + SpaParamType.Props, + [new SpaPodProperty(1, 0, new SpaFloat(1f))] + ) + ); } [TestMethod] @@ -51,10 +60,10 @@ public void ChildTypeAndItemsMustAgree() // so a mismatch is not something the format can express. Refused where it is written rather // than at write time, where the value is truncated or padded to fit and the result is a // wrong number rather than an error. - Assert.ThrowsExactly( - () => new SpaArray(SpaType.Id, [new SpaInt(1)])); - Assert.ThrowsExactly( - () => new SpaChoice(SpaChoiceType.Enum, SpaType.Long, [new SpaInt(1)])); + Assert.ThrowsExactly(() => new SpaArray(SpaType.Id, [new SpaInt(1)])); + Assert.ThrowsExactly(() => + new SpaChoice(SpaChoiceType.Enum, SpaType.Long, [new SpaInt(1)]) + ); // An empty union declares a type and carries nothing, which is legal and is what a choice // with no alternatives parses as. @@ -74,17 +83,28 @@ public void ValuesThatDifferInAnyPart_AreNotEqual() // anything else. Assert.AreNotEqual( new SpaArray(SpaType.Int, [new SpaInt(1)]), - new SpaArray(SpaType.Id, [new SpaId(1)])); + new SpaArray(SpaType.Id, [new SpaId(1)]) + ); // Same three values, different kind: the kind is what says how to read the positions, so // these two describe different things and must not compare equal. Assert.AreNotEqual( - new SpaChoice(SpaChoiceType.Enum, SpaType.Int, [new SpaInt(1), new SpaInt(0), new SpaInt(9)]), - new SpaChoice(SpaChoiceType.Range, SpaType.Int, [new SpaInt(1), new SpaInt(0), new SpaInt(9)])); + new SpaChoice( + SpaChoiceType.Enum, + SpaType.Int, + [new SpaInt(1), new SpaInt(0), new SpaInt(9)] + ), + new SpaChoice( + SpaChoiceType.Range, + SpaType.Int, + [new SpaInt(1), new SpaInt(0), new SpaInt(9)] + ) + ); Assert.AreNotEqual( new SpaObject(SpaType.ObjectProps, SpaParamType.Props, []), - new SpaObject(SpaType.ObjectProps, SpaParamType.Format, [])); + new SpaObject(SpaType.ObjectProps, SpaParamType.Format, []) + ); Assert.AreNotEqual(new SpaUnknown((SpaType)1, [1]), new SpaUnknown((SpaType)2, [1])); Assert.AreNotEqual(new SpaSequence(1, []), new SpaSequence(2, [])); @@ -99,8 +119,10 @@ public void EqualValues_HashTheSame_SoTheyWorkAsDictionaryKeys() [new SpaBytes([7, 7])] = "bytes", }; - Assert.AreEqual("volumes", - seen[new SpaArray(SpaType.Float, [new SpaFloat(0.5f), new SpaFloat(0.25f)])]); + Assert.AreEqual( + "volumes", + seen[new SpaArray(SpaType.Float, [new SpaFloat(0.5f), new SpaFloat(0.25f)])] + ); Assert.AreEqual("bytes", seen[new SpaBytes([7, 7])]); } @@ -134,16 +156,23 @@ public void TheRarerPodTypes_StillRoundTrip() SpaValue[] values = [ new SpaBitmap([0b1010_1010, 0b0101_0101]), - new SpaSequence(1, [new SpaControl(0, 2, new SpaFloat(0.5f)), - new SpaControl(480, 2, new SpaFloat(1.0f))]), + new SpaSequence( + 1, + [ + new SpaControl(0, 2, new SpaFloat(0.5f)), + new SpaControl(480, 2, new SpaFloat(1.0f)), + ] + ), new SpaNestedPod(new SpaInt(42)), new SpaStruct([new SpaInt(1), new SpaString("two"), new SpaFloat(3f)]), ]; foreach (SpaValue value in values) { - Assert.IsTrue(SpaPod.TryParse(SpaPod.ToBytes(value), out SpaValue? read), - $"{value.GetType().Name} did not parse back"); + Assert.IsTrue( + SpaPod.TryParse(SpaPod.ToBytes(value), out SpaValue? read), + $"{value.GetType().Name} did not parse back" + ); Assert.AreEqual(value, read, $"{value.GetType().Name} changed on the way through"); } } @@ -167,9 +196,16 @@ public void AnEmptyChoice_HasNoDefaultRatherThanThrowing() var empty = new SpaChoice(SpaChoiceType.Enum, SpaType.Int, []); Assert.IsNull(empty.Default); - var populated = new SpaChoice(SpaChoiceType.Step, SpaType.Int, - [new SpaInt(5), new SpaInt(0), new SpaInt(10), new SpaInt(1)]); - Assert.AreEqual(new SpaInt(5), populated.Default, "the first entry is the default, whatever the kind"); + var populated = new SpaChoice( + SpaChoiceType.Step, + SpaType.Int, + [new SpaInt(5), new SpaInt(0), new SpaInt(10), new SpaInt(1)] + ); + Assert.AreEqual( + new SpaInt(5), + populated.Default, + "the first entry is the default, whatever the kind" + ); } [TestMethod] @@ -265,11 +301,18 @@ public void RawKeysAndIds_AreCarriedThroughUnchanged() [TestMethod] public void FindingAPropertyIsByKeyAlone_WhicheverEnumSpelledIt() { - var props = new SpaObject(SpaType.ObjectProps, SpaParamType.Props, - [ - new SpaPodProperty((uint)SpaProp.Volume, 0, new SpaFloat(0.5f)), - new SpaPodProperty((uint)SpaProp.Mute, SpaPodPropFlags.Readonly, new SpaBool(false)), - ]); + var props = new SpaObject( + SpaType.ObjectProps, + SpaParamType.Props, + [ + new SpaPodProperty((uint)SpaProp.Volume, 0, new SpaFloat(0.5f)), + new SpaPodProperty( + (uint)SpaProp.Mute, + SpaPodPropFlags.Readonly, + new SpaBool(false) + ), + ] + ); Assert.AreEqual(new SpaFloat(0.5f), props[SpaProp.Volume]); Assert.AreEqual(SpaPodPropFlags.Readonly, props.Find(SpaProp.Mute)!.Flags); @@ -285,7 +328,8 @@ public void ARangeChoiceWithoutItsThreeValues_IsRefused() // either its minimum or its maximum and there is nothing in the pod to say which, so the // daemon reads whatever is at position 1 as the minimum. ArgumentException e = Assert.ThrowsExactly(() => - _ = new SpaChoice(SpaChoiceType.Range, SpaType.Int, [new SpaInt(1), new SpaInt(0)])); + _ = new SpaChoice(SpaChoiceType.Range, SpaType.Int, [new SpaInt(1), new SpaInt(0)]) + ); StringAssert.Contains(e.Message, "exactly 3"); } @@ -293,13 +337,18 @@ public void ARangeChoiceWithoutItsThreeValues_IsRefused() [TestMethod] public void AStepChoiceWithoutItsFourValues_IsRefused() => Assert.ThrowsExactly(() => - _ = new SpaChoice(SpaChoiceType.Step, SpaType.Int, - [new SpaInt(1), new SpaInt(0), new SpaInt(9)])); + _ = new SpaChoice( + SpaChoiceType.Step, + SpaType.Int, + [new SpaInt(1), new SpaInt(0), new SpaInt(9)] + ) + ); [TestMethod] public void ANoneChoiceCarryingMoreThanOneValue_IsRefused() => Assert.ThrowsExactly(() => - _ = new SpaChoice(SpaChoiceType.None, SpaType.Int, [new SpaInt(1), new SpaInt(2)])); + _ = new SpaChoice(SpaChoiceType.None, SpaType.Int, [new SpaInt(1), new SpaInt(2)]) + ); [TestMethod] public void TheChoicesWhoseArityIsNotFixed_TakeAnyCount() @@ -307,11 +356,22 @@ public void TheChoicesWhoseArityIsNotFixed_TakeAnyCount() // The other side of the check: Enum and Flags are lists, so a count rule would refuse pods // the daemon sends. An empty choice is refused by the writers, not here. _ = new SpaChoice(SpaChoiceType.Enum, SpaType.Int, [new SpaInt(1)]); - _ = new SpaChoice(SpaChoiceType.Enum, SpaType.Int, [new SpaInt(1), new SpaInt(2), new SpaInt(3)]); + _ = new SpaChoice( + SpaChoiceType.Enum, + SpaType.Int, + [new SpaInt(1), new SpaInt(2), new SpaInt(3)] + ); _ = new SpaChoice(SpaChoiceType.Flags, SpaType.Int, [new SpaInt(1), new SpaInt(2)]); _ = new SpaChoice(SpaChoiceType.None, SpaType.Int, [new SpaInt(1)]); - _ = new SpaChoice(SpaChoiceType.Range, SpaType.Int, [new SpaInt(5), new SpaInt(0), new SpaInt(9)]); - _ = new SpaChoice(SpaChoiceType.Step, SpaType.Int, - [new SpaInt(5), new SpaInt(0), new SpaInt(9), new SpaInt(1)]); + _ = new SpaChoice( + SpaChoiceType.Range, + SpaType.Int, + [new SpaInt(5), new SpaInt(0), new SpaInt(9)] + ); + _ = new SpaChoice( + SpaChoiceType.Step, + SpaType.Int, + [new SpaInt(5), new SpaInt(0), new SpaInt(9), new SpaInt(1)] + ); } } diff --git a/tests/PipeWire.NET.Tests/StreamCoreThreadingTests.cs b/tests/PipeWire.NET.Tests/StreamCoreThreadingTests.cs index 99cc692..9e64503 100644 --- a/tests/PipeWire.NET.Tests/StreamCoreThreadingTests.cs +++ b/tests/PipeWire.NET.Tests/StreamCoreThreadingTests.cs @@ -20,16 +20,27 @@ public sealed class StreamCoreThreadingTests [TestMethod] public void RealtimeProcessing_IsRefusedForStreams() { - Assert.ThrowsExactly( - () => PipeWireStreamCore.RequireLoopThreadProcess(PipeWireStreamFlags.RtProcess | PipeWireStreamFlags.MapBuffers)); + Assert.ThrowsExactly(() => + PipeWireStreamCore.RequireLoopThreadProcess( + PipeWireStreamFlags.RtProcess | PipeWireStreamFlags.MapBuffers + ) + ); } [TestMethod] public void TheFlagsTheStreamsUse_AreAccepted() { - PipeWireStreamCore.RequireLoopThreadProcess(PipeWireStreamFlags.MapBuffers | PipeWireStreamFlags.Autoconnect); PipeWireStreamCore.RequireLoopThreadProcess( - PipeWireStreamFlags.Autoconnect | PipeWireStreamFlags.MapBuffers | PipeWireStreamFlags.DontReconnect | PipeWireStreamFlags.Driver); - PipeWireStreamCore.RequireLoopThreadProcess(PipeWireStreamFlags.Inactive | PipeWireStreamFlags.AllocBuffers); + PipeWireStreamFlags.MapBuffers | PipeWireStreamFlags.Autoconnect + ); + PipeWireStreamCore.RequireLoopThreadProcess( + PipeWireStreamFlags.Autoconnect + | PipeWireStreamFlags.MapBuffers + | PipeWireStreamFlags.DontReconnect + | PipeWireStreamFlags.Driver + ); + PipeWireStreamCore.RequireLoopThreadProcess( + PipeWireStreamFlags.Inactive | PipeWireStreamFlags.AllocBuffers + ); } } diff --git a/tests/PipeWire.NET.Tests/StreamTransportContractTests.cs b/tests/PipeWire.NET.Tests/StreamTransportContractTests.cs index e6dc89f..446cbe5 100644 --- a/tests/PipeWire.NET.Tests/StreamTransportContractTests.cs +++ b/tests/PipeWire.NET.Tests/StreamTransportContractTests.cs @@ -45,8 +45,14 @@ public sealed partial class StreamTransportContractTests : PipeWireTestBase { foreach (string line in File.ReadLines($"/proc/self/fdinfo/{fd}")) { - if (line.StartsWith("ino:", StringComparison.Ordinal) - && ulong.TryParse(line.AsSpan(4).Trim(), System.Globalization.CultureInfo.InvariantCulture, out ulong ino)) + if ( + line.StartsWith("ino:", StringComparison.Ordinal) + && ulong.TryParse( + line.AsSpan(4).Trim(), + System.Globalization.CultureInfo.InvariantCulture, + out ulong ino + ) + ) { return ino; } @@ -65,8 +71,10 @@ public sealed partial class StreamTransportContractTests : PipeWireTestBase private static GbmAllocator RequireGbm() { - if (!OperatingSystem.IsLinux()) Assert.Inconclusive("PipeWire is a Linux daemon."); - if (!File.Exists("/dev/dri/renderD128")) Assert.Inconclusive("No GPU render node."); + if (!OperatingSystem.IsLinux()) + Assert.Inconclusive("PipeWire is a Linux daemon."); + if (!File.Exists("/dev/dri/renderD128")) + Assert.Inconclusive("No GPU render node."); try { @@ -93,17 +101,27 @@ private static GbmAllocator RequireGbm() [TestCategory("RequiresDaemon")] public async Task AProducersPixels_ReachTheConsumerUnaltered() { - if (!OperatingSystem.IsLinux()) Assert.Inconclusive("PipeWire is a Linux daemon."); + if (!OperatingSystem.IsLinux()) + Assert.Inconclusive("PipeWire is a Linux daemon."); using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(45)); - await using var ctx = new PipeWireContext("pwnet-content", ConsoleTestLoggerFactory.Instance); + await using var ctx = new PipeWireContext( + "pwnet-content", + ConsoleTestLoggerFactory.Instance + ); await ctx.StartAsync(cts.Token); var produced = 0; await using var output = new PipeWireVideoOutput( - ctx, "pwnet-content-src", Width, Height, PixelFormat.Bgra, 30); + ctx, + "pwnet-content-src", + Width, + Height, + PixelFormat.Bgra, + 30 + ); output.FillFrame += (_, pixels, stride, width, height, _) => { @@ -113,7 +131,8 @@ public async Task AProducersPixels_ReachTheConsumerUnaltered() for (var y = 0; y < height; y++) { Span row = pixels.Slice(y * stride, width * 4); - for (var x = 0; x < row.Length; x++) row[x] = (byte)(tag ^ (x & 0xFF)); + for (var x = 0; x < row.Length; x++) + row[x] = (byte)(tag ^ (x & 0xFF)); } return true; @@ -125,7 +144,8 @@ public async Task AProducersPixels_ReachTheConsumerUnaltered() for (var i = 0; i < 60 && nodeId is null; i++) { nodeId = output.NodeId; - if (nodeId is null) await Task.Delay(50, cts.Token); + if (nodeId is null) + await Task.Delay(50, cts.Token); } Assert.IsNotNull(nodeId, "the producer was never given a node id"); @@ -136,7 +156,8 @@ public async Task AProducersPixels_ReachTheConsumerUnaltered() await using var capture = new PipeWireVideoCapture(ctx, "pwnet-content-sink"); capture.FrameReady += (_, f) => { - if (f.Pixels.IsEmpty || f.Width != Width || f.Height != Height) return; + if (f.Pixels.IsEmpty || f.Width != Width || f.Height != Height) + return; // Recover the tag from the first pixel, then check the whole frame agrees with it. byte tag = (byte)(f.Pixels[0] ^ 0); @@ -164,7 +185,8 @@ public async Task AProducersPixels_ReachTheConsumerUnaltered() Assert.IsTrue( Volatile.Read(ref verified) > 5, - $"only {Volatile.Read(ref verified)} frames arrived intact"); + $"only {Volatile.Read(ref verified)} frames arrived intact" + ); } /// @@ -194,18 +216,29 @@ public async Task TheConsumersFrame_IsTheProducersGpuBuffer() try { - await using var ctx = new PipeWireContext("pwnet-zerocopy", ConsoleTestLoggerFactory.Instance); + await using var ctx = new PipeWireContext( + "pwnet-zerocopy", + ConsoleTestLoggerFactory.Instance + ); await ctx.StartAsync(cts.Token); long modifier = (long)GbmAllocator.LinearModifier; await using var output = new PipeWireVideoOutput( - ctx, "pwnet-zerocopy-src", Width, Height, PixelFormat.Bgra, 30); + ctx, + "pwnet-zerocopy-src", + Width, + Height, + PixelFormat.Bgra, + 30 + ); output.AllocateDmaBuf += (_, index, _, _, _, _, planes) => { - if (index >= PoolCap) return 0; - while (buffers.Count <= index) buffers.Add(gbm.CreateBgra(Width, Height)); + if (index >= PoolCap) + return 0; + while (buffers.Count <= index) + buffers.Add(gbm.CreateBgra(Width, Height)); GbmAllocator.Buffer b = buffers[index]; planes[0] = new VideoPlane(b.Fd, b.Offset, b.Stride, b.Size); return 1; @@ -218,7 +251,8 @@ public async Task TheConsumersFrame_IsTheProducersGpuBuffer() for (var i = 0; i < 60 && nodeId is null; i++) { nodeId = output.NodeId; - if (nodeId is null) await Task.Delay(50, cts.Token); + if (nodeId is null) + await Task.Delay(50, cts.Token); } Assert.IsNotNull(nodeId, "the producer was never given a node id"); @@ -235,30 +269,40 @@ public async Task TheConsumersFrame_IsTheProducersGpuBuffer() for (var i = 0; i < 120 && !got; i++) { got = capture.TryGetBorrowedFrame(out frame) && frame.IsFdBacked; - if (!got) await Task.Delay(50, cts.Token); + if (!got) + await Task.Delay(50, cts.Token); } Assert.IsTrue(got, "no fd-backed frame ever arrived"); ulong? consumerInode = InodeOf((int)frame[0].Fd); - Assert.IsNotNull(consumerInode, "the consumer's descriptor could not be stat'd, so it is not live"); + Assert.IsNotNull( + consumerInode, + "the consumer's descriptor could not be stat'd, so it is not live" + ); var producerInodes = new HashSet(); foreach (GbmAllocator.Buffer b in buffers) { - if (InodeOf((int)b.Fd) is { } ino) producerInodes.Add(ino); + if (InodeOf((int)b.Fd) is { } ino) + producerInodes.Add(ino); } - Assert.IsTrue(producerInodes.Count > 0, "none of the producer's buffers could be stat'd"); + Assert.IsTrue( + producerInodes.Count > 0, + "none of the producer's buffers could be stat'd" + ); Assert.IsTrue( producerInodes.Contains(consumerInode!.Value), "the consumer's frame does not name any buffer the producer allocated, so the path " - + "copied rather than passing the GPU buffer through"); + + "copied rather than passing the GPU buffer through" + ); } finally { - foreach (GbmAllocator.Buffer b in buffers) b.Dispose(); + foreach (GbmAllocator.Buffer b in buffers) + b.Dispose(); } } @@ -276,12 +320,17 @@ public async Task TheConsumersFrame_IsTheProducersGpuBuffer() [TestCategory("RequiresDaemon")] public async Task AProducersSamples_ReachTheConsumerUnalteredAndStamped() { - if (!OperatingSystem.IsLinux()) Assert.Inconclusive("PipeWire is a Linux daemon."); + if (!OperatingSystem.IsLinux()) + Assert.Inconclusive("PipeWire is a Linux daemon."); using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(45)); - const int rate = 48000, channels = 1; + const int rate = 48000, + channels = 1; - await using var ctx = new PipeWireContext("pwnet-audiocontent", ConsoleTestLoggerFactory.Instance); + await using var ctx = new PipeWireContext( + "pwnet-audiocontent", + ConsoleTestLoggerFactory.Instance + ); await ctx.StartAsync(cts.Token); string nodeName = $"pwnet-audiocontent-{Environment.ProcessId}"; @@ -290,12 +339,18 @@ public async Task AProducersSamples_ReachTheConsumerUnalteredAndStamped() uint next = 0; await using var output = new PipeWireAudioOutput( - ctx, nodeName, rate, channels, AudioSampleFormat.F32Le); + ctx, + nodeName, + rate, + channels, + AudioSampleFormat.F32Le + ); output.FillSamples += (_, samples, _, _, _) => { Span floats = MemoryMarshal.Cast(samples); - for (var i = 0; i < floats.Length; i++) floats[i] = next++; + for (var i = 0; i < floats.Length; i++) + floats[i] = next++; return samples.Length; }; @@ -308,41 +363,65 @@ public async Task AProducersSamples_ReachTheConsumerUnalteredAndStamped() await using var capture = new PipeWireAudioCapture(ctx, $"{nodeName}-sink"); capture.FrameReady += (_, f) => { - if (f.SampleRate != rate || f.Channels != channels) { Interlocked.Increment(ref badRate); return; } + if (f.SampleRate != rate || f.Channels != channels) + { + Interlocked.Increment(ref badRate); + return; + } // The queued time, not the header timestamp: no audio converter copies the header, so an // audio consumer stamps its packets with the cycle time its buffer was queued in. - if (f.QueuedTimeNs is > 0) Interlocked.Increment(ref stampedFrames); + if (f.QueuedTimeNs is > 0) + Interlocked.Increment(ref stampedFrames); ReadOnlySpan floats = MemoryMarshal.Cast(f.Samples); - lock (got) { if (got.Count < 20000) foreach (float v in floats) got.Add(v); } + lock (got) + { + if (got.Count < 20000) + foreach (float v in floats) + got.Add(v); + } }; - capture.Connect((await output.WaitForNodeIdAsync(cts.Token)), sampleRate: rate, channels: channels, - format: AudioSampleFormat.F32Le); + capture.Connect( + (await output.WaitForNodeIdAsync(cts.Token)), + sampleRate: rate, + channels: channels, + format: AudioSampleFormat.F32Le + ); await capture.WaitForStreamingAsync(cts.Token); await Task.Delay(800, cts.Token); float[] samplesSeen; - lock (got) samplesSeen = [.. got]; + lock (got) + samplesSeen = [.. got]; - Assert.AreEqual(0, Volatile.Read(ref badRate), "a frame arrived with the wrong rate or channel count"); + Assert.AreEqual( + 0, + Volatile.Read(ref badRate), + "a frame arrived with the wrong rate or channel count" + ); Assert.IsTrue(samplesSeen.Length > 2000, $"only {samplesSeen.Length} samples arrived"); int start = 0; - while (start < samplesSeen.Length && samplesSeen[start] == 0f) start++; + while (start < samplesSeen.Length && samplesSeen[start] == 0f) + start++; Assert.IsTrue(samplesSeen.Length - start > 1000, "the stream was silence throughout"); var breaks = 0; for (int i = start + 1; i < samplesSeen.Length; i++) - if (samplesSeen[i] != samplesSeen[i - 1] + 1f) breaks++; + if (samplesSeen[i] != samplesSeen[i - 1] + 1f) + breaks++; Assert.AreEqual( - 0, breaks, - $"the sample sequence broke {breaks} times, so audio was lost or duplicated in transit"); + 0, + breaks, + $"the sample sequence broke {breaks} times, so audio was lost or duplicated in transit" + ); Assert.IsTrue( Volatile.Read(ref stampedFrames) > 0, - "no audio frame carried a queued time, so a sender could not stamp its packets"); + "no audio frame carried a queued time, so a sender could not stamp its packets" + ); } } diff --git a/tests/PipeWire.NET.Tests/StreamingSurfaceTests.cs b/tests/PipeWire.NET.Tests/StreamingSurfaceTests.cs index 50bcb10..09b8cbf 100644 --- a/tests/PipeWire.NET.Tests/StreamingSurfaceTests.cs +++ b/tests/PipeWire.NET.Tests/StreamingSurfaceTests.cs @@ -37,7 +37,11 @@ private static void RequireLinux() var ctx = new PipeWireContext(name, ConsoleTestLoggerFactory.Instance); await ctx.StartAsync(); GstTestSource src = await GstTestSource.StartAsync( - ctx, name + "-src", Pipeline, mediaClass: "Video/Source"); + ctx, + name + "-src", + Pipeline, + mediaClass: "Video/Source" + ); return (ctx, src); } @@ -63,7 +67,8 @@ public async Task AnOwnedFrame_CarriesWhatAConsumerPullsItFor() PulledVideoFrame? frame = null; for (int i = 0; i < 100; i++) { - if (cap.TryGetFrame(out frame)) break; + if (cap.TryGetFrame(out frame)) + break; await Task.Delay(50, cts.Token); } @@ -74,7 +79,10 @@ public async Task AnOwnedFrame_CarriesWhatAConsumerPullsItFor() Assert.AreEqual(240, frame.Height); Assert.AreNotEqual(0UL, frame.SequenceNumber); Assert.AreNotEqual( - DrmFormat.Invalid, frame.DrmFourcc, "a frame with no fourcc cannot be imported"); + DrmFormat.Invalid, + frame.DrmFourcc, + "a frame with no fourcc cannot be imported" + ); } // Taking clears it, so the same frame must not come back a second time. @@ -105,7 +113,8 @@ public async Task ABorrowedFrame_ArrivesAndDescribesItself() for (int i = 0; i < 100 && !got; i++) { got = cap.TryGetBorrowedFrame(out frame); - if (!got) await Task.Delay(50, cts.Token); + if (!got) + await Task.Delay(50, cts.Token); } Assert.IsTrue(got, "borrowed retention was on but nothing was ever retained"); @@ -134,7 +143,11 @@ public async Task AStreamingCapture_ReportsItsQueueAndClock() await using var cap = new PipeWireVideoCapture(ctx, "pwnet-surface-queue-sink"); cap.FrameReady += (_, f) => { - lock (pts) { if (pts.Count < 64 && f.PresentationTimestampNs is { } t) pts.Add(t); } + lock (pts) + { + if (pts.Count < 64 && f.PresentationTimestampNs is { } t) + pts.Add(t); + } }; cap.Connect(src.NodeId); await cap.WaitForStreamingAsync(cts.Token); @@ -149,16 +162,22 @@ public async Task AStreamingCapture_ReportsItsQueueAndClock() // SPA_META_Header anyway", gstpipewiresink.c), so the graph clock legitimately stands // still here. What upstream provides for video is a per-frame header timestamp. long[] seen; - lock (pts) seen = [.. pts]; + lock (pts) + seen = [.. pts]; - Assert.IsTrue(seen.Length > 1, $"only {seen.Length} frames carried a presentation time"); + Assert.IsTrue( + seen.Length > 1, + $"only {seen.Length} frames carried a presentation time" + ); Assert.IsTrue( seen[^1] > seen[0], - $"the frames' presentation times did not advance while streaming ({seen[0]} then {seen[^1]})"); + $"the frames' presentation times did not advance while streaming ({seen[0]} then {seen[^1]})" + ); // An ordinary consumer is not lazily scheduled, and if it were it would still have to // report a queue - the two answers must at least be consistent with each other. - if (cap.IsLazy) Assert.IsNotNull(cap.Queue, "a lazy stream must still report its queue"); + if (cap.IsLazy) + Assert.IsNotNull(cap.Queue, "a lazy stream must still report its queue"); } } @@ -177,10 +196,9 @@ public async Task UpdatingPropertiesOnALiveStream_IsAccepted() cap.Connect(src.NodeId); await cap.WaitForStreamingAsync(cts.Token); - int changed = cap.UpdateProperties(new Dictionary - { - ["media.name"] = "renamed-while-running", - }); + int changed = cap.UpdateProperties( + new Dictionary { ["media.name"] = "renamed-while-running" } + ); Assert.IsTrue(changed > 0, "the daemon reported no property change"); @@ -193,7 +211,8 @@ public async Task UpdatingPropertiesOnALiveStream_IsAccepted() Assert.IsTrue( Volatile.Read(ref after) > 0, - "no frame arrived after the stream was retagged"); + "no frame arrived after the stream was retagged" + ); } } @@ -216,14 +235,18 @@ public async Task SkippingFrames_DoesNotStopTheStream() cap.FrameReady += (s, _) => { // Every other frame goes back unused. - if (Interlocked.Increment(ref seen) % 2 == 0) s.SkipCurrentFrame(); + if (Interlocked.Increment(ref seen) % 2 == 0) + s.SkipCurrentFrame(); }; cap.Connect(src.NodeId); await cap.WaitForStreamingAsync(cts.Token); await Task.Delay(700, cts.Token); - Assert.IsTrue(Volatile.Read(ref seen) > 4, "too few frames arrived while skipping half"); + Assert.IsTrue( + Volatile.Read(ref seen) > 4, + "too few frames arrived while skipping half" + ); // And it is still delivering after all that skipping, which returning a buffer unused // must not disturb. @@ -232,7 +255,8 @@ public async Task SkippingFrames_DoesNotStopTheStream() Assert.IsTrue( Volatile.Read(ref seen) > atRest, - "frames stopped arriving after buffers were skipped"); + "frames stopped arriving after buffers were skipped" + ); } } @@ -271,20 +295,26 @@ public async Task ARenegotiationAGstProducerMayNotAnswer_LeavesThisSideUsable() for (var i = 0; i < 100 && Volatile.Read(ref frames) == 0; i++) await Task.Delay(50, cts.Token); - Assert.IsTrue(Volatile.Read(ref frames) > 0, "the gst producer never delivered a frame"); + Assert.IsTrue( + Volatile.Read(ref frames) > 0, + "the gst producer never delivered a frame" + ); PixelFormat[] formats = [PixelFormat.Bgra]; Assert.IsTrue( cap.RequestFormat(formats, 160, 120), - "the renegotiation offer was refused outright"); + "the renegotiation offer was refused outright" + ); // Long enough for the peer to either re-settle the link or wedge. Either is a pass; // what is not is this side being taken down with it. await Task.Delay(TimeSpan.FromSeconds(3), cts.Token); Assert.AreNotEqual( - PipeWireStreamState.Error, last, - "a peer that did not answer the renegotiation put this stream into Error"); + PipeWireStreamState.Error, + last, + "a peer that did not answer the renegotiation put this stream into Error" + ); Assert.IsFalse(ctx.IsDisposed, "the context did not survive the renegotiation"); } @@ -296,7 +326,8 @@ public async Task ARenegotiationAGstProducerMayNotAnswer_LeavesThisSideUsable() Assert.IsTrue( reg.Current.Nodes.Any(), - "the connection stopped serving after a renegotiation the peer did not answer"); + "the connection stopped serving after a renegotiation the peer did not answer" + ); } } @@ -317,11 +348,20 @@ public async Task RequestingADifferentFormat_IsSentAndSurvived() RequireLinux(); using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(40)); - await using var ctx = new PipeWireContext("pwnet-surface-reneg", ConsoleTestLoggerFactory.Instance); + await using var ctx = new PipeWireContext( + "pwnet-surface-reneg", + ConsoleTestLoggerFactory.Instance + ); await ctx.StartAsync(cts.Token); await using var src = new PipeWireVideoOutput( - ctx, "pwnet-surface-reneg-src", 320, 240, PixelFormat.Bgra, 30); + ctx, + "pwnet-surface-reneg-src", + 320, + 240, + PixelFormat.Bgra, + 30 + ); src.FillFrame += (_, pixels, _, _, _, _) => { @@ -335,7 +375,8 @@ public async Task RequestingADifferentFormat_IsSentAndSurvived() for (var i = 0; i < 100 && nodeId is null; i++) { nodeId = src.NodeId; - if (nodeId is null) await Task.Delay(50, cts.Token); + if (nodeId is null) + await Task.Delay(50, cts.Token); } Assert.IsNotNull(nodeId, "the producer was never given a node id"); @@ -349,8 +390,10 @@ public async Task RequestingADifferentFormat_IsSentAndSurvived() cap.FrameReady += (_, _) => { - if (Volatile.Read(ref renegotiated)) Interlocked.Increment(ref afterReneg); - else Interlocked.Increment(ref before); + if (Volatile.Read(ref renegotiated)) + Interlocked.Increment(ref afterReneg); + else + Interlocked.Increment(ref before); }; cap.Connect(nodeId!.Value, [PixelFormat.Bgra]); @@ -361,7 +404,8 @@ public async Task RequestingADifferentFormat_IsSentAndSurvived() Assert.IsTrue( Volatile.Read(ref before) > 0, - "no frame arrived before the renegotiation, so there was nothing to interrupt"); + "no frame arrived before the renegotiation, so there was nothing to interrupt" + ); cap.StateChanged += (_, _, state) => states.Enqueue(state); Volatile.Write(ref renegotiated, true); @@ -371,7 +415,9 @@ public async Task RequestingADifferentFormat_IsSentAndSurvived() // not that the producer can be made to change size. PixelFormat[] formats = [PixelFormat.Bgra]; Assert.IsTrue( - cap.RequestFormat(formats, 160, 120), "the renegotiation offer was refused outright"); + cap.RequestFormat(formats, 160, 120), + "the renegotiation offer was refused outright" + ); // Survived means frames resume. A renegotiation takes the peer through a new format and new // buffers, which under load takes longer than any fixed window this used to assume (600 ms, @@ -383,7 +429,8 @@ public async Task RequestingADifferentFormat_IsSentAndSurvived() Assert.IsTrue( Volatile.Read(ref afterReneg) > 0, "no frame arrived in 10 s after a renegotiation request, so the stream did not survive it; " - + $"states since: {string.Join(" -> ", states)}"); + + $"states since: {string.Join(" -> ", states)}" + ); } [TestMethod] @@ -403,7 +450,8 @@ public async Task AMemFdFrame_MapsToItsOwnPixelsAtItsOffset() await using var cap = new PipeWireVideoCapture(ctx, "pwnet-surface-memfd-sink"); cap.FrameReady += (_, f) => { - if (f.Pixels.IsEmpty) return; + if (f.Pixels.IsEmpty) + return; if (f.BufferType != PipeWireBufferType.MemFd || f.Fd < 0) { Interlocked.Increment(ref other); @@ -411,18 +459,23 @@ public async Task AMemFdFrame_MapsToItsOwnPixelsAtItsOffset() } string? wrong = GraphSurfaceTests.CompareMappedPixels(f); - if (wrong is null) Interlocked.Increment(ref mapped); - else lock (failures) failures.Add(wrong); + if (wrong is null) + Interlocked.Increment(ref mapped); + else + lock (failures) + failures.Add(wrong); }; cap.Connect(src.NodeId); await cap.WaitForStreamingAsync(cts.Token); await Task.Delay(1000, cts.Token); - lock (failures) Assert.AreEqual(0, failures.Count, string.Join("; ", failures.Distinct())); + lock (failures) + Assert.AreEqual(0, failures.Count, string.Join("; ", failures.Distinct())); Assert.IsTrue( Volatile.Read(ref mapped) > 5, - $"only {mapped} MemFd frames were mapped ({other} frames of another type), so GStreamer's memfd path was not exercised"); + $"only {mapped} MemFd frames were mapped ({other} frames of another type), so GStreamer's memfd path was not exercised" + ); } } @@ -465,7 +518,8 @@ public async Task APullModeCapture_CanTriggerItsOwnCycles() Assert.IsTrue( seen.Contains(SpaNodeCommand.Start), "a streaming pull-mode capture reported no Start command; got: " - + string.Join(", ", seen.Select(c => c.ToString()).Distinct())); + + string.Join(", ", seen.Select(c => c.ToString()).Distinct()) + ); } } } diff --git a/tests/PipeWire.NET.Tests/SurfaceGuardTests.cs b/tests/PipeWire.NET.Tests/SurfaceGuardTests.cs index bd3c34b..de74265 100644 --- a/tests/PipeWire.NET.Tests/SurfaceGuardTests.cs +++ b/tests/PipeWire.NET.Tests/SurfaceGuardTests.cs @@ -72,7 +72,10 @@ public async Task FaultProperties_AnswerBeforeConnectAndAfterDisposal() output.Dispose(); - Assert.IsNull(output.LastProcessError, "a disposed stream threw rather than reporting no fault"); + Assert.IsNull( + output.LastProcessError, + "a disposed stream threw rather than reporting no fault" + ); Assert.AreEqual(0, output.ProcessErrorCount); } @@ -108,8 +111,10 @@ public async Task UpdateProperties_RefusesNullAndIgnoresAnEmptySet() Assert.ThrowsExactly(() => ctx.UpdateProperties(null!)); Assert.AreEqual( - 0, ctx.UpdateProperties(new Dictionary()), - "an empty set reported a change"); + 0, + ctx.UpdateProperties(new Dictionary()), + "an empty set reported a change" + ); } /// An exported node can raise an xrun, and stops being able to once disposed. @@ -129,17 +134,22 @@ public async Task ReportXrun_AnswersWhileExportedAndRefusesAfterDisposal() await ctx.StartAsync(TestContext.CancellationTokenSource.Token).ConfigureAwait(false); PipeWireNodeProvider node = PipeWireNodeProvider.Create( - ctx, "pwnet_guard_xrun", PipeWireExportedFormat.AudioF32(48000, 1)); + ctx, + "pwnet_guard_xrun", + PipeWireExportedFormat.AudioF32(48000, 1) + ); Assert.IsTrue( node.ReportXrun(1000, 100), - "an exported node could not report an xrun to the graph that scheduled it"); + "an exported node could not report an xrun to the graph that scheduled it" + ); node.Dispose(); Assert.IsFalse( node.ReportXrun(1000, 100), - "a disposed node called through a callback table it no longer owns"); + "a disposed node called through a callback table it no longer owns" + ); } /// Every member of every stream answers on an instance that was never connected. @@ -167,9 +177,18 @@ public async Task EveryStreamMember_AnswersBeforeConnect() var retag = new Dictionary { ["media.name"] = "guarded" }; await using var audioIn = new PipeWireAudioCapture(ctx, "pwnet_guard_sai"); - Shared(audioIn.NodeId, audioIn.LastProcessError, audioIn.ProcessErrorCount, - audioIn.IsDriving, audioIn.Queue, audioIn.IsLazy, audioIn.GraphClock, - audioIn.RateMatch, audioIn.Controls, audioIn.GetControl(0)); + Shared( + audioIn.NodeId, + audioIn.LastProcessError, + audioIn.ProcessErrorCount, + audioIn.IsDriving, + audioIn.Queue, + audioIn.IsLazy, + audioIn.GraphClock, + audioIn.RateMatch, + audioIn.Controls, + audioIn.GetControl(0) + ); audioIn.SkipCurrentFrame(); audioIn.TriggerProcess(); audioIn.SetError(-5, "no stream"); @@ -182,9 +201,18 @@ public async Task EveryStreamMember_AnswersBeforeConnect() Assert.ThrowsExactly(() => audioIn.SetControl(0, [1f])); await using var audioOut = new PipeWireAudioOutput(ctx, "pwnet_guard_sao"); - Shared(audioOut.NodeId, audioOut.LastProcessError, audioOut.ProcessErrorCount, - audioOut.IsDriving, audioOut.Queue, audioOut.IsLazy, audioOut.GraphClock, - audioOut.RateMatch, audioOut.Controls, audioOut.GetControl(0)); + Shared( + audioOut.NodeId, + audioOut.LastProcessError, + audioOut.ProcessErrorCount, + audioOut.IsDriving, + audioOut.Queue, + audioOut.IsLazy, + audioOut.GraphClock, + audioOut.RateMatch, + audioOut.Controls, + audioOut.GetControl(0) + ); audioOut.TriggerProcess(); audioOut.SetError(-5, "no stream"); audioOut.SetRate(1.0); @@ -198,9 +226,18 @@ public async Task EveryStreamMember_AnswersBeforeConnect() Assert.ThrowsExactly(() => audioOut.SetControl(0, [1f])); await using var videoIn = new PipeWireVideoCapture(ctx, "pwnet_guard_svi"); - Shared(videoIn.NodeId, videoIn.LastProcessError, videoIn.ProcessErrorCount, - videoIn.IsDriving, videoIn.Queue, videoIn.IsLazy, videoIn.GraphClock, - videoIn.RateMatch, videoIn.Controls, videoIn.GetControl(0)); + Shared( + videoIn.NodeId, + videoIn.LastProcessError, + videoIn.ProcessErrorCount, + videoIn.IsDriving, + videoIn.Queue, + videoIn.IsLazy, + videoIn.GraphClock, + videoIn.RateMatch, + videoIn.Controls, + videoIn.GetControl(0) + ); videoIn.SkipCurrentFrame(); videoIn.TriggerProcess(); videoIn.SetError(-5, "no stream"); @@ -214,9 +251,18 @@ public async Task EveryStreamMember_AnswersBeforeConnect() Assert.ThrowsExactly(() => videoIn.SetControl(0, [1f])); await using var videoOut = new PipeWireVideoOutput(ctx, "pwnet_guard_svo", 64, 64); - Shared(videoOut.NodeId, videoOut.LastProcessError, videoOut.ProcessErrorCount, - videoOut.IsDriving, videoOut.Queue, videoOut.IsLazy, videoOut.GraphClock, - videoOut.RateMatch, videoOut.Controls, videoOut.GetControl(0)); + Shared( + videoOut.NodeId, + videoOut.LastProcessError, + videoOut.ProcessErrorCount, + videoOut.IsDriving, + videoOut.Queue, + videoOut.IsLazy, + videoOut.GraphClock, + videoOut.RateMatch, + videoOut.Controls, + videoOut.GetControl(0) + ); videoOut.TriggerProcess(); videoOut.SetError(-5, "no stream"); videoOut.SetRate(1.0); @@ -231,10 +277,17 @@ public async Task EveryStreamMember_AnswersBeforeConnect() Assert.ThrowsExactly(() => videoOut.SetControl(0, [1f])); static void Shared( - uint? nodeId, Exception? lastError, long errorCount, bool driving, - PipeWireStreamQueue? queue, bool lazy, PipeWireGraphClock? clock, - PipeWireRateMatch? rateMatch, ImmutableArray controls, - PipeWireStreamControl? control) + uint? nodeId, + Exception? lastError, + long errorCount, + bool driving, + PipeWireStreamQueue? queue, + bool lazy, + PipeWireGraphClock? clock, + PipeWireRateMatch? rateMatch, + ImmutableArray controls, + PipeWireStreamControl? control + ) { Assert.IsNull(nodeId, "an unconnected stream claimed a node id"); Assert.IsNull(lastError); @@ -266,8 +319,9 @@ public async Task AFilter_RefusesOrdersBeforeItIsConnected() await using PipeWireContext ctx = Unstarted(); // A filter is built on the context's loop, so an unstarted context has nothing to build on. - Assert.ThrowsExactly( - () => PipeWireFilter.Create(ctx, "pwnet_guard_too_early")); + Assert.ThrowsExactly(() => + PipeWireFilter.Create(ctx, "pwnet_guard_too_early") + ); await ctx.StartAsync(TestContext.CancellationTokenSource.Token).ConfigureAwait(false); @@ -332,15 +386,18 @@ public async Task AContext_RefusesConnectionWorkBeforeItStarts() Assert.IsFalse(ctx.IsOnLoopThread, "an unstarted context claimed to be on its own loop"); Assert.ThrowsExactly( - () => ctx.UpdateProperties(new Dictionary { ["application.name"] = "x" }), - "an unconnected context sent properties nowhere and said nothing"); + () => + ctx.UpdateProperties(new Dictionary { ["application.name"] = "x" }), + "an unconnected context sent properties nowhere and said nothing" + ); await ctx.DisposeAsync(); await ctx.DisposeAsync(); await Assert.ThrowsExactlyAsync( async () => await ctx.StartAsync(TestContext.CancellationTokenSource.Token), - "a disposed context was started again"); + "a disposed context was started again" + ); } /// The dmabuf entry points refuse what they cannot negotiate, and leave no residue. @@ -366,10 +423,12 @@ public async Task TheDmaBufEntryPoints_RefuseBadOffersAndLeaveNoResidue() Assert.ThrowsExactly( () => output.ConnectDmaBuf(ReadOnlySpan.Empty), - "a dmabuf connect with no modifiers was allowed to negotiate"); + "a dmabuf connect with no modifiers was allowed to negotiate" + ); - Assert.ThrowsExactly( - () => output.ConnectDmaBuf(ReadOnlySpan.Empty)); + Assert.ThrowsExactly(() => + output.ConnectDmaBuf(ReadOnlySpan.Empty) + ); // The same refusal reached through the sync form, which has state to unwind on the way out. // @@ -379,19 +438,23 @@ public async Task TheDmaBufEntryPoints_RefuseBadOffersAndLeaveNoResidue() // would fail on every runner that has no GPU. if (DrmSyncobj.IsAvailable) { - Assert.ThrowsExactly( - () => output.ConnectDmaBufSync(ReadOnlySpan.Empty)); + Assert.ThrowsExactly(() => + output.ConnectDmaBufSync(ReadOnlySpan.Empty) + ); - Assert.ThrowsExactly( - () => output.ConnectDmaBufSync(ReadOnlySpan.Empty)); + Assert.ThrowsExactly(() => + output.ConnectDmaBufSync(ReadOnlySpan.Empty) + ); } else { - Assert.ThrowsExactly( - () => output.ConnectDmaBufSync(ReadOnlySpan.Empty)); + Assert.ThrowsExactly(() => + output.ConnectDmaBufSync(ReadOnlySpan.Empty) + ); - Assert.ThrowsExactly( - () => output.ConnectDmaBufSync(ReadOnlySpan.Empty)); + Assert.ThrowsExactly(() => + output.ConnectDmaBufSync(ReadOnlySpan.Empty) + ); } Assert.ThrowsExactly(() => output.StampSyncPoints(-1, 1, 2)); @@ -417,14 +480,22 @@ public async Task AConnectedStream_RefusesASecondConnect() await ctx.StartAsync(TestContext.CancellationTokenSource.Token).ConfigureAwait(false); await using var videoOut = new PipeWireVideoOutput(ctx, "pwnet_guard_twice_vo", 64, 64); - videoOut.FillFrame += (_, pixels, _, _, _, _) => { pixels.Clear(); return true; }; + videoOut.FillFrame += (_, pixels, _, _, _, _) => + { + pixels.Clear(); + return true; + }; videoOut.Connect(autoConnect: false); Assert.ThrowsExactly(() => videoOut.Connect(autoConnect: false)); Assert.ThrowsExactly(() => videoOut.ConnectDmaBuf([0L])); await using var audioOut = new PipeWireAudioOutput(ctx, "pwnet_guard_twice_ao"); - audioOut.FillSamples += (_, samples, _, _, _) => { samples.Clear(); return samples.Length; }; + audioOut.FillSamples += (_, samples, _, _, _) => + { + samples.Clear(); + return samples.Length; + }; audioOut.Connect(autoConnect: false); Assert.ThrowsExactly(() => audioOut.Connect(autoConnect: false)); @@ -459,14 +530,18 @@ public async Task ADspPort_RefusesToHandBackMoreThanItsBufferHolds() PipeWireFilterPort video = filter.AddVideoPort(PipeWirePortDirection.Out, "frames"); PipeWireFilterPort midi = filter.AddMidiPort(PipeWirePortDirection.Out, "events"); - await filter.ConnectAsync(PipeWireFilterFlags.RtProcess, TestContext.CancellationTokenSource.Token); + await filter.ConnectAsync( + PipeWireFilterFlags.RtProcess, + TestContext.CancellationTokenSource.Token + ); // Outside a cycle there is no buffer to dequeue at all, which is the same answer by a // different route and is what a caller reading the geometry too early actually hits. Assert.IsTrue(video.GetPixels(16, 16).IsEmpty); Assert.IsTrue( video.GetPixels(16384, 16384).IsEmpty, - "a port handed back a span for a frame far larger than any buffer it has"); + "a port handed back a span for a frame far larger than any buffer it has" + ); Assert.IsNull(midi.ReadEvents()); Assert.IsFalse(midi.WriteEvents([])); @@ -498,7 +573,10 @@ public async Task AMetadataWrite_RefusesANulInAnyField() await using PipeWireContext serverCtx = Unstarted(); await serverCtx.StartAsync(cts.Token).ConfigureAwait(false); - await using var readerCtx = new PipeWireContext("pwnet-guards-reader", ConsoleTestLoggerFactory.Instance); + await using var readerCtx = new PipeWireContext( + "pwnet-guards-reader", + ConsoleTestLoggerFactory.Instance + ); await readerCtx.StartAsync(cts.Token).ConfigureAwait(false); await using var reg = new PipeWireRegistry(readerCtx); @@ -506,8 +584,11 @@ public async Task AMetadataWrite_RefusesANulInAnyField() string storeName = $"pwnet-guard-store-{Environment.ProcessId}-{Random.Shared.Next():x}"; - await using PipeWireMetadataProvider provider = - PipeWireMetadataProvider.Create(serverCtx, storeName, export: true); + await using PipeWireMetadataProvider provider = PipeWireMetadataProvider.Create( + serverCtx, + storeName, + export: true + ); await provider.ReadyAsync(cts.Token); @@ -516,23 +597,28 @@ public async Task AMetadataWrite_RefusesANulInAnyField() { await reg.WaitForInitialEnumerationAsync(cts.Token); store = reg.BindMetadata(storeName); - if (store is null) await Task.Delay(50, cts.Token); + if (store is null) + await Task.Delay(50, cts.Token); } - if (store is null) Assert.Inconclusive("the exported store never came back through the registry."); + if (store is null) + Assert.Inconclusive("the exported store never came back through the registry."); await using (store) { await store!.ReadyAsync(cts.Token); - await Assert.ThrowsExactlyAsync( - async () => await store.SetAsync("pwnet\0key", "v", null, cancellationToken: cts.Token)); + await Assert.ThrowsExactlyAsync(async () => + await store.SetAsync("pwnet\0key", "v", null, cancellationToken: cts.Token) + ); - await Assert.ThrowsExactlyAsync( - async () => await store.SetAsync("pwnet.key", "v\0v", null, cancellationToken: cts.Token)); + await Assert.ThrowsExactlyAsync(async () => + await store.SetAsync("pwnet.key", "v\0v", null, cancellationToken: cts.Token) + ); - await Assert.ThrowsExactlyAsync( - async () => await store.SetAsync("pwnet.key", "v", "t\0t", cancellationToken: cts.Token)); + await Assert.ThrowsExactlyAsync(async () => + await store.SetAsync("pwnet.key", "v", "t\0t", cancellationToken: cts.Token) + ); // A key the store does not have reads as absent rather than as a default. Assert.IsNull(store.Get("pwnet.no.such.key")); diff --git a/tests/PipeWire.NET.Tests/SurfaceParityTests.cs b/tests/PipeWire.NET.Tests/SurfaceParityTests.cs index 3a244b5..4b505b4 100644 --- a/tests/PipeWire.NET.Tests/SurfaceParityTests.cs +++ b/tests/PipeWire.NET.Tests/SurfaceParityTests.cs @@ -28,7 +28,10 @@ private static void RequireLinux() Assert.Inconclusive("PipeWire is a Linux daemon."); } - private static async Task ConnectAsync(string name, CancellationToken cancellationToken) + private static async Task ConnectAsync( + string name, + CancellationToken cancellationToken + ) { var ctx = new PipeWireContext(name, ConsoleTestLoggerFactory.Instance); await ctx.StartAsync(cancellationToken); @@ -47,14 +50,24 @@ public async Task AnOutput_CanConnectToANodeRatherThanAnId() await reg.WaitForInitialEnumerationAsync(cts.Token); PipeWireNode sink = await reg.CreateVirtualSinkAsync( - "pwnet parity sink", cancellationToken: cts.Token); + "pwnet parity sink", + cancellationToken: cts.Token + ); await using var audio = new PipeWireAudioOutput(ctx, "pwnet_parity_audio_out"); - audio.FillSamples += (_, samples, _, _, _) => { samples.Clear(); return samples.Length; }; + audio.FillSamples += (_, samples, _, _, _) => + { + samples.Clear(); + return samples.Length; + }; audio.Connect(sink); await using var video = new PipeWireVideoOutput(ctx, "pwnet_parity_video_out", 64, 64); - video.FillFrame += (_, pixels, _, _, _, _) => { pixels.Clear(); return true; }; + video.FillFrame += (_, pixels, _, _, _, _) => + { + pixels.Clear(); + return true; + }; video.Connect(sink, autoConnect: false); // Reaching a node id at all is what proves the overload routed the connect rather than @@ -77,11 +90,15 @@ public async Task AVirtualSource_CanBeCreatedInOneCall() await reg.WaitForInitialEnumerationAsync(cts.Token); PipeWireNode source = await reg.CreateVirtualSourceAsync( - "pwnet parity source", cancellationToken: cts.Token); + "pwnet parity source", + cancellationToken: cts.Token + ); Assert.AreEqual( - "Audio/Source", source.MediaClass, - "the async form did not apply the media class its builder does"); + "Audio/Source", + source.MediaClass, + "the async form did not apply the media class its builder does" + ); await reg.DestroyGlobalAsync(source.NodeId, cts.Token); } @@ -101,7 +118,11 @@ public async Task EveryStreamType_CanTriggerAndCanWait() await using PipeWireContext ctx = await ConnectAsync("pwnet-parity-trigger", cts.Token); await using var audioOut = new PipeWireAudioOutput(ctx, "pwnet_parity_trig_ao"); - audioOut.FillSamples += (_, samples, _, _, _) => { samples.Clear(); return samples.Length; }; + audioOut.FillSamples += (_, samples, _, _, _) => + { + samples.Clear(); + return samples.Length; + }; audioOut.Connect(autoConnect: false); await using var audioIn = new PipeWireAudioCapture(ctx, "pwnet_parity_trig_ai"); @@ -116,12 +137,14 @@ public async Task EveryStreamType_CanTriggerAndCanWait() audioIn.TriggerProcess(); videoIn.TriggerProcess(); - foreach (Func wait in new Func[] - { - () => audioOut.TriggerProcessAndWaitAsync(cts.Token), - () => audioIn.TriggerProcessAndWaitAsync(cts.Token), - () => videoIn.TriggerProcessAndWaitAsync(cts.Token), - }) + foreach ( + Func wait in new Func[] + { + () => audioOut.TriggerProcessAndWaitAsync(cts.Token), + () => audioIn.TriggerProcessAndWaitAsync(cts.Token), + () => videoIn.TriggerProcessAndWaitAsync(cts.Token), + } + ) { try { @@ -155,11 +178,18 @@ public async Task EveryDisposableType_TakesBothFormsAndRepeats() filter.Dispose(); var node = PipeWireNodeProvider.Create( - ctx, "pwnet_parity_node", PipeWireExportedFormat.AudioF32(48000, 1)); + ctx, + "pwnet_parity_node", + PipeWireExportedFormat.AudioF32(48000, 1) + ); node.Dispose(); node.Dispose(); - var device = PipeWireDeviceProvider.Create(ctx, "pwnet_parity_device", "pwnet parity device"); + var device = PipeWireDeviceProvider.Create( + ctx, + "pwnet_parity_device", + "pwnet parity device" + ); await device.DisposeAsync(); await device.DisposeAsync(); @@ -188,11 +218,13 @@ public async Task APermissionsRead_RefusesToOverlapAndStopsAfterDisposal() PipeWireClient? own = null; for (var i = 0; i < 100 && own is null; i++) { - own = reg.Current.Clients.FirstOrDefault( - c => c.Properties.TryGetValue(PipeWireKeys.PW_KEY_APP_NAME, out string? v) - && v == "pwnet-perm-guard"); + own = reg.Current.Clients.FirstOrDefault(c => + c.Properties.TryGetValue(PipeWireKeys.PW_KEY_APP_NAME, out string? v) + && v == "pwnet-perm-guard" + ); - if (own is null) await Task.Delay(50, cts.Token); + if (own is null) + await Task.Delay(50, cts.Token); } Assert.IsNotNull(own, "no client in the graph is this process"); @@ -207,8 +239,13 @@ public async Task APermissionsRead_RefusesToOverlapAndStopsAfterDisposal() try { Assert.ThrowsExactly( - () => client.GetPermissionsAsync(cancellationToken: cts.Token).GetAwaiter().GetResult(), - "two reads were allowed in flight at once"); + () => + client + .GetPermissionsAsync(cancellationToken: cts.Token) + .GetAwaiter() + .GetResult(), + "two reads were allowed in flight at once" + ); } catch (AssertFailedException) when (first.IsCompleted) { @@ -222,7 +259,8 @@ public async Task APermissionsRead_RefusesToOverlapAndStopsAfterDisposal() await Assert.ThrowsExactlyAsync( async () => await client.GetPermissionsAsync(cancellationToken: cts.Token), - "a disposed proxy answered a permissions read"); + "a disposed proxy answered a permissions read" + ); } /// Removal reaches every shape of bound proxy, not only the parameter objects. @@ -241,20 +279,25 @@ public async Task RemovalReachesBothProxyShapes() await reg.WaitForInitialEnumerationAsync(cts.Token); PipeWireNode node = await reg.CreateVirtualSinkAsync( - "pwnet removed shapes", cancellationToken: cts.Token); + "pwnet removed shapes", + cancellationToken: cts.Token + ); // A port of that node: a parameter object, like the node itself. PipeWirePort? port = null; for (var i = 0; i < 100 && port is null; i++) { port = reg.Current.Ports.FirstOrDefault(p => p.NodeId == node.NodeId); - if (port is null) await Task.Delay(50, cts.Token); + if (port is null) + await Task.Delay(50, cts.Token); } Assert.IsNotNull(port, "the virtual sink never published a port"); await using PipeWirePortProxy portProxy = reg.BindPort(port!.PortId); - var portRemoved = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var portRemoved = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously + ); portProxy.Removed += () => portRemoved.TrySetResult(); Assert.IsFalse(portProxy.IsRemoved); @@ -286,12 +329,15 @@ public async Task RemovePort_RefusesAPortItDoesNotOwn() Assert.ThrowsExactly(() => one.RemovePort(null!)); Assert.ThrowsExactly( () => one.RemovePort(theirs), - "a filter removed a port belonging to another filter"); + "a filter removed a port belonging to another filter" + ); Assert.ThrowsExactly(() => one.UpdateProperties(null!)); Assert.AreEqual( - 0, one.UpdateProperties(new Dictionary()), - "an empty property set reported a change"); + 0, + one.UpdateProperties(new Dictionary()), + "an empty property set reported a change" + ); one.RemovePort(mine); Assert.AreEqual(0, one.Ports.Count); @@ -320,35 +366,53 @@ public async Task APermissionWrite_RefusesWhatItCannotSend() await client.ReadyAsync(cts.Token); await Assert.ThrowsExactlyAsync( - async () => await client.UpdatePermissionsAsync( - ReadOnlyMemory.Empty, cts.Token), - "an empty write was sent rather than refused"); + async () => + await client.UpdatePermissionsAsync( + ReadOnlyMemory.Empty, + cts.Token + ), + "an empty write was sent rather than refused" + ); // 0x1 is none of the five permission.h defines: a cast from the wrong enum looks exactly // like this, and forwarding it asks the daemon to read a number we cannot describe. - var undefined = new[] - { - new PipeWireObjectPermission(1, (PipeWirePermissions)0x1), - }; + var undefined = new[] { new PipeWireObjectPermission(1, (PipeWirePermissions)0x1) }; await Assert.ThrowsExactlyAsync( async () => await client.UpdatePermissionsAsync(undefined, cts.Token), - "an undefined permission bit was forwarded to the daemon"); + "an undefined permission bit was forwarded to the daemon" + ); // Link is defined but sits outside upstream's own PW_PERM_ALL, so it is the bit a mask // taken from All would wrongly refuse. Reaching the daemon at all is the assertion. await client.UpdatePermissionsAsync( - new[] { new PipeWireObjectPermission(any!.Id, PipeWirePermissions.ReadWriteExecuteMetadataLink) }, - cts.Token); + new[] + { + new PipeWireObjectPermission( + any!.Id, + PipeWirePermissions.ReadWriteExecuteMetadataLink + ), + }, + cts.Token + ); Assert.ThrowsExactly( - () => client.ConfineToAsync( - [new PipeWireObjectPermission(PipeWireClientProxy.AnyObject, PipeWirePermissions.All)], - cts.Token), - "a confining call accepted a default that contradicts the one it writes"); - - await Assert.ThrowsExactlyAsync( - async () => await client.UpdatePropertiesAsync(null!, cts.Token)); + () => + client.ConfineToAsync( + [ + new PipeWireObjectPermission( + PipeWireClientProxy.AnyObject, + PipeWirePermissions.All + ), + ], + cts.Token + ), + "a confining call accepted a default that contradicts the one it writes" + ); + + await Assert.ThrowsExactlyAsync(async () => + await client.UpdatePropertiesAsync(null!, cts.Token) + ); } /// Every member of every stream works once connected, and goes quiet once disposed. @@ -380,21 +444,33 @@ public async Task EveryStreamMember_WorksConnectedAndIsQuietAfterDisposal() audioIn.Connect(autoConnect: false); var audioOut = new PipeWireAudioOutput(ctx, "pwnet_conn_ao"); - audioOut.FillSamples += (_, samples, _, _, _) => { samples.Clear(); return samples.Length; }; + audioOut.FillSamples += (_, samples, _, _, _) => + { + samples.Clear(); + return samples.Length; + }; audioOut.Connect(autoConnect: false); var videoIn = new PipeWireVideoCapture(ctx, "pwnet_conn_vi"); videoIn.Connect(autoConnect: false); var videoOut = new PipeWireVideoOutput(ctx, "pwnet_conn_vo", 64, 64); - videoOut.FillFrame += (_, pixels, _, _, _, _) => { pixels.Clear(); return true; }; + videoOut.FillFrame += (_, pixels, _, _, _, _) => + { + pixels.Clear(); + return true; + }; videoOut.Connect(autoConnect: false); - foreach (Func> wait in new Func>[] - { - audioIn.WaitForNodeIdAsync, audioOut.WaitForNodeIdAsync, - videoIn.WaitForNodeIdAsync, videoOut.WaitForNodeIdAsync, - }) + foreach ( + Func> wait in new Func>[] + { + audioIn.WaitForNodeIdAsync, + audioOut.WaitForNodeIdAsync, + videoIn.WaitForNodeIdAsync, + videoOut.WaitForNodeIdAsync, + } + ) { Assert.AreNotEqual(0u, await wait(cts.Token), "a connected stream never got a node id"); } @@ -406,17 +482,35 @@ public async Task EveryStreamMember_WorksConnectedAndIsQuietAfterDisposal() // Reads that answer from the stream rather than from a null: each has a "no stream" arm the // guard tests cover, and this is the other one. - foreach (Action read in new Action[] - { - () => _ = audioIn.Queue, () => _ = audioIn.IsLazy, () => _ = audioIn.IsDriving, - () => _ = audioIn.GraphClock, () => _ = audioIn.RateMatch, () => _ = audioIn.Controls, - () => _ = audioOut.Queue, () => _ = audioOut.IsLazy, () => _ = audioOut.IsDriving, - () => _ = audioOut.GraphClock, () => _ = audioOut.RateMatch, () => _ = audioOut.Controls, - () => _ = videoIn.Queue, () => _ = videoIn.IsLazy, () => _ = videoIn.IsDriving, - () => _ = videoIn.GraphClock, () => _ = videoIn.RateMatch, () => _ = videoIn.Controls, - () => _ = videoOut.Queue, () => _ = videoOut.IsLazy, () => _ = videoOut.IsDriving, - () => _ = videoOut.GraphClock, () => _ = videoOut.RateMatch, () => _ = videoOut.Controls, - }) + foreach ( + Action read in new Action[] + { + () => _ = audioIn.Queue, + () => _ = audioIn.IsLazy, + () => _ = audioIn.IsDriving, + () => _ = audioIn.GraphClock, + () => _ = audioIn.RateMatch, + () => _ = audioIn.Controls, + () => _ = audioOut.Queue, + () => _ = audioOut.IsLazy, + () => _ = audioOut.IsDriving, + () => _ = audioOut.GraphClock, + () => _ = audioOut.RateMatch, + () => _ = audioOut.Controls, + () => _ = videoIn.Queue, + () => _ = videoIn.IsLazy, + () => _ = videoIn.IsDriving, + () => _ = videoIn.GraphClock, + () => _ = videoIn.RateMatch, + () => _ = videoIn.Controls, + () => _ = videoOut.Queue, + () => _ = videoOut.IsLazy, + () => _ = videoOut.IsDriving, + () => _ = videoOut.GraphClock, + () => _ = videoOut.RateMatch, + () => _ = videoOut.Controls, + } + ) { read(); } @@ -439,20 +533,31 @@ public async Task EveryStreamMember_WorksConnectedAndIsQuietAfterDisposal() audioIn.SkipCurrentFrame(); videoIn.SkipCurrentFrame(); - Assert.IsTrue(videoIn.RequestFormat([PixelFormat.Bgrx], 64, 64), "a connected capture refused a format request"); + Assert.IsTrue( + videoIn.RequestFormat([PixelFormat.Bgrx], 64, 64), + "a connected capture refused a format request" + ); Assert.IsTrue(videoOut.RequestFormat([PixelFormat.Bgrx], 64, 64)); // Out of range on a connected stream, which is the only place the range guards are reached: // unconnected, the null check answers first. - Assert.ThrowsExactly( - () => videoIn.RequestFormat([PixelFormat.Bgrx], 0, 64)); - Assert.ThrowsExactly( - () => videoOut.RequestFormat([PixelFormat.Bgrx], 64, -1)); + Assert.ThrowsExactly(() => + videoIn.RequestFormat([PixelFormat.Bgrx], 0, 64) + ); + Assert.ThrowsExactly(() => + videoOut.RequestFormat([PixelFormat.Bgrx], 64, -1) + ); Assert.ThrowsExactly( - () => audioIn.SetControl(0, []), "an empty control write was sent"); - - audioOut.SetError(-5, "a deliberate error, to prove the call reaches the daemon", cts.Token); + () => audioIn.SetControl(0, []), + "an empty control write was sent" + ); + + audioOut.SetError( + -5, + "a deliberate error, to prove the call reaches the daemon", + cts.Token + ); await audioIn.DisposeAsync(); await audioOut.DisposeAsync(); diff --git a/tests/PipeWire.NET.Tests/SyncLoopTests.cs b/tests/PipeWire.NET.Tests/SyncLoopTests.cs index 9669825..2e01cde 100644 --- a/tests/PipeWire.NET.Tests/SyncLoopTests.cs +++ b/tests/PipeWire.NET.Tests/SyncLoopTests.cs @@ -42,7 +42,13 @@ private static void RequireLinux() private static PipeWireAudioOutput SilentOutput(PipeWireContext ctx, string nodeName) { - var output = new PipeWireAudioOutput(ctx, nodeName, Rate, Channels, AudioSampleFormat.F32Le); + var output = new PipeWireAudioOutput( + ctx, + nodeName, + Rate, + Channels, + AudioSampleFormat.F32Le + ); output.FillSamples += (_, samples, _, _, _) => { samples.Clear(); @@ -53,14 +59,19 @@ private static PipeWireAudioOutput SilentOutput(PipeWireContext ctx, string node } private static async Task WaitForNodeAsync( - PipeWireRegistry reg, string name, CancellationToken ct) + PipeWireRegistry reg, + string name, + CancellationToken ct + ) { for (var i = 0; i < 100; i++) { - PipeWireNode? n = reg.Current.Nodes.FirstOrDefault( - x => string.Equals(x.NodeName, name, StringComparison.Ordinal)); + PipeWireNode? n = reg.Current.Nodes.FirstOrDefault(x => + string.Equals(x.NodeName, name, StringComparison.Ordinal) + ); - if (n is not null) return n; + if (n is not null) + return n; await Task.Delay(50, ct); } @@ -87,7 +98,10 @@ public async Task AnAnnouncedProcessLatency_IsWhatTheDaemonReportsForTheNode() string nodeName = $"pwnet-latency-{Environment.ProcessId}"; - await using var ctx = new PipeWireContext("pwnet-latency", ConsoleTestLoggerFactory.Instance); + await using var ctx = new PipeWireContext( + "pwnet-latency", + ConsoleTestLoggerFactory.Instance + ); await ctx.StartAsync(cts.Token); await using PipeWireAudioOutput output = SilentOutput(ctx, nodeName); @@ -99,7 +113,8 @@ public async Task AnAnnouncedProcessLatency_IsWhatTheDaemonReportsForTheNode() output.AnnounceLatency( new PipeWireLatency(SpaDirection.Output, 0, 0, 0, 0, announcedNs, announcedNs), - new PipeWireProcessLatency(0, 0, announcedNs)); + new PipeWireProcessLatency(0, 0, announcedNs) + ); await using var reg = new PipeWireRegistry(ctx); await reg.WaitForInitialEnumerationAsync(cts.Token); @@ -111,16 +126,21 @@ public async Task AnAnnouncedProcessLatency_IsWhatTheDaemonReportsForTheNode() for (var i = 0; i < 40 && reported?.Ns != announcedNs; i++) { reported = await control.GetProcessLatencyAsync(cts.Token); - if (reported?.Ns != announcedNs) await Task.Delay(100, cts.Token); + if (reported?.Ns != announcedNs) + await Task.Delay(100, cts.Token); } - Assert.IsNotNull(reported, "the daemon reported no process latency for a node that announced one"); + Assert.IsNotNull( + reported, + "the daemon reported no process latency for a node that announced one" + ); Assert.AreEqual( announcedNs, reported!.Ns, "the announced latency never reached the daemon, so the graph is compensating for a " - + "delay it cannot see"); + + "delay it cannot see" + ); } /// @@ -143,18 +163,29 @@ public async Task AResamplingStream_IsGivenARateMatchArea() string nodeName = $"pwnet-ratematch-{Environment.ProcessId}"; - await using var ctx = new PipeWireContext("pwnet-ratematch", ConsoleTestLoggerFactory.Instance); + await using var ctx = new PipeWireContext( + "pwnet-ratematch", + ConsoleTestLoggerFactory.Instance + ); await ctx.StartAsync(cts.Token); await using var output = new PipeWireAudioOutput( - ctx, nodeName, offRate, Channels, AudioSampleFormat.F32Le); + ctx, + nodeName, + offRate, + Channels, + AudioSampleFormat.F32Le + ); // Real samples, so the resampler has something to resample and the flow can be asserted. uint n = 0; output.FillSamples += (_, samples, _, _, _) => { - Span floats = System.Runtime.InteropServices.MemoryMarshal.Cast(samples); - for (var i = 0; i < floats.Length; i++) floats[i] = ++n; + Span floats = System.Runtime.InteropServices.MemoryMarshal.Cast( + samples + ); + for (var i = 0; i < floats.Length; i++) + floats[i] = ++n; return samples.Length; }; @@ -170,7 +201,8 @@ public async Task AResamplingStream_IsGivenARateMatchArea() for (var i = 0; i < 60 && match is null; i++) { match = output.RateMatch ?? capture.RateMatch; - if (match is null) await Task.Delay(50, cts.Token); + if (match is null) + await Task.Delay(50, cts.Token); } // Not a skip: the sessions this runs in fix the graph at 48000 (clock.allowed-rates), so a @@ -184,16 +216,19 @@ public async Task AResamplingStream_IsGivenARateMatchArea() Assert.IsTrue( match!.Value.Rate is > 0.5 and < 2.0, $"the rate-match ratio reads {match.Value.Rate}, which is not a resampling ratio - the " - + "io area is being read at the wrong offset"); + + "io area is being read at the wrong offset" + ); Assert.IsTrue( match.Value.Size < 1_000_000, - $"the rate-match size reads {match.Value.Size}, which is not a quantum"); + $"the rate-match size reads {match.Value.Size}, which is not a quantum" + ); // And the resampler was actually carrying audio while it reported that. Assert.IsTrue( Interlocked.Read(ref receivedSamples) > 0, - "the rate-match area was reported on a stream through which nothing flowed"); + "the rate-match area was reported on a stream through which nothing flowed" + ); } /// The frames one stream handled over a window, at each of three corrections. @@ -210,7 +245,11 @@ private sealed record RateSweep(long Neutral, long Below, long Above) /// Off the graph rate so a resampler is in the path: pw_stream_set_rate only sets /// rate_match->rate (stream.c), and without a resampler there is nothing to read it. /// - private static async Task SweepAsync(string name, bool correctTheOutput, CancellationToken ct) + private static async Task SweepAsync( + string name, + bool correctTheOutput, + CancellationToken ct + ) { const int offRate = 44100; string nodeName = $"{name}-{Environment.ProcessId}"; @@ -218,10 +257,16 @@ private static async Task SweepAsync(string name, bool correctTheOutp await using var ctx = new PipeWireContext(name, ConsoleTestLoggerFactory.Instance); await ctx.StartAsync(ct); - long framesAsked = 0, framesHanded = 0; + long framesAsked = 0, + framesHanded = 0; await using var output = new PipeWireAudioOutput( - ctx, nodeName, offRate, Channels, AudioSampleFormat.F32Le); + ctx, + nodeName, + offRate, + Channels, + AudioSampleFormat.F32Le + ); output.FillSamples += (_, samples, _, _, _) => { Interlocked.Add(ref framesAsked, samples.Length / (4 * Channels)); @@ -232,20 +277,29 @@ private static async Task SweepAsync(string name, bool correctTheOutp await using var capture = new PipeWireAudioCapture(ctx, $"{nodeName}-sink"); capture.FrameReady += (_, f) => Interlocked.Add(ref framesHanded, f.FrameCount); - capture.Connect((await output.WaitForNodeIdAsync(ct)), sampleRate: offRate, channels: Channels, - format: AudioSampleFormat.F32Le); + capture.Connect( + (await output.WaitForNodeIdAsync(ct)), + sampleRate: offRate, + channels: Channels, + format: AudioSampleFormat.F32Le + ); await capture.WaitForStreamingAsync(ct); await Task.Delay(400, ct); if ((correctTheOutput ? output.RateMatch : capture.RateMatch) is null) - Assert.Fail("no resampler on the corrected stream, although it runs off the graph rate"); + Assert.Fail( + "no resampler on the corrected stream, although it runs off the graph rate" + ); long Read() => Interlocked.Read(ref correctTheOutput ? ref framesAsked : ref framesHanded); async Task MeasureAt(double rate) { - if (correctTheOutput) output.SetRate(rate); else capture.SetRate(rate); + if (correctTheOutput) + output.SetRate(rate); + else + capture.SetRate(rate); await Task.Delay(250, ct); long before = Read(); await Task.Delay(TimeSpan.FromMilliseconds(900), ct); @@ -255,7 +309,10 @@ async Task MeasureAt(double rate) long neutral = await MeasureAt(1.0); long below = await MeasureAt(0.85); long above = await MeasureAt(1.15); - if (correctTheOutput) output.SetRate(1.0); else capture.SetRate(1.0); + if (correctTheOutput) + output.SetRate(1.0); + else + capture.SetRate(1.0); Assert.IsTrue(neutral > 0, "nothing flowed at the neutral rate"); return new RateSweep(neutral, below, above); @@ -307,8 +364,14 @@ public async Task ARateCorrectionOnACaptureStream_ScalesWhatItIsHanded() RateSweep r = await SweepAsync("pwnet-setrate-in", correctTheOutput: false, cts.Token); - Assert.IsTrue(r.Below < r.Neutral * 0.95, $"0.85 did not hand the capture fewer frames [{r}]"); - Assert.IsTrue(r.Above > r.Neutral * 1.05, $"1.15 did not hand the capture more frames [{r}]"); + Assert.IsTrue( + r.Below < r.Neutral * 0.95, + $"0.85 did not hand the capture fewer frames [{r}]" + ); + Assert.IsTrue( + r.Above > r.Neutral * 1.05, + $"1.15 did not hand the capture more frames [{r}]" + ); } /// @@ -365,7 +428,8 @@ public async Task AStreamingPair_RunsWithoutAccumulatingXruns() baseline!.Value, after!.Value, $"the node accumulated {after.Value - baseline.Value} xruns over two seconds of steady " - + "streaming; frames may line up, but the graph is missing its deadline to make that happen"); + + "streaming; frames may line up, but the graph is missing its deadline to make that happen" + ); } /// @@ -393,11 +457,18 @@ public async Task DrainingAnOutput_DeliversTheTailRatherThanDroppingIt() long produced = 0; await using var output = new PipeWireAudioOutput( - ctx, nodeName, Rate, 1, AudioSampleFormat.F32Le); + ctx, + nodeName, + Rate, + 1, + AudioSampleFormat.F32Le + ); output.FillSamples += (_, samples, _, _, _) => { - Span floats = System.Runtime.InteropServices.MemoryMarshal.Cast(samples); + Span floats = System.Runtime.InteropServices.MemoryMarshal.Cast( + samples + ); for (var i = 0; i < floats.Length; i++) { long n = Interlocked.Increment(ref produced); @@ -413,17 +484,24 @@ public async Task DrainingAnOutput_DeliversTheTailRatherThanDroppingIt() await using var capture = new PipeWireAudioCapture(ctx, $"{nodeName}-sink"); capture.FrameReady += (_, f) => { - ReadOnlySpan floats = - System.Runtime.InteropServices.MemoryMarshal.Cast(f.Samples); + ReadOnlySpan floats = System.Runtime.InteropServices.MemoryMarshal.Cast< + byte, + float + >(f.Samples); foreach (float v in floats) { - if (v > Volatile.Read(ref highestSeen)) Volatile.Write(ref highestSeen, v); + if (v > Volatile.Read(ref highestSeen)) + Volatile.Write(ref highestSeen, v); } }; - capture.Connect((await output.WaitForNodeIdAsync(cts.Token)), sampleRate: Rate, channels: 1, - format: AudioSampleFormat.F32Le); + capture.Connect( + (await output.WaitForNodeIdAsync(cts.Token)), + sampleRate: Rate, + channels: 1, + format: AudioSampleFormat.F32Le + ); await capture.WaitForStreamingAsync(cts.Token); @@ -433,7 +511,8 @@ public async Task DrainingAnOutput_DeliversTheTailRatherThanDroppingIt() Assert.IsTrue( Interlocked.Read(ref produced) >= total, - "the producer never got through the ramp, so there was no tail to drain"); + "the producer never got through the ramp, so there was no tail to drain" + ); await output.DrainAsync(cts.Token).WaitAsync(TimeSpan.FromSeconds(15), cts.Token); @@ -442,15 +521,14 @@ public async Task DrainingAnOutput_DeliversTheTailRatherThanDroppingIt() float seen = Volatile.Read(ref highestSeen); - Assert.IsTrue( - seen > 0, - "the consumer received none of the ramp at all"); + Assert.IsTrue(seen > 0, "the consumer received none of the ramp at all"); // The tail: within one quantum of the end of the ramp. A drain that dropped the queue // would leave the consumer short by however much was still buffered. Assert.IsTrue( seen >= total - 4096, - $"the consumer's last sample was {seen} of {total}; the drain dropped the tail"); + $"the consumer's last sample was {seen} of {total}; the drain dropped the tail" + ); Assert.IsNotNull(output.Queue, "the stream stopped answering after being drained"); } diff --git a/tests/PipeWire.NET.Tests/TestLogging.cs b/tests/PipeWire.NET.Tests/TestLogging.cs index af5d190..19b6cb8 100644 --- a/tests/PipeWire.NET.Tests/TestLogging.cs +++ b/tests/PipeWire.NET.Tests/TestLogging.cs @@ -60,25 +60,33 @@ private static LogLevel ReadMinimumLevel() => Enum.TryParse( Environment.GetEnvironmentVariable("PWNET_TEST_LOG_LEVEL"), ignoreCase: true, - out LogLevel level) + out LogLevel level + ) ? level : LogLevel.Trace; private sealed class ConsoleTestLogger(string category) : ILogger { - public IDisposable? BeginScope(TState state) where TState : notnull => null; + public IDisposable? BeginScope(TState state) + where TState : notnull => null; public bool IsEnabled(LogLevel logLevel) => logLevel >= MinimumLevel; - public void Log(LogLevel level, EventId eventId, TState state, Exception? exception, - Func formatter) + public void Log( + LogLevel level, + EventId eventId, + TState state, + Exception? exception, + Func formatter + ) { string line = $"[{level}] {category}: {formatter(state, exception)}"; if (level >= LogLevel.Error) { lock (Errors) { - if (_collecting) Errors.Add(line); + if (_collecting) + Errors.Add(line); } } diff --git a/tests/PipeWire.NET.Tests/ThirdPartyGraphTests.cs b/tests/PipeWire.NET.Tests/ThirdPartyGraphTests.cs index ecdcf7f..0836b3c 100644 --- a/tests/PipeWire.NET.Tests/ThirdPartyGraphTests.cs +++ b/tests/PipeWire.NET.Tests/ThirdPartyGraphTests.cs @@ -23,7 +23,9 @@ public sealed class ThirdPartyGraphTests : PipeWireTestBase private static readonly TimeSpan Budget = TimeSpan.FromSeconds(40); private static async Task<(PipeWireContext Context, PipeWireRegistry Registry)> ConnectAsync( - string name, CancellationToken cancellationToken) + string name, + CancellationToken cancellationToken + ) { var context = new PipeWireContext(name, ConsoleTestLoggerFactory.Instance); await context.StartAsync(cancellationToken); @@ -33,7 +35,10 @@ public sealed class ThirdPartyGraphTests : PipeWireTestBase } private static async Task WaitForAsync( - PipeWireRegistry registry, Func until, CancellationToken cancellationToken) + PipeWireRegistry registry, + Func until, + CancellationToken cancellationToken + ) { await foreach (PipeWireGraphSnapshot graph in registry.WatchAsync(cancellationToken)) if (until(graph)) @@ -43,8 +48,10 @@ private static async Task WaitForAsync( } private static Task WaitForPortsAsync( - PipeWireRegistry r, uint nodeId, CancellationToken ct) => - WaitForAsync(r, g => g.GetPortsForNode(nodeId).Length == 4, ct); + PipeWireRegistry r, + uint nodeId, + CancellationToken ct + ) => WaitForAsync(r, g => g.GetPortsForNode(nodeId).Length == 4, ct); // ---------------------------------------------------------------- we create, they see @@ -53,17 +60,27 @@ public async Task NodesWeCreate_AreVisibleToPwLinkWithTheSameIds() { PwTools.Require(); using var cts = new CancellationTokenSource(Budget); - (PipeWireContext ctx, PipeWireRegistry reg) = await ConnectAsync("pwnet-tp-visible", cts.Token); + (PipeWireContext ctx, PipeWireRegistry reg) = await ConnectAsync( + "pwnet-tp-visible", + cts.Token + ); await using (ctx) await using (reg) { PipeWireNode node = await reg.CreateVirtualSink("Visible") - .WithName("pwnet_tp_visible").ExecuteAsync(cts.Token); + .WithName("pwnet_tp_visible") + .ExecuteAsync(cts.Token); PipeWireGraphSnapshot graph = await WaitForPortsAsync(reg, node.NodeId, cts.Token); - Dictionary outputs = await PwTools.ListPortsAsync(outputs: true, cts.Token); - Dictionary inputs = await PwTools.ListPortsAsync(outputs: false, cts.Token); + Dictionary outputs = await PwTools.ListPortsAsync( + outputs: true, + cts.Token + ); + Dictionary inputs = await PwTools.ListPortsAsync( + outputs: false, + cts.Token + ); // Every port we report must exist for pw-link under the same id, or a user wiring by // hand and an app wiring by API are talking about different objects. @@ -72,10 +89,15 @@ public async Task NodesWeCreate_AreVisibleToPwLinkWithTheSameIds() string full = $"pwnet_tp_visible:{port.PortName}"; Dictionary side = port.IsDataOutput ? outputs : inputs; - Assert.IsTrue(side.TryGetValue(full, out uint theirId), - $"pw-link cannot see {full}, which we report as port {port.PortId}"); - Assert.AreEqual(port.PortId, theirId, - $"{full}: we call it {port.PortId}, pw-link calls it {theirId}"); + Assert.IsTrue( + side.TryGetValue(full, out uint theirId), + $"pw-link cannot see {full}, which we report as port {port.PortId}" + ); + Assert.AreEqual( + port.PortId, + theirId, + $"{full}: we call it {port.PortId}, pw-link calls it {theirId}" + ); } } } @@ -85,26 +107,45 @@ public async Task ALinkWeCreate_IsListedByPwLinkWithMatchingEndpoints() { PwTools.Require(); using var cts = new CancellationTokenSource(Budget); - (PipeWireContext ctx, PipeWireRegistry reg) = await ConnectAsync("pwnet-tp-ourlink", cts.Token); + (PipeWireContext ctx, PipeWireRegistry reg) = await ConnectAsync( + "pwnet-tp-ourlink", + cts.Token + ); await using (ctx) await using (reg) { - (PipeWireNode a, PipeWireNode b, PipeWireGraphSnapshot ready) = - await TwoNodesAsync(reg, "pwnet_tp_la", "pwnet_tp_lb", cts.Token); - - PipeWirePort output = ready.GetPortsForNode(a.NodeId, PipeWirePortDirection.Out) - .OrderBy(p => p.PortId).First(); - PipeWirePort input = ready.GetPortsForNode(b.NodeId, PipeWirePortDirection.In) - .OrderBy(p => p.PortId).First(); + (PipeWireNode a, PipeWireNode b, PipeWireGraphSnapshot ready) = await TwoNodesAsync( + reg, + "pwnet_tp_la", + "pwnet_tp_lb", + cts.Token + ); + + PipeWirePort output = ready + .GetPortsForNode(a.NodeId, PipeWirePortDirection.Out) + .OrderBy(p => p.PortId) + .First(); + PipeWirePort input = ready + .GetPortsForNode(b.NodeId, PipeWirePortDirection.In) + .OrderBy(p => p.PortId) + .First(); PipeWireLink link = await reg.CreateLink(output, input).ExecuteAsync(cts.Token); - List<(uint Link, uint Output, uint Input)> theirs = await PwTools.ListLinksAsync(cts.Token); - (uint Link, uint Output, uint Input) match = theirs.FirstOrDefault(l => l.Link == link.LinkId); + List<(uint Link, uint Output, uint Input)> theirs = await PwTools.ListLinksAsync( + cts.Token + ); + (uint Link, uint Output, uint Input) match = theirs.FirstOrDefault(l => + l.Link == link.LinkId + ); Assert.AreNotEqual(0u, match.Link, "pw-link does not list the link we created"); - Assert.AreEqual(output.PortId, match.Output, "we disagree on which port the link leaves"); + Assert.AreEqual( + output.PortId, + match.Output, + "we disagree on which port the link leaves" + ); Assert.AreEqual(input.PortId, match.Input, "we disagree on which port the link enters"); } } @@ -116,31 +157,52 @@ public async Task ALinkPwLinkCreates_ReachesOurSnapshotWithTheRightEndpoints() { PwTools.Require(); using var cts = new CancellationTokenSource(Budget); - (PipeWireContext ctx, PipeWireRegistry reg) = await ConnectAsync("pwnet-tp-theirlink", cts.Token); + (PipeWireContext ctx, PipeWireRegistry reg) = await ConnectAsync( + "pwnet-tp-theirlink", + cts.Token + ); await using (ctx) await using (reg) { - (PipeWireNode a, PipeWireNode b, PipeWireGraphSnapshot ready) = - await TwoNodesAsync(reg, "pwnet_tp_ta", "pwnet_tp_tb", cts.Token); - - PipeWirePort output = ready.GetPortsForNode(a.NodeId, PipeWirePortDirection.Out) - .OrderBy(p => p.PortId).First(); - PipeWirePort input = ready.GetPortsForNode(b.NodeId, PipeWirePortDirection.In) - .OrderBy(p => p.PortId).First(); - - await PwTools.LinkAsync($"pwnet_tp_ta:{output.PortName}", $"pwnet_tp_tb:{input.PortName}", cts.Token); + (PipeWireNode a, PipeWireNode b, PipeWireGraphSnapshot ready) = await TwoNodesAsync( + reg, + "pwnet_tp_ta", + "pwnet_tp_tb", + cts.Token + ); + + PipeWirePort output = ready + .GetPortsForNode(a.NodeId, PipeWirePortDirection.Out) + .OrderBy(p => p.PortId) + .First(); + PipeWirePort input = ready + .GetPortsForNode(b.NodeId, PipeWirePortDirection.In) + .OrderBy(p => p.PortId) + .First(); + + await PwTools.LinkAsync( + $"pwnet_tp_ta:{output.PortName}", + $"pwnet_tp_tb:{input.PortName}", + cts.Token + ); PipeWireGraphSnapshot linked = await WaitForAsync( - reg, g => g.GetOutputLinksForPort(output.PortId).Length == 1, cts.Token); + reg, + g => g.GetOutputLinksForPort(output.PortId).Length == 1, + cts.Token + ); PipeWireLink link = linked.GetOutputLinksForPort(output.PortId).Single(); Assert.AreEqual(output.PortId, link.OutputPortId); Assert.AreEqual(input.PortId, link.InputPortId); Assert.AreEqual(a.NodeId, link.OutputNodeId); Assert.AreEqual(b.NodeId, link.InputNodeId); - Assert.AreEqual(1, linked.GetInputLinksForPort(input.PortId).Length, - "the link must be indexed from the input side too"); + Assert.AreEqual( + 1, + linked.GetInputLinksForPort(input.PortId).Length, + "the link must be indexed from the input side too" + ); } } @@ -149,25 +211,39 @@ public async Task ALinkPwLinkRemoves_LeavesOurSnapshot() { PwTools.Require(); using var cts = new CancellationTokenSource(Budget); - (PipeWireContext ctx, PipeWireRegistry reg) = await ConnectAsync("pwnet-tp-unlink", cts.Token); + (PipeWireContext ctx, PipeWireRegistry reg) = await ConnectAsync( + "pwnet-tp-unlink", + cts.Token + ); await using (ctx) await using (reg) { - (PipeWireNode a, PipeWireNode b, PipeWireGraphSnapshot ready) = - await TwoNodesAsync(reg, "pwnet_tp_ua", "pwnet_tp_ub", cts.Token); - - PipeWirePort output = ready.GetPortsForNode(a.NodeId, PipeWirePortDirection.Out) - .OrderBy(p => p.PortId).First(); - PipeWirePort input = ready.GetPortsForNode(b.NodeId, PipeWirePortDirection.In) - .OrderBy(p => p.PortId).First(); + (PipeWireNode a, PipeWireNode b, PipeWireGraphSnapshot ready) = await TwoNodesAsync( + reg, + "pwnet_tp_ua", + "pwnet_tp_ub", + cts.Token + ); + + PipeWirePort output = ready + .GetPortsForNode(a.NodeId, PipeWirePortDirection.Out) + .OrderBy(p => p.PortId) + .First(); + PipeWirePort input = ready + .GetPortsForNode(b.NodeId, PipeWirePortDirection.In) + .OrderBy(p => p.PortId) + .First(); // The patchbay case. PipeWireLink link = await reg.CreateLink(output, input).ExecuteAsync(cts.Token); await PwTools.DisconnectAsync(link.LinkId, cts.Token); PipeWireGraphSnapshot after = await WaitForAsync( - reg, g => g.GetLink(link.LinkId) is null, cts.Token); + reg, + g => g.GetLink(link.LinkId) is null, + cts.Token + ); Assert.AreEqual(0, after.GetOutputLinksForPort(output.PortId).Length); Assert.AreEqual(0, after.GetInputLinksForPort(input.PortId).Length); @@ -179,25 +255,35 @@ public async Task ANodePwCliDestroys_LeavesOurSnapshotWithItsPorts() { PwTools.Require(); using var cts = new CancellationTokenSource(Budget); - (PipeWireContext ctx, PipeWireRegistry reg) = await ConnectAsync("pwnet-tp-destroy", cts.Token); + (PipeWireContext ctx, PipeWireRegistry reg) = await ConnectAsync( + "pwnet-tp-destroy", + cts.Token + ); await using (ctx) await using (reg) { PipeWireNode node = await reg.CreateVirtualSink("Doomed") - .WithName("pwnet_tp_doomed").WithLinger().ExecuteAsync(cts.Token); + .WithName("pwnet_tp_doomed") + .WithLinger() + .ExecuteAsync(cts.Token); PipeWireGraphSnapshot ready = await WaitForPortsAsync(reg, node.NodeId, cts.Token); uint[] portIds = [.. ready.GetPortsForNode(node.NodeId).Select(p => p.PortId)]; await PwTools.DestroyAsync(node.NodeId, cts.Token); PipeWireGraphSnapshot after = await WaitForAsync( - reg, g => g.GetNode(node.NodeId) is null, cts.Token); + reg, + g => g.GetNode(node.NodeId) is null, + cts.Token + ); Assert.AreEqual(0, after.GetPortsForNode(node.NodeId).Length); foreach (uint portId in portIds) - Assert.IsNull(after.GetPort(portId), - $"port {portId} outlived the node a third party destroyed"); + Assert.IsNull( + after.GetPort(portId), + $"port {portId} outlived the node a third party destroyed" + ); } } @@ -214,24 +300,41 @@ public async Task AMidiNode_ReportsNoChannelMap() // machines it runs on most. A filter with one MIDI port is how upstream's midi examples make // a MIDI node, and from the registry's side it is one like any other. using var cts = new CancellationTokenSource(Budget); - (PipeWireContext ctx, PipeWireRegistry reg) = await ConnectAsync("pwnet-tp-midimap", cts.Token); + (PipeWireContext ctx, PipeWireRegistry reg) = await ConnectAsync( + "pwnet-tp-midimap", + cts.Token + ); await using (ctx) await using (reg) { - await using PipeWireFilter filter = PipeWireFilter.Create(ctx, "pwnet_tp_midi", - new Dictionary { [PipeWireKeys.PW_KEY_MEDIA_CLASS] = "Midi/Sink" }); + await using PipeWireFilter filter = PipeWireFilter.Create( + ctx, + "pwnet_tp_midi", + new Dictionary { [PipeWireKeys.PW_KEY_MEDIA_CLASS] = "Midi/Sink" } + ); filter.AddMidiPort(PipeWirePortDirection.In, "midi_in"); await filter.ConnectAsync(cancellationToken: cts.Token); uint filterNode = await filter.WaitForNodeIdAsync(cts.Token); - PipeWireGraphSnapshot seen = await WaitForAsync(reg, g => g.GetNode(filterNode) is not null, cts.Token); + PipeWireGraphSnapshot seen = await WaitForAsync( + reg, + g => g.GetNode(filterNode) is not null, + cts.Token + ); PipeWireNode midi = seen.GetNode(filterNode)!; - Assert.AreEqual("Midi/Sink", midi.MediaClass, "the filter did not publish as a MIDI node"); + Assert.AreEqual( + "Midi/Sink", + midi.MediaClass, + "the filter did not publish as a MIDI node" + ); await using PipeWireNodeProxy control = reg.BindNode(midi.NodeId); - Assert.AreEqual(0, (await control.GetChannelMapAsync(cts.Token)).Length, - $"MIDI node {midi.NodeName} should not report audio channels"); + Assert.AreEqual( + 0, + (await control.GetChannelMapAsync(cts.Token)).Length, + $"MIDI node {midi.NodeName} should not report audio channels" + ); } } @@ -241,14 +344,20 @@ public async Task AThirdPartyNodeAppearsAndWeCanLinkToIt() { PwTools.Require(); using var cts = new CancellationTokenSource(Budget); - (PipeWireContext ctx, PipeWireRegistry reg) = await ConnectAsync("pwnet-tp-loopback", cts.Token); + (PipeWireContext ctx, PipeWireRegistry reg) = await ConnectAsync( + "pwnet-tp-loopback", + cts.Token + ); await using (ctx) await using (reg) { await SessionGates.RequireAudioRouteAsync(reg, cts.Token).ConfigureAwait(false); - await using PwTools.Loopback loop = await PwTools.StartLoopbackAsync("pwnet_tp_loop", cts.Token); + await using PwTools.Loopback loop = await PwTools.StartLoopbackAsync( + "pwnet_tp_loop", + cts.Token + ); // pw-loopback publishes a pair: input.NAME (Stream/Input/Audio) and output.NAME // (Stream/Output/Audio). @@ -257,41 +366,60 @@ public async Task AThirdPartyNodeAppearsAndWeCanLinkToIt() // Both directions, not just "has any port". A loopback's input node publishes its // inputs first and its monitors a moment later, so wait for both directions, // not just any port. - g => g.Nodes.Any(n => n.NodeName == "input.pwnet_tp_loop" - && g.GetPortsForNode(n.NodeId, PipeWirePortDirection.In).Any() - && g.GetPortsForNode(n.NodeId, PipeWirePortDirection.Out).Any()) - && g.Nodes.Any(n => n.NodeName == "output.pwnet_tp_loop"), - cts.Token); + g => + g.Nodes.Any(n => + n.NodeName == "input.pwnet_tp_loop" + && g.GetPortsForNode(n.NodeId, PipeWirePortDirection.In).Any() + && g.GetPortsForNode(n.NodeId, PipeWirePortDirection.Out).Any() + ) && g.Nodes.Any(n => n.NodeName == "output.pwnet_tp_loop"), + cts.Token + ); PipeWireNode theirs = graph.Nodes.First(n => n.NodeName == "input.pwnet_tp_loop"); - PipeWireNode theirOutputSide = graph.Nodes.First(n => n.NodeName == "output.pwnet_tp_loop"); + PipeWireNode theirOutputSide = graph.Nodes.First(n => + n.NodeName == "output.pwnet_tp_loop" + ); // Identity parsed from a media.class we did not invent, on a node we did not create. Assert.AreEqual(PipeWireMediaKind.Audio, theirs.Media); - Assert.AreEqual(PipeWireMediaFlow.Sink, theirs.Flow, - "Stream/Input/Audio is the graph writing into it"); - Assert.AreEqual(PipeWireMediaFlow.Source, theirOutputSide.Flow, - "Stream/Output/Audio is the graph reading from it"); + Assert.AreEqual( + PipeWireMediaFlow.Sink, + theirs.Flow, + "Stream/Input/Audio is the graph writing into it" + ); + Assert.AreEqual( + PipeWireMediaFlow.Source, + theirOutputSide.Flow, + "Stream/Output/Audio is the graph reading from it" + ); // Capability comes from ports, and the loopback input node has both: inputs to feed it // and monitors to read back from. Assert.IsTrue(graph.CanSendTo(theirs), "the loopback input must accept audio"); Assert.IsTrue(graph.CanCaptureFrom(theirs), "and expose monitors to read it back"); - PipeWirePort theirInput = graph.GetPortsForNode(theirs.NodeId, PipeWirePortDirection.In) - .OrderBy(p => p.PortId).First(); + PipeWirePort theirInput = graph + .GetPortsForNode(theirs.NodeId, PipeWirePortDirection.In) + .OrderBy(p => p.PortId) + .First(); PipeWireNode ours = await reg.CreateVirtualSink("Feeder") - .WithName("pwnet_tp_feeder").ExecuteAsync(cts.Token); + .WithName("pwnet_tp_feeder") + .ExecuteAsync(cts.Token); PipeWireGraphSnapshot ready = await WaitForPortsAsync(reg, ours.NodeId, cts.Token); - PipeWirePort ourOutput = ready.GetPortsForNode(ours.NodeId, PipeWirePortDirection.Out) - .OrderBy(p => p.PortId).First(); + PipeWirePort ourOutput = ready + .GetPortsForNode(ours.NodeId, PipeWirePortDirection.Out) + .OrderBy(p => p.PortId) + .First(); PipeWireLink link = await reg.CreateLink(ourOutput, theirInput).ExecuteAsync(cts.Token); Assert.IsNotNull(reg.Current.GetLink(link.LinkId)); - Assert.AreEqual(theirs.NodeId, reg.Current.GetLink(link.LinkId)!.InputNodeId, - "the link must land on the third-party node we targeted"); + Assert.AreEqual( + theirs.NodeId, + reg.Current.GetLink(link.LinkId)!.InputNodeId, + "the link must land on the third-party node we targeted" + ); } } @@ -301,7 +429,10 @@ public async Task AThirdPartyNodeDisappearing_TakesItsPortsAndLinksWithIt() { PwTools.Require(); using var cts = new CancellationTokenSource(Budget); - (PipeWireContext ctx, PipeWireRegistry reg) = await ConnectAsync("pwnet-tp-vanish", cts.Token); + (PipeWireContext ctx, PipeWireRegistry reg) = await ConnectAsync( + "pwnet-tp-vanish", + cts.Token + ); await using (ctx) await using (reg) @@ -310,25 +441,39 @@ public async Task AThirdPartyNodeDisappearing_TakesItsPortsAndLinksWithIt() uint theirNode; { - await using PwTools.Loopback loop = - await PwTools.StartLoopbackAsync("pwnet_tp_vanish", cts.Token); + await using PwTools.Loopback loop = await PwTools.StartLoopbackAsync( + "pwnet_tp_vanish", + cts.Token + ); PipeWireGraphSnapshot graph = await WaitForAsync( reg, - g => g.Nodes.Any(n => n.NodeName == "input.pwnet_tp_vanish" - && g.GetPortsForNode(n.NodeId).Length > 0), - cts.Token); + g => + g.Nodes.Any(n => + n.NodeName == "input.pwnet_tp_vanish" + && g.GetPortsForNode(n.NodeId).Length > 0 + ), + cts.Token + ); theirNode = graph.Nodes.First(n => n.NodeName == "input.pwnet_tp_vanish").NodeId; - Assert.IsTrue(graph.GetPortsForNode(theirNode).Length > 0, - "the node must have ports before we can prove they leave with it"); - } // the loopback process is killed here + Assert.IsTrue( + graph.GetPortsForNode(theirNode).Length > 0, + "the node must have ports before we can prove they leave with it" + ); + } // the loopback process is killed here PipeWireGraphSnapshot after = await WaitForAsync( - reg, g => g.GetNode(theirNode) is null, cts.Token); - - Assert.AreEqual(0, after.GetPortsForNode(theirNode).Length, - "a third party going away must not leave its ports in our graph"); + reg, + g => g.GetNode(theirNode) is null, + cts.Token + ); + + Assert.AreEqual( + 0, + after.GetPortsForNode(theirNode).Length, + "a third party going away must not leave its ports in our graph" + ); } } @@ -339,18 +484,29 @@ public async Task AThirdPartyChurningLinks_KeepsOurSnapshotConsistent() { PwTools.Require(); using var cts = new CancellationTokenSource(Budget); - (PipeWireContext ctx, PipeWireRegistry reg) = await ConnectAsync("pwnet-tp-churn", cts.Token); + (PipeWireContext ctx, PipeWireRegistry reg) = await ConnectAsync( + "pwnet-tp-churn", + cts.Token + ); await using (ctx) await using (reg) { - (PipeWireNode a, PipeWireNode b, PipeWireGraphSnapshot ready) = - await TwoNodesAsync(reg, "pwnet_tp_ca", "pwnet_tp_cb", cts.Token); - - PipeWirePort output = ready.GetPortsForNode(a.NodeId, PipeWirePortDirection.Out) - .OrderBy(p => p.PortId).First(); - PipeWirePort input = ready.GetPortsForNode(b.NodeId, PipeWirePortDirection.In) - .OrderBy(p => p.PortId).First(); + (PipeWireNode a, PipeWireNode b, PipeWireGraphSnapshot ready) = await TwoNodesAsync( + reg, + "pwnet_tp_ca", + "pwnet_tp_cb", + cts.Token + ); + + PipeWirePort output = ready + .GetPortsForNode(a.NodeId, PipeWirePortDirection.Out) + .OrderBy(p => p.PortId) + .First(); + PipeWirePort input = ready + .GetPortsForNode(b.NodeId, PipeWirePortDirection.In) + .OrderBy(p => p.PortId) + .First(); using var stop = new CancellationTokenSource(); Exception? torn = null; @@ -359,41 +515,70 @@ public async Task AThirdPartyChurningLinks_KeepsOurSnapshotConsistent() // A dedicated thread rather than a pool work item: the loop never awaits, so on the // pool it holds a worker for the whole test and the awaits below queue behind the // pool's thread injection. - Task reader = Task.Factory.StartNew(() => - { - try + Task reader = Task.Factory.StartNew( + () => { - while (!stop.IsCancellationRequested) + try { - PipeWireGraphSnapshot g = reg.Current; - foreach (PipeWireLink link in g.Links) + while (!stop.IsCancellationRequested) { - if (!g.GetOutputLinksForPort(link.OutputPortId).Contains(link)) - throw new InvalidOperationException($"link {link.LinkId} missing from its own index"); - if (!g.GetInputLinksForPort(link.InputPortId).Contains(link)) - throw new InvalidOperationException($"link {link.LinkId} missing from the input index"); + PipeWireGraphSnapshot g = reg.Current; + foreach (PipeWireLink link in g.Links) + { + if (!g.GetOutputLinksForPort(link.OutputPortId).Contains(link)) + throw new InvalidOperationException( + $"link {link.LinkId} missing from its own index" + ); + if (!g.GetInputLinksForPort(link.InputPortId).Contains(link)) + throw new InvalidOperationException( + $"link {link.LinkId} missing from the input index" + ); + } + Interlocked.Increment(ref reads); + Thread.Yield(); } - Interlocked.Increment(ref reads); - Thread.Yield(); } - } - catch (Exception ex) { torn ??= ex; } - }, CancellationToken.None, TaskCreationOptions.LongRunning, TaskScheduler.Default); + catch (Exception ex) + { + torn ??= ex; + } + }, + CancellationToken.None, + TaskCreationOptions.LongRunning, + TaskScheduler.Default + ); for (int i = 0; i < 6; i++) { - await PwTools.LinkAsync($"pwnet_tp_ca:{output.PortName}", $"pwnet_tp_cb:{input.PortName}", cts.Token); + await PwTools.LinkAsync( + $"pwnet_tp_ca:{output.PortName}", + $"pwnet_tp_cb:{input.PortName}", + cts.Token + ); PipeWireGraphSnapshot on = await WaitForAsync( - reg, g => g.GetOutputLinksForPort(output.PortId).Length == 1, cts.Token); - - await PwTools.DisconnectAsync(on.GetOutputLinksForPort(output.PortId).Single().LinkId, cts.Token); - await WaitForAsync(reg, g => g.GetOutputLinksForPort(output.PortId).Length == 0, cts.Token); + reg, + g => g.GetOutputLinksForPort(output.PortId).Length == 1, + cts.Token + ); + + await PwTools.DisconnectAsync( + on.GetOutputLinksForPort(output.PortId).Single().LinkId, + cts.Token + ); + await WaitForAsync( + reg, + g => g.GetOutputLinksForPort(output.PortId).Length == 0, + cts.Token + ); } await stop.CancelAsync(); await reader; - Assert.IsNull(torn, $"a reader saw an inconsistent graph while a third party edited it: {torn}"); + Assert.IsNull( + torn, + $"a reader saw an inconsistent graph while a third party edited it: {torn}" + ); Assert.IsTrue(Volatile.Read(ref reads) > 50, $"the reader barely ran ({reads} reads)"); } } @@ -403,47 +588,79 @@ public async Task OurViewOfLinks_MatchesPwLinkExactlyAfterChurn() { PwTools.Require(); using var cts = new CancellationTokenSource(Budget); - (PipeWireContext ctx, PipeWireRegistry reg) = await ConnectAsync("pwnet-tp-agree", cts.Token); + (PipeWireContext ctx, PipeWireRegistry reg) = await ConnectAsync( + "pwnet-tp-agree", + cts.Token + ); await using (ctx) await using (reg) { - (PipeWireNode a, PipeWireNode b, PipeWireGraphSnapshot ready) = - await TwoNodesAsync(reg, "pwnet_tp_aa", "pwnet_tp_ab", cts.Token); - - PipeWirePort[] outputs = [.. ready.GetPortsForNode(a.NodeId, PipeWirePortDirection.Out).OrderBy(p => p.PortId)]; - PipeWirePort[] inputs = [.. ready.GetPortsForNode(b.NodeId, PipeWirePortDirection.In).OrderBy(p => p.PortId)]; + (PipeWireNode a, PipeWireNode b, PipeWireGraphSnapshot ready) = await TwoNodesAsync( + reg, + "pwnet_tp_aa", + "pwnet_tp_ab", + cts.Token + ); + + PipeWirePort[] outputs = + [ + .. ready + .GetPortsForNode(a.NodeId, PipeWirePortDirection.Out) + .OrderBy(p => p.PortId), + ]; + PipeWirePort[] inputs = + [ + .. ready.GetPortsForNode(b.NodeId, PipeWirePortDirection.In).OrderBy(p => p.PortId), + ]; // Wire both channels, one by each side, then compare the whole picture. - PipeWireLink ourLink = await reg.CreateLink(outputs[0], inputs[0]).ExecuteAsync(cts.Token); - await PwTools.LinkAsync($"pwnet_tp_aa:{outputs[1].PortName}", $"pwnet_tp_ab:{inputs[1].PortName}", cts.Token); + PipeWireLink ourLink = await reg.CreateLink(outputs[0], inputs[0]) + .ExecuteAsync(cts.Token); + await PwTools.LinkAsync( + $"pwnet_tp_aa:{outputs[1].PortName}", + $"pwnet_tp_ab:{inputs[1].PortName}", + cts.Token + ); PipeWireGraphSnapshot graph = await WaitForAsync( reg, - g => g.GetOutputLinksForPort(outputs[0].PortId).Length == 1 - && g.GetOutputLinksForPort(outputs[1].PortId).Length == 1, - cts.Token); + g => + g.GetOutputLinksForPort(outputs[0].PortId).Length == 1 + && g.GetOutputLinksForPort(outputs[1].PortId).Length == 1, + cts.Token + ); - List<(uint Link, uint Output, uint Input)> theirs = await PwTools.ListLinksAsync(cts.Token); + List<(uint Link, uint Output, uint Input)> theirs = await PwTools.ListLinksAsync( + cts.Token + ); foreach (PipeWirePort port in outputs) { PipeWireLink mine = graph.GetOutputLinksForPort(port.PortId).Single(); - (uint Link, uint Output, uint Input) match = theirs.FirstOrDefault(l => l.Link == mine.LinkId); + (uint Link, uint Output, uint Input) match = theirs.FirstOrDefault(l => + l.Link == mine.LinkId + ); Assert.AreNotEqual(0u, match.Link, $"pw-link does not know link {mine.LinkId}"); Assert.AreEqual(mine.OutputPortId, match.Output); Assert.AreEqual(mine.InputPortId, match.Input); } - Assert.IsNotNull(graph.GetLink(ourLink.LinkId), "our own link must still be there among theirs"); + Assert.IsNotNull( + graph.GetLink(ourLink.LinkId), + "our own link must still be there among theirs" + ); } } // ---------------------------------------------------------------- helper - private static async Task<(PipeWireNode A, PipeWireNode B, PipeWireGraphSnapshot Ready)> TwoNodesAsync( - PipeWireRegistry reg, string nameA, string nameB, CancellationToken ct) + private static async Task<( + PipeWireNode A, + PipeWireNode B, + PipeWireGraphSnapshot Ready + )> TwoNodesAsync(PipeWireRegistry reg, string nameA, string nameB, CancellationToken ct) { PipeWireNode a = await reg.CreateVirtualSink(nameA).WithName(nameA).ExecuteAsync(ct); PipeWireNode b = await reg.CreateVirtualSink(nameB).WithName(nameB).ExecuteAsync(ct); @@ -451,7 +668,8 @@ public async Task OurViewOfLinks_MatchesPwLinkExactlyAfterChurn() PipeWireGraphSnapshot ready = await WaitForAsync( reg, g => g.GetPortsForNode(a.NodeId).Length == 4 && g.GetPortsForNode(b.NodeId).Length == 4, - ct); + ct + ); return (a, b, ready); } diff --git a/tests/PipeWire.NET.Tests/ThreadAffinityTests.cs b/tests/PipeWire.NET.Tests/ThreadAffinityTests.cs index 5aa95a6..5236dca 100644 --- a/tests/PipeWire.NET.Tests/ThreadAffinityTests.cs +++ b/tests/PipeWire.NET.Tests/ThreadAffinityTests.cs @@ -38,7 +38,10 @@ private static void RequireLinux() Assert.Inconclusive("PipeWire is a Linux daemon."); } - private static async Task ConnectAsync(string name, CancellationToken cancellationToken) + private static async Task ConnectAsync( + string name, + CancellationToken cancellationToken + ) { var ctx = new PipeWireContext(name, ConsoleTestLoggerFactory.Instance); await ctx.StartAsync(cancellationToken); @@ -63,7 +66,9 @@ public async Task AnRtFilter_ProcessesOffTheLoopThread() await reg.WaitForInitialEnumerationAsync(cts.Token); var loopThreads = new ConcurrentBag(); - var loopThreadSeen = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var loopThreadSeen = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously + ); reg.GraphChanged += (_, _) => { loopThreads.Add(Environment.CurrentManagedThreadId); @@ -75,7 +80,9 @@ public async Task AnRtFilter_ProcessesOffTheLoopThread() int processThread = 0; bool onLoopThreadInsideProcess = true; - var processed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var processed = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously + ); filter.ProcessCallback = (PipeWireFilter f, uint _, in PipeWireGraphClock _) => { @@ -91,9 +98,16 @@ public async Task AnRtFilter_ProcessesOffTheLoopThread() // A filter with no peer is not scheduled, so give it one: the graph runs once something // drives it, and a virtual sink is the cheapest driver this suite already knows how to make. PipeWireNode sink = await reg.CreateVirtualSinkAsync( - "pwnet rt affinity sink", cancellationToken: cts.Token); - - uint filterPort = await PortOfAsync(reg, filter.NodeId!.Value, PipeWirePortDirection.Out, cts.Token); + "pwnet rt affinity sink", + cancellationToken: cts.Token + ); + + uint filterPort = await PortOfAsync( + reg, + filter.NodeId!.Value, + PipeWirePortDirection.Out, + cts.Token + ); uint sinkPort = await PortOfAsync(reg, sink.NodeId, PipeWirePortDirection.In, cts.Token); await reg.CreateLinkAsync(filterPort, sinkPort, cts.Token); @@ -104,12 +118,14 @@ public async Task AnRtFilter_ProcessesOffTheLoopThread() Assert.AreNotEqual(0, processThread, "the process callback never ran"); Assert.IsFalse( loopThreads.Contains(processThread), - "an RtProcess filter was processed on the same thread the graph events arrive on"); + "an RtProcess filter was processed on the same thread the graph events arrive on" + ); Assert.IsFalse( onLoopThreadInsideProcess, "the context claimed the data loop was its own loop thread, which would let a filter " - + "take the loop lock from inside a realtime callback"); + + "take the loop lock from inside a realtime callback" + ); await reg.DestroyGlobalAsync(sink.NodeId, cts.Token); } @@ -143,14 +159,17 @@ public async Task TwoContexts_EachKeepTheirCallbacksOnTheirOwnLoop() // Something both registries must report, so both bags are certain to fill. PipeWireNode sink = await firstReg.CreateVirtualSinkAsync( - "pwnet affinity shared sink", cancellationToken: cts.Token); + "pwnet affinity shared sink", + cancellationToken: cts.Token + ); for (var i = 0; i < 200 && !secondReg.Current.Nodes.Any(n => n.NodeId == sink.NodeId); i++) await Task.Delay(50, cts.Token); Assert.IsTrue( secondReg.Current.Nodes.Any(n => n.NodeId == sink.NodeId), - "the second context never saw a node the first one made"); + "the second context never saw a node the first one made" + ); Assert.IsFalse(firstThreads.IsEmpty, "the first context reported nothing"); Assert.IsFalse(secondThreads.IsEmpty, "the second context reported nothing"); @@ -159,8 +178,10 @@ public async Task TwoContexts_EachKeepTheirCallbacksOnTheirOwnLoop() shared.IntersectWith(secondThreads); Assert.AreEqual( - 0, shared.Count, - "two contexts delivered callbacks on the same thread, so they are not two loops"); + 0, + shared.Count, + "two contexts delivered callbacks on the same thread, so they are not two loops" + ); await firstReg.DestroyGlobalAsync(sink.NodeId, cts.Token); } @@ -177,7 +198,10 @@ public async Task AContextBlockedInAHandler_DoesNotStallAnother() RequireLinux(); using var cts = new CancellationTokenSource(Budget); - await using PipeWireContext blocked = await ConnectAsync("pwnet-affinity-blocked", cts.Token); + await using PipeWireContext blocked = await ConnectAsync( + "pwnet-affinity-blocked", + cts.Token + ); await using PipeWireContext free = await ConnectAsync("pwnet-affinity-free", cts.Token); await using var blockedReg = new PipeWireRegistry(blocked); @@ -200,18 +224,24 @@ public async Task AContextBlockedInAHandler_DoesNotStallAnother() try { PipeWireNode sink = await freeReg.CreateVirtualSinkAsync( - "pwnet affinity block sink", cancellationToken: cts.Token); + "pwnet affinity block sink", + cancellationToken: cts.Token + ); // The blocked context is now sitting in its handler. The free one has to finish its own // round trip anyway, which is the whole assertion. await entered.Task.WaitAsync(TimeSpan.FromSeconds(15), cts.Token); PipeWireNode other = await freeReg.CreateVirtualSinkAsync( - "pwnet affinity free sink", cancellationToken: cts.Token); + "pwnet affinity free sink", + cancellationToken: cts.Token + ); Assert.AreNotEqual( - 0u, other.NodeId, - "a context could not finish its own work while another sat in a handler"); + 0u, + other.NodeId, + "a context could not finish its own work while another sat in a handler" + ); release.Set(); @@ -230,15 +260,20 @@ public async Task AContextBlockedInAHandler_DoesNotStallAnother() /// The id of one port of a node, once the daemon has published it. private static async Task PortOfAsync( - PipeWireRegistry registry, uint nodeId, PipeWirePortDirection direction, - CancellationToken cancellationToken) + PipeWireRegistry registry, + uint nodeId, + PipeWirePortDirection direction, + CancellationToken cancellationToken + ) { for (var i = 0; i < 200; i++) { - PipeWirePort? port = registry.Current.Ports - .FirstOrDefault(p => p.NodeId == nodeId && p.PortDirection == direction); + PipeWirePort? port = registry.Current.Ports.FirstOrDefault(p => + p.NodeId == nodeId && p.PortDirection == direction + ); - if (port is not null) return port.PortId; + if (port is not null) + return port.PortId; await Task.Delay(50, cancellationToken); } diff --git a/tests/PipeWire.NET.Tests/ToolOracleTests.cs b/tests/PipeWire.NET.Tests/ToolOracleTests.cs index 51047f2..373548c 100644 --- a/tests/PipeWire.NET.Tests/ToolOracleTests.cs +++ b/tests/PipeWire.NET.Tests/ToolOracleTests.cs @@ -29,7 +29,9 @@ private static void RequireLinux() } private static async Task<(PipeWireContext Context, PipeWireRegistry Registry)> ConnectAsync( - string name, CancellationToken cancellationToken) + string name, + CancellationToken cancellationToken + ) { var context = new PipeWireContext(name, ConsoleTestLoggerFactory.Instance); await context.StartAsync(cancellationToken); @@ -47,12 +49,17 @@ public async Task OurGraph_AgreesWithPwDumpOnEveryObjectKind() RequireLinux(); CliTool.Require("pw-dump"); using var cts = new CancellationTokenSource(Budget); - (PipeWireContext ctx, PipeWireRegistry registry) = await ConnectAsync("pwnet-oracle-dump", cts.Token); + (PipeWireContext ctx, PipeWireRegistry registry) = await ConnectAsync( + "pwnet-oracle-dump", + cts.Token + ); await using (ctx) await using (registry) { - PipeWireNode node = await registry.CreateVirtualSink("Oracle") - .WithName(Unique("pwnet_oracle")).ExecuteAsync(cts.Token); + PipeWireNode node = await registry + .CreateVirtualSink("Oracle") + .WithName(Unique("pwnet_oracle")) + .ExecuteAsync(cts.Token); // Both views are taken after the same barrier, so neither is a moving target - and the // comparison is by id, which is the only thing both sides agree to call an object. @@ -60,14 +67,38 @@ public async Task OurGraph_AgreesWithPwDumpOnEveryObjectKind() PwDump dump = await PwDump.CaptureAsync(cts.Token); PipeWireGraphSnapshot ours = registry.Current; - await AssertWeSeeAllAsync("Node", d => d.IdsOfKind("Node"), - g => g.Nodes.Select(n => n.NodeId), dump, registry, cts.Token); - await AssertWeSeeAllAsync("Port", d => d.IdsOfKind("Port"), - g => g.Ports.Select(p => p.PortId), dump, registry, cts.Token); - await AssertWeSeeAllAsync("Link", d => d.IdsOfKind("Link"), - g => g.Links.Select(l => l.LinkId), dump, registry, cts.Token); - await AssertWeSeeAllAsync("Device", d => d.IdsOfKind("Device"), - g => g.Devices.Select(x => x.Id), dump, registry, cts.Token); + await AssertWeSeeAllAsync( + "Node", + d => d.IdsOfKind("Node"), + g => g.Nodes.Select(n => n.NodeId), + dump, + registry, + cts.Token + ); + await AssertWeSeeAllAsync( + "Port", + d => d.IdsOfKind("Port"), + g => g.Ports.Select(p => p.PortId), + dump, + registry, + cts.Token + ); + await AssertWeSeeAllAsync( + "Link", + d => d.IdsOfKind("Link"), + g => g.Links.Select(l => l.LinkId), + dump, + registry, + cts.Token + ); + await AssertWeSeeAllAsync( + "Device", + d => d.IdsOfKind("Device"), + g => g.Devices.Select(x => x.Id), + dump, + registry, + cts.Token + ); // Clients are compared as a superset: pw-dump connects to produce the dump, so its own // client is in its output and cannot be in a snapshot taken before it ran. // The other direction, which is racy by construction: our snapshot is taken after the @@ -83,29 +114,66 @@ await AssertWeSeeAllAsync("Device", d => d.IdsOfKind("Device"), HashSet theirsNow = second.IdsOfKind("Client").ToHashSet(); HashSet oursNow = registry.Current.Clients.Select(c => c.Id).ToHashSet(); - List phantom = [.. weLack.Where(id => !theirsNow.Contains(id) && oursNow.Contains(id))]; + List phantom = + [ + .. weLack.Where(id => !theirsNow.Contains(id) && oursNow.Contains(id)), + ]; - Assert.IsTrue(phantom.Count == 0, - $"Client: we still report ids no dump has [{string.Join(",", phantom)}]"); + Assert.IsTrue( + phantom.Count == 0, + $"Client: we still report ids no dump has [{string.Join(",", phantom)}]" + ); } await AssertWeSeeAllAsync( "Client", - d => d.OfKind("Client") - .Where(e => !string.Equals(e.Prop("application.name"), "pw-dump", StringComparison.Ordinal)) - .Select(e => e.Id), - g => g.Clients.Select(c => c.Id), dump, registry, cts.Token); - await AssertWeSeeAllAsync("Factory", d => d.IdsOfKind("Factory"), - g => g.Factories.Select(f => f.Id), dump, registry, cts.Token); - await AssertWeSeeAllAsync("Module", d => d.IdsOfKind("Module"), - g => g.Modules.Select(m => m.Id), dump, registry, cts.Token); - await AssertWeSeeAllAsync("Metadata", d => d.IdsOfKind("Metadata"), - g => g.Metadata.Select(m => m.Id), dump, registry, cts.Token); + d => + d.OfKind("Client") + .Where(e => + !string.Equals( + e.Prop("application.name"), + "pw-dump", + StringComparison.Ordinal + ) + ) + .Select(e => e.Id), + g => g.Clients.Select(c => c.Id), + dump, + registry, + cts.Token + ); + await AssertWeSeeAllAsync( + "Factory", + d => d.IdsOfKind("Factory"), + g => g.Factories.Select(f => f.Id), + dump, + registry, + cts.Token + ); + await AssertWeSeeAllAsync( + "Module", + d => d.IdsOfKind("Module"), + g => g.Modules.Select(m => m.Id), + dump, + registry, + cts.Token + ); + await AssertWeSeeAllAsync( + "Metadata", + d => d.IdsOfKind("Metadata"), + g => g.Metadata.Select(m => m.Id), + dump, + registry, + cts.Token + ); // And the properties we parsed for our own node match what pw-dump read independently. PwDump.Entry? theirs = dump.ById(node.NodeId); Assert.IsNotNull(theirs, "pw-dump does not see a node we created"); - Assert.AreEqual(theirs!.Prop("node.name"), ours.GetNode(node.NodeId)!.NodeName, - "we and pw-dump disagree about a node's name"); + Assert.AreEqual( + theirs!.Prop("node.name"), + ours.GetNode(node.NodeId)!.NodeName, + "we and pw-dump disagree about a node's name" + ); await registry.DestroyGlobalAsync(node.NodeId, cts.Token); } @@ -133,7 +201,10 @@ public async Task ThePropertiesTheGatesClassifyOn_AreWhatPwDumpRead() RequireLinux(); CliTool.Require("pw-dump"); using var cts = new CancellationTokenSource(Budget); - (PipeWireContext ctx, PipeWireRegistry registry) = await ConnectAsync("pwnet-oracle-props", cts.Token); + (PipeWireContext ctx, PipeWireRegistry registry) = await ConnectAsync( + "pwnet-oracle-props", + cts.Token + ); await using (ctx) await using (registry) { @@ -146,25 +217,42 @@ public async Task ThePropertiesTheGatesClassifyOn_AreWhatPwDumpRead() foreach (PwDump.Entry entry in dump.OfKind("Device")) { PipeWireDevice? mine = ours.Devices.FirstOrDefault(d => d.Id == entry.Id); - if (mine is null) continue; // presence is the other test's business + if (mine is null) + continue; // presence is the other test's business if (!string.Equals(mine.Api, entry.Prop("device.api"), StringComparison.Ordinal)) disagreements.Add( - $"device {entry.Id}: device.api is '{entry.Prop("device.api")}', we say '{mine.Api}'"); - - if (!string.Equals(mine.MediaClass, entry.Prop("media.class"), StringComparison.Ordinal)) + $"device {entry.Id}: device.api is '{entry.Prop("device.api")}', we say '{mine.Api}'" + ); + + if ( + !string.Equals( + mine.MediaClass, + entry.Prop("media.class"), + StringComparison.Ordinal + ) + ) disagreements.Add( - $"device {entry.Id}: media.class is '{entry.Prop("media.class")}', we say '{mine.MediaClass}'"); + $"device {entry.Id}: media.class is '{entry.Prop("media.class")}', we say '{mine.MediaClass}'" + ); } foreach (PwDump.Entry entry in dump.OfKind("Node")) { PipeWireNode? mine = ours.GetNode(entry.Id); - if (mine is null) continue; - - if (!string.Equals(mine.MediaClass, entry.Prop("media.class"), StringComparison.Ordinal)) + if (mine is null) + continue; + + if ( + !string.Equals( + mine.MediaClass, + entry.Prop("media.class"), + StringComparison.Ordinal + ) + ) disagreements.Add( - $"node {entry.Id}: media.class is '{entry.Prop("media.class")}', we say '{mine.MediaClass}'"); + $"node {entry.Id}: media.class is '{entry.Prop("media.class")}', we say '{mine.MediaClass}'" + ); } Assert.IsTrue(disagreements.Count == 0, string.Join("; ", disagreements)); @@ -190,7 +278,8 @@ private static async Task AssertWeSeeAllAsync( Func> ours, PwDump dump, PipeWireRegistry registry, - CancellationToken cancellationToken) + CancellationToken cancellationToken + ) { HashSet wanted = theirs(dump).ToHashSet(); List missing = []; @@ -198,7 +287,8 @@ private static async Task AssertWeSeeAllAsync( for (int attempt = 0; attempt < 40; attempt++) { missing = [.. wanted.Except(ours(registry.Current).ToHashSet()).Order()]; - if (missing.Count == 0) return; + if (missing.Count == 0) + return; await Task.Delay(TimeSpan.FromMilliseconds(50), cancellationToken); } @@ -210,8 +300,10 @@ private static async Task AssertWeSeeAllAsync( List real = [.. missing.Where(stillThere.Contains)]; - Assert.IsTrue(real.Count == 0, - $"{kind}: pw-dump reports ids that never reached our graph [{string.Join(",", real)}]"); + Assert.IsTrue( + real.Count == 0, + $"{kind}: pw-dump reports ids that never reached our graph [{string.Join(",", real)}]" + ); } [TestMethod] @@ -220,12 +312,17 @@ public async Task AVolumeWpctlSets_IsReportedByUs() RequireLinux(); CliTool wpctl = CliTool.Require("wpctl"); using var cts = new CancellationTokenSource(Budget); - (PipeWireContext ctx, PipeWireRegistry registry) = await ConnectAsync("pwnet-oracle-wpctl", cts.Token); + (PipeWireContext ctx, PipeWireRegistry registry) = await ConnectAsync( + "pwnet-oracle-wpctl", + cts.Token + ); await using (ctx) await using (registry) { - PipeWireNode node = await registry.CreateVirtualSink("WpctlOracle") - .WithName(Unique("pwnet_wpctl")).ExecuteAsync(cts.Token); + PipeWireNode node = await registry + .CreateVirtualSink("WpctlOracle") + .WithName(Unique("pwnet_wpctl")) + .ExecuteAsync(cts.Token); string nodeName = node.NodeName!; await using PipeWireNodeProxy control = registry.BindNode(node.NodeId); @@ -240,10 +337,14 @@ public async Task AVolumeWpctlSets_IsReportedByUs() const float Asked = 0.42f; string id = node.NodeId.ToString(CultureInfo.InvariantCulture); (int exit, _, string stderr) = await wpctl.RunAsync( - ["set-volume", id, Asked.ToString(CultureInfo.InvariantCulture)], cts.Token); + ["set-volume", id, Asked.ToString(CultureInfo.InvariantCulture)], + cts.Token + ); if (exit != 0) - Assert.Inconclusive($"wpctl cannot set this node's volume on this session: {stderr}"); + Assert.Inconclusive( + $"wpctl cannot set this node's volume on this session: {stderr}" + ); // What the session actually holds now, in wpctl's own cubic scale, rather than what // was asked: the session manager may overwrite the write on its way through, and @@ -251,48 +352,70 @@ public async Task AVolumeWpctlSets_IsReportedByUs() float cubic = await ReadWpctlVolumeAsync(wpctl, id, cts.Token); float expected = cubic * cubic * cubic; - bool sawIt = await EventuallyAsync(async () => - { - ImmutableArray volumes = await control.GetChannelVolumesAsync(cts.Token); - return !volumes.IsDefaultOrEmpty && volumes.All(v => Math.Abs(v - expected) < 0.005f); - }, TimeSpan.FromSeconds(15), cts.Token); + bool sawIt = await EventuallyAsync( + async () => + { + ImmutableArray volumes = await control.GetChannelVolumesAsync(cts.Token); + return !volumes.IsDefaultOrEmpty + && volumes.All(v => Math.Abs(v - expected) < 0.005f); + }, + TimeSpan.FromSeconds(15), + cts.Token + ); if (!sawIt) { // Ids are reused under churn: if this id no longer names our node, wpctl and this // test talked to different objects and there is nothing to compare. PwDump dump = await PwDump.CaptureAsync(cts.Token); - string? owner = dump.OfKind("Node").FirstOrDefault(e => e.Id == node.NodeId) + string? owner = dump.OfKind("Node") + .FirstOrDefault(e => e.Id == node.NodeId) ?.Prop("node.name"); if (!string.Equals(owner, nodeName, StringComparison.Ordinal)) - Assert.Inconclusive($"node id {node.NodeId} changed hands mid-test (now '{owner}')"); + Assert.Inconclusive( + $"node id {node.NodeId} changed hands mid-test (now '{owner}')" + ); } - Assert.IsTrue(sawIt, + Assert.IsTrue( + sawIt, $"wpctl reports {cubic} (expecting {expected} linear) and we report " - + $"[{string.Join(",", await control.GetChannelVolumesAsync(cts.Token))}]"); + + $"[{string.Join(",", await control.GetChannelVolumesAsync(cts.Token))}]" + ); await registry.DestroyGlobalAsync(node.NodeId, cts.Token); } } private static async Task ReadWpctlVolumeAsync( - CliTool wpctl, string id, CancellationToken cancellationToken) + CliTool wpctl, + string id, + CancellationToken cancellationToken + ) { (int exit, string stdout, string stderr) = await wpctl.RunAsync( - ["get-volume", id], cancellationToken, TimeSpan.FromSeconds(10)); + ["get-volume", id], + cancellationToken, + TimeSpan.FromSeconds(10) + ); if (exit != 0) Assert.Inconclusive($"wpctl cannot read this node's volume on this session: {stderr}"); // "Volume: 0.42", with " [MUTED]" after it when muted. - System.Text.RegularExpressions.Match match = - System.Text.RegularExpressions.Regex.Match(stdout, @"Volume:\s*([0-9.]+)"); + System.Text.RegularExpressions.Match match = System.Text.RegularExpressions.Regex.Match( + stdout, + @"Volume:\s*([0-9.]+)" + ); float cubic = 0f; - bool parsed = match.Success && float.TryParse(match.Groups[1].Value, - System.Globalization.NumberStyles.Float, - System.Globalization.CultureInfo.InvariantCulture, - out cubic); + bool parsed = + match.Success + && float.TryParse( + match.Groups[1].Value, + System.Globalization.NumberStyles.Float, + System.Globalization.CultureInfo.InvariantCulture, + out cubic + ); if (!parsed) Assert.Inconclusive($"wpctl reported a volume this cannot parse: {stdout}"); @@ -305,7 +428,10 @@ public async Task ANativePwCatPlayer_IsSeenAsAStreamAndLinked() RequireLinux(); CliTool pwcat = CliTool.Require("pw-cat"); using var cts = new CancellationTokenSource(Budget); - (PipeWireContext ctx, PipeWireRegistry registry) = await ConnectAsync("pwnet-oracle-pwcat", cts.Token); + (PipeWireContext ctx, PipeWireRegistry registry) = await ConnectAsync( + "pwnet-oracle-pwcat", + cts.Token + ); await using (ctx) await using (registry) { @@ -313,36 +439,67 @@ public async Task ANativePwCatPlayer_IsSeenAsAStreamAndLinked() // is a plain libpipewire client, so it exercises the ordinary stream path. string name = Unique("pwnet_pwcat"); using Process player = pwcat.Start( - "--playback", "--target", "0", "--media-role", "Music", - "--rate", "48000", "--channels", "2", "--format", "s16", "--raw", - "--properties", $"node.name={name}", "/dev/zero"); + "--playback", + "--target", + "0", + "--media-role", + "Music", + "--rate", + "48000", + "--channels", + "2", + "--format", + "s16", + "--raw", + "--properties", + $"node.name={name}", + "/dev/zero" + ); try { - bool appeared = await EventuallyAsync(async () => - { - await registry.WaitForInitialEnumerationAsync(cts.Token); - return registry.Current.Nodes.Any(n => string.Equals(n.NodeName, name, StringComparison.Ordinal)); - }, TimeSpan.FromSeconds(20), cts.Token); + bool appeared = await EventuallyAsync( + async () => + { + await registry.WaitForInitialEnumerationAsync(cts.Token); + return registry.Current.Nodes.Any(n => + string.Equals(n.NodeName, name, StringComparison.Ordinal) + ); + }, + TimeSpan.FromSeconds(20), + cts.Token + ); if (!appeared) Assert.Inconclusive("pw-cat did not publish a node on this session."); - PipeWireNode published = registry.Current.Nodes - .First(n => string.Equals(n.NodeName, name, StringComparison.Ordinal)); + PipeWireNode published = registry.Current.Nodes.First(n => + string.Equals(n.NodeName, name, StringComparison.Ordinal) + ); // Its ports have to reach us too, not just the node. Assert.IsTrue( - await EventuallyAsync(async () => - { - await registry.WaitForInitialEnumerationAsync(cts.Token); - return !registry.Current.GetPortsForNode(published.NodeId).IsEmpty; - }, TimeSpan.FromSeconds(15), cts.Token), - "a native player's node arrived without its ports"); + await EventuallyAsync( + async () => + { + await registry.WaitForInitialEnumerationAsync(cts.Token); + return !registry.Current.GetPortsForNode(published.NodeId).IsEmpty; + }, + TimeSpan.FromSeconds(15), + cts.Token + ), + "a native player's node arrived without its ports" + ); } finally { - try { player.Kill(entireProcessTree: true); } catch (InvalidOperationException) { /* gone */ } + try + { + player.Kill(entireProcessTree: true); + } + catch (InvalidOperationException) + { /* gone */ + } player.WaitForExit(3000); } } @@ -354,26 +511,41 @@ public async Task PwMon_SeesTheSameAdditionsAndRemovalsWeRaise() RequireLinux(); CliTool pwmon = CliTool.Require("pw-mon"); using var cts = new CancellationTokenSource(Budget); - (PipeWireContext ctx, PipeWireRegistry registry) = await ConnectAsync("pwnet-oracle-pwmon", cts.Token); + (PipeWireContext ctx, PipeWireRegistry registry) = await ConnectAsync( + "pwnet-oracle-pwmon", + cts.Token + ); await using (ctx) await using (registry) { var added = new List(); var removed = new List(); - void OnAdded(PipeWireNode n) { lock (added) added.Add(n.NodeId); } - void OnRemoved(uint id) { lock (removed) removed.Add(id); } + void OnAdded(PipeWireNode n) + { + lock (added) + added.Add(n.NodeId); + } + void OnRemoved(uint id) + { + lock (removed) + removed.Add(id); + } registry.NodeAdded += OnAdded; registry.NodeRemoved += OnRemoved; using Process mon = pwmon.Start(); var monitorOutput = new System.Text.StringBuilder(); - Task pump = Task.Run(async () => - { - string? line; - while ((line = await mon.StandardOutput.ReadLineAsync(cts.Token)) is not null) - lock (monitorOutput) monitorOutput.AppendLine(line); - }, cts.Token); + Task pump = Task.Run( + async () => + { + string? line; + while ((line = await mon.StandardOutput.ReadLineAsync(cts.Token)) is not null) + lock (monitorOutput) + monitorOutput.AppendLine(line); + }, + cts.Token + ); try { @@ -383,14 +555,20 @@ public async Task PwMon_SeesTheSameAdditionsAndRemovalsWeRaise() bool dumped = await EventuallyAsync( () => { - lock (monitorOutput) return Task.FromResult(monitorOutput.Length > 0); + lock (monitorOutput) + return Task.FromResult(monitorOutput.Length > 0); }, - TimeSpan.FromSeconds(10), cts.Token); + TimeSpan.FromSeconds(10), + cts.Token + ); - if (!dumped) Assert.Inconclusive("pw-mon printed nothing, so it never started."); + if (!dumped) + Assert.Inconclusive("pw-mon printed nothing, so it never started."); - PipeWireNode node = await registry.CreateVirtualSink("MonOracle") - .WithName(Unique("pwnet_mon")).ExecuteAsync(cts.Token); + PipeWireNode node = await registry + .CreateVirtualSink("MonOracle") + .WithName(Unique("pwnet_mon")) + .ExecuteAsync(cts.Token); await registry.DestroyGlobalAsync(node.NodeId, cts.Token); await registry.WaitForInitialEnumerationAsync(cts.Token); @@ -402,36 +580,55 @@ public async Task PwMon_SeesTheSameAdditionsAndRemovalsWeRaise() () => { string current; - lock (monitorOutput) current = monitorOutput.ToString(); + lock (monitorOutput) + current = monitorOutput.ToString(); return Task.FromResult( current.Contains($"id: {node.NodeId}", StringComparison.Ordinal) - || current.Contains($"id:{node.NodeId}", StringComparison.Ordinal)); + || current.Contains($"id:{node.NodeId}", StringComparison.Ordinal) + ); }, - TimeSpan.FromSeconds(10), cts.Token); + TimeSpan.FromSeconds(10), + cts.Token + ); - lock (added) Assert.IsTrue(added.Contains(node.NodeId), "we never raised NodeAdded"); - lock (removed) Assert.IsTrue(removed.Contains(node.NodeId), "we never raised NodeRemoved"); + lock (added) + Assert.IsTrue(added.Contains(node.NodeId), "we never raised NodeAdded"); + lock (removed) + Assert.IsTrue(removed.Contains(node.NodeId), "we never raised NodeRemoved"); - Assert.IsTrue(mentioned, "pw-mon never mentioned an object we created and destroyed"); + Assert.IsTrue( + mentioned, + "pw-mon never mentioned an object we created and destroyed" + ); } finally { registry.NodeAdded -= OnAdded; registry.NodeRemoved -= OnRemoved; - try { mon.Kill(entireProcessTree: true); } catch (InvalidOperationException) { /* gone */ } + try + { + mon.Kill(entireProcessTree: true); + } + catch (InvalidOperationException) + { /* gone */ + } mon.WaitForExit(3000); } } } private static async Task EventuallyAsync( - Func> condition, TimeSpan within, CancellationToken cancellationToken) + Func> condition, + TimeSpan within, + CancellationToken cancellationToken + ) { long deadline = Environment.TickCount64 + (long)within.TotalMilliseconds; while (Environment.TickCount64 < deadline) { - if (await condition()) return true; + if (await condition()) + return true; await Task.Delay(50, cancellationToken); } diff --git a/tests/PipeWire.NET.Tests/TransportEndToEndTests.cs b/tests/PipeWire.NET.Tests/TransportEndToEndTests.cs index a0688cb..1d6ba4b 100644 --- a/tests/PipeWire.NET.Tests/TransportEndToEndTests.cs +++ b/tests/PipeWire.NET.Tests/TransportEndToEndTests.cs @@ -46,7 +46,8 @@ private static async Task NodeIdAsync(PipeWireVideoOutput output, Cancella { for (var i = 0; i < 60; i++) { - if (output.NodeId is { } id) return id; + if (output.NodeId is { } id) + return id; await Task.Delay(50, ct); } @@ -68,7 +69,10 @@ public async Task ARepublishedFrame_KeepsTheTimestampThePublisherGaveIt() RequireLinux(); using var cts = new CancellationTokenSource(Budget); - await using var ctx = new PipeWireContext("pwnet-republish", ConsoleTestLoggerFactory.Instance); + await using var ctx = new PipeWireContext( + "pwnet-republish", + ConsoleTestLoggerFactory.Instance + ); await ctx.StartAsync(cts.Token); // Times a transport would carry: a base far from any graph clock value, stepping by a @@ -79,7 +83,13 @@ public async Task ARepublishedFrame_KeepsTheTimestampThePublisherGaveIt() var published = new List(); await using var output = new PipeWireVideoOutput( - ctx, "pwnet-republish-src", Width, Height, PixelFormat.Bgra, 30); + ctx, + "pwnet-republish-src", + Width, + Height, + PixelFormat.Bgra, + 30 + ); output.FillFrame += (sender, pixels, stride, w, h, _) => { @@ -87,12 +97,14 @@ public async Task ARepublishedFrame_KeepsTheTimestampThePublisherGaveIt() lock (published) { pts = baseNs + (published.Count * stepNs); - if (published.Count < 32) published.Add(pts); + if (published.Count < 32) + published.Add(pts); } // The frame carries its own index too, so a consumer can tell which publish it has. byte tag = (byte)(pts / stepNs & 0xFF); - for (var y = 0; y < h; y++) pixels.Slice(y * stride, w * 4).Fill(tag); + for (var y = 0; y < h; y++) + pixels.Slice(y * stride, w * 4).Fill(tag); sender.NextPresentationTimestampNs = pts; return true; @@ -105,8 +117,13 @@ public async Task ARepublishedFrame_KeepsTheTimestampThePublisherGaveIt() await using var capture = new PipeWireVideoCapture(ctx, "pwnet-republish-sink"); capture.FrameReady += (_, f) => { - if (f.PresentationTimestampNs is not { } pts) return; - lock (received) { if (received.Count < 32) received.Add(pts); } + if (f.PresentationTimestampNs is not { } pts) + return; + lock (received) + { + if (received.Count < 32) + received.Add(pts); + } }; capture.Connect(nodeId, [PixelFormat.Bgra]); @@ -114,15 +131,25 @@ public async Task ARepublishedFrame_KeepsTheTimestampThePublisherGaveIt() for (var i = 0; i < 80; i++) { - lock (received) { if (received.Count >= 5) break; } + lock (received) + { + if (received.Count >= 5) + break; + } await Task.Delay(50, cts.Token); } - long[] sent, got; - lock (published) sent = [.. published]; - lock (received) got = [.. received]; + long[] sent, + got; + lock (published) + sent = [.. published]; + lock (received) + got = [.. received]; - Assert.IsTrue(got.Length >= 5, $"only {got.Length} frames arrived with a presentation time"); + Assert.IsTrue( + got.Length >= 5, + $"only {got.Length} frames arrived with a presentation time" + ); // Every time the consumer saw is one the publisher chose. var sentSet = sent.ToHashSet(); @@ -131,13 +158,15 @@ public async Task ARepublishedFrame_KeepsTheTimestampThePublisherGaveIt() Assert.IsTrue( sentSet.Contains(pts), $"the consumer read a presentation time of {pts}, which the publisher never set - " - + "the frame was stamped with the local cycle instead"); + + "the frame was stamped with the local cycle instead" + ); } // And they are in the range the publisher used, not graph-clock values. Assert.IsTrue( got.All(p => p >= baseNs), - "the timestamps are graph-clock values, so the publisher's own timing was discarded"); + "the timestamps are graph-clock values, so the publisher's own timing was discarded" + ); } /// @@ -162,7 +191,10 @@ public async Task APublishedPair_KeepsTheOffsetItWasPublishedWith() RequireLinux(); using var cts = new CancellationTokenSource(Budget); - await using var ctx = new PipeWireContext("pwnet-avpublish", ConsoleTestLoggerFactory.Instance); + await using var ctx = new PipeWireContext( + "pwnet-avpublish", + ConsoleTestLoggerFactory.Instance + ); await ctx.StartAsync(cts.Token); // The video leg is published a fixed distance ahead of where its cycle would put it. The @@ -173,11 +205,18 @@ public async Task APublishedPair_KeepsTheOffsetItWasPublishedWith() long videoPtsSeen = 0; await using var video = new PipeWireVideoOutput( - ctx, $"pwnet-avpublish-v-{Environment.ProcessId}", Width, Height, PixelFormat.Bgra, 30); + ctx, + $"pwnet-avpublish-v-{Environment.ProcessId}", + Width, + Height, + PixelFormat.Bgra, + 30 + ); video.FillFrame += (sender, pixels, stride, w, h, _) => { - if (sender.GraphClock is not { } clock) return false; + if (sender.GraphClock is not { } clock) + return false; // Expressed on the graph's own clock, which is the only thing a consumer can compare // against - a foreign epoch would be meaningless to it. @@ -187,7 +226,8 @@ public async Task APublishedPair_KeepsTheOffsetItWasPublishedWith() Volatile.Write(ref videoCycleSeen, (long)clock.TimeNs); Volatile.Write(ref videoPtsSeen, pts); - for (var y = 0; y < h; y++) pixels.Slice(y * stride, w * 4).Fill(0x5A); + for (var y = 0; y < h; y++) + pixels.Slice(y * stride, w * 4).Fill(0x5A); return true; }; @@ -197,12 +237,18 @@ public async Task APublishedPair_KeepsTheOffsetItWasPublishedWith() // Audio published alongside, carrying a ramp so its own delivery can be verified. uint n = 0; await using var audio = new PipeWireAudioOutput( - ctx, $"pwnet-avpublish-a-{Environment.ProcessId}", Rate, 1, AudioSampleFormat.F32Le); + ctx, + $"pwnet-avpublish-a-{Environment.ProcessId}", + Rate, + 1, + AudioSampleFormat.F32Le + ); audio.FillSamples += (_, samples, _, _, _) => { Span floats = MemoryMarshal.Cast(samples); - for (var i = 0; i < floats.Length; i++) floats[i] = ++n; + for (var i = 0; i < floats.Length; i++) + floats[i] = ++n; return samples.Length; }; @@ -214,41 +260,60 @@ public async Task APublishedPair_KeepsTheOffsetItWasPublishedWith() await using var videoSink = new PipeWireVideoCapture(ctx, "pwnet-avpublish-v-sink"); videoSink.FrameReady += (_, f) => { - if (f.PresentationTimestampNs is not { } pts || f.GraphTimeNs is not { } cycle) return; + if (f.PresentationTimestampNs is not { } pts || f.GraphTimeNs is not { } cycle) + return; // What the consumer can actually measure: how far ahead of the cycle it is being // shown in, the frame says it belongs. - lock (offsets) { if (offsets.Count < 32) offsets.Add(pts - cycle); } + lock (offsets) + { + if (offsets.Count < 32) + offsets.Add(pts - cycle); + } }; videoSink.Connect(videoNode, [PixelFormat.Bgra]); await using var audioSink = new PipeWireAudioCapture(ctx, "pwnet-avpublish-a-sink"); audioSink.FrameReady += (_, f) => Interlocked.Add(ref audioSamples, f.Samples.Length / 4); - audioSink.Connect((await audio.WaitForNodeIdAsync(cts.Token)), sampleRate: Rate, channels: 1, - format: AudioSampleFormat.F32Le); + audioSink.Connect( + (await audio.WaitForNodeIdAsync(cts.Token)), + sampleRate: Rate, + channels: 1, + format: AudioSampleFormat.F32Le + ); await videoSink.WaitForStreamingAsync(cts.Token); await audioSink.WaitForStreamingAsync(cts.Token); for (var i = 0; i < 100; i++) { - lock (offsets) { if (offsets.Count >= 5) break; } + lock (offsets) + { + if (offsets.Count >= 5) + break; + } await Task.Delay(50, cts.Token); } long[] measured; - lock (offsets) measured = [.. offsets]; + lock (offsets) + measured = [.. offsets]; - Assert.IsTrue(measured.Length >= 5, $"only {measured.Length} video frames carried both times"); + Assert.IsTrue( + measured.Length >= 5, + $"only {measured.Length} video frames carried both times" + ); Assert.IsTrue( Interlocked.Read(ref audioSamples) > 0, - "the audio leg of the session delivered nothing"); + "the audio leg of the session delivered nothing" + ); Assert.IsTrue( Volatile.Read(ref videoPtsSeen) > Volatile.Read(ref videoCycleSeen), - "the publisher never set a lead at all"); + "the publisher never set a lead at all" + ); // The offset survives the round trip. A tolerance of a few cycles, because the frame is // shown in the cycle after the one it was written in. @@ -259,7 +324,8 @@ public async Task APublishedPair_KeepsTheOffsetItWasPublishedWith() offset / 1e6, 50.0, $"a frame published {leadNs / 1e6:F0}ms ahead was received {offset / 1e6:F1}ms " - + "ahead; the publisher's A/V offset did not survive"); + + "ahead; the publisher's A/V offset did not survive" + ); } } @@ -284,7 +350,8 @@ public async Task ADecodedGpuFrame_IsRepublishedZeroCopyWithItsTiming() { RequireLinux(); - if (!File.Exists("/dev/dri/renderD128")) Assert.Inconclusive("No GPU render node."); + if (!File.Exists("/dev/dri/renderD128")) + Assert.Inconclusive("No GPU render node."); GbmAllocator gbm; try @@ -308,19 +375,30 @@ public async Task ADecodedGpuFrame_IsRepublishedZeroCopyWithItsTiming() { using (gbm) { - await using var ctx = new PipeWireContext("pwnet-republish-gpu", ConsoleTestLoggerFactory.Instance); + await using var ctx = new PipeWireContext( + "pwnet-republish-gpu", + ConsoleTestLoggerFactory.Instance + ); await ctx.StartAsync(cts.Token); long modifier = (long)GbmAllocator.LinearModifier; var published = new List(); await using var output = new PipeWireVideoOutput( - ctx, "pwnet-republish-gpu-src", Width, Height, PixelFormat.Bgra, 30); + ctx, + "pwnet-republish-gpu-src", + Width, + Height, + PixelFormat.Bgra, + 30 + ); output.AllocateDmaBuf += (_, index, _, _, _, _, planes) => { - if (index >= 8) return 0; - while (buffers.Count <= index) buffers.Add(gbm.CreateBgra(Width, Height)); + if (index >= 8) + return 0; + while (buffers.Count <= index) + buffers.Add(gbm.CreateBgra(Width, Height)); GbmAllocator.Buffer b = buffers[index]; planes[0] = new VideoPlane(b.Fd, b.Offset, b.Stride, b.Size); return 1; @@ -332,7 +410,8 @@ public async Task ADecodedGpuFrame_IsRepublishedZeroCopyWithItsTiming() lock (published) { pts = baseNs + (published.Count * stepNs); - if (published.Count < 32) published.Add(pts); + if (published.Count < 32) + published.Add(pts); } // The decoder's time, not this cycle's. @@ -346,7 +425,8 @@ public async Task ADecodedGpuFrame_IsRepublishedZeroCopyWithItsTiming() for (var i = 0; i < 60 && nodeId is null; i++) { nodeId = output.NodeId; - if (nodeId is null) await Task.Delay(50, cts.Token); + if (nodeId is null) + await Task.Delay(50, cts.Token); } Assert.IsNotNull(nodeId, "the republishing producer was never given a node id"); @@ -359,9 +439,15 @@ public async Task ADecodedGpuFrame_IsRepublishedZeroCopyWithItsTiming() var stamps = new List(); capture.FrameReady += (_, f) => { - if (f.BufferType != PipeWireBufferType.DmaBuf) return; - if (f.PresentationTimestampNs is not { } pts) return; - lock (stamps) { if (stamps.Count < 32) stamps.Add(pts); } + if (f.BufferType != PipeWireBufferType.DmaBuf) + return; + if (f.PresentationTimestampNs is not { } pts) + return; + lock (stamps) + { + if (stamps.Count < 32) + stamps.Add(pts); + } }; capture.Connect(nodeId!.Value, [PixelFormat.Bgra], modifiers: [modifier]); @@ -371,14 +457,18 @@ public async Task ADecodedGpuFrame_IsRepublishedZeroCopyWithItsTiming() for (var i = 0; i < 120 && !got; i++) { got = capture.TryGetBorrowedFrame(out borrowed) && borrowed.IsFdBacked; - if (!got) await Task.Delay(50, cts.Token); + if (!got) + await Task.Delay(50, cts.Token); } Assert.IsTrue(got, "no fd-backed frame was republished"); - long[] sent, seen; - lock (published) sent = [.. published]; - lock (stamps) seen = [.. stamps]; + long[] sent, + seen; + lock (published) + sent = [.. published]; + lock (stamps) + seen = [.. stamps]; Assert.IsTrue(seen.Length > 0, "no republished frame carried a presentation time"); @@ -389,11 +479,14 @@ public async Task ADecodedGpuFrame_IsRepublishedZeroCopyWithItsTiming() Assert.IsTrue( sentSet.Contains(pts), $"a republished frame arrived stamped {pts}, which the publisher never set - " - + "the decoder's timing was replaced by the local cycle"); + + "the decoder's timing was replaced by the local cycle" + ); } // And it is still the publisher's GPU buffer, not a copy of it. - ulong? consumerInode = StreamTransportContractTests.InodeOfForTests((int)borrowed[0].Fd); + ulong? consumerInode = StreamTransportContractTests.InodeOfForTests( + (int)borrowed[0].Fd + ); Assert.IsNotNull(consumerInode, "the republished descriptor is not live"); var producerInodes = new HashSet(); @@ -406,12 +499,14 @@ public async Task ADecodedGpuFrame_IsRepublishedZeroCopyWithItsTiming() Assert.IsTrue( producerInodes.Contains(consumerInode!.Value), "the republished frame does not name the buffer that was published, so the " - + "receive path copied it"); + + "receive path copied it" + ); } } finally { - foreach (GbmAllocator.Buffer b in buffers) b.Dispose(); + foreach (GbmAllocator.Buffer b in buffers) + b.Dispose(); } } @@ -430,15 +525,25 @@ public async Task ACaptureSession_TakesVideoAndAudioOnOneClock() RequireLinux(); using var cts = new CancellationTokenSource(Budget); - await using var ctx = new PipeWireContext("pwnet-avsession", ConsoleTestLoggerFactory.Instance); + await using var ctx = new PipeWireContext( + "pwnet-avsession", + ConsoleTestLoggerFactory.Instance + ); await ctx.StartAsync(cts.Token); await using var video = new PipeWireVideoOutput( - ctx, $"pwnet-avsession-v-{Environment.ProcessId}", Width, Height, PixelFormat.Bgra, 30); + ctx, + $"pwnet-avsession-v-{Environment.ProcessId}", + Width, + Height, + PixelFormat.Bgra, + 30 + ); video.FillFrame += (_, pixels, stride, w, h, _) => { - for (var y = 0; y < h; y++) pixels.Slice(y * stride, w * 4).Fill(0x3C); + for (var y = 0; y < h; y++) + pixels.Slice(y * stride, w * 4).Fill(0x3C); return true; }; @@ -447,12 +552,18 @@ public async Task ACaptureSession_TakesVideoAndAudioOnOneClock() uint n = 0; await using var audio = new PipeWireAudioOutput( - ctx, $"pwnet-avsession-a-{Environment.ProcessId}", Rate, 1, AudioSampleFormat.F32Le); + ctx, + $"pwnet-avsession-a-{Environment.ProcessId}", + Rate, + 1, + AudioSampleFormat.F32Le + ); audio.FillSamples += (_, samples, _, _, _) => { Span floats = MemoryMarshal.Cast(samples); - for (var i = 0; i < floats.Length; i++) floats[i] = ++n; + for (var i = 0; i < floats.Length; i++) + floats[i] = ++n; return samples.Length; }; @@ -462,18 +573,27 @@ public async Task ACaptureSession_TakesVideoAndAudioOnOneClock() await using var audioSink = new PipeWireAudioCapture(ctx, "pwnet-avsession-a-sink"); audioSink.FrameReady += (_, f) => { - if (f.GraphTimeNs is { } t) Volatile.Write(ref audioClock, t); + if (f.GraphTimeNs is { } t) + Volatile.Write(ref audioClock, t); }; - audioSink.Connect((await audio.WaitForNodeIdAsync(cts.Token)), sampleRate: Rate, channels: 1, - format: AudioSampleFormat.F32Le); + audioSink.Connect( + (await audio.WaitForNodeIdAsync(cts.Token)), + sampleRate: Rate, + channels: 1, + format: AudioSampleFormat.F32Le + ); await audioSink.WaitForStreamingAsync(cts.Token); - foreach (FrameRetention retention in new[] { FrameRetention.Owned, FrameRetention.Borrowed }) + foreach ( + FrameRetention retention in new[] { FrameRetention.Owned, FrameRetention.Borrowed } + ) { await using var videoSink = new PipeWireVideoCapture( - ctx, $"pwnet-avsession-v-sink-{retention}") + ctx, + $"pwnet-avsession-v-sink-{retention}" + ) { Retention = retention, }; @@ -482,7 +602,8 @@ public async Task ACaptureSession_TakesVideoAndAudioOnOneClock() var frames = 0; videoSink.FrameReady += (_, f) => { - if (f.GraphTimeNs is { } t) Volatile.Write(ref videoClock, t); + if (f.GraphTimeNs is { } t) + Volatile.Write(ref videoClock, t); Interlocked.Increment(ref frames); }; @@ -492,7 +613,8 @@ public async Task ACaptureSession_TakesVideoAndAudioOnOneClock() Assert.IsTrue( Volatile.Read(ref frames) > 0, - $"{retention} retention delivered no frames in the session"); + $"{retention} retention delivered no frames in the session" + ); // Whichever retention is in use, the frame is timed on the same clock as the audio, so // the two are comparable. Values seconds apart would mean separate time bases. @@ -504,7 +626,8 @@ public async Task ACaptureSession_TakesVideoAndAudioOnOneClock() Assert.IsTrue( Math.Abs(v - a) < 5_000_000_000, - $"{retention}: video at {v} and audio at {a} are not on one clock"); + $"{retention}: video at {v} and audio at {a} are not on one clock" + ); // And the retained frame itself is usable, which is the point of retaining it. if (retention == FrameRetention.Owned) diff --git a/tests/PipeWire.NET.Tests/TransportLifetimeTests.cs b/tests/PipeWire.NET.Tests/TransportLifetimeTests.cs index d544c0c..bb6733b 100644 --- a/tests/PipeWire.NET.Tests/TransportLifetimeTests.cs +++ b/tests/PipeWire.NET.Tests/TransportLifetimeTests.cs @@ -76,19 +76,29 @@ public async Task EveryBufferTheAppBacked_IsReleasedBackToIt() try { - await using (var ctx = new PipeWireContext("pwnet-release", ConsoleTestLoggerFactory.Instance)) + await using ( + var ctx = new PipeWireContext("pwnet-release", ConsoleTestLoggerFactory.Instance) + ) { await ctx.StartAsync(cts.Token); long modifier = (long)GbmAllocator.LinearModifier; await using var output = new PipeWireVideoOutput( - ctx, "pwnet-release-src", Width, Height, PixelFormat.Bgra, 30); + ctx, + "pwnet-release-src", + Width, + Height, + PixelFormat.Bgra, + 30 + ); output.AllocateDmaBuf += (_, index, _, _, _, _, planes) => { - if (index >= PoolCap) return 0; - while (buffers.Count <= index) buffers.Add(gbm.CreateBgra(Width, Height)); + if (index >= PoolCap) + return 0; + while (buffers.Count <= index) + buffers.Add(gbm.CreateBgra(Width, Height)); allocated.Enqueue(index); GbmAllocator.Buffer b = buffers[index]; planes[0] = new VideoPlane(b.Fd, b.Offset, b.Stride, b.Size); @@ -103,7 +113,8 @@ public async Task EveryBufferTheAppBacked_IsReleasedBackToIt() for (var i = 0; i < 60 && nodeId is null; i++) { nodeId = output.NodeId; - if (nodeId is null) await Task.Delay(50, cts.Token); + if (nodeId is null) + await Task.Delay(50, cts.Token); } Assert.IsNotNull(nodeId, "the producer was never given a node id"); @@ -115,7 +126,10 @@ public async Task EveryBufferTheAppBacked_IsReleasedBackToIt() await Task.Delay(TimeSpan.FromSeconds(3), cts.Token); - Assert.IsTrue(Volatile.Read(ref frames) > 0, "no frames flowed, so no pool was built"); + Assert.IsTrue( + Volatile.Read(ref frames) > 0, + "no frames flowed, so no pool was built" + ); Assert.IsFalse(allocated.IsEmpty, "the app was never asked to back a buffer"); // Teardown is what triggers the releases: the pool goes away with the stream. @@ -127,7 +141,8 @@ public async Task EveryBufferTheAppBacked_IsReleasedBackToIt() Assert.IsFalse( handedBack.Length == 0, $"the app backed {backed.Length} buffers and was told to release none of them; " - + "a zero-copy sender would hold every GPU allocation for the process lifetime"); + + "a zero-copy sender would hold every GPU allocation for the process lifetime" + ); // Nothing released that was never backed. var backedSet = backed.ToHashSet(); @@ -135,7 +150,8 @@ public async Task EveryBufferTheAppBacked_IsReleasedBackToIt() { Assert.IsTrue( backedSet.Contains(index), - $"released buffer index {index}, which the app never backed"); + $"released buffer index {index}, which the app never backed" + ); } // And every distinct backed index came back. @@ -143,12 +159,14 @@ public async Task EveryBufferTheAppBacked_IsReleasedBackToIt() { Assert.IsTrue( handedBack.Contains(index), - $"buffer index {index} was backed by the app and never released"); + $"buffer index {index} was backed by the app and never released" + ); } } finally { - foreach (GbmAllocator.Buffer b in buffers) b.Dispose(); + foreach (GbmAllocator.Buffer b in buffers) + b.Dispose(); } } @@ -172,20 +190,30 @@ public async Task EachTriggeredPublish_ReachesTheConsumerWithItsContent() using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(45)); - await using var ctx = new PipeWireContext("pwnet-trigwait", ConsoleTestLoggerFactory.Instance); + await using var ctx = new PipeWireContext( + "pwnet-trigwait", + ConsoleTestLoggerFactory.Instance + ); await ctx.StartAsync(cts.Token); var filled = 0; await using var output = new PipeWireVideoOutput( - ctx, "pwnet-trigwait-src", Width, Height, PixelFormat.Bgra, 30); + ctx, + "pwnet-trigwait-src", + Width, + Height, + PixelFormat.Bgra, + 30 + ); output.FillFrame += (_, pixels, stride, width, height, _) => { // Every byte of the frame carries this cycle's number, so a consumer can say which // publish it is looking at and whether the whole frame was written. byte tag = (byte)(Interlocked.Increment(ref filled) & 0xFF); - for (var y = 0; y < height; y++) pixels.Slice(y * stride, width * 4).Fill(tag); + for (var y = 0; y < height; y++) + pixels.Slice(y * stride, width * 4).Fill(tag); return true; }; @@ -197,7 +225,8 @@ public async Task EachTriggeredPublish_ReachesTheConsumerWithItsContent() for (var i = 0; i < 60 && nodeId is null; i++) { nodeId = output.NodeId; - if (nodeId is null) await Task.Delay(50, cts.Token); + if (nodeId is null) + await Task.Delay(50, cts.Token); } Assert.IsNotNull(nodeId, "the producer was never given a node id"); @@ -208,7 +237,8 @@ public async Task EachTriggeredPublish_ReachesTheConsumerWithItsContent() await using var capture = new PipeWireVideoCapture(ctx, "pwnet-trigwait-sink"); capture.FrameReady += (_, f) => { - if (f.Pixels.IsEmpty) return; + if (f.Pixels.IsEmpty) + return; byte tag = f.Pixels[0]; for (var y = 0; y < f.Height; y++) @@ -216,18 +246,30 @@ public async Task EachTriggeredPublish_ReachesTheConsumerWithItsContent() ReadOnlySpan row = f.Pixels.Slice(y * f.Stride, f.Width * 4); foreach (byte b in row) { - if (b != tag) { Interlocked.Increment(ref torn); return; } + if (b != tag) + { + Interlocked.Increment(ref torn); + return; + } } } - lock (tags) { if (tags.Count < 64) tags.Add(tag); } + lock (tags) + { + if (tags.Count < 64) + tags.Add(tag); + } }; capture.Connect(nodeId!.Value, [PixelFormat.Bgra]); await capture.WaitForStreamingAsync(cts.Token); - for (var i = 0; i < 100 && !output.IsDriving; i++) await Task.Delay(50, cts.Token); - Assert.IsTrue(output.IsDriving, "the daemon never made the driver-flagged output the graph's driver"); + for (var i = 0; i < 100 && !output.IsDriving; i++) + await Task.Delay(50, cts.Token); + Assert.IsTrue( + output.IsDriving, + "the daemon never made the driver-flagged output the graph's driver" + ); int before = Volatile.Read(ref filled); @@ -236,10 +278,12 @@ public async Task EachTriggeredPublish_ReachesTheConsumerWithItsContent() { int filledBefore = Volatile.Read(ref filled); int seenBefore; - lock (tags) seenBefore = tags.Count; + lock (tags) + seenBefore = tags.Count; try { - await output.TriggerProcessAndWaitAsync(cts.Token) + await output + .TriggerProcessAndWaitAsync(cts.Token) .WaitAsync(TimeSpan.FromSeconds(10), cts.Token); } catch (TimeoutException) @@ -249,36 +293,42 @@ await output.TriggerProcessAndWaitAsync(cts.Token) // trigger_done). A second trigger then says whether the graph is stuck or one // report was lost. int seenAfter; - lock (tags) seenAfter = tags.Count; - string state = $"trigger {i}: fills {Volatile.Read(ref filled) - filledBefore}, " + lock (tags) + seenAfter = tags.Count; + string state = + $"trigger {i}: fills {Volatile.Read(ref filled) - filledBefore}, " + $"frames delivered {seenAfter - seenBefore}, driving {output.IsDriving}"; - bool retried = await output.TriggerProcessAndWaitAsync(cts.Token) + bool retried = await output + .TriggerProcessAndWaitAsync(cts.Token) .WaitAsync(TimeSpan.FromSeconds(5), cts.Token) .ContinueWith(t => t.IsCompletedSuccessfully, TaskScheduler.Default); - Assert.Fail($"a triggered cycle never reported completion ({state}); " - + $"a further trigger {(retried ? "did" : "did not either")}"); + Assert.Fail( + $"a triggered cycle never reported completion ({state}); " + + $"a further trigger {(retried ? "did" : "did not either")}" + ); } } await Task.Delay(300, cts.Token); byte[] seen; - lock (tags) seen = [.. tags]; + lock (tags) + seen = [.. tags]; Assert.AreEqual(0, Volatile.Read(ref torn), "a frame arrived only partly written"); Assert.IsTrue( Volatile.Read(ref filled) - before >= triggers, - $"{triggers} triggered waits produced only {Volatile.Read(ref filled) - before} publishes"); + $"{triggers} triggered waits produced only {Volatile.Read(ref filled) - before} publishes" + ); - Assert.IsTrue( - seen.Length > 0, - "the triggered publishes never reached the consumer"); + Assert.IsTrue(seen.Length > 0, "the triggered publishes never reached the consumer"); // The values the consumer saw are ones the producer actually wrote. Assert.IsTrue( seen.Distinct().Count() > 1, - "every triggered frame carried the same value, so the consumer saw one buffer repeatedly"); + "every triggered frame carried the same value, so the consumer saw one buffer repeatedly" + ); Assert.IsNotNull(output.Queue, "the stream stopped answering after triggered publishes"); } diff --git a/tests/PipeWire.NET.Tests/TransportRoundTripTests.cs b/tests/PipeWire.NET.Tests/TransportRoundTripTests.cs index 856fe9e..44dcd83 100644 --- a/tests/PipeWire.NET.Tests/TransportRoundTripTests.cs +++ b/tests/PipeWire.NET.Tests/TransportRoundTripTests.cs @@ -44,7 +44,8 @@ private static async Task NodeIdAsync(PipeWireVideoOutput output, Cancella { for (var i = 0; i < 60; i++) { - if (output.NodeId is { } id) return id; + if (output.NodeId is { } id) + return id; await Task.Delay(50, ct); } @@ -53,8 +54,10 @@ private static async Task NodeIdAsync(PipeWireVideoOutput output, Cancella private static GbmAllocator RequireGbm() { - if (!OperatingSystem.IsLinux()) Assert.Inconclusive("PipeWire is a Linux daemon."); - if (!File.Exists("/dev/dri/renderD128")) Assert.Inconclusive("No GPU render node."); + if (!OperatingSystem.IsLinux()) + Assert.Inconclusive("PipeWire is a Linux daemon."); + if (!File.Exists("/dev/dri/renderD128")) + Assert.Inconclusive("No GPU render node."); try { @@ -81,19 +84,30 @@ public async Task TheFullTransportLoop_PreservesContentAndAlignment() try { - await using var ctx = new PipeWireContext("pwnet-loop", ConsoleTestLoggerFactory.Instance); + await using var ctx = new PipeWireContext( + "pwnet-loop", + ConsoleTestLoggerFactory.Instance + ); await ctx.StartAsync(cts.Token); long modifier = (long)GbmAllocator.LinearModifier; // - The origin, as a sender would capture from - await using var originVideo = new PipeWireVideoOutput( - ctx, $"pwnet-loop-v0-{Environment.ProcessId}", Width, Height, PixelFormat.Bgra, 30); + ctx, + $"pwnet-loop-v0-{Environment.ProcessId}", + Width, + Height, + PixelFormat.Bgra, + 30 + ); originVideo.AllocateDmaBuf += (_, index, _, _, _, _, planes) => { - if (index >= PoolCap) return 0; - while (originBuffers.Count <= index) originBuffers.Add(gbm.CreateBgra(Width, Height)); + if (index >= PoolCap) + return 0; + while (originBuffers.Count <= index) + originBuffers.Add(gbm.CreateBgra(Width, Height)); GbmAllocator.Buffer b = originBuffers[index]; planes[0] = new VideoPlane(b.Fd, b.Offset, b.Stride, b.Size); return 1; @@ -112,12 +126,18 @@ public async Task TheFullTransportLoop_PreservesContentAndAlignment() uint sample = 0; await using var originAudio = new PipeWireAudioOutput( - ctx, $"pwnet-loop-a0-{Environment.ProcessId}", Rate, 1, AudioSampleFormat.F32Le); + ctx, + $"pwnet-loop-a0-{Environment.ProcessId}", + Rate, + 1, + AudioSampleFormat.F32Le + ); originAudio.FillSamples += (_, samples, _, _, _) => { Span floats = MemoryMarshal.Cast(samples); - for (var i = 0; i < floats.Length; i++) floats[i] = ++sample; + for (var i = 0; i < floats.Length; i++) + floats[i] = ++sample; return samples.Length; }; @@ -130,7 +150,8 @@ public async Task TheFullTransportLoop_PreservesContentAndAlignment() await using var relayVideoIn = new PipeWireVideoCapture(ctx, "pwnet-loop-v-relay-in"); relayVideoIn.FrameReady += (_, f) => { - if (f.PresentationTimestampNs is { } pts) Volatile.Write(ref relayPts, pts); + if (f.PresentationTimestampNs is { } pts) + Volatile.Write(ref relayPts, pts); }; relayVideoIn.Connect(originVideoNode, [PixelFormat.Bgra], modifiers: [modifier]); @@ -138,23 +159,37 @@ public async Task TheFullTransportLoop_PreservesContentAndAlignment() await using var relayAudioIn = new PipeWireAudioCapture(ctx, "pwnet-loop-a-relay-in"); relayAudioIn.FrameReady += (_, f) => { - if (relayAudio.Count > 40000) return; - foreach (float v in MemoryMarshal.Cast(f.Samples)) relayAudio.Enqueue(v); + if (relayAudio.Count > 40000) + return; + foreach (float v in MemoryMarshal.Cast(f.Samples)) + relayAudio.Enqueue(v); }; - relayAudioIn.Connect((await originAudio.WaitForNodeIdAsync(cts.Token)), sampleRate: Rate, channels: 1, - format: AudioSampleFormat.F32Le); + relayAudioIn.Connect( + (await originAudio.WaitForNodeIdAsync(cts.Token)), + sampleRate: Rate, + channels: 1, + format: AudioSampleFormat.F32Le + ); await relayVideoIn.WaitForStreamingAsync(cts.Token); await relayAudioIn.WaitForStreamingAsync(cts.Token); await using var relayVideoOut = new PipeWireVideoOutput( - ctx, $"pwnet-loop-v1-{Environment.ProcessId}", Width, Height, PixelFormat.Bgra, 30); + ctx, + $"pwnet-loop-v1-{Environment.ProcessId}", + Width, + Height, + PixelFormat.Bgra, + 30 + ); relayVideoOut.AllocateDmaBuf += (_, index, _, _, _, _, planes) => { - if (index >= PoolCap) return 0; - while (relayBuffers.Count <= index) relayBuffers.Add(gbm.CreateBgra(Width, Height)); + if (index >= PoolCap) + return 0; + while (relayBuffers.Count <= index) + relayBuffers.Add(gbm.CreateBgra(Width, Height)); GbmAllocator.Buffer b = relayBuffers[index]; planes[0] = new VideoPlane(b.Fd, b.Offset, b.Stride, b.Size); return 1; @@ -165,7 +200,8 @@ public async Task TheFullTransportLoop_PreservesContentAndAlignment() // Carrying the time the frame arrived with is what makes this a transport rather // than a second, unrelated source. long pts = Volatile.Read(ref relayPts); - if (pts <= 0) return false; + if (pts <= 0) + return false; sender.NextPresentationTimestampNs = pts; return true; @@ -175,7 +211,12 @@ public async Task TheFullTransportLoop_PreservesContentAndAlignment() uint relayVideoNode = await NodeIdAsync(relayVideoOut, cts.Token); await using var relayAudioOut = new PipeWireAudioOutput( - ctx, $"pwnet-loop-a1-{Environment.ProcessId}", Rate, 1, AudioSampleFormat.F32Le); + ctx, + $"pwnet-loop-a1-{Environment.ProcessId}", + Rate, + 1, + AudioSampleFormat.F32Le + ); relayAudioOut.FillSamples += (_, samples, _, _, _) => { @@ -195,9 +236,15 @@ public async Task TheFullTransportLoop_PreservesContentAndAlignment() await using var finalVideo = new PipeWireVideoCapture(ctx, "pwnet-loop-v-out"); finalVideo.FrameReady += (_, f) => { - if (f.BufferType == PipeWireBufferType.DmaBuf) Interlocked.Increment(ref dmaBufOut); - if (f.PresentationTimestampNs is not { } pts || f.GraphTimeNs is not { } cycle) return; - lock (offsets) { if (offsets.Count < 32) offsets.Add(pts - cycle); } + if (f.BufferType == PipeWireBufferType.DmaBuf) + Interlocked.Increment(ref dmaBufOut); + if (f.PresentationTimestampNs is not { } pts || f.GraphTimeNs is not { } cycle) + return; + lock (offsets) + { + if (offsets.Count < 32) + offsets.Add(pts - cycle); + } }; finalVideo.Connect(relayVideoNode, [PixelFormat.Bgra], modifiers: [modifier]); @@ -208,13 +255,19 @@ public async Task TheFullTransportLoop_PreservesContentAndAlignment() { lock (audioOut) { - if (audioOut.Count > 20000) return; - foreach (float v in MemoryMarshal.Cast(f.Samples)) audioOut.Add(v); + if (audioOut.Count > 20000) + return; + foreach (float v in MemoryMarshal.Cast(f.Samples)) + audioOut.Add(v); } }; - finalAudio.Connect((await relayAudioOut.WaitForNodeIdAsync(cts.Token)), sampleRate: Rate, channels: 1, - format: AudioSampleFormat.F32Le); + finalAudio.Connect( + (await relayAudioOut.WaitForNodeIdAsync(cts.Token)), + sampleRate: Rate, + channels: 1, + format: AudioSampleFormat.F32Le + ); await finalVideo.WaitForStreamingAsync(cts.Token); await finalAudio.WaitForStreamingAsync(cts.Token); @@ -223,14 +276,20 @@ public async Task TheFullTransportLoop_PreservesContentAndAlignment() long[] measured; float[] heard; - lock (offsets) measured = [.. offsets]; - lock (audioOut) heard = [.. audioOut]; + lock (offsets) + measured = [.. offsets]; + lock (audioOut) + heard = [.. audioOut]; Assert.IsTrue( Volatile.Read(ref dmaBufOut) > 0, - "the far end received no DMA-BUF frames, so the loop fell back to host memory"); + "the far end received no DMA-BUF frames, so the loop fell back to host memory" + ); - Assert.IsTrue(measured.Length >= 3, $"only {measured.Length} frames completed the loop"); + Assert.IsTrue( + measured.Length >= 3, + $"only {measured.Length} frames completed the loop" + ); foreach (long offset in measured) { @@ -239,33 +298,41 @@ public async Task TheFullTransportLoop_PreservesContentAndAlignment() offset / 1e6, 80.0, $"a frame published {LeadNs / 1e6:F0}ms ahead came out {offset / 1e6:F1}ms ahead; " - + "the round trip lost the A/V alignment"); + + "the round trip lost the A/V alignment" + ); } int start = 0; - while (start < heard.Length && heard[start] == 0f) start++; + while (start < heard.Length && heard[start] == 0f) + start++; Assert.IsTrue( heard.Length - start > 500, - $"only {heard.Length - start} audio samples completed the loop"); + $"only {heard.Length - start} audio samples completed the loop" + ); var breaks = 0; for (int i = start + 1; i < heard.Length; i++) { // A zero means the relay queue ran dry, which is starvation rather than corruption. - if (heard[i] == 0f) break; - if (heard[i] != heard[i - 1] + 1f) breaks++; + if (heard[i] == 0f) + break; + if (heard[i] != heard[i - 1] + 1f) + breaks++; } Assert.IsTrue( breaks <= 2, $"the audio ramp broke {breaks} times across the loop, so samples were lost or " - + "reordered in transit"); + + "reordered in transit" + ); } finally { - foreach (GbmAllocator.Buffer b in originBuffers) b.Dispose(); - foreach (GbmAllocator.Buffer b in relayBuffers) b.Dispose(); + foreach (GbmAllocator.Buffer b in originBuffers) + b.Dispose(); + foreach (GbmAllocator.Buffer b in relayBuffers) + b.Dispose(); } } } diff --git a/tests/PipeWire.NET.Tests/UpstreamExampleTests.cs b/tests/PipeWire.NET.Tests/UpstreamExampleTests.cs index f271fd2..be37f1e 100644 --- a/tests/PipeWire.NET.Tests/UpstreamExampleTests.cs +++ b/tests/PipeWire.NET.Tests/UpstreamExampleTests.cs @@ -42,11 +42,14 @@ private static void RequireLinux() } private static async Task WaitForNodeIdAsync( - PipeWireVideoOutput output, CancellationToken ct) + PipeWireVideoOutput output, + CancellationToken ct + ) { for (var i = 0; i < 60; i++) { - if (output.NodeId is { } id) return id; + if (output.NodeId is { } id) + return id; await Task.Delay(50, ct); } @@ -67,13 +70,22 @@ public async Task AProducerOfferingANewFormat_KeepsItsConsumer() RequireLinux(); using var cts = new CancellationTokenSource(Budget); - await using var ctx = new PipeWireContext("pwnet-srcreneg", ConsoleTestLoggerFactory.Instance); + await using var ctx = new PipeWireContext( + "pwnet-srcreneg", + ConsoleTestLoggerFactory.Instance + ); await ctx.StartAsync(cts.Token); var filled = 0; await using var output = new PipeWireVideoOutput( - ctx, "pwnet-srcreneg-src", Width, Height, PixelFormat.Bgra, 30); + ctx, + "pwnet-srcreneg-src", + Width, + Height, + PixelFormat.Bgra, + 30 + ); output.FillFrame += (_, pixels, _, _, _, _) => { @@ -99,13 +111,15 @@ public async Task AProducerOfferingANewFormat_KeepsItsConsumer() PixelFormat[] formats = [PixelFormat.Bgra]; Assert.IsTrue( output.RequestFormat(formats, 160, 120), - "the producer's renegotiation offer was refused outright"); + "the producer's renegotiation offer was refused outright" + ); await Task.Delay(600, cts.Token); Assert.IsTrue( Volatile.Read(ref received) > 0, - "the consumer stopped receiving after the producer offered a new format"); + "the consumer stopped receiving after the producer offered a new format" + ); Assert.IsNotNull(output.Queue, "the producer stopped answering after renegotiating"); } @@ -125,11 +139,20 @@ public async Task AProducerOfferingARange_LetsTheConsumerFixateWithinIt() RequireLinux(); using var cts = new CancellationTokenSource(Budget); - await using var ctx = new PipeWireContext("pwnet-srcfixate", ConsoleTestLoggerFactory.Instance); + await using var ctx = new PipeWireContext( + "pwnet-srcfixate", + ConsoleTestLoggerFactory.Instance + ); await ctx.StartAsync(cts.Token); await using var output = new PipeWireVideoOutput( - ctx, "pwnet-srcfixate-src", Width, Height, PixelFormat.Bgra, 30); + ctx, + "pwnet-srcfixate-src", + Width, + Height, + PixelFormat.Bgra, + 30 + ); output.FillFrame += (_, pixels, _, _, _, _) => { @@ -144,7 +167,11 @@ public async Task AProducerOfferingARange_LetsTheConsumerFixateWithinIt() await using var capture = new PipeWireVideoCapture(ctx, "pwnet-srcfixate-sink"); capture.FrameReady += (_, f) => { - lock (sizes) { if (sizes.Count < 32) sizes.Add((f.Width, f.Height)); } + lock (sizes) + { + if (sizes.Count < 32) + sizes.Add((f.Width, f.Height)); + } }; capture.Connect(nodeId, [PixelFormat.Bgra]); @@ -153,12 +180,14 @@ public async Task AProducerOfferingARange_LetsTheConsumerFixateWithinIt() PixelFormat[] formats = [PixelFormat.Bgra]; Assert.IsTrue( output.RequestFormat(formats, 256, 144, frameRate: 30, fixedSize: false), - "the producer's range offer was refused outright"); + "the producer's range offer was refused outright" + ); await Task.Delay(800, cts.Token); (int W, int H)[] seen; - lock (sizes) seen = [.. sizes]; + lock (sizes) + seen = [.. sizes]; Assert.IsTrue(seen.Length > 0, "no frame arrived while a range was on offer"); @@ -169,8 +198,11 @@ public async Task AProducerOfferingARange_LetsTheConsumerFixateWithinIt() Assert.IsTrue(w > 0 && h > 0, $"a frame reported a degenerate size {w}x{h}"); } - Assert.AreEqual(1, seen.Distinct().Count(), - "the negotiated size changed between frames without a renegotiation in between"); + Assert.AreEqual( + 1, + seen.Distinct().Count(), + "the negotiated size changed between frames without a renegotiation in between" + ); } /// @@ -194,7 +226,8 @@ public async Task AProducerServingFromARing_DeliversAnUnbrokenSequence() RequireLinux(); using var cts = new CancellationTokenSource(Budget); - const int rate = 48000, channels = 1; + const int rate = 48000, + channels = 1; await using var ctx = new PipeWireContext("pwnet-ring", ConsoleTestLoggerFactory.Instance); await ctx.StartAsync(cts.Token); @@ -205,12 +238,20 @@ public async Task AProducerServingFromARing_DeliversAnUnbrokenSequence() uint next = 0; await using var output = new PipeWireAudioOutput( - ctx, nodeName, rate, channels, AudioSampleFormat.F32Le); + ctx, + nodeName, + rate, + channels, + AudioSampleFormat.F32Le + ); output.FillSamples += (_, samples, _, _, _) => { - Span floats = System.Runtime.InteropServices.MemoryMarshal.Cast(samples); - for (var i = 0; i < floats.Length; i++) floats[i] = next++; + Span floats = System.Runtime.InteropServices.MemoryMarshal.Cast( + samples + ); + for (var i = 0; i < floats.Length; i++) + floats[i] = next++; return samples.Length; }; @@ -220,45 +261,58 @@ public async Task AProducerServingFromARing_DeliversAnUnbrokenSequence() await using var capture = new PipeWireAudioCapture(ctx, $"{nodeName}-sink"); capture.FrameReady += (_, f) => { - ReadOnlySpan floats = - System.Runtime.InteropServices.MemoryMarshal.Cast(f.Samples); + ReadOnlySpan floats = System.Runtime.InteropServices.MemoryMarshal.Cast< + byte, + float + >(f.Samples); lock (received) { - if (received.Count > 20000) return; - foreach (float v in floats) received.Add(v); + if (received.Count > 20000) + return; + foreach (float v in floats) + received.Add(v); } }; - capture.Connect((await output.WaitForNodeIdAsync(cts.Token)), sampleRate: rate, channels: channels, - format: AudioSampleFormat.F32Le); + capture.Connect( + (await output.WaitForNodeIdAsync(cts.Token)), + sampleRate: rate, + channels: channels, + format: AudioSampleFormat.F32Le + ); await capture.WaitForStreamingAsync(cts.Token); await Task.Delay(700, cts.Token); float[] got; - lock (received) got = [.. received]; + lock (received) + got = [.. received]; Assert.IsTrue(got.Length > 2000, $"only {got.Length} samples arrived from the ring"); // Skip the head: the first cycles can carry silence the graph inserted before the producer // was scheduled, which is not the producer losing anything. int start = 0; - while (start < got.Length && got[start] == 0f) start++; + while (start < got.Length && got[start] == 0f) + start++; Assert.IsTrue(got.Length - start > 1000, "the stream was silence all the way through"); var breaks = 0; for (int i = start + 1; i < got.Length; i++) { - if (got[i] != got[i - 1] + 1f) breaks++; + if (got[i] != got[i - 1] + 1f) + breaks++; } // Exactly zero: every sample the producer wrote arrived once, in order. Assert.AreEqual( - 0, breaks, + 0, + breaks, $"the received sequence broke {breaks} times over {got.Length - start} samples, so " - + "buffers were dropped or served twice"); + + "buffers were dropped or served twice" + ); } /// @@ -278,11 +332,20 @@ public async Task AConsumerAskingForASizeTheProducerLacks_TakesWhatItIsGiven() RequireLinux(); using var cts = new CancellationTokenSource(Budget); - await using var ctx = new PipeWireContext("pwnet-playfixate", ConsoleTestLoggerFactory.Instance); + await using var ctx = new PipeWireContext( + "pwnet-playfixate", + ConsoleTestLoggerFactory.Instance + ); await ctx.StartAsync(cts.Token); await using var output = new PipeWireVideoOutput( - ctx, "pwnet-playfixate-src", Width, Height, PixelFormat.Bgra, 30); + ctx, + "pwnet-playfixate-src", + Width, + Height, + PixelFormat.Bgra, + 30 + ); output.FillFrame += (_, pixels, _, _, _, _) => { @@ -297,29 +360,35 @@ public async Task AConsumerAskingForASizeTheProducerLacks_TakesWhatItIsGiven() await using var capture = new PipeWireVideoCapture(ctx, "pwnet-playfixate-sink"); capture.FrameReady += (_, f) => { - lock (sizes) { if (sizes.Count < 16) sizes.Add((f.Width, f.Height)); } + lock (sizes) + { + if (sizes.Count < 16) + sizes.Add((f.Width, f.Height)); + } }; // A size the producer is not offering. The connect must still negotiate. - capture.Connect( - nodeId, [PixelFormat.Bgra], preferredWidth: 640, preferredHeight: 360); + capture.Connect(nodeId, [PixelFormat.Bgra], preferredWidth: 640, preferredHeight: 360); await capture.WaitForStreamingAsync(cts.Token); await Task.Delay(700, cts.Token); (int W, int H)[] seen; - lock (sizes) seen = [.. sizes]; + lock (sizes) + seen = [.. sizes]; Assert.IsTrue( seen.Length > 0, - "asking for a size the producer does not have stopped the negotiation entirely"); + "asking for a size the producer does not have stopped the negotiation entirely" + ); // What arrived is the producer's size, not the request - which is exactly why a consumer // must read the geometry rather than assume it. Assert.AreEqual( (Width, Height), seen[0], - "the negotiated geometry is neither what was asked for nor what the producer offers"); + "the negotiated geometry is neither what was asked for nor what the producer offers" + ); Assert.AreEqual(1, seen.Distinct().Count(), "the geometry changed between frames"); } @@ -353,7 +422,10 @@ public async Task PixelsWrittenByOneFilter_AreReadByAnotherOverADspVideoLink() RequireLinux(); using var cts = new CancellationTokenSource(Budget); - await using var ctx = new PipeWireContext("pwnet-dspvideo", ConsoleTestLoggerFactory.Instance); + await using var ctx = new PipeWireContext( + "pwnet-dspvideo", + ConsoleTestLoggerFactory.Instance + ); await ctx.StartAsync(cts.Token); await using var reg = new PipeWireRegistry(ctx); @@ -369,10 +441,12 @@ public async Task PixelsWrittenByOneFilter_AreReadByAnotherOverADspVideoLink() source.ProcessCallback = (f, _, in _) => { PipeWireVideoCycle v = f.VideoCycle; - if (!v.IsValid || v.Width == 0 || v.Height == 0) return; + if (!v.IsValid || v.Width == 0 || v.Height == 0) + return; Span pixels = srcOut.GetPixels(v.Width, v.Height); - if (pixels.IsEmpty) return; + if (pixels.IsEmpty) + return; // A value that changes every cycle, so a stale or repeated buffer is visible. float tag = Interlocked.Increment(ref written); @@ -392,17 +466,24 @@ public async Task PixelsWrittenByOneFilter_AreReadByAnotherOverADspVideoLink() play.ProcessCallback = (f, _, in _) => { PipeWireVideoCycle v = f.VideoCycle; - if (!v.IsValid || v.Width == 0 || v.Height == 0) return; + if (!v.IsValid || v.Width == 0 || v.Height == 0) + return; Span pixels = playIn.GetPixels(v.Width, v.Height); - if (pixels.IsEmpty) return; + if (pixels.IsEmpty) + return; float first = pixels[0]; - if (first == 0f) return; // a cycle before the source has written anything + if (first == 0f) + return; // a cycle before the source has written anything foreach (float px in pixels) { - if (px != first) { Interlocked.Increment(ref nonUniform); break; } + if (px != first) + { + Interlocked.Increment(ref nonUniform); + break; + } } // Recorded inside the cycle, where the geometry is actually available: four floats @@ -425,25 +506,35 @@ public async Task PixelsWrittenByOneFilter_AreReadByAnotherOverADspVideoLink() uint playNode = await play.WaitForNodeIdAsync(cts.Token); // Both ports have to reach the registry before they can be linked. - PipeWirePort? outPort = null, inPort = null; + PipeWirePort? outPort = null, + inPort = null; for (var i = 0; i < 100 && (outPort is null || inPort is null); i++) { PipeWireGraphSnapshot g = reg.Current; - outPort ??= g.Ports.FirstOrDefault( - p => p.NodeId == srcNode && p.PortDirection == PipeWirePortDirection.Out); - inPort ??= g.Ports.FirstOrDefault( - p => p.NodeId == playNode && p.PortDirection == PipeWirePortDirection.In); - - if (outPort is null || inPort is null) await Task.Delay(50, cts.Token); + outPort ??= g.Ports.FirstOrDefault(p => + p.NodeId == srcNode && p.PortDirection == PipeWirePortDirection.Out + ); + inPort ??= g.Ports.FirstOrDefault(p => + p.NodeId == playNode && p.PortDirection == PipeWirePortDirection.In + ); + + if (outPort is null || inPort is null) + await Task.Delay(50, cts.Token); } Assert.IsNotNull(outPort, "the source filter's video port never reached the graph"); Assert.IsNotNull(inPort, "the consuming filter's video port never reached the graph"); - Assert.AreEqual("32 bit float RGBA video", outPort!.DspFormat, - "the source port reached the graph declaring the wrong DSP format"); - Assert.AreEqual("32 bit float RGBA video", inPort!.DspFormat, - "the consuming port reached the graph declaring the wrong DSP format"); + Assert.AreEqual( + "32 bit float RGBA video", + outPort!.DspFormat, + "the source port reached the graph declaring the wrong DSP format" + ); + Assert.AreEqual( + "32 bit float RGBA video", + inPort!.DspFormat, + "the consuming port reached the graph declaring the wrong DSP format" + ); PipeWireLink link = await reg.CreateLink(outPort, inPort).ExecuteAsync(cts.Token); Assert.IsNotNull(link, "the two video ports could not be linked"); @@ -456,34 +547,43 @@ public async Task PixelsWrittenByOneFilter_AreReadByAnotherOverADspVideoLink() Assert.IsTrue( Volatile.Read(ref written) > 0, - "the source filter's process callback never got a video buffer to write into"); + "the source filter's process callback never got a video buffer to write into" + ); Assert.IsTrue( Volatile.Read(ref read) >= 4, - $"only {Volatile.Read(ref read)} cycles carried pixels across the link"); + $"only {Volatile.Read(ref read)} cycles carried pixels across the link" + ); Assert.AreEqual( - 0, Volatile.Read(ref nonUniform), - "a frame arrived with mixed values, so the buffer was torn or only partly written"); + 0, + Volatile.Read(ref nonUniform), + "a frame arrived with mixed values, so the buffer was torn or only partly written" + ); // The accessor handed back exactly one frame: width * height * 4 floats. int[] shortfalls; - lock (pixelCounts) shortfalls = [.. pixelCounts]; + lock (pixelCounts) + shortfalls = [.. pixelCounts]; Assert.IsTrue(shortfalls.Length > 0, "no buffer size was recorded"); foreach (int shortfall in shortfalls) { Assert.AreEqual( - 0, shortfall, - $"a video buffer was {shortfall} floats away from one RGBA frame"); + 0, + shortfall, + $"a video buffer was {shortfall} floats away from one RGBA frame" + ); } // What was read is something the source actually wrote, not an artefact. Assert.IsTrue( - Volatile.Read(ref lastRead) > 0 && Volatile.Read(ref lastRead) <= Volatile.Read(ref written), + Volatile.Read(ref lastRead) > 0 + && Volatile.Read(ref lastRead) <= Volatile.Read(ref written), $"the consumer read {Volatile.Read(ref lastRead)}, which the source never wrote " - + $"(it had written up to {Volatile.Read(ref written)})"); + + $"(it had written up to {Volatile.Read(ref written)})" + ); // Outside a cycle the geometry is deliberately not reported, so a filter cannot size its // next access from a frame the graph has moved on from. Every cycle above proves the other @@ -502,17 +602,21 @@ public async Task PixelsWrittenByOneFilter_AreReadByAnotherOverADspVideoLink() { await Task.Delay(50, cts.Token); int now = Volatile.Read(ref read); - if (now == quiet) break; + if (now == quiet) + break; quiet = now; } Assert.AreEqual( - quiet, Volatile.Read(ref read), - "the consuming filter is still being scheduled after SetActive(false)"); + quiet, + Volatile.Read(ref read), + "the consuming filter is still being scheduled after SetActive(false)" + ); Assert.IsFalse( play.VideoCycle.IsValid, - "the frame geometry is still being reported outside the process callback"); + "the frame geometry is still being reported outside the process callback" + ); } /// @@ -539,7 +643,8 @@ public async Task TimedMidiEventsWrittenByOneFilter_ArriveAtAnotherInTime() // A note-on and a note-off as UMP packets, at distinct offsets inside the cycle. byte[] noteOn = [0x20, 0x90, 0x3C, 0x64]; byte[] noteOff = [0x20, 0x80, 0x3C, 0x00]; - const uint onOffset = 0, offOffset = 64; + const uint onOffset = 0, + offOffset = 64; await using var ctx = new PipeWireContext("pwnet-midi", ConsoleTestLoggerFactory.Instance); await ctx.StartAsync(cts.Token); @@ -559,7 +664,8 @@ public async Task TimedMidiEventsWrittenByOneFilter_ArriveAtAnotherInTime() new SpaControl(offOffset, (uint)SpaControlType.Ump, new SpaBytes([.. noteOff])), ]; - if (srcOut.WriteEvents(events)) Interlocked.Increment(ref sent); + if (srcOut.WriteEvents(events)) + Interlocked.Increment(ref sent); }; await using PipeWireFilter sink = PipeWireFilter.Create(ctx, "pwnet_midi_sink"); @@ -569,14 +675,17 @@ public async Task TimedMidiEventsWrittenByOneFilter_ArriveAtAnotherInTime() sink.ProcessCallback = (_, _, in _) => { SpaSequence? seq = sinkIn.ReadEvents(); - if (seq is null || seq.Controls.Length == 0) return; + if (seq is null || seq.Controls.Length == 0) + return; lock (received) { - if (received.Count >= 16) return; + if (received.Count >= 16) + return; foreach (SpaControl c in seq.Controls) { - if (c.Value is SpaBytes b) received.Add((c.Offset, [.. b.Value])); + if (c.Value is SpaBytes b) + received.Add((c.Offset, [.. b.Value])); } } }; @@ -587,16 +696,20 @@ public async Task TimedMidiEventsWrittenByOneFilter_ArriveAtAnotherInTime() uint srcNode = await source.WaitForNodeIdAsync(cts.Token); uint sinkNode = await sink.WaitForNodeIdAsync(cts.Token); - PipeWirePort? outPort = null, inPort = null; + PipeWirePort? outPort = null, + inPort = null; for (var i = 0; i < 100 && (outPort is null || inPort is null); i++) { PipeWireGraphSnapshot g = reg.Current; - outPort ??= g.Ports.FirstOrDefault( - p => p.NodeId == srcNode && p.PortDirection == PipeWirePortDirection.Out); - inPort ??= g.Ports.FirstOrDefault( - p => p.NodeId == sinkNode && p.PortDirection == PipeWirePortDirection.In); - - if (outPort is null || inPort is null) await Task.Delay(50, cts.Token); + outPort ??= g.Ports.FirstOrDefault(p => + p.NodeId == srcNode && p.PortDirection == PipeWirePortDirection.Out + ); + inPort ??= g.Ports.FirstOrDefault(p => + p.NodeId == sinkNode && p.PortDirection == PipeWirePortDirection.In + ); + + if (outPort is null || inPort is null) + await Task.Delay(50, cts.Token); } Assert.IsNotNull(outPort, "the MIDI source port never reached the graph"); @@ -610,16 +723,22 @@ public async Task TimedMidiEventsWrittenByOneFilter_ArriveAtAnotherInTime() for (var i = 0; i < 100; i++) { - lock (received) { if (received.Count >= 4) break; } + lock (received) + { + if (received.Count >= 4) + break; + } await Task.Delay(50, cts.Token); } (uint Offset, byte[] Data)[] got; - lock (received) got = [.. received]; + lock (received) + got = [.. received]; Assert.IsTrue( Volatile.Read(ref sent) > 0, - "the source filter never got a buffer to write MIDI into"); + "the source filter never got a buffer to write MIDI into" + ); Assert.IsTrue(got.Length >= 4, $"only {got.Length} MIDI events crossed the link"); @@ -632,20 +751,23 @@ public async Task TimedMidiEventsWrittenByOneFilter_ArriveAtAnotherInTime() Assert.IsTrue( isOn || isOff, $"an event arrived with payload [{string.Join(" ", data.Select(b => b.ToString("X2")))}], " - + "which is neither packet that was sent"); + + "which is neither packet that was sent" + ); // And at the offset it was sent at: a sequence that lost its timing would deliver the // right notes at the wrong moment inside the quantum. Assert.AreEqual( isOn ? onOffset : offOffset, offset, - "a MIDI event arrived at the wrong offset within the cycle"); + "a MIDI event arrived at the wrong offset within the cycle" + ); } Assert.IsTrue( got.Any(e => e.Data.AsSpan().SequenceEqual(noteOn)) - && got.Any(e => e.Data.AsSpan().SequenceEqual(noteOff)), - "only one of the two packets ever arrived"); + && got.Any(e => e.Data.AsSpan().SequenceEqual(noteOff)), + "only one of the two packets ever arrived" + ); } /// @@ -663,7 +785,10 @@ public async Task EveryDspFormat_ReachesTheGraphAsTheStringPipeWireReads() RequireLinux(); using var cts = new CancellationTokenSource(Budget); - await using var ctx = new PipeWireContext("pwnet-dspformats", ConsoleTestLoggerFactory.Instance); + await using var ctx = new PipeWireContext( + "pwnet-dspformats", + ConsoleTestLoggerFactory.Instance + ); await ctx.StartAsync(cts.Token); await using var reg = new PipeWireRegistry(ctx); @@ -687,15 +812,19 @@ public async Task EveryDspFormat_ReachesTheGraphAsTheStringPipeWireReads() for (var i = 0; i < 100 && ports.Count < 5; i++) { ports = [.. reg.Current.Ports.Where(p => p.NodeId == nodeId)]; - if (ports.Count < 5) await Task.Delay(100, cts.Token); + if (ports.Count < 5) + await Task.Delay(100, cts.Token); } Assert.AreEqual(5, ports.Count, "not every DSP port reached the graph"); Dictionary byName = ports.ToDictionary( - p => p.Properties.GetValueOrDefault(PipeWireKeys.PW_KEY_PORT_NAME) ?? p.PortId.ToString(), + p => + p.Properties.GetValueOrDefault(PipeWireKeys.PW_KEY_PORT_NAME) + ?? p.PortId.ToString(), p => p.DspFormat, - StringComparer.Ordinal); + StringComparer.Ordinal + ); Assert.AreEqual("32 bit float mono audio", byName["audio_in"]); Assert.AreEqual("8 bit raw midi", byName["midi_in"]); @@ -710,8 +839,9 @@ public async Task EveryDspFormat_ReachesTheGraphAsTheStringPipeWireReads() // control.ump is one of the port's own properties, outside the keys the daemon copies onto // the registry global (impl-port.c global_keys). It arrives with the port's info once the // port is bound, and binding files it into the registry's record of the port. - PipeWirePort ump = ports.Single( - p => p.Properties.GetValueOrDefault(PipeWireKeys.PW_KEY_PORT_NAME) == "ump_in"); + PipeWirePort ump = ports.Single(p => + p.Properties.GetValueOrDefault(PipeWireKeys.PW_KEY_PORT_NAME) == "ump_in" + ); await using PipeWirePortProxy bound = reg.BindPort(ump.PortId); for (var i = 0; i < 50 && ump.Properties.GetValueOrDefault("control.ump") is null; i++) { @@ -727,7 +857,8 @@ public async Task EveryDspFormat_ReachesTheGraphAsTheStringPipeWireReads() Assert.AreEqual( "true", ump.Properties.GetValueOrDefault("control.ump")?.ToLowerInvariant(), - "the UMP port reached the graph as plain MIDI, so MIDI 2.0 packets would be misread"); + "the UMP port reached the graph as plain MIDI, so MIDI 2.0 packets would be misread" + ); } } } diff --git a/tests/PipeWire.NET.Tests/VideoMetadataTypeTests.cs b/tests/PipeWire.NET.Tests/VideoMetadataTypeTests.cs index ce3a4a5..ee2c593 100644 --- a/tests/PipeWire.NET.Tests/VideoMetadataTypeTests.cs +++ b/tests/PipeWire.NET.Tests/VideoMetadataTypeTests.cs @@ -16,8 +16,17 @@ public void ACursorCarriesItsPositionAndHotspotSeparately() { ReadOnlySpan pixels = [1, 2, 3, 4]; VideoCursor cursor = new( - id: 7, x: 100, y: 200, hotspotX: 3, hotspotY: 4, - format: PixelFormat.Bgra, width: 32, height: 32, stride: 128, pixels: pixels); + id: 7, + x: 100, + y: 200, + hotspotX: 3, + hotspotY: 4, + format: PixelFormat.Bgra, + width: 32, + height: 32, + stride: 128, + pixels: pixels + ); // Position and hotspot are different things: the hotspot is the offset within the bitmap // that actually points, so swapping them draws the cursor up and to the left of the mouse. @@ -41,8 +50,17 @@ public void ACursorCarriesItsPositionAndHotspotSeparately() public void ACursorThatMovedWithoutChangingImageHasNoPixels() { VideoCursor cursor = new( - id: 1, x: 5, y: 6, hotspotX: 0, hotspotY: 0, - format: PixelFormat.Unknown, width: 0, height: 0, stride: 0, pixels: default); + id: 1, + x: 5, + y: 6, + hotspotX: 0, + hotspotY: 0, + format: PixelFormat.Unknown, + width: 0, + height: 0, + stride: 0, + pixels: default + ); Assert.IsTrue(cursor.Pixels.IsEmpty); Assert.AreEqual(0u, cursor.Width); diff --git a/tests/PipeWire.NET.Tests/VirtualCamera.cs b/tests/PipeWire.NET.Tests/VirtualCamera.cs index 7c2f562..1ffa0f4 100644 --- a/tests/PipeWire.NET.Tests/VirtualCamera.cs +++ b/tests/PipeWire.NET.Tests/VirtualCamera.cs @@ -47,14 +47,20 @@ private static bool Run(string file, string args, out string output) { try { - using Process? p = Process.Start(new ProcessStartInfo(file, args) + using Process? p = Process.Start( + new ProcessStartInfo(file, args) + { + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + } + ); + + if (p is null) { - RedirectStandardOutput = true, - RedirectStandardError = true, - UseShellExecute = false, - }); - - if (p is null) { output = "could not start " + file; return false; } + output = "could not start " + file; + return false; + } output = p.StandardOutput.ReadToEnd() + p.StandardError.ReadToEnd(); p.WaitForExit(20_000); @@ -100,8 +106,14 @@ public static async Task StartAsync(CancellationToken ct) if (!File.Exists(device)) { - if (!Run("sudo", $"-n modprobe v4l2loopback devices=1 video_nr={VideoNr} " - + $"card_label={CardLabel} exclusive_caps=0", out string modprobe)) + if ( + !Run( + "sudo", + $"-n modprobe v4l2loopback devices=1 video_nr={VideoNr} " + + $"card_label={CardLabel} exclusive_caps=0", + out string modprobe + ) + ) { Assert.Inconclusive($"could not load v4l2loopback: {modprobe.Trim()}"); } @@ -121,18 +133,31 @@ public static async Task StartAsync(CancellationToken ct) UseShellExecute = false, }; - foreach (string arg in new[] - { - "-nostdin", "-loglevel", "error", - "-re", "-f", "lavfi", "-i", "testsrc=size=320x240:rate=30", - "-pix_fmt", "yuyv422", "-f", "v4l2", device, - }) + foreach ( + string arg in new[] + { + "-nostdin", + "-loglevel", + "error", + "-re", + "-f", + "lavfi", + "-i", + "testsrc=size=320x240:rate=30", + "-pix_fmt", + "yuyv422", + "-f", + "v4l2", + device, + } + ) { psi.ArgumentList.Add(arg); } Process? feeder = Process.Start(psi); - if (feeder is null) Assert.Inconclusive("could not start ffmpeg to feed the virtual camera."); + if (feeder is null) + Assert.Inconclusive("could not start ffmpeg to feed the virtual camera."); // Drained, not merely redirected. A child whose pipe fills up blocks writing to it and then // never exits, which hangs teardown - and with the test host holding the read end, the whole @@ -141,7 +166,12 @@ public static async Task StartAsync(CancellationToken ct) feeder!.OutputDataReceived += (_, _) => { }; feeder.ErrorDataReceived += (_, e) => { - if (e.Data is not null) lock (stderr) { if (stderr.Length < 4000) stderr.AppendLine(e.Data); } + if (e.Data is not null) + lock (stderr) + { + if (stderr.Length < 4000) + stderr.AppendLine(e.Data); + } }; feeder.BeginOutputReadLine(); @@ -155,7 +185,8 @@ public static async Task StartAsync(CancellationToken ct) if (feeder.HasExited) { string err; - lock (stderr) err = stderr.ToString(); + lock (stderr) + err = stderr.ToString(); Assert.Inconclusive($"ffmpeg could not feed {device}: {err.Trim()}"); } From 19bc81624d85ec70e31138d024bf264c686cf631 Mon Sep 17 00:00:00 2001 From: Agash Date: Wed, 23 Sep 2026 11:05:46 +0200 Subject: [PATCH 2/4] build: move to .NET 11 RC1 and refresh packages --- CONTRIBUTING.md | 2 +- Directory.Packages.props | 4 ++-- global.json | 2 +- .../PipeWire.NET.SampleConsole.csproj | 3 ++- samples/PipeWire.NET.SampleConsole/Session.cs | 2 ++ tests/PipeWire.NET.Tests/AvSyncTests.cs | 2 ++ tests/PipeWire.NET.Tests/FdOwnershipTests.cs | 24 +++++++++---------- .../PipeWire.NET.Tests.csproj | 2 ++ 8 files changed, 24 insertions(+), 17 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8c9343f..a8000e2 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -9,7 +9,7 @@ dotnet build PipeWire.NET.slnx dotnet test --filter "TestCategory!=Integration" ``` -The build treats warnings as errors and targets .NET 10 (and .NET 11 preview). +The build treats warnings as errors and targets .NET 10 and .NET 11. Integration tests need a running PipeWire daemon on Linux. Some use GStreamer to produce real sources; install `gstreamer1.0-tools gstreamer1.0-plugins-base gstreamer1.0-plugins-good diff --git a/Directory.Packages.props b/Directory.Packages.props index 4d5c937..23691b5 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -14,10 +14,10 @@ /> - + diff --git a/global.json b/global.json index abe2e1d..62939fe 100644 --- a/global.json +++ b/global.json @@ -1,6 +1,6 @@ { "sdk": { - "version": "11.0.100-preview.7.26381.103", + "version": "11.0.100-rc.1.26425.128", "rollForward": "latestFeature", "allowPrerelease": true }, diff --git a/samples/PipeWire.NET.SampleConsole/PipeWire.NET.SampleConsole.csproj b/samples/PipeWire.NET.SampleConsole/PipeWire.NET.SampleConsole.csproj index 123b98d..821b658 100644 --- a/samples/PipeWire.NET.SampleConsole/PipeWire.NET.SampleConsole.csproj +++ b/samples/PipeWire.NET.SampleConsole/PipeWire.NET.SampleConsole.csproj @@ -2,10 +2,11 @@ Exe net10.0;net11.0 + true 10.0.11 + >10.0.12 linux-x64;linux-arm64 false