"Blocking the main thread on a GameTask.RunInThreadAsync result is a permanent deadlock"
▸ 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:
RunInThreadAsyncis itself anasyncmethod. It posts the work to a worker thread, thenawaits aTaskCompletionSource.- That
awaitcaptures the caller'sSynchronizationContext. On the main thread, that context isExpirableSynchronizationContext, whosePostonly 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 —
awaitit. Join aRunInThreadAsynctask withawait, 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
RunInThreadAsyncat 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'sSynchronizationContexthas 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.
- Published.