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, the answer was eight milliseconds wide, and Meta shipped the fix within days of seeing the evidence.
Siebzehn Tage lang starb unser WebXR-Spiel bei jedem Kaltstart der Meta Quest — lautlos, ohne einen einzigen Protokolleintrag. Vier Thesen waren falsch, die Antwort war acht Millisekunden breit, und Meta hat den Fix binnen Tagen nach Sichtung der Belege ausgeliefert.
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.
Nothing was logged. No error, no warning, no rejected promise. The immersive session simply stopped existing. It took seventeen days to find out why — and in the end we did not change 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.
What it wasn't
The crash always followed the start of a music track, so audio looked guilty. Four theories followed, and each was killed by a test built to kill it. If you are chasing something similar, this is the list you can skip:
decodeAudioData
Twenty-two decodes fired in a burst during an active session. All completed, nothing died.
HTMLAudioElement is exactly what creates one.
Two things made this harder than it needed to be, and both are worth knowing if you debug WebXR on a headset. Timers are throttled while you are inside VR — the 2D document is hidden, so a setInterval heartbeat marks nothing; only the XR frame loop is a trustworthy clock. And localStorage never flushes on a hard kill, so the marker you most need is guaranteed to be the missing one. We moved telemetry to batched beacons driven by the frame loop, and added a build stamp — because for several days the headset had also been running code we had already replaced.
What it was
Once four theories were dead, the fastest move was subtraction. We built a separate WebXR page, packaged it with Bubblewrap into its own TWA, and staged it: bare scene, animation, short clips, looping media element. Everything ran. The media element stage died every time. Six hundred kilobytes reproduced what forty thousand lines had hidden — and, crucially, Meta could now run it themselves.
The unfiltered capture then gave up the answer:
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)
AAudioStreamBuilder_openStream() — this one is fineAAudioStreamBuilder_openStream() — the looping media elementdestroyTimeoutThe most useful line in the whole capture is the one that doesn't crash. Seven seconds earlier another audio stream opens and nothing happens. Opening an audio stream is not the trigger — the second one belongs to a looping HTMLAudioElement, and that creates a media session. The trigger is the media session state change. That single contrast explains everything 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.
Working with Meta Developer Support
We expected this to be the slow part of the story. It turned out to be the fastest.
Our first report was wrong. We led with OpenGLRenderer setStopped(1) — the last line before the app vanished — and presented it as the cause. It was the end of the chain, not the beginning.
The reply didn't argue with our conclusion. It asked for the one thing that would settle it: a completely unfiltered capture from all buffers, all priorities, across every process. That was exactly the right question, and it was one we had not thought to ask ourselves.
adb logcat -b all -v threadtime "*:V"
From there it moved quickly. We delivered two clean captures and a signed reproduction APK. Meta read a difficult log, located the defect in their own browser, confirmed it, and on 24 August 2026 told us a fix is shipping in a coming version of the Oculus Browser — with no change required in our app.
For a one-person studio filing a bug against a platform's own browser, that is not the outcome you plan for. The whole exchange took days, not months. We want to say that plainly, because the opposite story gets told far more often: the guidance was precise, the engineering was taken seriously, and the fix happened because someone on the other side actually read the evidence. Our part was simply to stop guessing and hand over something that could be executed.
What we took from it
- Read a log from the start of the chainOur first report described the last line before the silence. The cause sat five seconds earlier, in a buffer we were not capturing.
- Subtract instead of addingFour theories and a fortnight of instrumentation moved us less than one afternoon of building a minimal reproduction.
- 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.
PHOTONKATANA is a WebXR title for the Meta Quest, running at photonkatana.outerfar.com. The minimal reproduction and both unfiltered captures 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.
Protokolliert wurde nichts. Kein Fehler, keine Warnung, kein abgelehntes Promise. Die immersive Sitzung hörte einfach auf zu existieren. Es hat siebzehn Tage gedauert, die Ursache zu finden — 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.
Was es nicht war
Der Absturz folgte immer auf den Start eines Musikstücks, also sah der Ton schuldig aus. Vier Thesen folgten, und jede starb an einem Test, der sie töten sollte. Wer an etwas Ähnlichem sitzt, kann diese Liste überspringen:
decodeAudioData
Zweiundzwanzig Dekodierungen als Schwall während einer aktiven Sitzung. Alle liefen durch, nichts starb.
HTMLAudioElement.
Zwei Dinge haben das schwerer gemacht als nötig, und beide sollte kennen, wer WebXR auf einer Brille debuggt. Zeitgeber werden gedrosselt, solange man in VR ist — das 2D-Dokument ist versteckt, ein setInterval-Herzschlag markiert also gar nichts; nur die XR-Bildschleife ist eine verlässliche Uhr. Und localStorage wird bei hartem Prozessende nie geschrieben, die dringend gebrauchte Marke fehlt also garantiert. Wir sind auf gebündelte Beacons aus der Bildschleife umgestiegen und haben einen Build-Stempel ergänzt — denn tagelang lief auf der Brille zusätzlich Code, den wir längst ersetzt hatten.
Was es war
Als vier Thesen tot waren, war der schnellste Zug Subtraktion. Wir haben eine eigene WebXR-Seite gebaut, mit Bubblewrap in eine eigene TWA verpackt und gestaffelt: nackte Szene, Animation, kurze Clips, dauerhaft laufendes Media-Element. Alles lief. Die Stufe mit dem Media-Element starb jedes Mal. Sechshundert Kilobyte reproduzierten, was vierzigtausend Zeilen verdeckt hatten — und, entscheidend: Meta konnte es jetzt selbst ausführen.
Die ungefilterte Aufzeichnung gab dann die Antwort her:
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)
AAudioStreamBuilder_openStream() — dieser hier ist unauffälligAAudioStreamBuilder_openStream() — das laufende Media-ElementdestroyTimeoutDie nützlichste Zeile der ganzen Aufzeichnung ist die, die nicht abstürzt. Sieben Sekunden früher öffnet sich ein weiterer Audiostrom, und nichts passiert. Das Öffnen eines Audiostroms ist nicht der Auslöser — der zweite gehört zu einem dauerhaft laufenden HTMLAudioElement, und das erzeugt eine Mediensitzung. Der Auslöser ist der Zustandswechsel der Mediensitzung. Dieser eine Gegensatz erklärt alles auf einen Schlag: warum Einzelclips harmlos waren, warum dekodierte Puffer über einen AudioContext harmlos waren, warum die Dateigröße nie zählte, und warum die Musik weiterlief, nachdem die App weg war.
Die Zusammenarbeit mit dem Meta Developer Support
Wir hatten erwartet, dass das der zähe Teil der Geschichte wird. Es wurde der schnellste.
Unser erster Bericht war falsch. Wir führten mit OpenGLRenderer setStopped(1) auf — der letzten Zeile, bevor die App verschwand — und stellten sie als Ursache dar. Sie war das Ende der Kette, nicht der Anfang.
Die Antwort hat unsere Schlussfolgerung nicht zerredet. Sie bat um genau das eine, was die Sache entscheiden würde: eine vollständig ungefilterte Aufzeichnung aus allen Puffern, allen Prioritäten, über alle Prozesse hinweg. Das war exakt die richtige Frage — und eine, die wir uns selbst nicht gestellt hatten.
adb logcat -b all -v threadtime "*:V"
Ab da ging es schnell. Wir haben zwei saubere Aufzeichnungen und eine signierte Reproduktions-APK geliefert. Meta hat ein schwieriges Protokoll gelesen, den Fehler im eigenen Browser lokalisiert, ihn bestätigt und uns am 24. August 2026 mitgeteilt, dass ein Fix mit einer kommenden Version des Oculus Browser ausgeliefert wird — ohne jede Änderung an unserer App.
Für ein Ein-Personen-Studio, das einen Fehler im Browser einer Plattform meldet, ist das nicht das Ergebnis, mit dem man plant. Der gesamte Austausch hat Tage gedauert, nicht Monate. Das wollen wir ausdrücklich sagen, weil die gegenteilige Geschichte weit häufiger erzählt wird: die Hinweise waren präzise, die technische Prüfung ernsthaft, und der Fix kam zustande, weil auf der anderen Seite jemand die Belege tatsächlich gelesen hat. Unser Teil war schlicht, mit dem Raten aufzuhören und etwas zu übergeben, das man ausführen kann.
Was wir mitgenommen haben
- Ein Protokoll vom Anfang der Kette lesenUnser erster Bericht beschrieb die letzte Zeile vor der Stille. Die Ursache lag fünf Sekunden früher, in einem Puffer, den wir nicht aufzeichneten.
- Subtrahieren statt hinzufügenVier Thesen und zwei Wochen Messtechnik haben weniger gebracht als ein Nachmittag mit einer minimalen Reproduktion.
- 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.
PHOTONKATANA ist ein WebXR-Titel für die Meta Quest, erreichbar unter photonkatana.outerfar.com. Die minimale Reproduktion und beide ungefilterten Aufzeichnungen 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.