the symptom, in your words

"Blocking the main thread on a GameTask.RunInThreadAsync result is a permanent deadlock"

✓ verified on 26.07.22
lane Tooling & environmentposted

▸ SYMPTOM

The game "hangs" but doesn't crash. The process stays alive, the native window still responds to the OS, but the frame loop is dead: nothing renders new frames, any MCP/automation bridge stops answering, and the log simply stops — often right after a "non-yielding async" warning. There's no exception and no stack trace. Verified on engine 26.07.22.

▸ CAUSE

You blocked the main thread on a Task returned by GameTask.RunInThreadAsync, using .Result or .Wait().

That is a permanent deadlock, not a slow await. From engine source (TaskSource.cs), the mechanism is:

  • RunInThreadAsync is itself an async method. It posts the work to a worker thread, then awaits a TaskCompletionSource.
  • That await captures the caller's SynchronizationContext. On the main thread, that context is ExpirableSynchronizationContext, whose Post only enqueues the continuation.
  • That queue is drained from the engine's own frame loop.

So the returned Task can only ever complete via a main-thread frame tick — and a main thread already blocked inside .Result will never run another frame tick. It is waiting on a completion that has no way to be dispatched: a closed loop. The engine's own escape hatch for this shape (SyncContext.RunBlocking, which pumps the async task queue inside its own wait loop) is internal and unreachable from game or gamemenu code.

▸ FIX

Never synchronously block the main thread on one of these tasks.

  • Normal case — await it. Join a RunInThreadAsync task with await, from an async flow. This is the intended pattern and has no hazard.
  • If you genuinely need a synchronous result on the main thread, don't route through RunInThreadAsync at all — compute it synchronously on the main thread. Detect "am I on the main thread" inside the primitive and take a sequential fallback path there.
  • Worker threads are fine. A worker thread may block on these tasks freely with .Result / .Wait(). The hazard is main-thread-only, because only the main thread's SynchronizationContext has this queue-drained-by-the-caller property.

Detection sweep: grep game and gamemenu code for .Result and .Wait( on anything fed by GameTask.RunInThreadAsync. Any hit reachable from a main-thread call path is this bug waiting to happen.

▸ WHY IT WORKS

The deadlock exists only because the continuation and the thread that must run it are the same thread, and that thread is parked. await hands control back to the frame loop so the continuation can be dispatched; a synchronous main-thread computation never crosses the thread boundary in the first place; and a worker thread that blocks isn't the thread responsible for draining the completion queue. Each safe pattern removes the circular wait.

Verified on engine 26.07.22: seen in a real project.
s&box moves fast; an undated fix is a liability. Spot a stale detail?
changelog
  • Published.

Want to know when new guides or fixes drop? Join the community to help build this out. Report gotchas, flag outdated fixes, or just lurk.

Join the Discord