diff --git a/Skyline.slnx b/Skyline.slnx index 260e78a..141feaa 100644 --- a/Skyline.slnx +++ b/Skyline.slnx @@ -18,12 +18,14 @@ + + diff --git a/src/Skyline.Interaction.Gpu/GpuTransfer.cs b/src/Skyline.Interaction.Gpu/GpuTransfer.cs new file mode 100644 index 0000000..d4e3874 --- /dev/null +++ b/src/Skyline.Interaction.Gpu/GpuTransfer.cs @@ -0,0 +1,101 @@ +// SPDX-License-Identifier: Apache-2.0 +using Silk.NET.WebGPU; + +namespace Skyline.Interaction.Gpu; + +/// +/// A GPU surface offered for transfer: a wgpu texture handle plus the metadata +/// a taker needs to bind it. The source owns the texture and keeps it alive for +/// the life of the offer; the taker must read or copy it before the offer +/// expires or is revoked. The handle never round-trips through the processor — +/// that is the whole point of the GPU bridge. +/// +public readonly record struct GpuSurfaceHandle( + nint Texture, TextureFormat Format, uint Width, uint Height); + +/// +/// One offered GPU surface, with provenance and policy — the GPU twin of +/// . Provenance and policy come from the same +/// model, so a surface transfer answers the +/// same who/how-far/how-long questions a text transfer does. +/// +public sealed record GpuTransferOffer( + string Id, GpuSurfaceHandle Surface, Provenance Provenance, TransferPolicy Policy); + +/// +/// The GPU transfer seam: offer a surface, take one by id (subject to its +/// policy), revoke, or list the live ones. Mirrors +/// for GPU handles instead of string payloads. +/// +public interface IGpuTransferBroker +{ + GpuTransferOffer Offer(GpuSurfaceHandle surface, Provenance provenance, TransferPolicy? policy = null); + GpuTransferOffer? Take(string id, Actor taker); + bool Revoke(string id); + IReadOnlyList List(); +} + +/// +/// The default in-process GPU transfer broker. Same lifetime and locality rules +/// as the text broker: it holds offers until they expire or are revoked, denies +/// a remote taker an offer its policy keeps local, and prunes lapsed offers +/// against an injected . +/// +public sealed class InProcessGpuTransferBroker(TimeProvider? time = null) : IGpuTransferBroker +{ + private readonly TimeProvider _time = time ?? TimeProvider.System; + private readonly Lock _gate = new(); + private readonly List _offers = []; + + public GpuTransferOffer Offer(GpuSurfaceHandle surface, Provenance provenance, TransferPolicy? policy = null) + { + var offer = new GpuTransferOffer( + Guid.NewGuid().ToString("n"), surface, provenance, policy ?? TransferPolicy.Default); + lock (_gate) + { + _offers.Add(offer); + } + return offer; + } + + public GpuTransferOffer? Take(string id, Actor taker) + { + lock (_gate) + { + PruneLocked(); + var offer = _offers.Find(o => o.Id == id); + if (offer is null) + { + return null; + } + if (taker.Locality == ActorLocality.Remote && !offer.Policy.AllowRemote) + { + return null; + } + return offer; + } + } + + public bool Revoke(string id) + { + lock (_gate) + { + return _offers.RemoveAll(o => o.Id == id) > 0; + } + } + + public IReadOnlyList List() + { + lock (_gate) + { + PruneLocked(); + return _offers.ToArray(); + } + } + + private void PruneLocked() + { + var now = _time.GetUtcNow(); + _offers.RemoveAll(o => o.Policy.ExpiresAt is { } expiresAt && expiresAt <= now); + } +} diff --git a/src/Skyline.Interaction.Gpu/Skyline.Interaction.Gpu.csproj b/src/Skyline.Interaction.Gpu/Skyline.Interaction.Gpu.csproj new file mode 100644 index 0000000..1474956 --- /dev/null +++ b/src/Skyline.Interaction.Gpu/Skyline.Interaction.Gpu.csproj @@ -0,0 +1,10 @@ + + + Skyline.Interaction.Gpu + Optional GPU bridge for the Skyline interaction tier: a wgpu-handle transfer representation so a surface copy never round-trips to the processor. Pairs Skyline.Interaction's transfer model with Skyline.Gpu. + + + + + + diff --git a/tests/Skyline.Interaction.Gpu.Tests/GlobalUsings.cs b/tests/Skyline.Interaction.Gpu.Tests/GlobalUsings.cs new file mode 100644 index 0000000..540383d --- /dev/null +++ b/tests/Skyline.Interaction.Gpu.Tests/GlobalUsings.cs @@ -0,0 +1 @@ +global using Microsoft.VisualStudio.TestTools.UnitTesting; diff --git a/tests/Skyline.Interaction.Gpu.Tests/GpuTransferTests.cs b/tests/Skyline.Interaction.Gpu.Tests/GpuTransferTests.cs new file mode 100644 index 0000000..ded8328 --- /dev/null +++ b/tests/Skyline.Interaction.Gpu.Tests/GpuTransferTests.cs @@ -0,0 +1,98 @@ +using Silk.NET.WebGPU; + +namespace Skyline.Interaction.Gpu.Tests; + +[TestClass] +public class GpuTransferTests +{ + private sealed class FixedTime(DateTimeOffset now) : TimeProvider + { + public DateTimeOffset Now { get; set; } = now; + + public override DateTimeOffset GetUtcNow() => Now; + } + + private static readonly DateTimeOffset T0 = new(2026, 1, 1, 0, 0, 0, TimeSpan.Zero); + private static readonly Actor Local = new("u", "User", ActorKind.Human, ActorLocality.Local); + private static readonly Actor Remote = new("r", "Remote", ActorKind.Human, ActorLocality.Remote); + private static readonly GpuSurfaceHandle Surface = new(0xABCD, TextureFormat.Rgba8Unorm, 64, 48); + + private static Provenance Prov() => new(Local, T0); + + [TestMethod] + public void SurfaceHandleCarriesFields() + { + Assert.AreEqual((nint)0xABCD, Surface.Texture); + Assert.AreEqual(TextureFormat.Rgba8Unorm, Surface.Format); + Assert.AreEqual(64u, Surface.Width); + Assert.AreEqual(48u, Surface.Height); + } + + [TestMethod] + public void OfferIsListedAndTakeable() + { + var broker = new InProcessGpuTransferBroker(new FixedTime(T0)); + var offer = broker.Offer(Surface, Prov()); + + Assert.AreEqual(TransferPolicy.Default, offer.Policy); + Assert.AreEqual(Surface, offer.Surface); + CollectionAssert.AreEqual(new[] { offer }, broker.List().ToArray()); + + var taken = broker.Take(offer.Id, Local); + Assert.IsNotNull(taken); + Assert.AreEqual(offer.Id, taken!.Id); + } + + [TestMethod] + public void TakeUnknownIdReturnsNull() + { + var broker = new InProcessGpuTransferBroker(new FixedTime(T0)); + Assert.IsNull(broker.Take("missing", Local)); + } + + [TestMethod] + public void RemoteTakerIsDeniedALocalOnlyOffer() + { + var broker = new InProcessGpuTransferBroker(new FixedTime(T0)); + var offer = broker.Offer(Surface, Prov()); // Default policy: local only + Assert.IsNull(broker.Take(offer.Id, Remote)); + } + + [TestMethod] + public void RemoteTakerIsAllowedWhenThePolicyAllowsIt() + { + var broker = new InProcessGpuTransferBroker(new FixedTime(T0)); + var policy = new TransferPolicy(TransferScope.Session, AllowRemote: true); + var offer = broker.Offer(Surface, Prov(), policy); + var taken = broker.Take(offer.Id, Remote); + Assert.IsNotNull(taken); + Assert.AreEqual(offer.Id, taken!.Id); + } + + [TestMethod] + public void RevokeRemovesAnOffer() + { + var broker = new InProcessGpuTransferBroker(new FixedTime(T0)); + var offer = broker.Offer(Surface, Prov()); + Assert.IsTrue(broker.Revoke(offer.Id)); + Assert.IsFalse(broker.Revoke(offer.Id)); // already gone + Assert.AreEqual(0, broker.List().Count); + } + + [TestMethod] + public void ExpiredOffersArePrunedAndLiveOnesSurvive() + { + var clock = new FixedTime(T0); + var broker = new InProcessGpuTransferBroker(clock); + var expiring = broker.Offer(Surface, Prov(), + new TransferPolicy(TransferScope.Session, AllowRemote: false, ExpiresAt: T0.AddSeconds(10))); + var permanent = broker.Offer(Surface, Prov()); // no expiry + + clock.Now = T0.AddSeconds(11); + + Assert.IsNull(broker.Take(expiring.Id, Local), "an expired offer is pruned before a take"); + var live = broker.List(); + Assert.AreEqual(1, live.Count); + Assert.AreEqual(permanent.Id, live[0].Id); + } +} diff --git a/tests/Skyline.Interaction.Gpu.Tests/Skyline.Interaction.Gpu.Tests.csproj b/tests/Skyline.Interaction.Gpu.Tests/Skyline.Interaction.Gpu.Tests.csproj new file mode 100644 index 0000000..8c60b2f --- /dev/null +++ b/tests/Skyline.Interaction.Gpu.Tests/Skyline.Interaction.Gpu.Tests.csproj @@ -0,0 +1,14 @@ + + + false + + + + + + + + + + + diff --git a/tools/cover.sh b/tools/cover.sh index 4818b1b..b233633 100755 --- a/tools/cover.sh +++ b/tools/cover.sh @@ -23,6 +23,6 @@ dotnet reportgenerator \ -reports:"coverage/unit/*/coverage.cobertura.xml;coverage/windowed.cobertura.xml" \ -targetdir:coverage/report \ "-reporttypes:TextSummary" \ - "-assemblyfilters:+Skyline;+Skyline.Gpu;+Skyline.Render;+Skyline.Interaction;+Skyline.Interaction.Ui" + "-assemblyfilters:+Skyline;+Skyline.Gpu;+Skyline.Render;+Skyline.Interaction;+Skyline.Interaction.Ui;+Skyline.Interaction.Gpu" cat coverage/report/Summary.txt