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