diff --git a/installer/PSLauncher.iss b/installer/PSLauncher.iss index 741630d..8e47839 100644 --- a/installer/PSLauncher.iss +++ b/installer/PSLauncher.iss @@ -11,7 +11,7 @@ #define MyAppName "PROSERVE Launcher" #define MyAppShortName "PS_Launcher" -#define MyAppVersion "0.23.1" +#define MyAppVersion "0.23.2" #define MyAppPublisher "ASTERION VR" #define MyAppURL "https://asterionvr.com" #define MyAppExeName "PS_Launcher.exe" diff --git a/src/PSLauncher.App/App.xaml.cs b/src/PSLauncher.App/App.xaml.cs index 5924e2e..cef361f 100644 --- a/src/PSLauncher.App/App.xaml.cs +++ b/src/PSLauncher.App/App.xaml.cs @@ -97,7 +97,19 @@ public partial class App : Application Log.Information("PSLauncher starting (logs in {Path})", LogsDirectory); AppDomain.CurrentDomain.UnhandledException += (_, args) => - Log.Fatal((Exception)args.ExceptionObject, "Unhandled exception (AppDomain)"); + { + // CRITIQUE : on flush AVANT que le process meure. Sans ça, sur les + // exceptions de corruption d'état (AccessViolation depuis P/Invoke, + // SEH natif…) le buffer Serilog n'est jamais écrit sur disque, et + // le crash est totalement invisible dans les logs — exactement le + // mode de panne qu'on a eu avec OpenVR pendant le démarrage SteamVR. + try + { + Log.Fatal((Exception)args.ExceptionObject, "Unhandled exception (AppDomain) IsTerminating={IsTerminating}", args.IsTerminating); + Log.CloseAndFlush(); + } + catch { /* dernier recours — on ne peut plus rien faire */ } + }; DispatcherUnhandledException += (_, args) => { Log.Error(args.Exception, "Unhandled UI exception"); diff --git a/src/PSLauncher.App/PSLauncher.App.csproj b/src/PSLauncher.App/PSLauncher.App.csproj index f9cf722..2bcbffe 100644 --- a/src/PSLauncher.App/PSLauncher.App.csproj +++ b/src/PSLauncher.App/PSLauncher.App.csproj @@ -15,9 +15,9 @@ PROSERVE Launcher © 2026 ASTERION VR — All rights reserved PSLauncher.App - 0.23.1 - 0.23.1.0 - 0.23.1.0 + 0.23.2 + 0.23.2.0 + 0.23.2.0 true diff --git a/src/PSLauncher.Core/Health/OpenVR/OpenVrService.cs b/src/PSLauncher.Core/Health/OpenVR/OpenVrService.cs index 0eea724..9b311b5 100644 --- a/src/PSLauncher.Core/Health/OpenVR/OpenVrService.cs +++ b/src/PSLauncher.Core/Health/OpenVR/OpenVrService.cs @@ -28,6 +28,18 @@ public sealed class OpenVrService : IOpenVrService, IDisposable private bool _resolverEnsured; private bool _initAttempted; private VrRuntimeState _lastLoggedState = (VrRuntimeState)(-99); // sentinelle « jamais loggé » + + /// + /// Timestamp UTC de la PREMIÈRE détection de vrserver.exe lancé après une + /// période où il était absent. Remis à null si vrserver disparaît. Sert à + /// imposer un cooldown avant d'oser init OpenVR : entre l'apparition de + /// vrserver dans la liste process et le moment où ses drivers + ses shared + /// memories sont prêts à servir des requêtes vtable, il y a typiquement 5-10 s + /// de race window pendant laquelle + /// + premier appel = AccessViolation (lecture mémoire pas encore mappée). + /// + private DateTime? _vrServerFirstSeenUtc; + private const double VrServerSettleSeconds = 8.0; private uint _initToken; private IntPtr _vrSystemVTable = IntPtr.Zero; @@ -146,17 +158,28 @@ public sealed class OpenVrService : IOpenVrService, IDisposable return VrRuntimeState.HmdAbsent; } - // Garde supplémentaire avant l'init : vrserver.exe DOIT être actif. Sans - // lui, VR_InitInternal2 en mode Background peut soit hang, soit charger - // des drivers (lighthouse, oculus_link…) qui plantent en SEH si SteamVR - // n'a jamais été démarré sur cette session. Cette garde évite ce scénario - // au prix de ne plus rien lire jusqu'à ce que l'utilisateur lance SteamVR - // — ce qui est le comportement attendu de toute façon. + // Garde 1 : vrserver.exe DOIT être actif. Sans lui, VR_InitInternal2 en + // mode Background peut soit hang, soit charger des drivers (lighthouse, + // oculus_link…) qui plantent en SEH. if (!IsVrServerRunning()) { return VrRuntimeState.HmdAbsent; } + // Garde 2 : on attend que vrserver soit en l'air depuis assez longtemps + // pour que ses drivers et ses shared memories soient initialisés. + // Sinon : init succeed → premier vtable call → AccessViolation natif + // (la shared memory n'est pas encore mappée). Le SEH passe au travers du + // CLR via Marshal.GetDelegateForFunctionPointer et tue le process. + // 8s = compromis entre réactivité (l'utilisateur a lancé SteamVR, on veut + // pas attendre 30 s) et fiabilité (sur SSD c'est ~3-5 s, sur HDD ça peut + // monter à 7 s). On garde HmdAbsent affiché pendant le warmup ; quand le + // ticker se redéclenche après le délai, on enchaînera proprement sur Ready. + if (!HasVrServerSettled()) + { + return VrRuntimeState.HmdAbsent; + } + // Init en mode Background : on lit les states sans devenir une scene app. // L'init peut prendre 100-500 ms la première fois ; bloquer le caller est OK // car le health loop tourne sur thread dédié. @@ -371,9 +394,32 @@ public sealed class OpenVrService : IOpenVrService, IDisposable _vrServerCacheValue = false; } _vrServerCacheUntil = now.AddSeconds(2); + + // Update first-seen tracking : on note l'instant où vrserver apparaît, + // et on l'oublie quand il disparaît. C'est ce timestamp qu'on consulte + // dans EnsureReady pour décider si la fenêtre de race est passée. + if (_vrServerCacheValue && _vrServerFirstSeenUtc is null) + { + _vrServerFirstSeenUtc = now; + _logger.LogInformation("vrserver.exe just appeared — waiting {Secs}s for SteamVR drivers/IPC to settle before init", VrServerSettleSeconds); + } + else if (!_vrServerCacheValue && _vrServerFirstSeenUtc is not null) + { + _logger.LogInformation("vrserver.exe disappeared — resetting OpenVR session"); + _vrServerFirstSeenUtc = null; + // Si on était Ready, l'invalider : la prochaine session SteamVR exigera un nouvel init + if (_initToken != 0) ShutdownInternal(); + _initAttempted = false; + } return _vrServerCacheValue; } + private bool HasVrServerSettled() + { + if (_vrServerFirstSeenUtc is null) return false; + return (DateTime.UtcNow - _vrServerFirstSeenUtc.Value).TotalSeconds >= VrServerSettleSeconds; + } + private void MarkSessionBroken() { ShutdownInternal();