From b15f86febe810ae1efdca572e4c63577ed64b4cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jacob=20Fr=C3=B8lund?= <91736983+jfrolund@users.noreply.github.com> Date: Wed, 19 Aug 2026 08:46:39 +0200 Subject: [PATCH] Idle the render thread while the OpenXR session is stopped MCOpenXR already tracks session state in isActive -- set false on XR_SESSION_STATE_STOPPING and _IDLE, true on _READY/_VISIBLE/_FOCUSED -- but the render path never reads it. poll() checks only initialized. So when the session stops (headset taken off, app backgrounded) xrWaitFrame returns immediately with XR_ERROR_SESSION_NOT_RUNNING instead of pacing us to the display rate. Nothing bails out, so xrBeginFrame, xrLocateViews and five xrLocateSpace calls all run and all fail, and with nothing left to block on the render thread free-runs. Measured on a Quest 2: roughly 1400 error lines a second, sustained for as long as the headset was left alone, growing latestlog.txt to 118MB in a single session. The allocation churn from formatting those messages drives the heap to its ceiling, and the game is eventually killed with reason=3 (LOW_MEMORY) -- typically hours later while unattended. It also made the headset's own auto-sleep unusable, since sleeping just drove the same path harder. Return early from updatePose() when the session is not running, sleeping briefly so the thread idles instead of spinning, and skip frame submission in OpenXRStereoRenderer#endFrame for the same reason. Rate limit logError() per call site and result as well, so any other failing call cannot flood the log the same way. The guard goes inside updatePose() rather than poll() because poll() uses paired Profiler.popPush/pop, and returning early there unbalances the profiler stack. After the fix the same test produced zero error lines and 263 bytes of log growth across a 40 minute sleep, and the game has since run for over a day with auto-sleep enabled and no LOW_MEMORY kills. --- .../client_vr/provider/openxr/MCOpenXR.java | 41 ++++++++++++++++++- .../provider/openxr/OpenXRStereoRenderer.java | 6 +++ 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/common/src/main/java/org/vivecraft/client_vr/provider/openxr/MCOpenXR.java b/common/src/main/java/org/vivecraft/client_vr/provider/openxr/MCOpenXR.java index d5902ba90..7ea7b91f1 100644 --- a/common/src/main/java/org/vivecraft/client_vr/provider/openxr/MCOpenXR.java +++ b/common/src/main/java/org/vivecraft/client_vr/provider/openxr/MCOpenXR.java @@ -76,6 +76,11 @@ public class MCOpenXR extends MCVR { public String systemName; private boolean inputInitialized; protected static final DeviceCompat device = DeviceCompat.detectDevice(); + /** how long the render thread idles per frame while the OpenXR session isn't running */ + private static final long INACTIVE_SESSION_SLEEP_MS = 50L; + /** minimum gap between two identical error messages, to stop a failing call flooding the log */ + private static final long ERROR_LOG_INTERVAL_MS = 1000L; + private final Map errorLogState = new HashMap<>(); public MCOpenXR(Minecraft mc, ClientDataHolderVR dh) { super(mc, dh, VivecraftVRMod.INSTANCE); @@ -186,6 +191,19 @@ private void updatePose() { return; } + // While the session isn't running (headset taken off, app sent to the background) xrWaitFrame + // returns immediately with an error instead of pacing us to the display rate, so every call + // below fails too and the render thread free-runs, logging thousands of errors a second. + // Idle here until the session comes back rather than spinning on a dead session. + if (!this.isActive) { + try { + Thread.sleep(INACTIVE_SESSION_SLEEP_MS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + return; + } + try (MemoryStack stack = MemoryStack.stackPush()) { XrFrameState frameState = XrFrameState.calloc(stack).type(XR10.XR_TYPE_FRAME_STATE); @@ -1343,7 +1361,28 @@ private String getResultName(int xrResult) { */ protected void logError(int xrResult, String caller, String... args) { if (xrResult < 0) { - VRSettings.LOGGER.error("{} for {} errored: {}", caller, String.join(" ", args), getResultName(xrResult)); + String target = String.join(" ", args); + // A call that fails once usually fails every frame, so rate limit per call site and + // result, and report how many were dropped once it recovers. + String key = caller + '\0' + target + '\0' + xrResult; + long now = System.currentTimeMillis(); + long[] state = this.errorLogState.computeIfAbsent(key, k -> new long[]{Long.MIN_VALUE, 0L}); + + if (now - state[0] < ERROR_LOG_INTERVAL_MS) { + state[1]++; + return; + } + + long suppressed = state[1]; + state[0] = now; + state[1] = 0L; + + if (suppressed > 0) { + VRSettings.LOGGER.error("{} for {} errored: {} ({} identical errors suppressed)", caller, target, + getResultName(xrResult), suppressed); + } else { + VRSettings.LOGGER.error("{} for {} errored: {}", caller, target, getResultName(xrResult)); + } } } } diff --git a/common/src/main/java/org/vivecraft/client_vr/provider/openxr/OpenXRStereoRenderer.java b/common/src/main/java/org/vivecraft/client_vr/provider/openxr/OpenXRStereoRenderer.java index 1a2916f9c..c8dcc3c61 100644 --- a/common/src/main/java/org/vivecraft/client_vr/provider/openxr/OpenXRStereoRenderer.java +++ b/common/src/main/java/org/vivecraft/client_vr/provider/openxr/OpenXRStereoRenderer.java @@ -134,6 +134,12 @@ public Matrix4f getProjectionMatrix(int eyeType, float nearClip, float farClip) @Override public void endFrame() throws RenderConfigException { + // No frame was begun while the session is stopped, so releasing images and submitting a + // composition layer would only fail with XR_ERROR_SESSION_NOT_RUNNING. See MCOpenXR#updatePose. + if (!this.openxr.isActive()) { + return; + } + try (MemoryStack stack = MemoryStack.stackPush()) { PointerBuffer layers = stack.callocPointer(1); int error;