diff --git a/Runtime/Scripts/UniTask.meta b/Runtime/Scripts/UniTask.meta new file mode 100644 index 00000000..45727607 --- /dev/null +++ b/Runtime/Scripts/UniTask.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 7f5f50e598f7646458d6958db6c7246a +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/Scripts/UniTask/YieldInstructionUniTaskExtensions.cs b/Runtime/Scripts/UniTask/YieldInstructionUniTaskExtensions.cs new file mode 100644 index 00000000..84747d49 --- /dev/null +++ b/Runtime/Scripts/UniTask/YieldInstructionUniTaskExtensions.cs @@ -0,0 +1,93 @@ +#if LIVEKIT_UNITASK +using System.Threading; +using Cysharp.Threading.Tasks; + +namespace LiveKit +{ + /// + /// Bridges the SDK's / + /// surface to UniTask, adding support. Available only when + /// the com.cysharp.unitask package is installed; the assembly is otherwise excluded + /// via a defineConstraint on LIVEKIT_UNITASK. + /// + public static class YieldInstructionUniTaskExtensions + { + /// + /// Wraps the instruction as a . The task completes when the + /// instruction's transitions to true, or + /// faults with if the token fires + /// first. + /// + /// + /// Cancellation has "abandon awaiter" semantics: the underlying FFI request keeps + /// running and any result is discarded. Wire-level cancellation is not yet + /// supported. Error inspection stays on the instruction itself — the awaiter does + /// not throw on , matching the existing + /// yield return / await behavior. + /// + public static UniTask AsUniTask(this YieldInstruction instruction, CancellationToken cancellationToken = default) + { + if (instruction == null) throw new System.ArgumentNullException(nameof(instruction)); + if (instruction.IsDone) return UniTask.CompletedTask; + if (cancellationToken.IsCancellationRequested) return UniTask.FromCanceled(cancellationToken); + + var source = new UniTaskCompletionSource(); + CancellationTokenRegistration registration = default; + + if (cancellationToken.CanBeCanceled) + { + registration = cancellationToken.Register(static state => + { + var s = (UniTaskCompletionSource)state; + s.TrySetCanceled(); + }, source); + } + + // YieldInstruction.RegisterContinuation fires the callback exactly once and is + // race-safe between FFI-thread completion and main-thread registration. Either + // TrySetResult or TrySetCanceled wins; the loser is a no-op. + instruction.GetAwaiter().OnCompleted(() => + { + registration.Dispose(); + source.TrySetResult(); + }); + + return source.Task; + } + + /// + /// UniTask-bridged equivalent of awaiting a once. + /// Call between chunks; each + /// AsUniTask call awaits the next chunk or end-of-stream. + /// + public static UniTask AsUniTask(this StreamYieldInstruction instruction, CancellationToken cancellationToken = default) + { + if (instruction == null) throw new System.ArgumentNullException(nameof(instruction)); + // GetAwaiter().IsCompleted folds together IsCurrentReadDone || IsEos and is + // the only public way to check the combined state from outside the LiveKit asm. + if (instruction.GetAwaiter().IsCompleted) return UniTask.CompletedTask; + if (cancellationToken.IsCancellationRequested) return UniTask.FromCanceled(cancellationToken); + + var source = new UniTaskCompletionSource(); + CancellationTokenRegistration registration = default; + + if (cancellationToken.CanBeCanceled) + { + registration = cancellationToken.Register(static state => + { + var s = (UniTaskCompletionSource)state; + s.TrySetCanceled(); + }, source); + } + + instruction.GetAwaiter().OnCompleted(() => + { + registration.Dispose(); + source.TrySetResult(); + }); + + return source.Task; + } + } +} +#endif diff --git a/Runtime/Scripts/UniTask/YieldInstructionUniTaskExtensions.cs.meta b/Runtime/Scripts/UniTask/YieldInstructionUniTaskExtensions.cs.meta new file mode 100644 index 00000000..42453b89 --- /dev/null +++ b/Runtime/Scripts/UniTask/YieldInstructionUniTaskExtensions.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 1264e1f4f8a8d4ad9ab94cdc2909a3a1 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/Scripts/UniTask/livekit.unity.Runtime.UniTask.asmdef b/Runtime/Scripts/UniTask/livekit.unity.Runtime.UniTask.asmdef new file mode 100644 index 00000000..d00c9d6a --- /dev/null +++ b/Runtime/Scripts/UniTask/livekit.unity.Runtime.UniTask.asmdef @@ -0,0 +1,25 @@ +{ + "name": "LiveKit.UniTask", + "rootNamespace": "LiveKit.UniTaskExtensions", + "references": [ + "LiveKit", + "UniTask" + ], + "includePlatforms": [], + "excludePlatforms": [], + "allowUnsafeCode": false, + "overrideReferences": false, + "precompiledReferences": [], + "autoReferenced": true, + "defineConstraints": [ + "LIVEKIT_UNITASK" + ], + "versionDefines": [ + { + "name": "com.cysharp.unitask", + "expression": "2.0.0", + "define": "LIVEKIT_UNITASK" + } + ], + "noEngineReferences": false +} diff --git a/Runtime/Scripts/UniTask/livekit.unity.Runtime.UniTask.asmdef.meta b/Runtime/Scripts/UniTask/livekit.unity.Runtime.UniTask.asmdef.meta new file mode 100644 index 00000000..3a7e76a5 --- /dev/null +++ b/Runtime/Scripts/UniTask/livekit.unity.Runtime.UniTask.asmdef.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: a7fbb1537932e48f4a28030ab7a3ac51 +AssemblyDefinitionImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Samples~/Meet/Assets/Runtime/MeetManager.cs b/Samples~/Meet/Assets/Runtime/MeetManager.cs index 225c7a0c..cb17080d 100644 --- a/Samples~/Meet/Assets/Runtime/MeetManager.cs +++ b/Samples~/Meet/Assets/Runtime/MeetManager.cs @@ -1,5 +1,6 @@ -using System.Collections; using System.Collections.Generic; +using System.Threading; +using Cysharp.Threading.Tasks; using UnityEngine; using UnityEngine.UI; using LiveKit; @@ -97,9 +98,14 @@ private void OnDestroy() #region UI Callbacks + // Action-compatible button handlers; the async work fires-and-forgets via Forget() + // so exceptions are surfaced through UniTask's tracker instead of swallowed by + // async void. Cancellation is tied to the MonoBehaviour lifetime via + // GetCancellationTokenOnDestroy so scene change / app quit aborts in-flight FFI awaits. + private void OnStartCall() { - StartCoroutine(ConnectToRoom()); + ConnectToRoom(this.GetCancellationTokenOnDestroy()).Forget(); } private void OnEndCall() @@ -116,7 +122,7 @@ private void OnEndCall() private void OnToggleCamera() { if (!_cameraActive) - StartCoroutine(PublishLocalCamera()); + PublishLocalCamera(this.GetCancellationTokenOnDestroy()).Forget(); else UnpublishLocalCamera(); } @@ -125,7 +131,7 @@ private void OnToggleMicrophone() { if (!_microphoneActive) { - StartCoroutine(PublishLocalMicrophone()); + PublishLocalMicrophone(this.GetCancellationTokenOnDestroy()).Forget(); buttonBar.SetMicrophoneOn(true); } else @@ -147,17 +153,17 @@ private void OnPublishData() #region Connection - private IEnumerator ConnectToRoom() + private async UniTask ConnectToRoom(CancellationToken cancellationToken) { - if (_room != null) yield break; + if (_room != null) return; var fetch = _tokenSourceComponent.FetchConnectionDetails(new TokenSourceFetchOptions()); - yield return fetch; + await fetch.AsUniTask(cancellationToken); if (fetch.IsError) { Debug.LogError($"Failed to fetch connection details: {fetch.Exception?.Message}"); - yield break; + return; } var details = fetch.Result; @@ -172,13 +178,13 @@ private IEnumerator ConnectToRoom() _room.DataReceived += OnDataReceived; var connect = _room.Connect(details.ServerUrl, details.ParticipantToken, new RoomOptions()); - yield return connect; + await connect.AsUniTask(cancellationToken); if (connect.IsError) { Debug.LogError("LiveKit connection failed"); _room = null; - yield break; + return; } Debug.Log($"Connected to {_room.Name}"); @@ -390,14 +396,19 @@ private void OnTrackUnmuted(TrackPublication publication, Participant participan #region Local Camera - private IEnumerator PublishLocalCamera() + private async UniTask PublishLocalCamera(CancellationToken cancellationToken) { - if (_cameraActive) yield break; + if (_cameraActive) return; if (_webCamTexture == null) - yield return CameraDeviceProvider.Open(frameRate, t => _webCamTexture = t); + { + // CameraDeviceProvider.Open is still a plain IEnumerator (it touches Unity's + // WebCamTexture lifecycle), so bridge it through UniTask's IEnumerator adapter. + await CameraDeviceProvider.Open(frameRate, t => _webCamTexture = t) + .ToUniTask(cancellationToken: cancellationToken); + } - if (_webCamTexture == null) yield break; + if (_webCamTexture == null) return; EnsureParticipantTile(_localId); var tile = _participantTiles[_localId]; @@ -414,9 +425,9 @@ private IEnumerator PublishLocalCamera() }; var publish = _room.LocalParticipant.PublishTrack(_localVideoTrack, options); - yield return publish; + await publish.AsUniTask(cancellationToken); - if (publish.IsError) yield break; + if (publish.IsError) return; source.TextureReceived += tex => { @@ -427,6 +438,7 @@ private IEnumerator PublishLocalCamera() _cameraActive = true; _localRtcVideoSource = source; source.Start(); + // Long-running per-frame pump — keep on the coroutine driver, not awaitable. StartCoroutine(source.Update()); buttonBar.SetCameraOn(true); @@ -449,9 +461,9 @@ private void UnpublishLocalCamera() #region Local Microphone - private IEnumerator PublishLocalMicrophone() + private async UniTask PublishLocalMicrophone(CancellationToken cancellationToken) { - if (_audioObjects.ContainsKey(LocalAudioTrackName)) yield break; + if (_audioObjects.ContainsKey(LocalAudioTrackName)) return; Microphone.Start(null, true, 10, 44100); @@ -469,9 +481,9 @@ private IEnumerator PublishLocalMicrophone() }; var publish = _room.LocalParticipant.PublishTrack(_localAudioTrack, options); - yield return publish; + await publish.AsUniTask(cancellationToken); - if (publish.IsError) yield break; + if (publish.IsError) return; _microphoneActive = true; _audioObjects[LocalAudioTrackName] = audioObject; diff --git a/Samples~/Meet/Packages/manifest.json b/Samples~/Meet/Packages/manifest.json index 5d47b9f9..4b0284f8 100644 --- a/Samples~/Meet/Packages/manifest.json +++ b/Samples~/Meet/Packages/manifest.json @@ -1,5 +1,6 @@ { "dependencies": { + "com.cysharp.unitask": "https://github.com/Cysharp/UniTask.git?path=src/UniTask/Assets/Plugins/UniTask", "com.unity.collab-proxy": "2.2.0", "com.unity.feature.2d": "2.0.0", "com.unity.ide.rider": "3.0.27", diff --git a/Samples~/Meet/Packages/packages-lock.json b/Samples~/Meet/Packages/packages-lock.json index 443a1ff0..82f11be6 100644 --- a/Samples~/Meet/Packages/packages-lock.json +++ b/Samples~/Meet/Packages/packages-lock.json @@ -1,5 +1,12 @@ { "dependencies": { + "com.cysharp.unitask": { + "version": "https://github.com/Cysharp/UniTask.git?path=src/UniTask/Assets/Plugins/UniTask", + "depth": 0, + "source": "git", + "dependencies": {}, + "hash": "e5acc106ee196bc5a32fb14cdf2987b0f96d11e0" + }, "com.unity.2d.animation": { "version": "9.1.0", "depth": 1, diff --git a/Tests/PlayMode/UniTask.meta b/Tests/PlayMode/UniTask.meta new file mode 100644 index 00000000..bc43a857 --- /dev/null +++ b/Tests/PlayMode/UniTask.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 1ef4cd187b61c4a388e674497c3ac63d +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tests/PlayMode/UniTask/LiveKit.PlayModeTests.UniTask.asmdef b/Tests/PlayMode/UniTask/LiveKit.PlayModeTests.UniTask.asmdef new file mode 100644 index 00000000..37e31869 --- /dev/null +++ b/Tests/PlayMode/UniTask/LiveKit.PlayModeTests.UniTask.asmdef @@ -0,0 +1,31 @@ +{ + "name": "PlayModeTests.UniTask", + "rootNamespace": "LiveKit.PlayModeTests.UniTask", + "references": [ + "UnityEngine.TestRunner", + "UnityEditor.TestRunner", + "LiveKit", + "LiveKit.UniTask", + "UniTask" + ], + "includePlatforms": [], + "excludePlatforms": [], + "allowUnsafeCode": false, + "overrideReferences": true, + "precompiledReferences": [ + "nunit.framework.dll" + ], + "autoReferenced": false, + "defineConstraints": [ + "UNITY_INCLUDE_TESTS", + "LIVEKIT_UNITASK" + ], + "versionDefines": [ + { + "name": "com.cysharp.unitask", + "expression": "2.0.0", + "define": "LIVEKIT_UNITASK" + } + ], + "noEngineReferences": false +} diff --git a/Tests/PlayMode/UniTask/LiveKit.PlayModeTests.UniTask.asmdef.meta b/Tests/PlayMode/UniTask/LiveKit.PlayModeTests.UniTask.asmdef.meta new file mode 100644 index 00000000..ba465dbf --- /dev/null +++ b/Tests/PlayMode/UniTask/LiveKit.PlayModeTests.UniTask.asmdef.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 693ef0d1937d94c97aa2969770e3b59c +AssemblyDefinitionImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tests/PlayMode/UniTask/RoomUniTaskTests.cs b/Tests/PlayMode/UniTask/RoomUniTaskTests.cs new file mode 100644 index 00000000..69dce085 --- /dev/null +++ b/Tests/PlayMode/UniTask/RoomUniTaskTests.cs @@ -0,0 +1,71 @@ +#if LIVEKIT_UNITASK +using System.Threading; +using Cysharp.Threading.Tasks; +using NUnit.Framework; +using UnityEngine.TestTools; + +namespace LiveKit.PlayModeTests.UniTaskBridge +{ + public class RoomUniTaskTests + { + // Synthetic instruction used by the unit tests below — they verify the + // AsUniTask extension's behavior directly against the public setter contract + // (IsError then IsDone, mirroring the production completion order in + // Room.cs / Participant.cs / Track.cs) without needing the FFI. + private sealed class TestInstruction : YieldInstruction + { + public void Complete() => IsDone = true; + public void CompleteWithError() { IsError = true; IsDone = true; } + } + + // AsUniTask must complete when IsDone transitions to true, with the + // instruction's IsError visible on resume — parity with the await path + // covered by the Stage 1 Connect_FailsWithInvalidUrl_Awaitable test. + [UnityTest] + public System.Collections.IEnumerator AsUniTask_CompletesOnIsDone() => UniTask.ToCoroutine(async () => + { + var instruction = new TestInstruction(); + var task = instruction.AsUniTask(); + Assert.IsFalse(instruction.IsDone, "Sanity: instruction must not be done before Complete()"); + + instruction.CompleteWithError(); + await task; + + Assert.IsTrue(instruction.IsDone, "UniTask should not resume before IsDone"); + Assert.IsTrue(instruction.IsError, "Error state must be visible on resume"); + }); + + // Cancellation has abandon-awaiter semantics: the UniTask faults with + // OperationCanceledException, but the underlying request is not aborted. + // The synthetic instruction is never completed — only the token fires. + [UnityTest] + public System.Collections.IEnumerator AsUniTask_Cancellation_ThrowsOperationCanceled() => UniTask.ToCoroutine(async () => + { + var instruction = new TestInstruction(); + using var cts = new CancellationTokenSource(); + + var task = instruction.AsUniTask(cts.Token); + cts.Cancel(); + + bool threw = false; + try + { + await task; + } + catch (System.OperationCanceledException) + { + threw = true; + } + + Assert.IsTrue(threw, "Expected OperationCanceledException when token was cancelled"); + Assert.IsFalse(instruction.IsDone, "Abandon-awaiter semantics: underlying instruction is untouched"); + }); + + // End-to-end coverage of the FFI path is handled by the migrated Meet sample + // (Samples~/Meet/Assets/Runtime/MeetManager.cs). An additional E2E test here + // was tried and removed: FFI error logs arrive asynchronously and their delivery + // window races UniTask's synchronous resume, so the LogAssert tracking was + // brittle across test order. The unit tests above cover the extension's logic. + } +} +#endif diff --git a/Tests/PlayMode/UniTask/RoomUniTaskTests.cs.meta b/Tests/PlayMode/UniTask/RoomUniTaskTests.cs.meta new file mode 100644 index 00000000..588a85ab --- /dev/null +++ b/Tests/PlayMode/UniTask/RoomUniTaskTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 8fbde2a93f51d461ba697e4688c30e13 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: