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();