Skip to content

fix: tick native watchdog before SetFormat to prevent black screen - #355

Merged
winnerspiros merged 4 commits into
masterfrom
fix/black-screen-surface-teardown
May 23, 2026
Merged

fix: tick native watchdog before SetFormat to prevent black screen#355
winnerspiros merged 4 commits into
masterfrom
fix/black-screen-surface-teardown

Conversation

@winnerspiros

Copy link
Copy Markdown
Owner

Problem

After the last build, the APK shows a black screen on launch. The app starts but never renders any frames.

Root Cause

The native crash log shows the watchdog fired with 'no managed heartbeat ever observed (Update thread did not tick)'. The sequence:

  1. Surface is born as RGB565 (Vulkan configured, but Window.SetFormat ignored by OEM)
  2. SurfaceChanged fires with RGB565 format
  3. The RGB565 guard calls SetFormat(RGBA8888) which triggers a synchronous surface teardown on the UI thread
  4. The UI thread is blocked for hundreds of milliseconds
  5. The Update thread can't send heartbeats during this window
  6. Native watchdog fires at 10s → process killed → black screen

Fix

Call NativeWatchdog.Heartbeat() right before SetFormat to reset the watchdog timer from the UI thread. The native watchdog checks g_lastHeartbeatMonotonicSec which is updated by a simple atomic store — safe to call from any thread.

This approach was chosen over setting surfaceEvent before SetFormat because the latter introduces a data race: the draw thread could read surfaceGlobalRef while SurfaceDestroyed is freeing it, causing a use-after-free Vulkan driver crash.

Changes

  • osu.Android/OsuGameActivity.cs: In the RGB565 guard within SurfaceChanged, tick the native watchdog before calling SetFormat
  • Added using osu.Android.Native import for NativeWatchdog access

Ubuntu added 2 commits May 23, 2026 18:36
When the surface is born as RGB565 on Vulkan, the RGB565 guard in
SurfaceChanged calls SetFormat(RGBA8888) to trigger a surface recreate.
Previously, this reset surfaceEvent and returned early, blocking the
draw thread on surfaceEvent.Wait(5000) while the UI thread was stuck
in the synchronous SetFormat call. This blocked the entire managed
runtime from sending heartbeats, causing the native watchdog to fire
at 10s and producing a black screen.

The fix sets surfaceEvent BEFORE calling SetFormat, so the draw thread
can proceed with the current (soon-to-be-recreated) surface. The new
surface triggers another SurfaceChanged which sets surfaceEvent again
with the correct RGBA8888 format.

Fixes the issue where the APK shows a black screen after the last build.
When the surface is born as RGB565 on Vulkan, the RGB565 guard in
SurfaceChanged calls SetFormat(RGBA8888) which triggers a synchronous
surface teardown on the UI thread. This blocks the UI thread for hundreds
of milliseconds, preventing the Update thread from sending heartbeats to
the native watchdog. The native watchdog (10s default) then fires, killing
the process and producing a black screen.

The fix calls NativeWatchdog.Heartbeat() right before SetFormat to reset
the watchdog timer from the UI thread. The native watchdog checks
g_lastHeartbeatMonotonicSec which is updated by a simple atomic store
— safe to call from any thread and never throws.

This is safer than the previous approach of setting surfaceEvent before
SetFormat, which introduced a data race between the draw thread reading
surfaceGlobalRef and SurfaceDestroyed freeing it.
Copilot AI review requested due to automatic review settings May 23, 2026 18:59
@coderabbitai

coderabbitai Bot commented May 23, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@winnerspiros, we couldn't start this review because you've used your available PR reviews for now.

Your plan currently allows 1 review/hour. Refill in 19 minutes and 27 seconds.

Your organization has run out of usage credits. Purchase more in the billing tab.

⌛ How to resolve this issue?

After more review capacity refills, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than trial, open-source, and free plans. In all cases, review capacity refills continuously over time.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 6c0ff723-f8ad-44f2-b705-4f6f78c49878

📥 Commits

Reviewing files that changed from the base of the PR and between 59300b0 and 1734d2c.

📒 Files selected for processing (2)
  • osu.Android/LogManagement.cs
  • osu.Android/OsuGameActivity.cs
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/black-screen-surface-teardown

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

The previous commit removed surfaceEvent.Reset() which introduced a
dangling pointer race: the draw thread could read surfaceGlobalRef while
SurfaceDestroyed is freeing it. The fix keeps the NativeWatchdog.Heartbeat()
call before SetFormat (to prevent the watchdog timeout) but restores the
surfaceEvent.Reset() to prevent the draw thread from proceeding with a
stale surface handle.

The sequence is now:
1. Tick native watchdog (resets 10s timer)
2. surfaceEvent.Reset() (prevents draw thread from using stale handle)
3. SetFormat(RGBA8888) (triggers surface teardown)
4. return (prevents fall-through)

The new surface triggers SurfaceChanged again which sets surfaceEvent
with the correct RGBA8888 format.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

On fresh installs where framework.ini doesn't exist yet, write
'Renderer = OpenGL' as the default instead of letting the framework
fall through to Vulkan (which is the default for Automatic on Android
when the GPU reports Vulkan support).

Vulkan causes black screens on several Adreno GPU families (7xx series
in particular) because the Veldrid Vulkan backend either times out its
5s SurfaceHandle poll or hands a stale ANativeWindow to
vkCreateAndroidSurfaceKHR. Plus the synchronous SetFormat(RGBA8888)
call blocks the UI thread, preventing managed heartbeats and triggering
the native watchdog.

OpenGL ES is the safer default; users can switch to Vulkan in
Settings → Graphics → Renderer if their device handles it well.

Also includes the NativeWatchdog.Heartbeat() tick before SetFormat
(from previous commit) as a safety net for devices that already have
Vulkan configured.
@winnerspiros
winnerspiros merged commit 5a29c57 into master May 23, 2026
15 of 23 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants