Skip to content
Merged
Show file tree
Hide file tree
Changes from 18 commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
80e9801
native stopwatch on exit
NickKhalow May 28, 2026
c4ac040
native implementaiton
NickKhalow May 28, 2026
40dc008
move to hide the native
NickKhalow May 28, 2026
19f92e4
fix compilation
NickKhalow May 28, 2026
fae0ce7
add missing import
NickKhalow May 28, 2026
d305e00
subscribe to exit from entry point
NickKhalow May 29, 2026
d2da965
no-livekit-mode flag
NickKhalow Jun 3, 2026
20b118a
remove unused import
NickKhalow Jun 3, 2026
4527665
Merge branch 'main' into chore/delay-on-exit-fix
NickKhalow Jun 3, 2026
41eded1
manually destroy all OnDestroy callbacks
NickKhalow Jun 3, 2026
c0218fe
add editor guard becaue method uses dangerous API for Editor mode
NickKhalow Jun 3, 2026
14da527
gracefull cleanup for crashdetector and prefs
NickKhalow Jun 4, 2026
cd0d857
soft-shutdown flag
NickKhalow Jun 4, 2026
106edf4
terminate process after graceful shutdown
NickKhalow Jun 4, 2026
100706e
sentry graceful flush
NickKhalow Jun 4, 2026
93894b0
native shutdown stopwatch via flags
NickKhalow Jun 4, 2026
89d5fc0
Merge branch 'dev' into chore/delay-on-exit-fix
NickKhalow Jun 4, 2026
4941684
Update Explorer/Assets/DCL/Infrastructure/Utility/ExitUtils.cs
NickKhalow Jun 4, 2026
9a28f16
Revert "Update Explorer/Assets/DCL/Infrastructure/Utility/ExitUtils.cs"
NickKhalow Jun 4, 2026
cd25a0c
shutdown stopwatch
NickKhalow Jun 4, 2026
df6989b
remove complete wait
NickKhalow Jun 4, 2026
f66a2aa
drop dispose on about to exit
NickKhalow Jun 4, 2026
01f3907
restore Complete call
NickKhalow Jun 4, 2026
a9a6360
disable IsAboutToExit in editor
NickKhalow Jun 4, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,15 @@ public static class AppArgsFlags
public const string LSD_REMOTE_AB_SERVER = "lsd-remote-ab-server";
public const string LSD_REMOTE_AB_WORLD = "lsd-remote-ab-world";

public const string NO_LIVEKIT_MODE = "no-livekit-mode";

public const string NATIVE_SHUTDOWN_STOPWATCH = "native-shutdown-stopwatch";

/// <summary>
/// Use Unity's Application.Quit() (full native teardown) on exit instead of the default hard process termination. For native debugging only.
/// </summary>
public const string SOFT_SHUTDOWN = "soft-shutdown";

public static class Multiplayer
{
public const string COMPRESSION = "compression";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -430,12 +430,21 @@ static IMultiPool MultiPoolFactory() =>

var voiceChatRoom = new VoiceChatActivatableConnectiveRoom();

IRoomHub roomHub = new RoomHub(
localSceneDevelopment ? IConnectiveRoom.Null.INSTANCE : archipelagoIslandRoom,
gateKeeperSceneRoom,
chatRoom,
voiceChatRoom
);
IRoomHub roomHub;

if (appArgs.HasFlag(AppArgsFlags.NO_LIVEKIT_MODE))
{
roomHub = NullRoomHub.INSTANCE;
}
else
{
roomHub = new RoomHub(
localSceneDevelopment ? IConnectiveRoom.Null.INSTANCE : archipelagoIslandRoom,
gateKeeperSceneRoom,
chatRoom,
voiceChatRoom
);
}

var islandThroughputBunch = new ThroughputBufferBunch(new ThroughputBuffer(), new ThroughputBuffer());
var sceneThroughputBunch = new ThroughputBufferBunch(new ThroughputBuffer(), new ThroughputBuffer());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -96,13 +96,28 @@ public class MainSceneLoader : MonoBehaviour, ICoroutineRunner
private FileStream? singleInstanceLock;
private ErrorPopupWithRetryView? clockDesyncPopupPrefab;

private bool canShutdown;

private void Awake()
{
InitializeFlowAsync(destroyCancellationToken).Forget();
}

private void OnDestroy()
{
Shutdown();
}

private void Shutdown()
{
if (PlayerLoopHelper.IsMainThread == false)
return;

if (canShutdown == false)
return;

canShutdown = false;

DisableAllSelectableTransitions();

if (dynamicWorldContainer != null)
Expand Down Expand Up @@ -144,6 +159,11 @@ public void ApplyConfig(IAppArgs applicationParametersParser)
{
if (applicationParametersParser.TryGetValue(AppArgsFlags.ENVIRONMENT, out string? environment))
ParseEnvironment(environment!);

ExitUtils.Configure(
softShutdown: applicationParametersParser.HasFlag(AppArgsFlags.SOFT_SHUTDOWN),
nativeShutdownStopwatch: applicationParametersParser.HasFlag(AppArgsFlags.NATIVE_SHUTDOWN_STOPWATCH)
);
}

private void ParseEnvironment(string environment)
Expand All @@ -154,6 +174,9 @@ private void ParseEnvironment(string environment)

private async UniTask InitializeFlowAsync(CancellationToken ct)
{
canShutdown = true;
ExitUtils.RegisterCleanUpCandidate(new OnQuittingCleanUpCandidate(nameof(MainSceneLoader), Shutdown));

IAppArgs applicationParametersParser = new ApplicationParametersParser(
#if UNITY_EDITOR
debugSettings.AppParameters
Expand Down
223 changes: 208 additions & 15 deletions Explorer/Assets/DCL/Infrastructure/Utility/ExitUtils.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,12 @@
using System.Collections.Generic;
using System.Diagnostics;
using System.Reflection;
using System.Runtime.InteropServices;
using DCL.Diagnostics;
using DCL.Prefs;
using UnityEngine;
using Utility.Multithreading;
using RichTypes;
#if UNITY_EDITOR
using UnityEditor;
#endif
Expand Down Expand Up @@ -49,22 +52,24 @@ public static class ExitUtils
private static readonly Mutex<List<OnQuittingCleanUpCandidate>> candidates = new (new ()); // IGNORE_LINE_WEBGL_THREAD_SAFETY_FLAG
private static readonly Atomic<bool> isExiting = new (false);

private static bool useSoftShutdown;
private static bool useNativeShutdownStopwatch;

[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.BeforeSceneLoad)]
private static void SubscribeToApplicationQuitting()
{
#if UNITY_EDITOR
Patch.Reset();
#endif
// Dirty reflection hack because there is no other way to view the subs of Application.quitting
Patch.ApplicationQuittingFirstSubscriberSelfPatchWithTimers();

#if UNITY_EDITOR
// ensure isExiting is false on reopening
isExiting.Set(false);
using (var scope = candidates.Lock()) // IGNORE_LINE_WEBGL_THREAD_SAFETY_FLAG
scope.Value.Clear();
#endif
Application.quitting += OnApplicationQuitting;

// Dirty reflection hack because there is no other way to view the subs of Application.quitting
Patch.Apply();
}

private static void OnApplicationQuitting()
Expand Down Expand Up @@ -114,8 +119,22 @@ public static void UnregisterCleanUpCandidate(string name)
}
}

public static void Configure(bool softShutdown, bool nativeShutdownStopwatch)
{
useSoftShutdown = softShutdown;
useNativeShutdownStopwatch = nativeShutdownStopwatch;
ReportHub.LogProductionInfo($"[ExitUtils] Configured: softShutdown - {useSoftShutdown}, nativeShutdownStopwatch - {useNativeShutdownStopwatch}");
}

// Safe to call multiple times
public static void Exit()
{
if (Cysharp.Threading.Tasks.PlayerLoopHelper.IsMainThread == false)
{
ReportHub.LogProductionInfo("[ExitUtils] Exit() cannot be called from not a main thread");
return;
}

Stopwatch stopwatch = Stopwatch.StartNew();
ReportHub.LogProductionInfo($"[ExitUtils] Exit requested at {DateTime.UtcNow:O}");

Expand All @@ -127,6 +146,13 @@ public static void Exit()

isExiting.Set(true);

#if UNITY_STANDALONE_WIN
if (useNativeShutdownStopwatch)
{
StartExitStopwatch();
}
#endif

using (var scope = candidates.Lock()) // IGNORE_LINE_WEBGL_THREAD_SAFETY_FLAG
{
foreach (OnQuittingCleanUpCandidate candidate in scope.Value)
Expand All @@ -135,8 +161,15 @@ public static void Exit()

ReportHub.LogProductionInfo($"[ExitUtils] CleanUpCandidates finished at {stopwatch.ElapsedMilliseconds}ms");

// Reflection may drop the values. resubscribe to be sure.
Patch.ApplicationQuittingFirstSubscriberSelfPatchWithTimers();
// Flush save file only AFTER the candidates
DCLPlayerPrefs.SaveSync();
ReportHub.LogProductionInfo($"[ExitUtils] DCLPlayerPrefs flushed at {stopwatch.ElapsedMilliseconds}ms");

// Reflection may drop the values. Reapply to be sure, and to move TryTerminateSelf back to the
// last position in case anything subscribed to Application.quitting after the previous Apply().
Patch.Apply();

ReportHub.LogProductionInfo($"[ExitUtils] Begin Quit call {stopwatch.ElapsedMilliseconds}ms");
#if UNITY_EDITOR
EditorApplication.isPlaying = false;
#else
Expand All @@ -145,23 +178,188 @@ public static void Exit()
ReportHub.LogProductionInfo($"[ExitUtils] Quit call dispatched at {stopwatch.ElapsedMilliseconds}ms");
}

#if UNITY_STANDALONE_WIN
// Expected to be called from main thread only.
private static void StartExitStopwatch()
{
string targetPath = NewTargetPath();
string exePath = StopwatchExePath();

int pid = Process.GetCurrentProcess().Id; // IL2CPP safe

// --target-pid <pid> -o <file>
string[] args = new []
{
"--target-pid",
pid.ToString(),
"-o",
targetPath
};

ReportHub.LogProductionInfo($"[ExitUtils] Start measuring native exit delay");

Result<int> result = Plugins.DclNativeProcesses.DclProcesses.Start(exePath, args);

if (result.Success == false)
{
ReportHub.LogProductionInfo($"[ExitUtils] Cannot start measuring native exit delay: {result.ErrorMessage}");
}
}

private static string NewTargetPath()
{
string dir = UnityEngine.Application.persistentDataPath;
long unixTime = System.DateTimeOffset.UtcNow.ToUnixTimeSeconds();
string name = $"exit_stopwatch_{unixTime}.log";
string path = System.IO.Path.Combine(dir, name);
return path;
}

private static string StopwatchExePath()
{
string dir = UnityEngine.Application.streamingAssetsPath;
string path = System.IO.Path.Combine(dir, "dcl_exit_stopwatch.exe");
return path;
}
#endif

#if UNITY_STANDALONE_WIN || UNITY_STANDALONE_OSX
private static void TryTerminateSelf()
{
int pid = Process.GetCurrentProcess().Id; // IL2CPP safe
ReportHub.LogProductionInfo($"[ExitUtils] Terminating process pid={pid}");

if (useSoftShutdown)
{
ReportHub.LogProductionInfo($"[ExitUtils] Terminating process aborted due useSoftShutdown mode");
return;
}

#if UNITY_STANDALONE_WIN
IntPtr handle = OpenProcess(PROCESS_TERMINATE, false, (uint)pid);

if (handle == IntPtr.Zero)
{
ReportHub.LogProductionInfo($"[ExitUtils] OpenProcess failed (err {Marshal.GetLastWin32Error()})");
return;
}

if (TerminateProcess(handle, 0) == false)
{
ReportHub.LogProductionInfo($"[ExitUtils] TerminateProcess failed (err {Marshal.GetLastWin32Error()})");
CloseHandle(handle);
Comment thread
NickKhalow marked this conversation as resolved.
}
Comment thread
NickKhalow marked this conversation as resolved.
#elif UNITY_STANDALONE_OSX
if (kill(pid, SIGKILL) != 0)
{
ReportHub.LogProductionInfo($"[ExitUtils] kill(SIGKILL) failed (errno {Marshal.GetLastPInvokeError()})");
}
#endif
}

#if UNITY_STANDALONE_WIN

private const uint PROCESS_TERMINATE = 0x0001;

[DllImport("kernel32.dll", SetLastError = true)]
private static extern IntPtr OpenProcess(uint dwDesiredAccess, bool bInheritHandle, uint dwProcessId);

[DllImport("kernel32.dll", SetLastError = true)]
private static extern bool TerminateProcess(IntPtr hProcess, uint uExitCode);

[DllImport("kernel32.dll", SetLastError = true)]
private static extern bool CloseHandle(IntPtr hObject);

#elif UNITY_STANDALONE_OSX

private const int SIGKILL = 9;

[DllImport("libc", SetLastError = true)]
private static extern int kill(int pid, int sig);

#endif

#endif

private static class Patch
{
private static readonly HashSet<Action> wrapped = new ();
private static FieldInfo quittingField;

// Safe to call multiple times.
public static void Apply()
{
ApplicationQuittingFirstSubscriberSelfPatchWithTimers();
RegisterTerminateSelf();
}

public static void Reset()
{
wrapped.Clear();
}

private static FieldInfo? QuitFieldInfo()
{
quittingField ??= typeof(Application).GetField("quitting", BindingFlags.NonPublic | BindingFlags.Static);

if (quittingField == null)
{
ReportHub.LogProductionInfo("[ExitUtils.Patch] Cannot find Application.quitting backing field");
}

return quittingField;
}

// Keeps TryTerminateSelf as the very LAST Application.quitting subscriber.
// Safe to call multiple times.
private static void RegisterTerminateSelf()
{
#if (UNITY_STANDALONE_WIN || UNITY_STANDALONE_OSX) && !UNITY_EDITOR
try
{
var quittingField = QuitFieldInfo();
if (quittingField == null)
{
return;
}

Action terminateSelf = TryTerminateSelf;

wrapped.Add(terminateSelf);

Action? current = quittingField.GetValue(null) as Action;
Delegate[] handlers = current?.GetInvocationList() ?? Array.Empty<Delegate>();

Action? rebuilt = null;

// Delete if exists
foreach (Delegate handler in handlers)
{
if (handler is not Action action) continue;
if (action == terminateSelf) continue;

rebuilt += action;
}

// Append at the very end
rebuilt += terminateSelf;

quittingField.SetValue(null, rebuilt);
}
catch (Exception e) { ReportHub.LogException(e, ReportCategory.UNSPECIFIED); }
#endif
}

// Method is safe to be called multiple times. Idempotency
public static void ApplicationQuittingFirstSubscriberSelfPatchWithTimers()
private static void ApplicationQuittingFirstSubscriberSelfPatchWithTimers()
{
ReportHub.LogProductionInfo($"[ExitUtils.Patch] Invoke ApplicationQuittingFirstSubscriberSelfPatchWithTimers, actions wrapped {wrapped.Count}");

try
{
quittingField ??= typeof(Application).GetField("quitting", BindingFlags.NonPublic | BindingFlags.Static);

var quittingField = QuitFieldInfo();
if (quittingField == null)
{
ReportHub.LogProductionInfo("[ExitUtils.Patch] Cannot find Application.quitting backing field, per-subscriber timing disabled");
return;
}

Expand Down Expand Up @@ -193,11 +391,6 @@ public static void ApplicationQuittingFirstSubscriberSelfPatchWithTimers()
catch (Exception e) { ReportHub.LogException(e, ReportCategory.UNSPECIFIED); }
}

public static void Reset()
{
wrapped.Clear();
}

private static Action WrapWithTimer(Action original)
{
string label = $"{original.Method.DeclaringType?.FullName}.{original.Method.Name}";
Expand Down
Loading
Loading