Eight Milliseconds
Acht Millisekunden
For seventeen days our WebXR game died on every cold start of the Meta Quest — silently, with nothing logged. Four theories were wrong, three of our own instruments were lying, and the answer was eight milliseconds wide.
Siebzehn Tage lang starb unser WebXR-Spiel bei jedem Kaltstart der Meta Quest — lautlos, ohne einen einzigen Protokolleintrag. Vier Thesen waren falsch, drei unserer eigenen Messinstrumente logen, und die Antwort war acht Millisekunden breit.
The symptom
PHOTONKATANA runs on the Meta Quest as a Trusted Web Activity — a thin native wrapper around the same WebXR build that runs in the browser. From the first sideload it had one reproducible defect: after every restart of the headset, the app died a few seconds in. Start it a second time and it ran fine. Open the browser version once beforehand and it ran fine too.
Nothing was logged. No error, no warning, no rejected promise. The immersive session simply stopped existing, and Horizon OS returned to the home environment as if the user had asked it to.
It took seventeen days to find out why. The cause was not in our code, and by the end we had not changed a single line of the game to fix it.
An uncaught NullPointerException inside the browser's media session handling — eight milliseconds after a looping audio element opened its output stream.
Four theories, four dead ends
Every early theory was plausible, and every one of them was wrong. They are worth listing, because the way each was eliminated is the actual method — and because if you are debugging something similar, this is the list you can skip.
canplaythrough ever fired.
decodeAudioData
Decoding on the main thread is a classic cold-start hazard, and our boot decoded several megabytes of clips. We fired twenty-two decodes in a burst during an active session. All of them completed, and nothing died. The 821 KB test had already pointed the same way — that path used a media element and no Web Audio decode at all.
HTMLAudioElement is exactly what creates one. Short one-shot clips, released after playback, never triggered anything.
The red herring that cost us a week
Our first report to Meta led with OpenGLRenderer setStopped(1), because that was the last meaningful line we could see before the app disappeared. It is real, and it is entirely irrelevant: it is the renderer shutting down at the end of a chain that had already started five seconds earlier. We were reading the bottom of the stack and describing it as the top.
Debugging with no console
The harder half of this problem was not the bug. It was that an immersive Custom Tab on a standalone headset gives you almost nothing to observe with, and three separate instruments lied to us before we noticed.
localStorage loses your last write
Our first tracer wrote progress markers to localStorage and read them back on the next launch. Writes are synchronous to JavaScript but flushed asynchronously by the browser — and a process killed hard never flushes. Every marker we most needed was the one guaranteed to be missing. We moved to batched beacons sent to our own server, plus a build stamp so we could prove which code the device had actually executed.
Timers stop while you are inside VR
A setInterval heartbeat looked like a clean death-clock: last tick before silence marks the moment of death. It marked nothing. During an immersive session the 2D document is hidden, and hidden-document timers are throttled hard. We spent days treating a throttled timer as a crash boundary. The only trustworthy clock in that state is the XR frame loop, so that is what drove telemetry from then on.
The device was running last week's code
For several days the headset ran a build we had already replaced, because the wrapper, the service worker and the CDN each held their own opinion about freshness. Nothing in the app tells you this is happening; the symptoms simply stop matching your source. The fix was unglamorous — no-cache at the edge, no-store on navigation requests, a normalised cache key, and a visible build stamp in the diagnostics so a stale run is obvious on sight.
An await that never returns
Separately, session.updateTargetFrameRate() never settled on the cold path, and it sat as a bare await immediately before our start call. The consequence was a permanently black screen with no error at all. It now runs behind a one-second Promise.race; on device the timeout fires at 1003 ms every time, which is its own small piece of evidence.
The minimal reproduction
Once four theories were dead and the instruments were honest, the fastest remaining move was not more instrumentation. It was subtraction.
We built a separate project — a plain WebXR page, its own manifest and service worker, packaged with Bubblewrap into its own TWA under its own package id, sharing only the Digital Asset Links fingerprint. Then we staged it: a bare scene, a scene with animation, a scene with short clips, a scene with a looping media element. Each stage a button, each stage independently launchable.
Everything ran. The stage with the looping media element died, every time, exactly like the game. Roughly six hundred kilobytes of code now reproduced a bug we had failed to isolate in forty thousand lines. That artefact is what made the rest of the conversation possible: Meta could run it themselves.
The log that answered it
Meta Developer Support asked for the one thing we had not captured — a completely unfiltered capture from all buffers, all priorities, across every process:
adb logcat -b all -v threadtime "*:V"
Headset rebooted immediately before, browser never opened in between, capture started nineteen seconds before launch, nothing filtered. We ran it twice — once on the minimal reproduction, once on the shipping game. Both showed the same chain, and the game's capture carried the full Java stack trace:
E AndroidRuntime: org.chromium.base.JniAndroid$UncaughtExceptionException Caused by: java.lang.NullPointerException: Attempt to invoke virtual method 'java.lang.String java.lang.Class.getName()' on a null object reference at android.content.ComponentName.<init>(ComponentName.java:133) at android.content.Intent.<init>(Intent.java:7662) at org.chromium.content.browser.MediaSessionImpl .mediaSessionStateChanged(chromium-OculusBrowser.apk:26)
An Intent constructed with a ComponentName whose class is null. Inside an immersive WebXR Custom Tab, that component appears not to exist. The exception is uncaught, so the process is already doomed at the moment it is thrown — everything visible afterwards is cleanup.
AAudioStreamBuilder_openStream() — this one is fineAAudioStreamBuilder_openStream() — the looping media elementdestroyTimeoutThe line that proved it
The most useful entry in the whole capture is the one that doesn't crash. Seven seconds before the failure, another audio output stream opens and nothing happens. Opening an audio stream is not the trigger.
What separates the two is that the second one belongs to a looping HTMLAudioElement, and that creates a media session. The trigger is not audio. It is the media session state change. That single contrast explains every earlier observation at once: why one-shot clips were safe, why decoded buffers through an AudioContext were safe, why file size never mattered, and why the music went on playing after the app was gone.
The outcome
We submitted both captures with the timeline, the stack trace and the reproduction APK. Meta Developer Support confirmed the defect and, on 24 August 2026, that a browser-side fix is shipping in a coming version of the Oculus Browser — with no change required in our app.
We want to be precise about the shape of that, because it is easy to tell this story badly. This was not a platform ignoring a developer. The support engineer asked for exactly the right capture, read a difficult log carefully, and took a browser bug seriously on the evidence. Our part was to stop guessing and hand over something that could be run. That exchange took days, not months.
What we took from it
- Read a log from the start of the chainOur first report described the last line before the silence. The actual cause sat five seconds earlier, in a buffer we were not capturing. If a log has an end that looks like a cause, look for its beginning.
- Trust no instrument you have not falsifiedThree of ours were lying: storage that never flushed, timers that were throttled, and a device serving stale code. Each one cost days, and each was invisible from inside the app.
- Subtract instead of addingFour theories and a fortnight of instrumentation moved us less than one afternoon of building a minimal reproduction. Six hundred kilobytes isolated what forty thousand lines had hidden.
- Give the vendor something they can executeA description invites a discussion. A signed APK that fails in one click, with a capture and a timestamped timeline, invites a fix.
- Falsify, don't confirmEvery theory here was killed by a test designed to kill it. The one that survived — the music kept playing after the process died — survived because we kept attacking it and could not.
PHOTONKATANA is a WebXR title for the Meta Quest, running at photonkatana.outerfar.com. The minimal reproduction, both unfiltered captures and the full correspondence are available on request — c.prokop@outerfar.com. If you are shipping WebXR in a Trusted Web Activity and hitting the same wall, get in touch; we would rather you skipped the seventeen days.
Das Symptom
PHOTONKATANA läuft auf der Meta Quest als Trusted Web Activity — eine dünne native Hülle um denselben WebXR-Build, der auch im Browser läuft. Vom ersten Sideload an hatte sie genau einen reproduzierbaren Fehler: nach jedem Neustart der Brille starb die App nach wenigen Sekunden. Beim zweiten Start lief sie. Wer vorher einmal die Browser-Version geöffnet hatte, bei dem lief sie auch.
Protokolliert wurde nichts. Kein Fehler, keine Warnung, kein abgelehntes Promise. Die immersive Sitzung hörte einfach auf zu existieren, und Horizon OS kehrte in die Home-Umgebung zurück, als hätte man es darum gebeten.
Es hat siebzehn Tage gedauert, die Ursache zu finden. Sie lag nicht in unserem Code, und am Ende haben wir keine einzige Zeile des Spiels geändert, um sie zu beheben.
Eine unbehandelte NullPointerException in der Mediensitzungs-Verwaltung des Browsers — acht Millisekunden nachdem ein dauerhaft laufendes Audio-Element seinen Ausgabestrom geöffnet hatte.
Vier Thesen, vier Sackgassen
Jede frühe These war plausibel, und jede war falsch. Sie sind es wert, aufgezählt zu werden — weil die Art, wie jede einzelne widerlegt wurde, die eigentliche Methode ist, und weil du diese Liste überspringen kannst, wenn du an etwas Ähnlichem sitzt.
canplaythrough überhaupt feuerte.
decodeAudioData
Dekodieren auf dem Hauptthread ist eine klassische Kaltstart-Gefahr, und unser Boot dekodierte mehrere Megabyte an Clips. Wir haben zweiundzwanzig Dekodierungen als Schwall während einer aktiven Sitzung ausgelöst. Alle liefen durch, nichts starb. Der 821-KB-Test hatte bereits in dieselbe Richtung gezeigt — dieser Weg benutzte ein Media-Element und gar keinen Web-Audio-Decode.
HTMLAudioElement. Kurze Einzelclips, die nach dem Abspielen freigegeben werden, lösten nie etwas aus.
Die falsche Fährte, die uns eine Woche gekostet hat
Unser erster Bericht an Meta führte mit OpenGLRenderer setStopped(1) auf, weil das die letzte aussagekräftige Zeile war, die wir vor dem Verschwinden der App sehen konnten. Sie ist echt, und sie ist vollkommen belanglos: der Renderer fährt am Ende einer Kette herunter, die fünf Sekunden früher begonnen hatte. Wir haben das untere Ende des Stapels gelesen und als oberes beschrieben.
Fehlersuche ohne Konsole
Die schwierigere Hälfte dieses Problems war nicht der Fehler. Es war, dass ein immersiver Custom Tab auf einer autarken Brille kaum etwas hergibt, womit man beobachten könnte — und drei getrennte Messinstrumente haben uns belogen, bevor es uns aufgefallen ist.
localStorage verliert deinen letzten Schreibvorgang
Unser erster Tracer schrieb Fortschrittsmarken in den localStorage und las sie beim nächsten Start zurück. Schreibvorgänge sind für JavaScript synchron, werden vom Browser aber asynchron auf die Platte gebracht — und ein hart beendeter Prozess bringt nichts mehr weg. Genau die Marke, die wir am dringendsten brauchten, war garantiert die fehlende. Wir sind auf gebündelte Beacons an unseren eigenen Server umgestiegen, dazu ein Build-Stempel, mit dem sich beweisen ließ, welchen Code das Gerät tatsächlich ausgeführt hatte.
Zeitgeber stehen still, solange du in VR bist
Ein setInterval-Herzschlag sah nach einer sauberen Todesuhr aus: der letzte Schlag vor der Stille markiert den Todeszeitpunkt. Er markierte gar nichts. Während einer immersiven Sitzung ist das 2D-Dokument versteckt, und Zeitgeber versteckter Dokumente werden hart gedrosselt. Wir haben tagelang eine Drosselung als Absturzgrenze behandelt. Die einzige verlässliche Uhr in diesem Zustand ist die XR-Bildschleife — von da an lief die Telemetrie über sie.
Das Gerät führte den Code der Vorwoche aus
Mehrere Tage lang lief auf der Brille ein Build, den wir längst ersetzt hatten, weil Wrapper, Service Worker und Auslieferung jeweils eine eigene Meinung zur Aktualität hatten. Nichts in der App sagt einem das; die Symptome hören einfach auf, zur Quelle zu passen. Die Lösung war unglamourös — no-cache an der Auslieferung, no-store auf Navigationsanfragen, ein normalisierter Cache-Schlüssel und ein sichtbarer Build-Stempel in der Diagnose, damit ein veralteter Lauf auf einen Blick auffällt.
Ein await, das nie zurückkommt
Unabhängig davon kam session.updateTargetFrameRate() auf dem kalten Pfad nie zurück und stand als nacktes await unmittelbar vor unserem Startaufruf. Die Folge war ein dauerhaft schwarzes Bild — ohne jede Fehlermeldung. Der Aufruf läuft jetzt hinter einem Promise.race mit einer Sekunde Zeitlimit; auf dem Gerät greift es jedes Mal bei 1003 ms, was für sich genommen schon ein kleines Beweisstück ist.
Die minimale Reproduktion
Als vier Thesen tot waren und die Instrumente ehrlich, war der schnellste verbleibende Zug nicht mehr Messtechnik. Es war Subtraktion.
Wir haben ein eigenes Projekt gebaut — eine schlichte WebXR-Seite mit eigenem Manifest und Service Worker, mit Bubblewrap in eine eigene TWA unter eigener Paket-Id verpackt, gemeinsam mit dem Spiel nur der Digital-Asset-Links-Fingerabdruck. Dann haben wir es gestaffelt: nackte Szene, Szene mit Animation, Szene mit kurzen Clips, Szene mit dauerhaft laufendem Media-Element. Jede Stufe eine Schaltfläche, jede Stufe einzeln startbar.
Alles lief. Die Stufe mit dem dauerhaft laufenden Media-Element starb, jedes Mal, genau wie das Spiel. Rund sechshundert Kilobyte Code reproduzierten damit einen Fehler, den wir in vierzigtausend Zeilen nicht hatten isolieren können. Dieses Artefakt hat den Rest des Gesprächs erst möglich gemacht: Meta konnte es selbst ausführen.
Das Protokoll, das die Antwort enthielt
Der Meta Developer Support bat um genau das, was wir nie aufgezeichnet hatten — eine vollständig ungefilterte Aufzeichnung aus allen Puffern, allen Prioritäten, über alle Prozesse hinweg:
adb logcat -b all -v threadtime "*:V"
Brille unmittelbar davor neu gestartet, Browser dazwischen nie geöffnet, Aufzeichnung neunzehn Sekunden vor dem Start begonnen, nichts gefiltert. Wir haben es zweimal gemacht — einmal mit der minimalen Reproduktion, einmal mit dem ausgelieferten Spiel. Beide zeigten dieselbe Kette, und die Aufzeichnung des Spiels trug den vollständigen Java-Stacktrace:
E AndroidRuntime: org.chromium.base.JniAndroid$UncaughtExceptionException Caused by: java.lang.NullPointerException: Attempt to invoke virtual method 'java.lang.String java.lang.Class.getName()' on a null object reference at android.content.ComponentName.<init>(ComponentName.java:133) at android.content.Intent.<init>(Intent.java:7662) at org.chromium.content.browser.MediaSessionImpl .mediaSessionStateChanged(chromium-OculusBrowser.apk:26)
Ein Intent, gebaut mit einer ComponentName, deren Klasse null ist. Im immersiven WebXR-Custom-Tab scheint diese Komponente nicht zu existieren. Die Ausnahme ist unbehandelt, der Prozess ist also bereits in dem Moment verloren, in dem sie geworfen wird — alles danach Sichtbare ist Aufräumarbeit.
AAudioStreamBuilder_openStream() — dieser hier ist unauffälligAAudioStreamBuilder_openStream() — das laufende Media-ElementdestroyTimeoutDie Zeile, die es bewiesen hat
Der nützlichste Eintrag der ganzen Aufzeichnung ist der, der nicht abstürzt. Sieben Sekunden vor dem Fehler öffnet sich ein weiterer Audio-Ausgabestrom, und nichts passiert. Das Öffnen eines Audiostroms ist nicht der Auslöser.
Der Unterschied zwischen beiden: der zweite gehört zu einem dauerhaft laufenden HTMLAudioElement, und das erzeugt eine Mediensitzung. Der Auslöser ist nicht der Ton. Es ist der Zustandswechsel der Mediensitzung. Dieser eine Gegensatz erklärt alle früheren Beobachtungen auf einen Schlag: warum Einzelclips harmlos waren, warum dekodierte Puffer über einen AudioContext harmlos waren, warum die Dateigröße nie eine Rolle spielte, und warum die Musik weiterlief, nachdem die App weg war.
Das Ergebnis
Wir haben beide Aufzeichnungen mit Zeitachse, Stacktrace und der Reproduktions-APK eingereicht. Der Meta Developer Support hat den Fehler bestätigt und am 24. August 2026 mitgeteilt, dass ein Fix auf Browserseite mit einer kommenden Version des Oculus Browser ausgeliefert wird — ohne jede Änderung an unserer App.
Uns ist wichtig, das genau einzuordnen, weil sich diese Geschichte leicht schlecht erzählen lässt. Hier hat keine Plattform einen Entwickler ignoriert. Der Support-Ingenieur hat nach genau der richtigen Aufzeichnung gefragt, ein schwieriges Protokoll sorgfältig gelesen und einen Browserfehler auf Basis der Belege ernst genommen. Unser Teil war, mit dem Raten aufzuhören und etwas zu übergeben, das man ausführen kann. Dieser Austausch hat Tage gedauert, nicht Monate.
Was wir mitgenommen haben
- Ein Protokoll vom Anfang der Kette lesenUnser erster Bericht beschrieb die letzte Zeile vor der Stille. Die eigentliche Ursache lag fünf Sekunden früher, in einem Puffer, den wir nicht aufzeichneten. Wenn ein Protokoll ein Ende hat, das nach Ursache aussieht, such seinen Anfang.
- Keinem Instrument trauen, das man nicht widerlegt hatDrei unserer Instrumente haben gelogen: Speicher, der nie geschrieben wurde, gedrosselte Zeitgeber und ein Gerät mit veraltetem Code. Jedes hat Tage gekostet, und keines war von innerhalb der App sichtbar.
- Subtrahieren statt hinzufügenVier Thesen und zwei Wochen Messtechnik haben uns weniger gebracht als ein Nachmittag, an dem wir eine minimale Reproduktion gebaut haben. Sechshundert Kilobyte haben isoliert, was vierzigtausend Zeilen verdeckt hatten.
- Dem Hersteller etwas Ausführbares gebenEine Beschreibung lädt zu einer Diskussion ein. Eine signierte APK, die mit einem Klick versagt, mit Aufzeichnung und Zeitachse, lädt zu einem Fix ein.
- Widerlegen, nicht bestätigenJede These hier ist an einem Test gestorben, der sie töten sollte. Die eine, die überlebte — die Musik lief weiter, nachdem der Prozess tot war — überlebte, weil wir sie immer wieder angegriffen haben und nicht durchkamen.
PHOTONKATANA ist ein WebXR-Titel für die Meta Quest, erreichbar unter photonkatana.outerfar.com. Die minimale Reproduktion, beide ungefilterten Aufzeichnungen und der vollständige Schriftverkehr sind auf Anfrage verfügbar — c.prokop@outerfar.com. Wer WebXR in einer Trusted Web Activity ausliefert und gegen dieselbe Wand läuft: meldet euch. Uns wäre lieber, ihr spart euch die siebzehn Tage.