v0.23.2 — Fix AccessViolation pendant le démarrage de SteamVR

Cause : race condition. Quand l'utilisateur lance SteamVR alors que le
launcher tourne déjà, vrserver.exe apparaît dans la liste des process
avant que ses drivers et ses shared memories soient prêts à servir des
requêtes vtable. La séquence problématique :

  1. tick health loop → IsVrServerRunning() = true (vrserver vient juste
     d'apparaître dans la process list)
  2. VR_InitInternal2 → succeed (init est tolérant, ne lit pas la SHM)
  3. VR_GetGenericInterface → vtable pointer valide
  4. premier appel vtable (IsTrackedDeviceConnected, slot 21) → lit dans
     une shared memory pas encore allouée par vrserver → AccessViolation
  5. SEH passe au travers du CLR via Marshal.GetDelegateForFunctionPointer
     → process killed sans que catch (Exception) ne se déclenche.

Stack confirmée par l'Event Viewer Windows :
  System.AccessViolationException
    at OpenVrService.Query(System.String)
    at SystemHealthService.CheckVrDevice(...)
    at health loop background task

Fix : cooldown de 8 s après la première détection de vrserver. On note
le timestamp UTC de la première apparition, et on refuse l'init tant que
moins de 8 s n'ont passé. Pendant le warmup, la pill reste sur "SteamVR
non lancé" (HmdAbsent), puis bascule sur Ready au tick suivant. Si
vrserver disparaît, on reset le timestamp + on tear-down la session
proprement, prêts pour un prochain cycle de démarrage.

8 s = compromis : sur SSD le ramp-up des drivers SteamVR prend ~3-5 s,
sur HDD ça peut monter à 7 s. La marge couvre les deux.

Bonus diag : flush Serilog avant que le process meure dans le hook
AppDomain.UnhandledException. Sans ça, la trace de la fatale n'arrivait
jamais sur disque (exactement ce qu'on a vu dans les logs : silence
total avant le redémarrage suivant).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-03 20:08:32 +02:00
parent b94c0235b6
commit 07ead6011f
4 changed files with 69 additions and 11 deletions

View File

@@ -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"

View File

@@ -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");

View File

@@ -15,9 +15,9 @@
<Product>PROSERVE Launcher</Product>
<Copyright>© 2026 ASTERION VR — All rights reserved</Copyright>
<RootNamespace>PSLauncher.App</RootNamespace>
<Version>0.23.1</Version>
<AssemblyVersion>0.23.1.0</AssemblyVersion>
<FileVersion>0.23.1.0</FileVersion>
<Version>0.23.2</Version>
<AssemblyVersion>0.23.2.0</AssemblyVersion>
<FileVersion>0.23.2.0</FileVersion>
<!-- Single-file self-contained publish profile (used by `dotnet publish`) -->
<PublishSingleFile>true</PublishSingleFile>

View File

@@ -28,6 +28,18 @@ public sealed class OpenVrService : IOpenVrService, IDisposable
private bool _resolverEnsured;
private bool _initAttempted;
private VrRuntimeState _lastLoggedState = (VrRuntimeState)(-99); // sentinelle « jamais loggé »
/// <summary>
/// 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 <see cref="OpenVRNative.GetVTableSlot"/>
/// + premier appel = AccessViolation (lecture mémoire pas encore mappée).
/// </summary>
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();