From f4e560d51d5d6099f2580776e033e6bc3922f886 Mon Sep 17 00:00:00 2001 From: Max Heimbrock <43608204+MaxHeimbrock@users.noreply.github.com> Date: Thu, 21 May 2026 17:20:48 +0200 Subject: [PATCH 01/17] Add GetAwaiter to YieldInstruction and StreamYieldInstruction Stage 1 of the UniTask migration: enable `await room.Connect(...)` and similar without taking on a UniTask dependency. The awaiter's continuation is invoked from the existing IsDone / IsCurrentReadDone / IsEos property setters, so all nine concrete instructions (Connect, PublishTrack, RPC, SendText/File, stream open/write/close, etc.) become awaitable with no change to their completion code paths. Race between FFI-thread completion and main-thread await registration is resolved with a sentinel-value Interlocked.CompareExchange on a single continuation slot. GetResult() is intentionally a no-op so the await surface keeps strict parity with `yield return` (callers still inspect IsError); a throwing variant can be layered on later. Co-Authored-By: Claude Opus 4.7 (1M context) --- Runtime/Scripts/Internal/YieldInstruction.cs | 144 ++++++++++++++++++- Tests/PlayMode/RoomTests.cs | 30 ++++ 2 files changed, 171 insertions(+), 3 deletions(-) diff --git a/Runtime/Scripts/Internal/YieldInstruction.cs b/Runtime/Scripts/Internal/YieldInstruction.cs index 748e884d..d6520c47 100644 --- a/Runtime/Scripts/Internal/YieldInstruction.cs +++ b/Runtime/Scripts/Internal/YieldInstruction.cs @@ -1,4 +1,6 @@ using System; +using System.Runtime.CompilerServices; +using System.Threading; using UnityEngine; namespace LiveKit @@ -13,10 +15,79 @@ public class YieldInstruction : CustomYieldInstruction private volatile bool _isDone; private volatile bool _isError; - public bool IsDone { get => _isDone; protected set => _isDone = value; } + // Sentinel published once completion has fired so any continuation registered + // afterwards runs inline instead of being silently dropped. + private static readonly Action s_completedSentinel = () => { }; + private Action? _continuation; + + public bool IsDone + { + get => _isDone; + protected set + { + _isDone = value; + if (value) InvokeContinuation(); + } + } public bool IsError { get => _isError; protected set => _isError = value; } public override bool keepWaiting => !_isDone; + + /// + /// Returns an awaiter so callers can await this instruction directly. + /// + /// + /// The awaiter completes when becomes true. As with the + /// coroutine path, success vs. failure is inspected on the instruction itself + /// ( and any subclass-specific result fields); GetResult + /// does not throw. + /// + public YieldInstructionAwaiter GetAwaiter() => new YieldInstructionAwaiter(this); + + internal void RegisterContinuation(Action continuation) + { + // Race between completion-side (FFI thread writes sentinel) and await-side + // (registers continuation): CompareExchange decides who wrote first. + // null -> we won, completion will invoke our continuation later + // sentinel -> completion already fired; invoke inline + // other -> a second awaiter beat us here, which we don't support + var prev = Interlocked.CompareExchange(ref _continuation, continuation, null); + if (prev == null) return; + if (ReferenceEquals(prev, s_completedSentinel)) + { + continuation(); + return; + } + throw new InvalidOperationException( + "YieldInstruction does not support multiple awaiters; await it only once."); + } + + private void InvokeContinuation() + { + var prev = Interlocked.Exchange(ref _continuation, s_completedSentinel); + if (prev != null && !ReferenceEquals(prev, s_completedSentinel)) + { + prev(); + } + } + } + + public readonly struct YieldInstructionAwaiter : INotifyCompletion + { + private readonly YieldInstruction _instruction; + + internal YieldInstructionAwaiter(YieldInstruction instruction) + { + _instruction = instruction; + } + + public bool IsCompleted => _instruction.IsDone; + + public void OnCompleted(Action continuation) => _instruction.RegisterContinuation(continuation); + + // Intentionally a no-op. Parity with the coroutine path: callers inspect IsError + // and subclass-specific result fields on the instruction itself. + public void GetResult() { } } public class StreamYieldInstruction : CustomYieldInstruction @@ -28,12 +99,31 @@ public class StreamYieldInstruction : CustomYieldInstruction private volatile bool _isEos; private volatile bool _isCurrentReadDone; + private static readonly Action s_completedSentinel = () => { }; + private Action? _continuation; + /// /// True if the stream has reached the end. /// - public bool IsEos { get => _isEos; protected set => _isEos = value; } + public bool IsEos + { + get => _isEos; + protected set + { + _isEos = value; + if (value) InvokeContinuation(); + } + } - internal bool IsCurrentReadDone { get => _isCurrentReadDone; set => _isCurrentReadDone = value; } + internal bool IsCurrentReadDone + { + get => _isCurrentReadDone; + set + { + _isCurrentReadDone = value; + if (value) InvokeContinuation(); + } + } public override bool keepWaiting => !_isCurrentReadDone && !_isEos; @@ -50,6 +140,54 @@ public override void Reset() throw new InvalidOperationException("Cannot reset after end of stream"); } _isCurrentReadDone = false; + // Drop the sentinel published by the previous completion so the next awaiter + // can install a fresh continuation. Safe because Reset is only called after the + // previous read's await has already resumed. + Volatile.Write(ref _continuation, null); + } + + /// + /// Returns an awaiter that completes when the next chunk is ready or the stream ends. + /// Call between iterations to await the following chunk. + /// + public StreamYieldInstructionAwaiter GetAwaiter() => new StreamYieldInstructionAwaiter(this); + + internal void RegisterContinuation(Action continuation) + { + var prev = Interlocked.CompareExchange(ref _continuation, continuation, null); + if (prev == null) return; + if (ReferenceEquals(prev, s_completedSentinel)) + { + continuation(); + return; + } + throw new InvalidOperationException( + "StreamYieldInstruction does not support multiple concurrent awaiters; await it once per chunk."); + } + + private void InvokeContinuation() + { + var prev = Interlocked.Exchange(ref _continuation, s_completedSentinel); + if (prev != null && !ReferenceEquals(prev, s_completedSentinel)) + { + prev(); + } } } + + public readonly struct StreamYieldInstructionAwaiter : INotifyCompletion + { + private readonly StreamYieldInstruction _instruction; + + internal StreamYieldInstructionAwaiter(StreamYieldInstruction instruction) + { + _instruction = instruction; + } + + public bool IsCompleted => _instruction.IsCurrentReadDone || _instruction.IsEos; + + public void OnCompleted(Action continuation) => _instruction.RegisterContinuation(continuation); + + public void GetResult() { } + } } diff --git a/Tests/PlayMode/RoomTests.cs b/Tests/PlayMode/RoomTests.cs index 1a871e5c..ee27f1da 100644 --- a/Tests/PlayMode/RoomTests.cs +++ b/Tests/PlayMode/RoomTests.cs @@ -1,5 +1,7 @@ using System.Collections; +using System.Threading.Tasks; using NUnit.Framework; +using UnityEngine; using UnityEngine.TestTools; using LiveKit.Proto; using LiveKit.PlayModeTests.Utils; @@ -26,6 +28,34 @@ public IEnumerator Connect_FailsWithInvalidUrl() Assert.IsNotNull(context.ConnectionError, "Expected connection to fail"); } + // Parity check for the awaitable surface added in Stage 1 of the UniTask migration: + // awaiting a ConnectInstruction must observe the same IsError signal that + // yield return does. The outer driver stays IEnumerator because Unity's PlayMode + // runner does not accept [Test] async Task — the await itself is what we're + // validating, wrapped in a Task that the coroutine polls. + [UnityTest, Category("E2E")] + public IEnumerator Connect_FailsWithInvalidUrl_Awaitable() + { + LogAssert.ignoreFailingMessages = true; + + using var room = new Room(); + var connect = room.Connect("invalid-url", "token", new RoomOptions()); + var awaitTask = AwaitInstruction(connect); + + yield return new WaitUntil(() => awaitTask.IsCompleted); + + LogAssert.ignoreFailingMessages = false; + + Assert.IsNull(awaitTask.Exception, awaitTask.Exception?.ToString()); + Assert.IsTrue(connect.IsDone, "Awaiter should not resume before IsDone"); + Assert.IsTrue(connect.IsError, "Expected connection to fail"); + } + + private static async Task AwaitInstruction(YieldInstruction instruction) + { + await instruction; + } + [UnityTest, Category("E2E")] public IEnumerator RoomName_MatchesProvided() { From 5c4e1be93bfbd0727adaa006579abfcb4989f777 Mon Sep 17 00:00:00 2001 From: Max Heimbrock <43608204+MaxHeimbrock@users.noreply.github.com> Date: Tue, 9 Jun 2026 11:48:16 +0200 Subject: [PATCH 02/17] Make Stage 1 awaiter test deterministic instead of FFI-flaky MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Connect_FailsWithInvalidUrl_Awaitable failed intermittently in the full PlayMode suite: awaiting the ConnectInstruction resumes the instant IsDone is set, but the FFI emits its "error while connecting" log batch a frame or two later — after the test had already reset LogAssert.ignoreFailingMessages, so the late error surfaced as an unhandled message and failed the test. It only passed in isolation because the timing happened to line up. Replace it with two deterministic tests driven by a synthetic YieldInstruction subclass: one for the OnCompleted path (await registered while pending, then completed) and one for the IsCompleted fast path (already done before await). These exercise the GetAwaiter logic directly with no FFI, no dev server, and no LogAssert race. The real connect-fail path stays covered by the existing Connect_FailsWithInvalidUrl coroutine test. Co-Authored-By: Claude Opus 4.8 (1M context) --- Tests/PlayMode/RoomTests.cs | 50 ++++++++++++++++++++++++++----------- 1 file changed, 36 insertions(+), 14 deletions(-) diff --git a/Tests/PlayMode/RoomTests.cs b/Tests/PlayMode/RoomTests.cs index ee27f1da..9cdc3031 100644 --- a/Tests/PlayMode/RoomTests.cs +++ b/Tests/PlayMode/RoomTests.cs @@ -28,27 +28,49 @@ public IEnumerator Connect_FailsWithInvalidUrl() Assert.IsNotNull(context.ConnectionError, "Expected connection to fail"); } - // Parity check for the awaitable surface added in Stage 1 of the UniTask migration: - // awaiting a ConnectInstruction must observe the same IsError signal that - // yield return does. The outer driver stays IEnumerator because Unity's PlayMode - // runner does not accept [Test] async Task — the await itself is what we're - // validating, wrapped in a Task that the coroutine polls. - [UnityTest, Category("E2E")] - public IEnumerator Connect_FailsWithInvalidUrl_Awaitable() + // Deterministic coverage of the GetAwaiter surface added in Stage 1, using a + // synthetic instruction so the awaiter logic is exercised without the FFI. These + // are intentionally NOT [Category("E2E")] — they need no dev server. The real + // connect-fail path stays covered by Connect_FailsWithInvalidUrl above; an earlier + // E2E variant of these was flaky because the FFI emits its error log asynchronously, + // which races LogAssert in the frame after the await has already resumed. + private sealed class TestYieldInstruction : YieldInstruction { - LogAssert.ignoreFailingMessages = true; + public void Complete() => IsDone = true; + public void CompleteWithError() { IsError = true; IsDone = true; } + } - using var room = new Room(); - var connect = room.Connect("invalid-url", "token", new RoomOptions()); - var awaitTask = AwaitInstruction(connect); + // OnCompleted path: await registers a continuation while the instruction is still + // pending, then completion fires it and IsError is visible on resume. + [UnityTest] + public IEnumerator GetAwaiter_ResumesOnCompletion_AndSurfacesIsError() + { + var instruction = new TestYieldInstruction(); + var awaitTask = AwaitInstruction(instruction); + Assert.IsFalse(awaitTask.IsCompleted, "Awaiter must not resume before IsDone"); + instruction.CompleteWithError(); yield return new WaitUntil(() => awaitTask.IsCompleted); - LogAssert.ignoreFailingMessages = false; + Assert.IsNull(awaitTask.Exception, awaitTask.Exception?.ToString()); + Assert.IsTrue(instruction.IsDone, "Awaiter resumed, so IsDone must be observable"); + Assert.IsTrue(instruction.IsError, "IsError must be visible on resume"); + } + + // IsCompleted fast path: instruction is already done before it is awaited, so the + // awaiter completes without ever registering a continuation. + [UnityTest] + public IEnumerator GetAwaiter_CompletesImmediately_WhenAlreadyDone() + { + var instruction = new TestYieldInstruction(); + instruction.Complete(); + + var awaitTask = AwaitInstruction(instruction); + yield return new WaitUntil(() => awaitTask.IsCompleted); Assert.IsNull(awaitTask.Exception, awaitTask.Exception?.ToString()); - Assert.IsTrue(connect.IsDone, "Awaiter should not resume before IsDone"); - Assert.IsTrue(connect.IsError, "Expected connection to fail"); + Assert.IsTrue(instruction.IsDone); + Assert.IsFalse(instruction.IsError); } private static async Task AwaitInstruction(YieldInstruction instruction) From 503c4f5066303bf6bebb841e426232908020a992 Mon Sep 17 00:00:00 2001 From: Max Heimbrock <43608204+MaxHeimbrock@users.noreply.github.com> Date: Thu, 21 May 2026 18:01:11 +0200 Subject: [PATCH 03/17] Add optional UniTask surface behind a version-define-gated asmdef MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stage 2 of the UniTask migration. The new LiveKit.UniTask asmdef hosts an AsUniTask extension on YieldInstruction and StreamYieldInstruction; the asmdef compiles only when com.cysharp.unitask is installed (the versionDefine auto-activates LIVEKIT_UNITASK). When UniTask is absent, the extension simply does not exist — no compile error, no runtime cost, no impact on Stage 1's awaiter. AsUniTask wraps the existing one-shot completion path in a UniTaskCompletionSource and adds CancellationToken support with "abandon awaiter" semantics: a cancel faults the UniTask with OperationCanceledException, but the underlying FFI request is not aborted. GetResult stays non-throwing for IsError parity with yield return / await; throwing variants can be layered on later. Includes a UniTask migration of Samples~/Meet to demonstrate the new path end-to-end (Connect / PublishLocalCamera / PublishLocalMicrophone all switch to async UniTask with cancellation tied to GetCancellationTokenOnDestroy). Long-running per-frame pumps stay on StartCoroutine since they aren't request/response. Co-Authored-By: Claude Opus 4.7 (1M context) Remove UniTask package --- Runtime/Scripts/UniTask.meta | 8 ++ .../YieldInstructionUniTaskExtensions.cs | 93 +++++++++++++++++++ .../YieldInstructionUniTaskExtensions.cs.meta | 11 +++ .../livekit.unity.Runtime.UniTask.asmdef | 25 +++++ .../livekit.unity.Runtime.UniTask.asmdef.meta | 7 ++ Tests/PlayMode/UniTask.meta | 8 ++ .../LiveKit.PlayModeTests.UniTask.asmdef | 31 +++++++ .../LiveKit.PlayModeTests.UniTask.asmdef.meta | 7 ++ Tests/PlayMode/UniTask/RoomUniTaskTests.cs | 71 ++++++++++++++ .../PlayMode/UniTask/RoomUniTaskTests.cs.meta | 11 +++ 10 files changed, 272 insertions(+) create mode 100644 Runtime/Scripts/UniTask.meta create mode 100644 Runtime/Scripts/UniTask/YieldInstructionUniTaskExtensions.cs create mode 100644 Runtime/Scripts/UniTask/YieldInstructionUniTaskExtensions.cs.meta create mode 100644 Runtime/Scripts/UniTask/livekit.unity.Runtime.UniTask.asmdef create mode 100644 Runtime/Scripts/UniTask/livekit.unity.Runtime.UniTask.asmdef.meta create mode 100644 Tests/PlayMode/UniTask.meta create mode 100644 Tests/PlayMode/UniTask/LiveKit.PlayModeTests.UniTask.asmdef create mode 100644 Tests/PlayMode/UniTask/LiveKit.PlayModeTests.UniTask.asmdef.meta create mode 100644 Tests/PlayMode/UniTask/RoomUniTaskTests.cs create mode 100644 Tests/PlayMode/UniTask/RoomUniTaskTests.cs.meta 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/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: From 4f9d97fce168d9976cde7270940515f2f801b6e6 Mon Sep 17 00:00:00 2001 From: Max Heimbrock <43608204+MaxHeimbrock@users.noreply.github.com> Date: Tue, 9 Jun 2026 14:18:16 +0200 Subject: [PATCH 04/17] Add IUniTaskAsyncEnumerable adapter for incremental byte/text streams MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stage 3 of the UniTask migration. Exposes ByteStreamReader/TextStreamReader incremental reads as IUniTaskAsyncEnumerable so chunks can be consumed with `await foreach`, building on Stage 1's StreamYieldInstruction awaiter and Stage 2's AsUniTask. A single generic extension AsAsyncEnumerable(this ReadIncrementalInstructionBase) covers both byte[] and string readers. The loop mirrors the coroutine consumer's observable behavior: await a chunk, yield it, re-check IsEos AFTER yielding (Reset() is disallowed past EoS), and Reset() for the next chunk. On EoS carrying a StreamError the enumerable throws that error — idiomatic for await foreach, the one place the UniTask surface throws rather than exposing IsError. Cancellation surfaces as OperationCanceledException with abandon-awaiter semantics. To let the separate LiveKit.UniTask assembly drive the loop, two members are widened to public (both already public on the sibling DataTrack.ReadFrameInstruction, behavior-preserving): StreamYieldInstruction.IsCurrentReadDone getter and ReadIncrementalInstructionBase.LatestChunk. The runtime and test UniTask asmdefs gain a UniTask.Linq reference (source of UniTaskAsyncEnumerable.Create / IUniTaskAsyncEnumerable), and InternalsVisibleTo is extended to the PlayModeTests.UniTask assembly so the deterministic tests can construct a synthetic reader (the same FfiHandle-based seam the EditMode tests use). DataTrack frame streaming is intentionally out of scope (its ReadFrameInstruction has no awaiter and no Reset) — a possible follow-up. Co-Authored-By: Claude Opus 4.8 (1M context) --- Runtime/Scripts/AssemblyInfo.cs | 1 + Runtime/Scripts/DataStream.cs | 8 +- Runtime/Scripts/Internal/YieldInstruction.cs | 10 +- .../UniTask/StreamReaderUniTaskExtensions.cs | 79 ++++++++++ .../StreamReaderUniTaskExtensions.cs.meta | 11 ++ .../livekit.unity.Runtime.UniTask.asmdef | 3 +- .../LiveKit.PlayModeTests.UniTask.asmdef | 3 +- Tests/PlayMode/UniTask/StreamUniTaskTests.cs | 142 ++++++++++++++++++ .../UniTask/StreamUniTaskTests.cs.meta | 11 ++ 9 files changed, 263 insertions(+), 5 deletions(-) create mode 100644 Runtime/Scripts/UniTask/StreamReaderUniTaskExtensions.cs create mode 100644 Runtime/Scripts/UniTask/StreamReaderUniTaskExtensions.cs.meta create mode 100644 Tests/PlayMode/UniTask/StreamUniTaskTests.cs create mode 100644 Tests/PlayMode/UniTask/StreamUniTaskTests.cs.meta diff --git a/Runtime/Scripts/AssemblyInfo.cs b/Runtime/Scripts/AssemblyInfo.cs index e667b32e..9e802523 100644 --- a/Runtime/Scripts/AssemblyInfo.cs +++ b/Runtime/Scripts/AssemblyInfo.cs @@ -2,3 +2,4 @@ [assembly: InternalsVisibleTo("EditModeTests")] [assembly: InternalsVisibleTo("PlayModeTests")] +[assembly: InternalsVisibleTo("PlayModeTests.UniTask")] diff --git a/Runtime/Scripts/DataStream.cs b/Runtime/Scripts/DataStream.cs index 68b18b5a..131ccf56 100644 --- a/Runtime/Scripts/DataStream.cs +++ b/Runtime/Scripts/DataStream.cs @@ -95,7 +95,13 @@ public abstract class ReadIncrementalInstructionBase : StreamYieldInst /// public bool IsError => Error != null; - protected TContent LatestChunk + /// + /// The chunk from the most recent completed read. Throws the captured + /// if the last read errored. Public so the optional + /// UniTask async-enumerable adapter can read it generically; the typed + /// Bytes/Text accessors on the concrete readers delegate here. + /// + public TContent LatestChunk { get { diff --git a/Runtime/Scripts/Internal/YieldInstruction.cs b/Runtime/Scripts/Internal/YieldInstruction.cs index d6520c47..80a29010 100644 --- a/Runtime/Scripts/Internal/YieldInstruction.cs +++ b/Runtime/Scripts/Internal/YieldInstruction.cs @@ -115,10 +115,16 @@ protected set } } - internal bool IsCurrentReadDone + /// + /// True once a chunk is ready for the current read (before is + /// called for the next one). Public getter mirrors the sibling + /// DataTrack.ReadFrameInstruction.IsCurrentReadDone; the setter stays internal + /// because only the SDK's stream readers advance this state. + /// + public bool IsCurrentReadDone { get => _isCurrentReadDone; - set + internal set { _isCurrentReadDone = value; if (value) InvokeContinuation(); diff --git a/Runtime/Scripts/UniTask/StreamReaderUniTaskExtensions.cs b/Runtime/Scripts/UniTask/StreamReaderUniTaskExtensions.cs new file mode 100644 index 00000000..f55a9165 --- /dev/null +++ b/Runtime/Scripts/UniTask/StreamReaderUniTaskExtensions.cs @@ -0,0 +1,79 @@ +#if LIVEKIT_UNITASK +using System.Threading; +using Cysharp.Threading.Tasks; +using Cysharp.Threading.Tasks.Linq; + +namespace LiveKit +{ + /// + /// Exposes the SDK's incremental stream readers as + /// so chunks can be consumed with await foreach. Available only when the + /// com.cysharp.unitask package is installed (gated by LIVEKIT_UNITASK). + /// + public static class StreamReaderUniTaskExtensions + { + /// + /// Adapts an incremental stream read into an async sequence of chunks. Works for both + /// (byte[]) and + /// (string). + /// + /// + /// Iteration ends when the stream reaches end-of-stream. If the stream ends with an + /// error, the enumerable throws that (idiomatic for + /// await foreach; this is the one place the UniTask surface throws rather than + /// exposing IsError). Cancellation (via the token or the enumerator) surfaces as + /// with abandon-awaiter semantics — the + /// underlying FFI read is not cancelled on the wire. + /// + /// Like the coroutine consumer, this delivers the current chunk on the iteration where + /// end-of-stream is also observed, then stops. Chunks buffered beyond the + /// current one when end-of-stream arrives are not drainable — a pre-existing limitation + /// of the reader (its Reset() is disallowed past end-of-stream), not specific to + /// this adapter. + /// + public static IUniTaskAsyncEnumerable AsAsyncEnumerable( + this ReadIncrementalInstructionBase instruction, + CancellationToken cancellationToken = default) + { + if (instruction == null) throw new System.ArgumentNullException(nameof(instruction)); + + return UniTaskAsyncEnumerable.Create(async (writer, token) => + { + // The enumerator hands us its own token; honor both it and the caller's. + using var linked = CancellationTokenSource.CreateLinkedTokenSource(token, cancellationToken); + var ct = linked.Token; + + while (true) + { + // Completes when a chunk is ready (IsCurrentReadDone) or the stream ends (IsEos). + await instruction.AsUniTask(ct); + + if (instruction.IsCurrentReadDone) + { + var chunk = instruction.LatestChunk; + await writer.YieldAsync(chunk); + + // Re-check IsEos AFTER yielding: end-of-stream may have arrived while + // the consumer was suspended. Reset() throws once IsEos is set, so this + // re-check (not a value captured before the yield) is what keeps the + // loop safe — mirroring the coroutine consumer's "if (IsEos) break; + // else Reset()" ordering. + if (instruction.IsEos) + { + if (instruction.IsError) throw instruction.Error; + return; + } + + instruction.Reset(); + continue; + } + + // Not IsCurrentReadDone => end-of-stream with nothing left to read. + if (instruction.IsError) throw instruction.Error; + return; + } + }); + } + } +} +#endif diff --git a/Runtime/Scripts/UniTask/StreamReaderUniTaskExtensions.cs.meta b/Runtime/Scripts/UniTask/StreamReaderUniTaskExtensions.cs.meta new file mode 100644 index 00000000..883d5c63 --- /dev/null +++ b/Runtime/Scripts/UniTask/StreamReaderUniTaskExtensions.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: ba02c0c61aa014db28635be5e1cf6e64 +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 index d00c9d6a..a1e3e218 100644 --- a/Runtime/Scripts/UniTask/livekit.unity.Runtime.UniTask.asmdef +++ b/Runtime/Scripts/UniTask/livekit.unity.Runtime.UniTask.asmdef @@ -3,7 +3,8 @@ "rootNamespace": "LiveKit.UniTaskExtensions", "references": [ "LiveKit", - "UniTask" + "UniTask", + "UniTask.Linq" ], "includePlatforms": [], "excludePlatforms": [], diff --git a/Tests/PlayMode/UniTask/LiveKit.PlayModeTests.UniTask.asmdef b/Tests/PlayMode/UniTask/LiveKit.PlayModeTests.UniTask.asmdef index 37e31869..db1f62ad 100644 --- a/Tests/PlayMode/UniTask/LiveKit.PlayModeTests.UniTask.asmdef +++ b/Tests/PlayMode/UniTask/LiveKit.PlayModeTests.UniTask.asmdef @@ -6,7 +6,8 @@ "UnityEditor.TestRunner", "LiveKit", "LiveKit.UniTask", - "UniTask" + "UniTask", + "UniTask.Linq" ], "includePlatforms": [], "excludePlatforms": [], diff --git a/Tests/PlayMode/UniTask/StreamUniTaskTests.cs b/Tests/PlayMode/UniTask/StreamUniTaskTests.cs new file mode 100644 index 00000000..bb07f4b6 --- /dev/null +++ b/Tests/PlayMode/UniTask/StreamUniTaskTests.cs @@ -0,0 +1,142 @@ +#if LIVEKIT_UNITASK +using System; +using System.Collections.Generic; +using System.Threading; +using Cysharp.Threading.Tasks; +using LiveKit.Internal; +using NUnit.Framework; +using UnityEngine.TestTools; + +namespace LiveKit.PlayModeTests.UniTaskBridge +{ + public class StreamUniTaskTests + { + // Synthetic incremental reader that drives the base chunk/EoS machinery directly, + // with no FFI — the same seam used by the EditMode DataStreamIncrementalReadTests. + // FfiHandle is public; new FfiHandle(IntPtr.Zero) is a valid dummy handle. + private sealed class TestIncrementalReader : ReadIncrementalInstructionBase + { + public TestIncrementalReader(FfiHandle h) : base(h) { } + public void PushChunk(string content) => OnChunk(content); + public void PushEos(LiveKit.Proto.StreamError error = null) => OnEos(error); + } + + // Chunks pushed and consumed one at a time arrive in order; the sequence ends when + // EoS is observed. Manual enumeration interleaves push/pull so EoS only follows a + // fully drained queue (matching how chunks arrive over time in production). + [UnityTest] + public System.Collections.IEnumerator AsAsyncEnumerable_DeliversChunksInOrder_ThenStops() => UniTask.ToCoroutine(async () => + { + using var handle = new FfiHandle(IntPtr.Zero); + var reader = new TestIncrementalReader(handle); + + var e = reader.AsAsyncEnumerable().GetAsyncEnumerator(); + try + { + reader.PushChunk("A"); + Assert.IsTrue(await e.MoveNextAsync(), "Expected chunk A"); + Assert.AreEqual("A", e.Current); + + reader.PushChunk("B"); + Assert.IsTrue(await e.MoveNextAsync(), "Expected chunk B"); + Assert.AreEqual("B", e.Current); + + reader.PushChunk("C"); + Assert.IsTrue(await e.MoveNextAsync(), "Expected chunk C"); + Assert.AreEqual("C", e.Current); + + reader.PushEos(); + Assert.IsFalse(await e.MoveNextAsync(), "Enumeration must end at EoS"); + } + finally + { + await e.DisposeAsync(); + } + }); + + // The current chunk is delivered even when EoS is already set at the time it is read, + // then the sequence ends. (Chunks buffered beyond the current one when EoS arrives are + // not drainable — a pre-existing reader limitation, asserted here for clarity.) + [UnityTest] + public System.Collections.IEnumerator AsAsyncEnumerable_DeliversFinalChunkThenEos() => UniTask.ToCoroutine(async () => + { + using var handle = new FfiHandle(IntPtr.Zero); + var reader = new TestIncrementalReader(handle); + + reader.PushChunk("only"); + reader.PushEos(); + + var observed = new List(); + await foreach (var chunk in reader.AsAsyncEnumerable()) + observed.Add(chunk); + + CollectionAssert.AreEqual(new[] { "only" }, observed); + }); + + // A chunk delivered before the stream errors is observed; the subsequent error EoS + // then surfaces as a thrown StreamError. Manual enumeration models the real timeline + // (chunk arrives, is consumed, THEN the error ends the stream) — note that once the + // error is set, LatestChunk itself throws, so the error must follow chunk delivery. + [UnityTest] + public System.Collections.IEnumerator AsAsyncEnumerable_ThrowsStreamError_AfterDeliveringChunk() => UniTask.ToCoroutine(async () => + { + using var handle = new FfiHandle(IntPtr.Zero); + var reader = new TestIncrementalReader(handle); + + var e = reader.AsAsyncEnumerable().GetAsyncEnumerator(); + try + { + reader.PushChunk("partial"); + Assert.IsTrue(await e.MoveNextAsync(), "Expected the pre-error chunk"); + Assert.AreEqual("partial", e.Current); + + reader.PushEos(new LiveKit.Proto.StreamError { Description = "boom" }); + + StreamError caught = null; + try + { + await e.MoveNextAsync(); + } + catch (StreamError ex) + { + caught = ex; + } + + Assert.IsNotNull(caught, "Expected the error EoS to throw a StreamError"); + Assert.AreEqual("boom", caught.Message); + } + finally + { + await e.DisposeAsync(); + } + }); + + // A cancelled token surfaces as OperationCanceledException with abandon-awaiter + // semantics: nothing is observed and the underlying reader is untouched. + [UnityTest] + public System.Collections.IEnumerator AsAsyncEnumerable_Cancellation_ThrowsOperationCanceled() => UniTask.ToCoroutine(async () => + { + using var handle = new FfiHandle(IntPtr.Zero); + var reader = new TestIncrementalReader(handle); + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + var observed = new List(); + bool threw = false; + try + { + await foreach (var chunk in reader.AsAsyncEnumerable(cts.Token)) + observed.Add(chunk); + } + catch (OperationCanceledException) + { + threw = true; + } + + Assert.IsTrue(threw, "Expected OperationCanceledException for a cancelled token"); + CollectionAssert.IsEmpty(observed); + Assert.IsFalse(reader.IsEos, "Abandon-awaiter semantics: reader state is untouched"); + }); + } +} +#endif diff --git a/Tests/PlayMode/UniTask/StreamUniTaskTests.cs.meta b/Tests/PlayMode/UniTask/StreamUniTaskTests.cs.meta new file mode 100644 index 00000000..a10435c8 --- /dev/null +++ b/Tests/PlayMode/UniTask/StreamUniTaskTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: a2e6312b068f8432fa2b267f28d3e10b +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: From 632297b1f96b7cace6dc1475df3ba10b04b1d3f7 Mon Sep 17 00:00:00 2001 From: Max Heimbrock <43608204+MaxHeimbrock@users.noreply.github.com> Date: Tue, 9 Jun 2026 14:40:16 +0200 Subject: [PATCH 05/17] Document async/await + optional UniTask integration in the README MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stage 4 (capstone) of the UniTask migration. Adds a README section covering the three interchangeable async styles the SDK now supports, and states the policy: coroutines remain the default and fully supported; async/await and UniTask are additive opt-ins; the coroutine API is not deprecated. - async/await with no dependency (instructions are awaitable; inspect IsError, await does not throw — parity with yield return). - UniTask opt-in (com.cysharp.unitask + LIVEKIT_UNITASK): AsUniTask with CancellationToken, UniTask.WhenAll composition, and AsAsyncEnumerable for await foreach over incremental streams (throws StreamError on error EoS). Examples use the verified public signatures (Connect(url, token, RoomOptions), PublishTrack(track, options), ReadIncremental().AsAsyncEnumerable()) and point to the Meet sample (UniTask) and Basic sample (coroutines) as references. Docs-only; no code, no deprecation, no version bump. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 53 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/README.md b/README.md index b72f1dde..7aa13fe9 100644 --- a/README.md +++ b/README.md @@ -313,6 +313,59 @@ Debug.Log("Connected to " + room.Name); +## Asynchronous programming: coroutines, async/await, and UniTask + +The SDK exposes three interchangeable styles for awaiting asynchronous operations. Coroutines, async/await and UniTask. + +**1. Coroutines (default, no dependency)** — shown throughout this README. + +**2. async/await (no dependency)** — every operation returns an awaitable instruction (`ConnectInstruction`, `PublishTrackInstruction`, `PerformRpcInstruction`, the stream read instructions, …), so you can `await` it directly. As with coroutines, you inspect success/failure on the instruction (`IsError`) — `await` does not throw. Continuations resume on Unity's main thread. + +```cs +async void Start() +{ + var room = new Room(); + var connect = room.Connect("ws://localhost:7880", "", new RoomOptions()); + await connect; + if (!connect.IsError) + Debug.Log("Connected to " + room.Name); +} +``` + +> Use `async void` only for top-level event handlers (e.g. button callbacks); its exceptions surface to Unity's log rather than to a caller. Prefer `async Task`/`async UniTaskVoid` elsewhere. + +**3. UniTask (optional)** — install [UniTask](https://github.com/Cysharp/UniTask) (`com.cysharp.unitask`). The SDK auto-detects it via the `LIVEKIT_UNITASK` scripting define and enables the `LiveKit.UniTask` assembly, which adds `CancellationToken` support, composition, and async streams. + +Cancellation (abandon-awaiter semantics — the underlying request is not cancelled on the wire): + +```cs +await room.Connect("ws://localhost:7880", "", new RoomOptions()) + .AsUniTask(cancellationToken); +``` + +Run operations in parallel: + +```cs +await UniTask.WhenAll( + room.LocalParticipant.PublishTrack(cameraTrack, cameraOptions).AsUniTask(ct), + room.LocalParticipant.PublishTrack(microphoneTrack, microphoneOptions).AsUniTask(ct)); +``` + +Consume an incremental stream with `await foreach`. The sequence ends at end-of-stream; if the stream ends with an error it throws a `StreamError`: + +```cs +try +{ + await foreach (var chunk in reader.ReadIncremental().AsAsyncEnumerable(ct)) + Process(chunk); +} +catch (StreamError e) +{ + Debug.LogError(e.Message); +} +``` + + ### Publishing microphone From 6c3dcea2e51eb152be8a5de992cf7d8286c186b0 Mon Sep 17 00:00:00 2001 From: Max Heimbrock <43608204+MaxHeimbrock@users.noreply.github.com> Date: Thu, 18 Jun 2026 11:22:15 +0200 Subject: [PATCH 06/17] Docs: check IsError after UniTask.WhenAll in the parallel example AsUniTask does not throw on failure (parity with the coroutine path), so the parallel-publish example silently ignored failed operations. Keep the instruction references, await WhenAll, then inspect IsError on each. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 7aa13fe9..ed1e1518 100644 --- a/README.md +++ b/README.md @@ -343,12 +343,18 @@ await room.Connect("ws://localhost:7880", "", new RoomOptions()) .AsUniTask(cancellationToken); ``` -Run operations in parallel: +Run operations in parallel. `AsUniTask` does not throw on failure (matching the +coroutine path), so keep the instructions and check `IsError` on each after the +`await` — otherwise a failed operation passes silently: ```cs -await UniTask.WhenAll( - room.LocalParticipant.PublishTrack(cameraTrack, cameraOptions).AsUniTask(ct), - room.LocalParticipant.PublishTrack(microphoneTrack, microphoneOptions).AsUniTask(ct)); +var publishCamera = room.LocalParticipant.PublishTrack(cameraTrack, cameraOptions); +var publishMicrophone = room.LocalParticipant.PublishTrack(microphoneTrack, microphoneOptions); + +await UniTask.WhenAll(publishCamera.AsUniTask(ct), publishMicrophone.AsUniTask(ct)); + +if (publishCamera.IsError || publishMicrophone.IsError) + Debug.LogError("Failed to publish one or more tracks"); ``` Consume an incremental stream with `await foreach`. The sequence ends at end-of-stream; if the stream ends with an error it throws a `StreamError`: From 90fbb8eb84c9a6de610061eb613f922f85bcd899 Mon Sep 17 00:00:00 2001 From: Max Heimbrock <43608204+MaxHeimbrock@users.noreply.github.com> Date: Thu, 18 Jun 2026 11:54:43 +0200 Subject: [PATCH 07/17] Make the await/UniTask path throw on failure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously a failed operation completed the await silently (GetResult was a no-op) and callers had to check IsError — surprising for async/await users (unlike coroutines, the .NET convention is that failures throw), an easy source of silent failures. YieldInstructionAwaiter.GetResult now throws when IsError. The thrown type is the instruction's typed error where one exists (StreamError, RpcError, PublishDataTrackError) and a new LiveKitException otherwise (Connect, PublishTrack, SetMetadata/Name/Attributes, PublishData, GetStats, …). This is wired via an internal virtual YieldInstruction.CreateAwaitException() overridden per instruction; Connect/PublishTrack/FfiInstruction now retain the error message they previously discarded. AsUniTask funnels through GetResult so the UniTask faults identically (TrySetException / FromException). Coroutines are unchanged: yield return never calls GetResult, so it still inspects IsError. Tests and README updated accordingly. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 34 +++++++++++-------- Runtime/Scripts/Internal/FfiInstruction.cs | 13 ++++++- .../Scripts/Internal/TaskYieldInstruction.cs | 2 ++ Runtime/Scripts/Internal/YieldInstruction.cs | 29 ++++++++++++---- Runtime/Scripts/LiveKitException.cs | 18 ++++++++++ Runtime/Scripts/LiveKitException.cs.meta | 11 ++++++ Runtime/Scripts/Participant.cs | 21 ++++++++++++ Runtime/Scripts/Room.cs | 9 +++++ Runtime/Scripts/Track.cs | 3 ++ .../YieldInstructionUniTaskExtensions.cs | 32 ++++++++++++----- Tests/PlayMode/RoomTests.cs | 29 ++++++++++++---- Tests/PlayMode/UniTask/RoomUniTaskTests.cs | 28 +++++++++++---- 12 files changed, 186 insertions(+), 43 deletions(-) create mode 100644 Runtime/Scripts/LiveKitException.cs create mode 100644 Runtime/Scripts/LiveKitException.cs.meta diff --git a/README.md b/README.md index ed1e1518..57cca741 100644 --- a/README.md +++ b/README.md @@ -319,16 +319,21 @@ The SDK exposes three interchangeable styles for awaiting asynchronous operation **1. Coroutines (default, no dependency)** — shown throughout this README. -**2. async/await (no dependency)** — every operation returns an awaitable instruction (`ConnectInstruction`, `PublishTrackInstruction`, `PerformRpcInstruction`, the stream read instructions, …), so you can `await` it directly. As with coroutines, you inspect success/failure on the instruction (`IsError`) — `await` does not throw. Continuations resume on Unity's main thread. +**2. async/await (no dependency)** — every operation returns an awaitable instruction (`ConnectInstruction`, `PublishTrackInstruction`, `PerformRpcInstruction`, the stream read instructions, …), so you can `await` it directly. A failed operation throws — the instruction's typed error where one exists (`StreamError`, `RpcError`) or a `LiveKitException` otherwise. (Coroutines are unchanged: `yield return` never throws, so they still inspect `IsError`.) Continuations resume on Unity's main thread. ```cs async void Start() { var room = new Room(); - var connect = room.Connect("ws://localhost:7880", "", new RoomOptions()); - await connect; - if (!connect.IsError) + try + { + await room.Connect("ws://localhost:7880", "", new RoomOptions()); Debug.Log("Connected to " + room.Name); + } + catch (LiveKitException e) + { + Debug.LogError("Failed to connect: " + e.Message); + } } ``` @@ -343,18 +348,19 @@ await room.Connect("ws://localhost:7880", "", new RoomOptions()) .AsUniTask(cancellationToken); ``` -Run operations in parallel. `AsUniTask` does not throw on failure (matching the -coroutine path), so keep the instructions and check `IsError` on each after the -`await` — otherwise a failed operation passes silently: +Run operations in parallel. If either fails, `WhenAll` surfaces the exception: ```cs -var publishCamera = room.LocalParticipant.PublishTrack(cameraTrack, cameraOptions); -var publishMicrophone = room.LocalParticipant.PublishTrack(microphoneTrack, microphoneOptions); - -await UniTask.WhenAll(publishCamera.AsUniTask(ct), publishMicrophone.AsUniTask(ct)); - -if (publishCamera.IsError || publishMicrophone.IsError) - Debug.LogError("Failed to publish one or more tracks"); +try +{ + await UniTask.WhenAll( + room.LocalParticipant.PublishTrack(cameraTrack, cameraOptions).AsUniTask(ct), + room.LocalParticipant.PublishTrack(microphoneTrack, microphoneOptions).AsUniTask(ct)); +} +catch (LiveKitException e) +{ + Debug.LogError("Failed to publish a track: " + e.Message); +} ``` Consume an incremental stream with `await foreach`. The sequence ends at end-of-stream; if the stream ends with an error it throws a `StreamError`: diff --git a/Runtime/Scripts/Internal/FfiInstruction.cs b/Runtime/Scripts/Internal/FfiInstruction.cs index 93e89074..7ef958d1 100644 --- a/Runtime/Scripts/Internal/FfiInstruction.cs +++ b/Runtime/Scripts/Internal/FfiInstruction.cs @@ -11,6 +11,8 @@ namespace LiveKit /// The protobuf callback type (e.g. SetLocalMetadataCallback). public class FfiInstruction : YieldInstruction where TCallback : class { + private string _error; + internal FfiInstruction( ulong asyncId, Func selector, @@ -21,16 +23,21 @@ internal FfiInstruction( selector, e => { - IsError = !string.IsNullOrEmpty(errorExtractor(e)); + _error = errorExtractor(e); + IsError = !string.IsNullOrEmpty(_error); IsDone = true; }, () => { + _error = "Canceled"; IsError = true; IsDone = true; }, dispatchToMainThread: false); } + + internal override Exception CreateAwaitException() => + string.IsNullOrEmpty(_error) ? base.CreateAwaitException() : new LiveKitException(_error); } /// @@ -67,6 +74,8 @@ internal FfiStreamInstruction( }, dispatchToMainThread: false); } + + internal override Exception CreateAwaitException() => Error ?? base.CreateAwaitException(); } /// @@ -121,5 +130,7 @@ internal FfiStreamResultInstruction( }, dispatchToMainThread: false); } + + internal override Exception CreateAwaitException() => Error ?? base.CreateAwaitException(); } } diff --git a/Runtime/Scripts/Internal/TaskYieldInstruction.cs b/Runtime/Scripts/Internal/TaskYieldInstruction.cs index d5b39c8d..65ce9dfe 100644 --- a/Runtime/Scripts/Internal/TaskYieldInstruction.cs +++ b/Runtime/Scripts/Internal/TaskYieldInstruction.cs @@ -13,6 +13,8 @@ public sealed class TaskYieldInstruction : YieldInstruction public T Result { get; private set; } public Exception Exception { get; private set; } + internal override Exception CreateAwaitException() => Exception ?? base.CreateAwaitException(); + internal TaskYieldInstruction(Task task) { // Continuation may run on a thread-pool thread; the volatile IsDone/IsError fields in diff --git a/Runtime/Scripts/Internal/YieldInstruction.cs b/Runtime/Scripts/Internal/YieldInstruction.cs index 80a29010..0788c4ed 100644 --- a/Runtime/Scripts/Internal/YieldInstruction.cs +++ b/Runtime/Scripts/Internal/YieldInstruction.cs @@ -37,13 +37,22 @@ protected set /// Returns an awaiter so callers can await this instruction directly. /// /// - /// The awaiter completes when becomes true. As with the - /// coroutine path, success vs. failure is inspected on the instruction itself - /// ( and any subclass-specific result fields); GetResult - /// does not throw. + /// The awaiter completes when becomes true. On failure + /// () it throws the instruction's typed error where one exists + /// (e.g. , RpcError) or a + /// otherwise. The coroutine path (yield return) never throws — it inspects + /// instead. /// public YieldInstructionAwaiter GetAwaiter() => new YieldInstructionAwaiter(this); + /// + /// Builds the exception thrown by the awaiter when is true. + /// Subclasses override to surface their typed error (e.g. , + /// RpcError); the default is a generic . + /// + internal virtual Exception CreateAwaitException() => + new LiveKitException("The awaited LiveKit operation failed. Inspect IsError on the instruction for details."); + internal void RegisterContinuation(Action continuation) { // Race between completion-side (FFI thread writes sentinel) and await-side @@ -85,9 +94,15 @@ internal YieldInstructionAwaiter(YieldInstruction instruction) public void OnCompleted(Action continuation) => _instruction.RegisterContinuation(continuation); - // Intentionally a no-op. Parity with the coroutine path: callers inspect IsError - // and subclass-specific result fields on the instruction itself. - public void GetResult() { } + // Throws on failure so `await` matches .NET async conventions (a failed operation + // surfaces as an exception, not a silently-completed await). The thrown type is the + // instruction's typed error where one exists (e.g. StreamError, RpcError), otherwise a + // generic LiveKitException. Coroutine consumers never call this — they inspect IsError. + public void GetResult() + { + if (_instruction.IsError) + throw _instruction.CreateAwaitException(); + } } public class StreamYieldInstruction : CustomYieldInstruction diff --git a/Runtime/Scripts/LiveKitException.cs b/Runtime/Scripts/LiveKitException.cs new file mode 100644 index 00000000..4f9df9af --- /dev/null +++ b/Runtime/Scripts/LiveKitException.cs @@ -0,0 +1,18 @@ +using System; + +namespace LiveKit +{ + /// + /// Thrown when an awaited LiveKit operation completes with an error (its + /// is true) and the instruction has no more specific + /// typed error to surface (e.g. , RpcError). + /// + /// + /// Only the await / UniTask paths throw. Coroutine consumers (yield return) + /// are unaffected — they inspect on the instruction. + /// + public class LiveKitException : Exception + { + public LiveKitException(string message) : base(message) { } + } +} diff --git a/Runtime/Scripts/LiveKitException.cs.meta b/Runtime/Scripts/LiveKitException.cs.meta new file mode 100644 index 00000000..ed9a5030 --- /dev/null +++ b/Runtime/Scripts/LiveKitException.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 2f0a831f838184f57bbcbf90b63a22fb +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/Scripts/Participant.cs b/Runtime/Scripts/Participant.cs index 33728144..c23b50a2 100644 --- a/Runtime/Scripts/Participant.cs +++ b/Runtime/Scripts/Participant.cs @@ -626,6 +626,7 @@ public sealed class PublishTrackInstruction : YieldInstruction private ulong _asyncId; private Dictionary _internalTracks; private ILocalTrack _localTrack; + private string _error; internal PublishTrackInstruction(ulong asyncId, ILocalTrack localTrack, Dictionary internalTracks) { @@ -643,6 +644,7 @@ internal void OnPublish(PublishTrackCallback e) if (e.AsyncId != _asyncId) return; + _error = e.Error; IsError = !string.IsNullOrEmpty(e.Error); IsDone = true; var publication = new LocalTrackPublication(e.Publication.Info, FfiHandle.FromOwnedHandle(e.Publication.Handle)); @@ -653,9 +655,13 @@ internal void OnPublish(PublishTrackCallback e) void OnCanceled() { + _error = "Canceled"; IsError = true; IsDone = true; } + + internal override System.Exception CreateAwaitException() => + new LiveKitException(string.IsNullOrEmpty(_error) ? "Failed to publish track." : _error); } public sealed class SetLocalMetadataInstruction : FfiInstruction @@ -726,6 +732,9 @@ void OnCanceled() IsError = true; IsDone = true; } + + internal override System.Exception CreateAwaitException() => + new LiveKitException(string.IsNullOrEmpty(Error) ? "Failed to publish data." : Error); } /// @@ -792,6 +801,8 @@ public string Payload /// See for more information on error codes. /// public RpcError Error { get; private set; } + + internal override System.Exception CreateAwaitException() => Error ?? base.CreateAwaitException(); } /// @@ -846,6 +857,8 @@ public TextStreamInfo Info } public StreamError Error { get; private set; } + + internal override System.Exception CreateAwaitException() => Error ?? base.CreateAwaitException(); } /// @@ -900,6 +913,8 @@ public ByteStreamInfo Info } public StreamError Error { get; private set; } + + internal override System.Exception CreateAwaitException() => Error ?? base.CreateAwaitException(); } /// @@ -954,6 +969,8 @@ public TextStreamWriter Writer } public StreamError Error { get; private set; } + + internal override System.Exception CreateAwaitException() => Error ?? base.CreateAwaitException(); } /// @@ -1008,6 +1025,8 @@ public ByteStreamWriter Writer } public StreamError Error { get; private set; } + + internal override System.Exception CreateAwaitException() => Error ?? base.CreateAwaitException(); } /// @@ -1067,6 +1086,8 @@ public LocalDataTrack Track } public PublishDataTrackError Error { get; private set; } + + internal override System.Exception CreateAwaitException() => Error ?? base.CreateAwaitException(); } /// Helpers for setting fields whose underlying type diff --git a/Runtime/Scripts/Room.cs b/Runtime/Scripts/Room.cs index 8302f9c9..c3d5cd66 100644 --- a/Runtime/Scripts/Room.cs +++ b/Runtime/Scripts/Room.cs @@ -651,6 +651,7 @@ public sealed class ConnectInstruction : YieldInstruction private ulong _asyncId; private Room _room; private RoomOptions _roomOptions; + private string _error; internal ConnectInstruction(ulong asyncId, Room room, RoomOptions options) { @@ -678,6 +679,10 @@ void OnConnect(ConnectCallback e) _room.OnConnect(e); } + else + { + _error = e.Error; + } IsError = !success; IsDone = true; @@ -685,8 +690,12 @@ void OnConnect(ConnectCallback e) void OnCanceled() { + _error = "Canceled"; IsError = true; IsDone = true; } + + internal override Exception CreateAwaitException() => + new LiveKitException(string.IsNullOrEmpty(_error) ? "Failed to connect to the room." : _error); } } diff --git a/Runtime/Scripts/Track.cs b/Runtime/Scripts/Track.cs index 2fa873b6..6a251fab 100644 --- a/Runtime/Scripts/Track.cs +++ b/Runtime/Scripts/Track.cs @@ -227,5 +227,8 @@ private void OnCanceled() IsError = true; IsDone = true; } + + internal override System.Exception CreateAwaitException() => + new LiveKitException(string.IsNullOrEmpty(Error) ? "Failed to get session stats." : Error); } } diff --git a/Runtime/Scripts/UniTask/YieldInstructionUniTaskExtensions.cs b/Runtime/Scripts/UniTask/YieldInstructionUniTaskExtensions.cs index 84747d49..ac469c5b 100644 --- a/Runtime/Scripts/UniTask/YieldInstructionUniTaskExtensions.cs +++ b/Runtime/Scripts/UniTask/YieldInstructionUniTaskExtensions.cs @@ -19,16 +19,22 @@ public static class YieldInstructionUniTaskExtensions /// 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. + /// On failure the task faults with the instruction's error — the typed error where one + /// exists (e.g. , RpcError) or a + /// otherwise — matching a direct await of the instruction. Cancellation has + /// "abandon awaiter" semantics: the underlying FFI request keeps running and any result is + /// discarded; wire-level cancellation is not yet supported. /// 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 (instruction.IsDone) + { + // Already complete: surface failure the same way `await` does (GetResult throws + // on IsError) so AsUniTask and a direct await behave identically. + try { instruction.GetAwaiter().GetResult(); return UniTask.CompletedTask; } + catch (System.Exception ex) { return UniTask.FromException(ex); } + } if (cancellationToken.IsCancellationRequested) return UniTask.FromCanceled(cancellationToken); var source = new UniTaskCompletionSource(); @@ -45,11 +51,21 @@ public static UniTask AsUniTask(this YieldInstruction instruction, CancellationT // 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. + // this completion or TrySetCanceled wins; the loser is a no-op. GetResult() throws + // the instruction's typed error on failure, which we route to TrySetException so the + // UniTask faults exactly like a direct await would. instruction.GetAwaiter().OnCompleted(() => { registration.Dispose(); - source.TrySetResult(); + try + { + instruction.GetAwaiter().GetResult(); + source.TrySetResult(); + } + catch (System.Exception ex) + { + source.TrySetException(ex); + } }); return source.Task; diff --git a/Tests/PlayMode/RoomTests.cs b/Tests/PlayMode/RoomTests.cs index 9cdc3031..dfb904b0 100644 --- a/Tests/PlayMode/RoomTests.cs +++ b/Tests/PlayMode/RoomTests.cs @@ -40,25 +40,40 @@ private sealed class TestYieldInstruction : YieldInstruction public void CompleteWithError() { IsError = true; IsDone = true; } } - // OnCompleted path: await registers a continuation while the instruction is still - // pending, then completion fires it and IsError is visible on resume. + // OnCompleted path, success: await registers a continuation while the instruction is + // still pending, then a non-error completion resumes it without throwing. [UnityTest] - public IEnumerator GetAwaiter_ResumesOnCompletion_AndSurfacesIsError() + public IEnumerator GetAwaiter_ResumesOnCompletion_NoThrowOnSuccess() { var instruction = new TestYieldInstruction(); var awaitTask = AwaitInstruction(instruction); Assert.IsFalse(awaitTask.IsCompleted, "Awaiter must not resume before IsDone"); - instruction.CompleteWithError(); + instruction.Complete(); yield return new WaitUntil(() => awaitTask.IsCompleted); Assert.IsNull(awaitTask.Exception, awaitTask.Exception?.ToString()); Assert.IsTrue(instruction.IsDone, "Awaiter resumed, so IsDone must be observable"); - Assert.IsTrue(instruction.IsError, "IsError must be visible on resume"); } - // IsCompleted fast path: instruction is already done before it is awaited, so the - // awaiter completes without ever registering a continuation. + // OnCompleted path, failure: a completion with IsError makes the await throw — surfaced + // here as a faulted task carrying a LiveKitException (the base instruction's default). + [UnityTest] + public IEnumerator GetAwaiter_ThrowsOnError() + { + var instruction = new TestYieldInstruction(); + var awaitTask = AwaitInstruction(instruction); + Assert.IsFalse(awaitTask.IsCompleted, "Awaiter must not resume before IsDone"); + + instruction.CompleteWithError(); + yield return new WaitUntil(() => awaitTask.IsCompleted); + + Assert.IsTrue(awaitTask.IsFaulted, "await must throw when the instruction completes with an error"); + Assert.IsInstanceOf(awaitTask.Exception?.InnerException); + } + + // IsCompleted fast path: instruction is already done (no error) before it is awaited, so + // the awaiter completes without ever registering a continuation and without throwing. [UnityTest] public IEnumerator GetAwaiter_CompletesImmediately_WhenAlreadyDone() { diff --git a/Tests/PlayMode/UniTask/RoomUniTaskTests.cs b/Tests/PlayMode/UniTask/RoomUniTaskTests.cs index 69dce085..5a16f625 100644 --- a/Tests/PlayMode/UniTask/RoomUniTaskTests.cs +++ b/Tests/PlayMode/UniTask/RoomUniTaskTests.cs @@ -18,21 +18,37 @@ private sealed class TestInstruction : YieldInstruction 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. + // AsUniTask completes (without throwing) when IsDone transitions to true on success, + // mirroring a direct await of the instruction. [UnityTest] - public System.Collections.IEnumerator AsUniTask_CompletesOnIsDone() => UniTask.ToCoroutine(async () => + public System.Collections.IEnumerator AsUniTask_CompletesOnSuccess() => 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(); + instruction.Complete(); await task; Assert.IsTrue(instruction.IsDone, "UniTask should not resume before IsDone"); - Assert.IsTrue(instruction.IsError, "Error state must be visible on resume"); + Assert.IsFalse(instruction.IsError); + }); + + // On failure, AsUniTask faults with the instruction's error (here the base + // LiveKitException) — parity with a direct await, whose GetResult throws. + [UnityTest] + public System.Collections.IEnumerator AsUniTask_ThrowsOnError() => UniTask.ToCoroutine(async () => + { + var instruction = new TestInstruction(); + var task = instruction.AsUniTask(); + + instruction.CompleteWithError(); + + LiveKitException caught = null; + try { await task; } + catch (LiveKitException e) { caught = e; } + + Assert.IsNotNull(caught, "AsUniTask must fault when the instruction completes with an error"); }); // Cancellation has abandon-awaiter semantics: the UniTask faults with From bb57212a14d9bb5ee0995ab92ac66af4be1806c0 Mon Sep 17 00:00:00 2001 From: Max Heimbrock <43608204+MaxHeimbrock@users.noreply.github.com> Date: Thu, 18 Jun 2026 12:00:12 +0200 Subject: [PATCH 08/17] Install UniTask in the Meet test project so CI exercises it The LiveKit.UniTask runtime assembly and its PlayMode tests are gated by LIVEKIT_UNITASK, which only activates when com.cysharp.unitask is present. Without the package in the Meet project, that whole surface (AsUniTask, AsAsyncEnumerable, and their tests) was excluded from compilation, so CI never exercised it. Add the dependency (and lock it) so the opt-in path is built and tested. Confirmed locally: the gated UniTask/stream tests now run (10 passed). Co-Authored-By: Claude Opus 4.8 (1M context) --- Samples~/Meet/Packages/manifest.json | 1 + Samples~/Meet/Packages/packages-lock.json | 47 +++++++++++++---------- 2 files changed, 27 insertions(+), 21 deletions(-) diff --git a/Samples~/Meet/Packages/manifest.json b/Samples~/Meet/Packages/manifest.json index 9c61b3b4..5bb1321b 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.7.1", "com.unity.feature.2d": "2.0.1", "com.unity.ide.rider": "3.0.36", diff --git a/Samples~/Meet/Packages/packages-lock.json b/Samples~/Meet/Packages/packages-lock.json index 26ac6ead..b256737d 100644 --- a/Samples~/Meet/Packages/packages-lock.json +++ b/Samples~/Meet/Packages/packages-lock.json @@ -1,11 +1,18 @@ { "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.2.0", + "version": "9.1.0", "depth": 1, "source": "registry", "dependencies": { - "com.unity.2d.common": "8.1.0", + "com.unity.2d.common": "8.0.2", "com.unity.2d.sprite": "1.0.0", "com.unity.collections": "1.1.0", "com.unity.modules.animation": "1.0.0", @@ -14,7 +21,7 @@ "url": "https://packages.unity.com" }, "com.unity.2d.aseprite": { - "version": "1.1.9", + "version": "1.1.1", "depth": 1, "source": "registry", "dependencies": { @@ -26,7 +33,7 @@ "url": "https://packages.unity.com" }, "com.unity.2d.common": { - "version": "8.1.0", + "version": "8.0.2", "depth": 2, "source": "registry", "dependencies": { @@ -39,22 +46,20 @@ "url": "https://packages.unity.com" }, "com.unity.2d.pixel-perfect": { - "version": "5.1.0", + "version": "5.0.3", "depth": 1, "source": "registry", - "dependencies": { - "com.unity.modules.imgui": "1.0.0" - }, + "dependencies": {}, "url": "https://packages.unity.com" }, "com.unity.2d.psdimporter": { - "version": "8.1.0", + "version": "8.0.4", "depth": 1, "source": "registry", "dependencies": { - "com.unity.2d.common": "8.1.0", + "com.unity.2d.common": "8.0.2", "com.unity.2d.sprite": "1.0.0", - "com.unity.2d.animation": "9.2.0" + "com.unity.2d.animation": "9.1.0" }, "url": "https://packages.unity.com" }, @@ -65,11 +70,11 @@ "dependencies": {} }, "com.unity.2d.spriteshape": { - "version": "9.1.0", + "version": "9.0.2", "depth": 1, "source": "registry", "dependencies": { - "com.unity.2d.common": "8.1.0", + "com.unity.2d.common": "8.0.1", "com.unity.mathematics": "1.1.0", "com.unity.modules.physics2d": "1.0.0" }, @@ -85,7 +90,7 @@ } }, "com.unity.2d.tilemap.extras": { - "version": "3.1.3", + "version": "3.1.2", "depth": 1, "source": "registry", "dependencies": { @@ -97,7 +102,7 @@ "url": "https://packages.unity.com" }, "com.unity.burst": { - "version": "1.8.21", + "version": "1.8.12", "depth": 3, "source": "registry", "dependencies": { @@ -135,14 +140,14 @@ "depth": 0, "source": "builtin", "dependencies": { - "com.unity.2d.animation": "9.2.0", - "com.unity.2d.pixel-perfect": "5.1.0", - "com.unity.2d.psdimporter": "8.1.0", + "com.unity.2d.animation": "9.1.0", + "com.unity.2d.pixel-perfect": "5.0.3", + "com.unity.2d.psdimporter": "8.0.4", "com.unity.2d.sprite": "1.0.0", - "com.unity.2d.spriteshape": "9.1.0", + "com.unity.2d.spriteshape": "9.0.2", "com.unity.2d.tilemap": "1.0.0", - "com.unity.2d.tilemap.extras": "3.1.3", - "com.unity.2d.aseprite": "1.1.9" + "com.unity.2d.tilemap.extras": "3.1.2", + "com.unity.2d.aseprite": "1.1.1" } }, "com.unity.ide.rider": { From 78ad5c2b738ccbb232183bc536c1c41a48a4f8aa Mon Sep 17 00:00:00 2001 From: Max Heimbrock <43608204+MaxHeimbrock@users.noreply.github.com> Date: Thu, 18 Jun 2026 12:08:05 +0200 Subject: [PATCH 09/17] Revert "Make the await/UniTask path throw on failure" This reverts commit 90fbb8eb84c9a6de610061eb613f922f85bcd899. --- README.md | 34 ++++++++----------- Runtime/Scripts/Internal/FfiInstruction.cs | 13 +------ .../Scripts/Internal/TaskYieldInstruction.cs | 2 -- Runtime/Scripts/Internal/YieldInstruction.cs | 29 ++++------------ Runtime/Scripts/LiveKitException.cs | 18 ---------- Runtime/Scripts/LiveKitException.cs.meta | 11 ------ Runtime/Scripts/Participant.cs | 21 ------------ Runtime/Scripts/Room.cs | 9 ----- Runtime/Scripts/Track.cs | 3 -- .../YieldInstructionUniTaskExtensions.cs | 32 +++++------------ Tests/PlayMode/RoomTests.cs | 29 ++++------------ Tests/PlayMode/UniTask/RoomUniTaskTests.cs | 28 ++++----------- 12 files changed, 43 insertions(+), 186 deletions(-) delete mode 100644 Runtime/Scripts/LiveKitException.cs delete mode 100644 Runtime/Scripts/LiveKitException.cs.meta diff --git a/README.md b/README.md index 57cca741..ed1e1518 100644 --- a/README.md +++ b/README.md @@ -319,21 +319,16 @@ The SDK exposes three interchangeable styles for awaiting asynchronous operation **1. Coroutines (default, no dependency)** — shown throughout this README. -**2. async/await (no dependency)** — every operation returns an awaitable instruction (`ConnectInstruction`, `PublishTrackInstruction`, `PerformRpcInstruction`, the stream read instructions, …), so you can `await` it directly. A failed operation throws — the instruction's typed error where one exists (`StreamError`, `RpcError`) or a `LiveKitException` otherwise. (Coroutines are unchanged: `yield return` never throws, so they still inspect `IsError`.) Continuations resume on Unity's main thread. +**2. async/await (no dependency)** — every operation returns an awaitable instruction (`ConnectInstruction`, `PublishTrackInstruction`, `PerformRpcInstruction`, the stream read instructions, …), so you can `await` it directly. As with coroutines, you inspect success/failure on the instruction (`IsError`) — `await` does not throw. Continuations resume on Unity's main thread. ```cs async void Start() { var room = new Room(); - try - { - await room.Connect("ws://localhost:7880", "", new RoomOptions()); + var connect = room.Connect("ws://localhost:7880", "", new RoomOptions()); + await connect; + if (!connect.IsError) Debug.Log("Connected to " + room.Name); - } - catch (LiveKitException e) - { - Debug.LogError("Failed to connect: " + e.Message); - } } ``` @@ -348,19 +343,18 @@ await room.Connect("ws://localhost:7880", "", new RoomOptions()) .AsUniTask(cancellationToken); ``` -Run operations in parallel. If either fails, `WhenAll` surfaces the exception: +Run operations in parallel. `AsUniTask` does not throw on failure (matching the +coroutine path), so keep the instructions and check `IsError` on each after the +`await` — otherwise a failed operation passes silently: ```cs -try -{ - await UniTask.WhenAll( - room.LocalParticipant.PublishTrack(cameraTrack, cameraOptions).AsUniTask(ct), - room.LocalParticipant.PublishTrack(microphoneTrack, microphoneOptions).AsUniTask(ct)); -} -catch (LiveKitException e) -{ - Debug.LogError("Failed to publish a track: " + e.Message); -} +var publishCamera = room.LocalParticipant.PublishTrack(cameraTrack, cameraOptions); +var publishMicrophone = room.LocalParticipant.PublishTrack(microphoneTrack, microphoneOptions); + +await UniTask.WhenAll(publishCamera.AsUniTask(ct), publishMicrophone.AsUniTask(ct)); + +if (publishCamera.IsError || publishMicrophone.IsError) + Debug.LogError("Failed to publish one or more tracks"); ``` Consume an incremental stream with `await foreach`. The sequence ends at end-of-stream; if the stream ends with an error it throws a `StreamError`: diff --git a/Runtime/Scripts/Internal/FfiInstruction.cs b/Runtime/Scripts/Internal/FfiInstruction.cs index 7ef958d1..93e89074 100644 --- a/Runtime/Scripts/Internal/FfiInstruction.cs +++ b/Runtime/Scripts/Internal/FfiInstruction.cs @@ -11,8 +11,6 @@ namespace LiveKit /// The protobuf callback type (e.g. SetLocalMetadataCallback). public class FfiInstruction : YieldInstruction where TCallback : class { - private string _error; - internal FfiInstruction( ulong asyncId, Func selector, @@ -23,21 +21,16 @@ internal FfiInstruction( selector, e => { - _error = errorExtractor(e); - IsError = !string.IsNullOrEmpty(_error); + IsError = !string.IsNullOrEmpty(errorExtractor(e)); IsDone = true; }, () => { - _error = "Canceled"; IsError = true; IsDone = true; }, dispatchToMainThread: false); } - - internal override Exception CreateAwaitException() => - string.IsNullOrEmpty(_error) ? base.CreateAwaitException() : new LiveKitException(_error); } /// @@ -74,8 +67,6 @@ internal FfiStreamInstruction( }, dispatchToMainThread: false); } - - internal override Exception CreateAwaitException() => Error ?? base.CreateAwaitException(); } /// @@ -130,7 +121,5 @@ internal FfiStreamResultInstruction( }, dispatchToMainThread: false); } - - internal override Exception CreateAwaitException() => Error ?? base.CreateAwaitException(); } } diff --git a/Runtime/Scripts/Internal/TaskYieldInstruction.cs b/Runtime/Scripts/Internal/TaskYieldInstruction.cs index 65ce9dfe..d5b39c8d 100644 --- a/Runtime/Scripts/Internal/TaskYieldInstruction.cs +++ b/Runtime/Scripts/Internal/TaskYieldInstruction.cs @@ -13,8 +13,6 @@ public sealed class TaskYieldInstruction : YieldInstruction public T Result { get; private set; } public Exception Exception { get; private set; } - internal override Exception CreateAwaitException() => Exception ?? base.CreateAwaitException(); - internal TaskYieldInstruction(Task task) { // Continuation may run on a thread-pool thread; the volatile IsDone/IsError fields in diff --git a/Runtime/Scripts/Internal/YieldInstruction.cs b/Runtime/Scripts/Internal/YieldInstruction.cs index 0788c4ed..80a29010 100644 --- a/Runtime/Scripts/Internal/YieldInstruction.cs +++ b/Runtime/Scripts/Internal/YieldInstruction.cs @@ -37,22 +37,13 @@ protected set /// Returns an awaiter so callers can await this instruction directly. /// /// - /// The awaiter completes when becomes true. On failure - /// () it throws the instruction's typed error where one exists - /// (e.g. , RpcError) or a - /// otherwise. The coroutine path (yield return) never throws — it inspects - /// instead. + /// The awaiter completes when becomes true. As with the + /// coroutine path, success vs. failure is inspected on the instruction itself + /// ( and any subclass-specific result fields); GetResult + /// does not throw. /// public YieldInstructionAwaiter GetAwaiter() => new YieldInstructionAwaiter(this); - /// - /// Builds the exception thrown by the awaiter when is true. - /// Subclasses override to surface their typed error (e.g. , - /// RpcError); the default is a generic . - /// - internal virtual Exception CreateAwaitException() => - new LiveKitException("The awaited LiveKit operation failed. Inspect IsError on the instruction for details."); - internal void RegisterContinuation(Action continuation) { // Race between completion-side (FFI thread writes sentinel) and await-side @@ -94,15 +85,9 @@ internal YieldInstructionAwaiter(YieldInstruction instruction) public void OnCompleted(Action continuation) => _instruction.RegisterContinuation(continuation); - // Throws on failure so `await` matches .NET async conventions (a failed operation - // surfaces as an exception, not a silently-completed await). The thrown type is the - // instruction's typed error where one exists (e.g. StreamError, RpcError), otherwise a - // generic LiveKitException. Coroutine consumers never call this — they inspect IsError. - public void GetResult() - { - if (_instruction.IsError) - throw _instruction.CreateAwaitException(); - } + // Intentionally a no-op. Parity with the coroutine path: callers inspect IsError + // and subclass-specific result fields on the instruction itself. + public void GetResult() { } } public class StreamYieldInstruction : CustomYieldInstruction diff --git a/Runtime/Scripts/LiveKitException.cs b/Runtime/Scripts/LiveKitException.cs deleted file mode 100644 index 4f9df9af..00000000 --- a/Runtime/Scripts/LiveKitException.cs +++ /dev/null @@ -1,18 +0,0 @@ -using System; - -namespace LiveKit -{ - /// - /// Thrown when an awaited LiveKit operation completes with an error (its - /// is true) and the instruction has no more specific - /// typed error to surface (e.g. , RpcError). - /// - /// - /// Only the await / UniTask paths throw. Coroutine consumers (yield return) - /// are unaffected — they inspect on the instruction. - /// - public class LiveKitException : Exception - { - public LiveKitException(string message) : base(message) { } - } -} diff --git a/Runtime/Scripts/LiveKitException.cs.meta b/Runtime/Scripts/LiveKitException.cs.meta deleted file mode 100644 index ed9a5030..00000000 --- a/Runtime/Scripts/LiveKitException.cs.meta +++ /dev/null @@ -1,11 +0,0 @@ -fileFormatVersion: 2 -guid: 2f0a831f838184f57bbcbf90b63a22fb -MonoImporter: - externalObjects: {} - serializedVersion: 2 - defaultReferences: [] - executionOrder: 0 - icon: {instanceID: 0} - userData: - assetBundleName: - assetBundleVariant: diff --git a/Runtime/Scripts/Participant.cs b/Runtime/Scripts/Participant.cs index c23b50a2..33728144 100644 --- a/Runtime/Scripts/Participant.cs +++ b/Runtime/Scripts/Participant.cs @@ -626,7 +626,6 @@ public sealed class PublishTrackInstruction : YieldInstruction private ulong _asyncId; private Dictionary _internalTracks; private ILocalTrack _localTrack; - private string _error; internal PublishTrackInstruction(ulong asyncId, ILocalTrack localTrack, Dictionary internalTracks) { @@ -644,7 +643,6 @@ internal void OnPublish(PublishTrackCallback e) if (e.AsyncId != _asyncId) return; - _error = e.Error; IsError = !string.IsNullOrEmpty(e.Error); IsDone = true; var publication = new LocalTrackPublication(e.Publication.Info, FfiHandle.FromOwnedHandle(e.Publication.Handle)); @@ -655,13 +653,9 @@ internal void OnPublish(PublishTrackCallback e) void OnCanceled() { - _error = "Canceled"; IsError = true; IsDone = true; } - - internal override System.Exception CreateAwaitException() => - new LiveKitException(string.IsNullOrEmpty(_error) ? "Failed to publish track." : _error); } public sealed class SetLocalMetadataInstruction : FfiInstruction @@ -732,9 +726,6 @@ void OnCanceled() IsError = true; IsDone = true; } - - internal override System.Exception CreateAwaitException() => - new LiveKitException(string.IsNullOrEmpty(Error) ? "Failed to publish data." : Error); } /// @@ -801,8 +792,6 @@ public string Payload /// See for more information on error codes. /// public RpcError Error { get; private set; } - - internal override System.Exception CreateAwaitException() => Error ?? base.CreateAwaitException(); } /// @@ -857,8 +846,6 @@ public TextStreamInfo Info } public StreamError Error { get; private set; } - - internal override System.Exception CreateAwaitException() => Error ?? base.CreateAwaitException(); } /// @@ -913,8 +900,6 @@ public ByteStreamInfo Info } public StreamError Error { get; private set; } - - internal override System.Exception CreateAwaitException() => Error ?? base.CreateAwaitException(); } /// @@ -969,8 +954,6 @@ public TextStreamWriter Writer } public StreamError Error { get; private set; } - - internal override System.Exception CreateAwaitException() => Error ?? base.CreateAwaitException(); } /// @@ -1025,8 +1008,6 @@ public ByteStreamWriter Writer } public StreamError Error { get; private set; } - - internal override System.Exception CreateAwaitException() => Error ?? base.CreateAwaitException(); } /// @@ -1086,8 +1067,6 @@ public LocalDataTrack Track } public PublishDataTrackError Error { get; private set; } - - internal override System.Exception CreateAwaitException() => Error ?? base.CreateAwaitException(); } /// Helpers for setting fields whose underlying type diff --git a/Runtime/Scripts/Room.cs b/Runtime/Scripts/Room.cs index c3d5cd66..8302f9c9 100644 --- a/Runtime/Scripts/Room.cs +++ b/Runtime/Scripts/Room.cs @@ -651,7 +651,6 @@ public sealed class ConnectInstruction : YieldInstruction private ulong _asyncId; private Room _room; private RoomOptions _roomOptions; - private string _error; internal ConnectInstruction(ulong asyncId, Room room, RoomOptions options) { @@ -679,10 +678,6 @@ void OnConnect(ConnectCallback e) _room.OnConnect(e); } - else - { - _error = e.Error; - } IsError = !success; IsDone = true; @@ -690,12 +685,8 @@ void OnConnect(ConnectCallback e) void OnCanceled() { - _error = "Canceled"; IsError = true; IsDone = true; } - - internal override Exception CreateAwaitException() => - new LiveKitException(string.IsNullOrEmpty(_error) ? "Failed to connect to the room." : _error); } } diff --git a/Runtime/Scripts/Track.cs b/Runtime/Scripts/Track.cs index 6a251fab..2fa873b6 100644 --- a/Runtime/Scripts/Track.cs +++ b/Runtime/Scripts/Track.cs @@ -227,8 +227,5 @@ private void OnCanceled() IsError = true; IsDone = true; } - - internal override System.Exception CreateAwaitException() => - new LiveKitException(string.IsNullOrEmpty(Error) ? "Failed to get session stats." : Error); } } diff --git a/Runtime/Scripts/UniTask/YieldInstructionUniTaskExtensions.cs b/Runtime/Scripts/UniTask/YieldInstructionUniTaskExtensions.cs index ac469c5b..84747d49 100644 --- a/Runtime/Scripts/UniTask/YieldInstructionUniTaskExtensions.cs +++ b/Runtime/Scripts/UniTask/YieldInstructionUniTaskExtensions.cs @@ -19,22 +19,16 @@ public static class YieldInstructionUniTaskExtensions /// first. /// /// - /// On failure the task faults with the instruction's error — the typed error where one - /// exists (e.g. , RpcError) or a - /// otherwise — matching a direct await of the instruction. Cancellation has - /// "abandon awaiter" semantics: the underlying FFI request keeps running and any result is - /// discarded; wire-level cancellation is not yet supported. + /// 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) - { - // Already complete: surface failure the same way `await` does (GetResult throws - // on IsError) so AsUniTask and a direct await behave identically. - try { instruction.GetAwaiter().GetResult(); return UniTask.CompletedTask; } - catch (System.Exception ex) { return UniTask.FromException(ex); } - } + if (instruction.IsDone) return UniTask.CompletedTask; if (cancellationToken.IsCancellationRequested) return UniTask.FromCanceled(cancellationToken); var source = new UniTaskCompletionSource(); @@ -51,21 +45,11 @@ public static UniTask AsUniTask(this YieldInstruction instruction, CancellationT // YieldInstruction.RegisterContinuation fires the callback exactly once and is // race-safe between FFI-thread completion and main-thread registration. Either - // this completion or TrySetCanceled wins; the loser is a no-op. GetResult() throws - // the instruction's typed error on failure, which we route to TrySetException so the - // UniTask faults exactly like a direct await would. + // TrySetResult or TrySetCanceled wins; the loser is a no-op. instruction.GetAwaiter().OnCompleted(() => { registration.Dispose(); - try - { - instruction.GetAwaiter().GetResult(); - source.TrySetResult(); - } - catch (System.Exception ex) - { - source.TrySetException(ex); - } + source.TrySetResult(); }); return source.Task; diff --git a/Tests/PlayMode/RoomTests.cs b/Tests/PlayMode/RoomTests.cs index dfb904b0..9cdc3031 100644 --- a/Tests/PlayMode/RoomTests.cs +++ b/Tests/PlayMode/RoomTests.cs @@ -40,40 +40,25 @@ private sealed class TestYieldInstruction : YieldInstruction public void CompleteWithError() { IsError = true; IsDone = true; } } - // OnCompleted path, success: await registers a continuation while the instruction is - // still pending, then a non-error completion resumes it without throwing. + // OnCompleted path: await registers a continuation while the instruction is still + // pending, then completion fires it and IsError is visible on resume. [UnityTest] - public IEnumerator GetAwaiter_ResumesOnCompletion_NoThrowOnSuccess() + public IEnumerator GetAwaiter_ResumesOnCompletion_AndSurfacesIsError() { var instruction = new TestYieldInstruction(); var awaitTask = AwaitInstruction(instruction); Assert.IsFalse(awaitTask.IsCompleted, "Awaiter must not resume before IsDone"); - instruction.Complete(); + instruction.CompleteWithError(); yield return new WaitUntil(() => awaitTask.IsCompleted); Assert.IsNull(awaitTask.Exception, awaitTask.Exception?.ToString()); Assert.IsTrue(instruction.IsDone, "Awaiter resumed, so IsDone must be observable"); + Assert.IsTrue(instruction.IsError, "IsError must be visible on resume"); } - // OnCompleted path, failure: a completion with IsError makes the await throw — surfaced - // here as a faulted task carrying a LiveKitException (the base instruction's default). - [UnityTest] - public IEnumerator GetAwaiter_ThrowsOnError() - { - var instruction = new TestYieldInstruction(); - var awaitTask = AwaitInstruction(instruction); - Assert.IsFalse(awaitTask.IsCompleted, "Awaiter must not resume before IsDone"); - - instruction.CompleteWithError(); - yield return new WaitUntil(() => awaitTask.IsCompleted); - - Assert.IsTrue(awaitTask.IsFaulted, "await must throw when the instruction completes with an error"); - Assert.IsInstanceOf(awaitTask.Exception?.InnerException); - } - - // IsCompleted fast path: instruction is already done (no error) before it is awaited, so - // the awaiter completes without ever registering a continuation and without throwing. + // IsCompleted fast path: instruction is already done before it is awaited, so the + // awaiter completes without ever registering a continuation. [UnityTest] public IEnumerator GetAwaiter_CompletesImmediately_WhenAlreadyDone() { diff --git a/Tests/PlayMode/UniTask/RoomUniTaskTests.cs b/Tests/PlayMode/UniTask/RoomUniTaskTests.cs index 5a16f625..69dce085 100644 --- a/Tests/PlayMode/UniTask/RoomUniTaskTests.cs +++ b/Tests/PlayMode/UniTask/RoomUniTaskTests.cs @@ -18,37 +18,21 @@ private sealed class TestInstruction : YieldInstruction public void CompleteWithError() { IsError = true; IsDone = true; } } - // AsUniTask completes (without throwing) when IsDone transitions to true on success, - // mirroring a direct await of the instruction. + // 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_CompletesOnSuccess() => UniTask.ToCoroutine(async () => + 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.Complete(); + instruction.CompleteWithError(); await task; Assert.IsTrue(instruction.IsDone, "UniTask should not resume before IsDone"); - Assert.IsFalse(instruction.IsError); - }); - - // On failure, AsUniTask faults with the instruction's error (here the base - // LiveKitException) — parity with a direct await, whose GetResult throws. - [UnityTest] - public System.Collections.IEnumerator AsUniTask_ThrowsOnError() => UniTask.ToCoroutine(async () => - { - var instruction = new TestInstruction(); - var task = instruction.AsUniTask(); - - instruction.CompleteWithError(); - - LiveKitException caught = null; - try { await task; } - catch (LiveKitException e) { caught = e; } - - Assert.IsNotNull(caught, "AsUniTask must fault when the instruction completes with an error"); + Assert.IsTrue(instruction.IsError, "Error state must be visible on resume"); }); // Cancellation has abandon-awaiter semantics: the UniTask faults with From 4149d022b170e2d753ad60fd8a88621df082fe67 Mon Sep 17 00:00:00 2001 From: Max Heimbrock <43608204+MaxHeimbrock@users.noreply.github.com> Date: Thu, 18 Jun 2026 12:33:38 +0200 Subject: [PATCH 10/17] Resume awaiter continuations on the Unity main thread MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A custom INotifyCompletion awaiter resumes on whatever thread completes the operation. Connect completes on the main thread (dispatchToMainThread:true), but operations registered dispatchToMainThread:false (SetMetadata, stream writes, …) and data-stream chunk events complete on the FFI callback thread — so `await` / `await foreach` resumed off the main thread, and touching a Unity API there threw "can only be called from the main thread". Coroutines never had this problem (they resume on the main thread via the player loop). Route both awaiter continuations through AwaiterScheduler.Resume, which posts to the captured main-thread SynchronizationContext. When already on the main thread (e.g. Connect) it runs inline to avoid an extra frame of latency. This makes the await path's threading identical to the coroutine path — and the README's "continuations resume on Unity's main thread" now holds. Adds GetAwaiter_ResumesOnMainThread_WhenCompletedOffThread, which completes a synthetic instruction from a thread-pool thread and asserts the await resumes on the main thread (red before this change, green after). Co-Authored-By: Claude Opus 4.8 (1M context) --- Runtime/Scripts/Internal/YieldInstruction.cs | 28 +++++++++++++++--- Tests/PlayMode/RoomTests.cs | 31 ++++++++++++++++++++ 2 files changed, 55 insertions(+), 4 deletions(-) diff --git a/Runtime/Scripts/Internal/YieldInstruction.cs b/Runtime/Scripts/Internal/YieldInstruction.cs index 80a29010..44785dc1 100644 --- a/Runtime/Scripts/Internal/YieldInstruction.cs +++ b/Runtime/Scripts/Internal/YieldInstruction.cs @@ -1,10 +1,30 @@ using System; using System.Runtime.CompilerServices; using System.Threading; +using LiveKit.Internal; using UnityEngine; namespace LiveKit { + // Resumes awaiter continuations on Unity's main thread. Completion may be signalled on the + // FFI callback thread (operations registered dispatchToMainThread:false, and data-stream + // chunk events), but a custom awaiter otherwise resumes inline on the completing thread — + // leaving callers unable to touch Unity APIs after an await. Posting through the captured + // main-thread SynchronizationContext keeps the await path's threading identical to the + // coroutine path. When already on the main thread (e.g. Connect, which completes there) the + // continuation runs inline to avoid an extra frame of latency. + internal static class AwaiterScheduler + { + internal static void Resume(Action continuation) + { + var context = FfiClient.Instance._context; + if (context == null || SynchronizationContext.Current == context) + continuation(); + else + context.Post(static state => ((Action)state)(), continuation); + } + } + public class YieldInstruction : CustomYieldInstruction { // Backing fields are volatile because completion may run on the FFI callback @@ -55,7 +75,7 @@ internal void RegisterContinuation(Action continuation) if (prev == null) return; if (ReferenceEquals(prev, s_completedSentinel)) { - continuation(); + AwaiterScheduler.Resume(continuation); return; } throw new InvalidOperationException( @@ -67,7 +87,7 @@ private void InvokeContinuation() var prev = Interlocked.Exchange(ref _continuation, s_completedSentinel); if (prev != null && !ReferenceEquals(prev, s_completedSentinel)) { - prev(); + AwaiterScheduler.Resume(prev); } } } @@ -164,7 +184,7 @@ internal void RegisterContinuation(Action continuation) if (prev == null) return; if (ReferenceEquals(prev, s_completedSentinel)) { - continuation(); + AwaiterScheduler.Resume(continuation); return; } throw new InvalidOperationException( @@ -176,7 +196,7 @@ private void InvokeContinuation() var prev = Interlocked.Exchange(ref _continuation, s_completedSentinel); if (prev != null && !ReferenceEquals(prev, s_completedSentinel)) { - prev(); + AwaiterScheduler.Resume(prev); } } } diff --git a/Tests/PlayMode/RoomTests.cs b/Tests/PlayMode/RoomTests.cs index 9cdc3031..fdea45bb 100644 --- a/Tests/PlayMode/RoomTests.cs +++ b/Tests/PlayMode/RoomTests.cs @@ -1,4 +1,5 @@ using System.Collections; +using System.Threading; using System.Threading.Tasks; using NUnit.Framework; using UnityEngine; @@ -73,11 +74,41 @@ public IEnumerator GetAwaiter_CompletesImmediately_WhenAlreadyDone() Assert.IsFalse(instruction.IsError); } + // An await continuation must resume on the Unity main thread even when the instruction + // completes on a background thread — which is what the FFI callback thread does for + // operations registered dispatchToMainThread:false (SetMetadata, stream writes, …). + // Coroutines always resume on the main thread; the await path must match so callers can + // safely touch Unity APIs after the await. RED until the awaiter marshals continuations + // to the main SynchronizationContext. + [UnityTest] + public IEnumerator GetAwaiter_ResumesOnMainThread_WhenCompletedOffThread() + { + var mainThreadId = Thread.CurrentThread.ManagedThreadId; + var instruction = new TestYieldInstruction(); + + var awaitTask = AwaitAndGetResumeThread(instruction); + Assert.IsFalse(awaitTask.IsCompleted, "Awaiter must not resume before IsDone"); + + // Complete from a thread-pool thread, mimicking the FFI callback thread. + Task.Run(() => instruction.Complete()); + + yield return new WaitUntil(() => awaitTask.IsCompleted); + + Assert.AreEqual(mainThreadId, awaitTask.Result, + "await must resume on the Unity main thread, not the thread that completed the instruction"); + } + private static async Task AwaitInstruction(YieldInstruction instruction) { await instruction; } + private static async Task AwaitAndGetResumeThread(YieldInstruction instruction) + { + await instruction; + return Thread.CurrentThread.ManagedThreadId; + } + [UnityTest, Category("E2E")] public IEnumerator RoomName_MatchesProvided() { From c3af3ba91738cf5b6f8f046c790766855bb87703 Mon Sep 17 00:00:00 2001 From: Max Heimbrock <43608204+MaxHeimbrock@users.noreply.github.com> Date: Thu, 18 Jun 2026 12:56:06 +0200 Subject: [PATCH 11/17] Implement ICriticalNotifyCompletion on the awaiters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add UnsafeOnCompleted to YieldInstructionAwaiter and StreamYieldInstructionAwaiter so the async state machine can resume without capturing ExecutionContext (one fewer allocation per await). Safe because the continuation doesn't rely on the flowed context — AwaiterScheduler marshals it to the main thread regardless. Co-Authored-By: Claude Opus 4.8 (1M context) --- Runtime/Scripts/Internal/YieldInstruction.cs | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/Runtime/Scripts/Internal/YieldInstruction.cs b/Runtime/Scripts/Internal/YieldInstruction.cs index 44785dc1..f3419001 100644 --- a/Runtime/Scripts/Internal/YieldInstruction.cs +++ b/Runtime/Scripts/Internal/YieldInstruction.cs @@ -92,7 +92,7 @@ private void InvokeContinuation() } } - public readonly struct YieldInstructionAwaiter : INotifyCompletion + public readonly struct YieldInstructionAwaiter : ICriticalNotifyCompletion { private readonly YieldInstruction _instruction; @@ -105,6 +105,11 @@ internal YieldInstructionAwaiter(YieldInstruction instruction) public void OnCompleted(Action continuation) => _instruction.RegisterContinuation(continuation); + // ICriticalNotifyCompletion lets the async state machine skip ExecutionContext capture + // on the hot path. We don't depend on the flowed context (AwaiterScheduler marshals to + // the main thread on its own), so this is safe and avoids a per-await allocation. + public void UnsafeOnCompleted(Action continuation) => _instruction.RegisterContinuation(continuation); + // Intentionally a no-op. Parity with the coroutine path: callers inspect IsError // and subclass-specific result fields on the instruction itself. public void GetResult() { } @@ -201,7 +206,7 @@ private void InvokeContinuation() } } - public readonly struct StreamYieldInstructionAwaiter : INotifyCompletion + public readonly struct StreamYieldInstructionAwaiter : ICriticalNotifyCompletion { private readonly StreamYieldInstruction _instruction; @@ -214,6 +219,10 @@ internal StreamYieldInstructionAwaiter(StreamYieldInstruction instruction) public void OnCompleted(Action continuation) => _instruction.RegisterContinuation(continuation); + // See YieldInstructionAwaiter.UnsafeOnCompleted — skips ExecutionContext capture; the + // continuation is marshalled to the main thread by AwaiterScheduler regardless. + public void UnsafeOnCompleted(Action continuation) => _instruction.RegisterContinuation(continuation); + public void GetResult() { } } } From 9cbcb216096ea2e62ebf684538e66efe7b78a7a0 Mon Sep 17 00:00:00 2001 From: Max Heimbrock <43608204+MaxHeimbrock@users.noreply.github.com> Date: Thu, 18 Jun 2026 12:56:06 +0200 Subject: [PATCH 12/17] Dispose AsUniTask cancellation registration on the cancel path Previously the CancellationTokenRegistration was disposed only when the instruction completed. A cancelled-but-never-completed awaiter kept its registration (and closure) pinned to a long-lived CancellationTokenSource. Dispose it from the cancel callback too; idempotent with the completion-path dispose, and safe because the pre-cancelled token is handled earlier. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../YieldInstructionUniTaskExtensions.cs | 24 ++++++++++++------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/Runtime/Scripts/UniTask/YieldInstructionUniTaskExtensions.cs b/Runtime/Scripts/UniTask/YieldInstructionUniTaskExtensions.cs index 84747d49..9ede7016 100644 --- a/Runtime/Scripts/UniTask/YieldInstructionUniTaskExtensions.cs +++ b/Runtime/Scripts/UniTask/YieldInstructionUniTaskExtensions.cs @@ -36,11 +36,17 @@ public static UniTask AsUniTask(this YieldInstruction instruction, CancellationT if (cancellationToken.CanBeCanceled) { - registration = cancellationToken.Register(static state => + // Dispose the registration on the cancel path too, so a cancelled-but-never- + // completed awaiter doesn't keep its registration (and this closure) alive on a + // long-lived CancellationTokenSource until the instruction eventually completes. + // Safe: the early IsCancellationRequested check above means the token isn't + // already cancelled, so Register won't invoke this synchronously before + // 'registration' is assigned; disposing from within the callback does not block. + registration = cancellationToken.Register(() => { - var s = (UniTaskCompletionSource)state; - s.TrySetCanceled(); - }, source); + source.TrySetCanceled(); + registration.Dispose(); + }); } // YieldInstruction.RegisterContinuation fires the callback exactly once and is @@ -73,11 +79,13 @@ public static UniTask AsUniTask(this StreamYieldInstruction instruction, Cancell if (cancellationToken.CanBeCanceled) { - registration = cancellationToken.Register(static state => + // See the YieldInstruction overload: dispose on cancel so the registration isn't + // pinned to a long-lived CancellationTokenSource when the read never completes. + registration = cancellationToken.Register(() => { - var s = (UniTaskCompletionSource)state; - s.TrySetCanceled(); - }, source); + source.TrySetCanceled(); + registration.Dispose(); + }); } instruction.GetAwaiter().OnCompleted(() => From 81646814a99cff75ef7d7c64142f6d05943a0299 Mon Sep 17 00:00:00 2001 From: Max Heimbrock <43608204+MaxHeimbrock@users.noreply.github.com> Date: Thu, 18 Jun 2026 12:56:06 +0200 Subject: [PATCH 13/17] Docs: clarify the await vs await-foreach error model MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Note that awaiting an instruction (and AsUniTask) never throws on failure — inspect IsError — while the stream enumerable throws StreamError, since await foreach has no post-loop point to check IsError. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/README.md b/README.md index ed1e1518..8af61e9d 100644 --- a/README.md +++ b/README.md @@ -370,6 +370,11 @@ catch (StreamError e) Debug.LogError(e.Message); } ``` + +> Error-handling differs by API: awaiting an instruction (and `AsUniTask`) never throws on a +> failed operation — you inspect `IsError` after the `await`. The stream enumerable is the +> exception: `await foreach` has no post-loop point to check `IsError`, so a mid-stream failure +> surfaces by throwing `StreamError`. ### Publishing microphone From a6bff73bd3ae5648ef797ada8683c5be5ccca2b5 Mon Sep 17 00:00:00 2001 From: Max Heimbrock <43608204+MaxHeimbrock@users.noreply.github.com> Date: Thu, 18 Jun 2026 14:15:53 +0200 Subject: [PATCH 14/17] Remove implementation-process references from code comments Drop comments that referenced the staged rollout and development history (e.g. "Stage 1", an earlier flaky test variant, "pre-existing limitation") and a stale reference to a removed test. Reword to describe current behavior only. No code changes. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Scripts/UniTask/StreamReaderUniTaskExtensions.cs | 9 ++++----- Tests/PlayMode/RoomTests.cs | 10 ++++------ Tests/PlayMode/UniTask/RoomUniTaskTests.cs | 5 ++--- Tests/PlayMode/UniTask/StreamUniTaskTests.cs | 2 +- 4 files changed, 11 insertions(+), 15 deletions(-) diff --git a/Runtime/Scripts/UniTask/StreamReaderUniTaskExtensions.cs b/Runtime/Scripts/UniTask/StreamReaderUniTaskExtensions.cs index f55a9165..45ea1284 100644 --- a/Runtime/Scripts/UniTask/StreamReaderUniTaskExtensions.cs +++ b/Runtime/Scripts/UniTask/StreamReaderUniTaskExtensions.cs @@ -25,11 +25,10 @@ public static class StreamReaderUniTaskExtensions /// with abandon-awaiter semantics — the /// underlying FFI read is not cancelled on the wire. /// - /// Like the coroutine consumer, this delivers the current chunk on the iteration where - /// end-of-stream is also observed, then stops. Chunks buffered beyond the - /// current one when end-of-stream arrives are not drainable — a pre-existing limitation - /// of the reader (its Reset() is disallowed past end-of-stream), not specific to - /// this adapter. + /// The current chunk is delivered on the iteration where end-of-stream is also observed, + /// then iteration stops. Chunks buffered beyond the current one when + /// end-of-stream arrives are not drainable, because the reader disallows Reset() + /// past end-of-stream. /// public static IUniTaskAsyncEnumerable AsAsyncEnumerable( this ReadIncrementalInstructionBase instruction, diff --git a/Tests/PlayMode/RoomTests.cs b/Tests/PlayMode/RoomTests.cs index fdea45bb..ebb303ea 100644 --- a/Tests/PlayMode/RoomTests.cs +++ b/Tests/PlayMode/RoomTests.cs @@ -29,12 +29,10 @@ public IEnumerator Connect_FailsWithInvalidUrl() Assert.IsNotNull(context.ConnectionError, "Expected connection to fail"); } - // Deterministic coverage of the GetAwaiter surface added in Stage 1, using a - // synthetic instruction so the awaiter logic is exercised without the FFI. These - // are intentionally NOT [Category("E2E")] — they need no dev server. The real - // connect-fail path stays covered by Connect_FailsWithInvalidUrl above; an earlier - // E2E variant of these was flaky because the FFI emits its error log asynchronously, - // which races LogAssert in the frame after the await has already resumed. + // Deterministic coverage of the awaiter using a synthetic instruction, so the logic is + // exercised without the FFI (no dev server needed — hence not [Category("E2E")]). A live + // connect would be non-deterministic here: the FFI emits its error log asynchronously and + // would race LogAssert. The connect-fail path itself is covered by Connect_FailsWithInvalidUrl. private sealed class TestYieldInstruction : YieldInstruction { public void Complete() => IsDone = true; diff --git a/Tests/PlayMode/UniTask/RoomUniTaskTests.cs b/Tests/PlayMode/UniTask/RoomUniTaskTests.cs index 69dce085..18bf3c60 100644 --- a/Tests/PlayMode/UniTask/RoomUniTaskTests.cs +++ b/Tests/PlayMode/UniTask/RoomUniTaskTests.cs @@ -18,9 +18,8 @@ private sealed class TestInstruction : YieldInstruction 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. + // AsUniTask completes when IsDone transitions to true, with the instruction's IsError + // visible on resume — parity with awaiting the instruction directly. [UnityTest] public System.Collections.IEnumerator AsUniTask_CompletesOnIsDone() => UniTask.ToCoroutine(async () => { diff --git a/Tests/PlayMode/UniTask/StreamUniTaskTests.cs b/Tests/PlayMode/UniTask/StreamUniTaskTests.cs index bb07f4b6..56b4c4d7 100644 --- a/Tests/PlayMode/UniTask/StreamUniTaskTests.cs +++ b/Tests/PlayMode/UniTask/StreamUniTaskTests.cs @@ -56,7 +56,7 @@ public System.Collections.IEnumerator AsAsyncEnumerable_DeliversChunksInOrder_Th // The current chunk is delivered even when EoS is already set at the time it is read, // then the sequence ends. (Chunks buffered beyond the current one when EoS arrives are - // not drainable — a pre-existing reader limitation, asserted here for clarity.) + // not drainable, because the reader disallows Reset() past EoS.) [UnityTest] public System.Collections.IEnumerator AsAsyncEnumerable_DeliversFinalChunkThenEos() => UniTask.ToCoroutine(async () => { From 8f6d0fa8b5bf8d49664b22e96f43f0d411b3710d Mon Sep 17 00:00:00 2001 From: Max Heimbrock <43608204+MaxHeimbrock@users.noreply.github.com> Date: Thu, 18 Jun 2026 14:41:42 +0200 Subject: [PATCH 15/17] Fix stream-error swallow on inline completion; narrow LatestChunk OnEos assigned IsEos before Error, but the IsEos setter fires the awaiter continuation. When completion runs inline on the main thread the continuation observed IsError == false and delivered the final chunk as success instead of throwing the stream error. Assign Error first, matching the sibling DataTrack.ReadFrameInstruction.SetEos ordering. Also revert LatestChunk from public back to internal (it throws on get and exists only for the UniTask adapter) and grant the adapter access via InternalsVisibleTo("LiveKit.UniTask"), instead of widening public surface for an optional integration. Co-Authored-By: Claude Opus 4.8 (1M context) --- Runtime/Scripts/AssemblyInfo.cs | 1 + Runtime/Scripts/DataStream.cs | 15 ++++++++++----- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/Runtime/Scripts/AssemblyInfo.cs b/Runtime/Scripts/AssemblyInfo.cs index 9e802523..148f70a0 100644 --- a/Runtime/Scripts/AssemblyInfo.cs +++ b/Runtime/Scripts/AssemblyInfo.cs @@ -3,3 +3,4 @@ [assembly: InternalsVisibleTo("EditModeTests")] [assembly: InternalsVisibleTo("PlayModeTests")] [assembly: InternalsVisibleTo("PlayModeTests.UniTask")] +[assembly: InternalsVisibleTo("LiveKit.UniTask")] diff --git a/Runtime/Scripts/DataStream.cs b/Runtime/Scripts/DataStream.cs index 131ccf56..4a0339e8 100644 --- a/Runtime/Scripts/DataStream.cs +++ b/Runtime/Scripts/DataStream.cs @@ -97,11 +97,12 @@ public abstract class ReadIncrementalInstructionBase : StreamYieldInst /// /// The chunk from the most recent completed read. Throws the captured - /// if the last read errored. Public so the optional - /// UniTask async-enumerable adapter can read it generically; the typed - /// Bytes/Text accessors on the concrete readers delegate here. + /// if the last read errored. Internal so the optional + /// UniTask async-enumerable adapter (which has InternalsVisibleTo access) can read + /// it generically; the typed Bytes/Text accessors on the concrete + /// readers delegate here. /// - public TContent LatestChunk + internal TContent LatestChunk { get { @@ -159,11 +160,15 @@ protected void OnEos(Proto.StreamError protoError) { lock (_gate) { - IsEos = true; + // Assign Error before flipping IsEos. The IsEos setter fires the awaiter + // continuation, which inspects IsError/Error on resume; when completion runs + // inline on the main thread, setting IsEos first would let the continuation + // observe IsError == false and silently swallow the stream error. if (protoError != null) { Error = new StreamError(protoError); } + IsEos = true; } } } From 4949383ff887532eb5abe91a1d9a705696168eb8 Mon Sep 17 00:00:00 2001 From: Max Heimbrock <43608204+MaxHeimbrock@users.noreply.github.com> Date: Thu, 18 Jun 2026 14:55:22 +0200 Subject: [PATCH 16/17] Align UniTask asmdef rootNamespace with declared namespaces Both UniTask asmdefs declared a rootNamespace that didn't match the code: the runtime extensions live in namespace LiveKit (not LiveKit.UniTaskExtensions) and the tests in LiveKit.PlayModeTests.UniTaskBridge (not .UniTask). rootNamespace is advisory (it only seeds the namespace for newly created scripts), so this has no compile effect, but it removes a misleading signal and prevents future new files from drifting into the wrong namespace. Co-Authored-By: Claude Opus 4.8 (1M context) --- Runtime/Scripts/UniTask/livekit.unity.Runtime.UniTask.asmdef | 2 +- Tests/PlayMode/UniTask/LiveKit.PlayModeTests.UniTask.asmdef | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Runtime/Scripts/UniTask/livekit.unity.Runtime.UniTask.asmdef b/Runtime/Scripts/UniTask/livekit.unity.Runtime.UniTask.asmdef index a1e3e218..c9a8f374 100644 --- a/Runtime/Scripts/UniTask/livekit.unity.Runtime.UniTask.asmdef +++ b/Runtime/Scripts/UniTask/livekit.unity.Runtime.UniTask.asmdef @@ -1,6 +1,6 @@ { "name": "LiveKit.UniTask", - "rootNamespace": "LiveKit.UniTaskExtensions", + "rootNamespace": "LiveKit", "references": [ "LiveKit", "UniTask", diff --git a/Tests/PlayMode/UniTask/LiveKit.PlayModeTests.UniTask.asmdef b/Tests/PlayMode/UniTask/LiveKit.PlayModeTests.UniTask.asmdef index db1f62ad..40855316 100644 --- a/Tests/PlayMode/UniTask/LiveKit.PlayModeTests.UniTask.asmdef +++ b/Tests/PlayMode/UniTask/LiveKit.PlayModeTests.UniTask.asmdef @@ -1,6 +1,6 @@ { "name": "PlayModeTests.UniTask", - "rootNamespace": "LiveKit.PlayModeTests.UniTask", + "rootNamespace": "LiveKit.PlayModeTests.UniTaskBridge", "references": [ "UnityEngine.TestRunner", "UnityEditor.TestRunner", From c515702021a1fac2eca54636f6d7b08b7c0abcbd Mon Sep 17 00:00:00 2001 From: Max Heimbrock <43608204+MaxHeimbrock@users.noreply.github.com> Date: Thu, 18 Jun 2026 15:03:15 +0200 Subject: [PATCH 17/17] Pin UniTask to 2.5.11 in the Meet sample MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The git dependency had no ref, so it resolved to UniTask master HEAD at resolution time — CI reproducibility relied solely on the lockfile hash. Pin the manifest URL to the 2.5.11 release tag and update the lockfile to the matching commit (2e993ff18f28c931602a07292df0b0804eebef99) so the version CI exercises is explicit and deterministic. Co-Authored-By: Claude Opus 4.8 (1M context) --- Samples~/Meet/Packages/manifest.json | 2 +- Samples~/Meet/Packages/packages-lock.json | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Samples~/Meet/Packages/manifest.json b/Samples~/Meet/Packages/manifest.json index 5bb1321b..60663d1e 100644 --- a/Samples~/Meet/Packages/manifest.json +++ b/Samples~/Meet/Packages/manifest.json @@ -1,6 +1,6 @@ { "dependencies": { - "com.cysharp.unitask": "https://github.com/Cysharp/UniTask.git?path=src/UniTask/Assets/Plugins/UniTask", + "com.cysharp.unitask": "https://github.com/Cysharp/UniTask.git?path=src/UniTask/Assets/Plugins/UniTask#2.5.11", "com.unity.collab-proxy": "2.7.1", "com.unity.feature.2d": "2.0.1", "com.unity.ide.rider": "3.0.36", diff --git a/Samples~/Meet/Packages/packages-lock.json b/Samples~/Meet/Packages/packages-lock.json index b256737d..99c8a782 100644 --- a/Samples~/Meet/Packages/packages-lock.json +++ b/Samples~/Meet/Packages/packages-lock.json @@ -1,11 +1,11 @@ { "dependencies": { "com.cysharp.unitask": { - "version": "https://github.com/Cysharp/UniTask.git?path=src/UniTask/Assets/Plugins/UniTask", + "version": "https://github.com/Cysharp/UniTask.git?path=src/UniTask/Assets/Plugins/UniTask#2.5.11", "depth": 0, "source": "git", "dependencies": {}, - "hash": "e5acc106ee196bc5a32fb14cdf2987b0f96d11e0" + "hash": "2e993ff18f28c931602a07292df0b0804eebef99" }, "com.unity.2d.animation": { "version": "9.1.0",