Skip to content

[Bug]: Play-mode screenshot re-enters the PlayerLoop (EditorApplication.Step from ExecuteTasks) - unbounded Editor.log and Editor crash #1289

Description

@TCLowe1982

Summary

manage_camera(action="screenshot") taken while the Editor is in play mode drives the PlayerLoop from a context that is already inside it. Unity reports the re-entrancy, and from that point the Editor emits an internal assertion continuously. In our case it wrote 20.4 GB to Editor.log and the Editor crashed unattended overnight.

Still present on main (v10.1.0, c14de1e6dc01) — verified 2026-07-28.

Cause

ScreenshotUtility.CaptureCompositedAfterFrame waits for the capture by stepping the Editor:

// Runtime/Helpers/ScreenshotUtility.cs:194 (main)
for (int i = 0; i < timeoutSteps && !done; i++)
{
    UnityEditor.EditorApplication.Step();
}

EditorApplication.Step() drives the PlayerLoop. But this is reached from TransportCommandDispatcher.RequestMainThreadPumpUnitySynchronizationContext.ExecuteTasks, which Unity already executes from inside the PlayerLoop. So the loop is driven from within itself.

Unity's message, with the package stack (verbatim from Editor.log):

An abnormal situation has occurred: the PlayerLoop internal function has been called recursively.
UnityEngine.StackTraceUtility:ExtractStackTrace ()
MCPForUnity.Runtime.Helpers.ScreenshotUtility:CaptureCompositedAfterFrame (int,int) (at .../ScreenshotUtility.cs:191)
MCPForUnity.Runtime.Helpers.ScreenshotUtility:CaptureComposited (string,int,bool,bool,int,string) (at .../ScreenshotUtility.cs:221)
MCPForUnity.Editor.Tools.ManageScene:CaptureScreenshot (...) (at .../ManageScene.cs:618)
MCPForUnity.Editor.Tools.Cameras.ManageCamera:HandleCommand (...) (at .../ManageCamera.cs:74)
MCPForUnity.Editor.Tools.CommandRegistry:ExecuteCommand (...) (at .../CommandRegistry.cs:305)
MCPForUnity.Editor.Services.Transport.TransportCommandDispatcher:ProcessCommand (...) (at .../TransportCommandDispatcher.cs:366)
MCPForUnity.Editor.Services.Transport.TransportCommandDispatcher:ProcessQueue () (at .../TransportCommandDispatcher.cs:262)
MCPForUnity.Editor.Services.Transport.TransportCommandDispatcher:<RequestMainThreadPump>g__Pump|11_0 () (at .../TransportCommandDispatcher.cs:184)
UnityEngine.UnitySynchronizationContext/WorkRequest:Invoke ()
UnityEngine.UnitySynchronizationContext:Exec ()
UnityEngine.UnitySynchronizationContext:ExecuteTasks ()

Note the last three frames: the call arrives through Unity's own main-thread pump, which runs inside the PlayerLoop.

What follows

After the re-entrant step, the log fills with one line and never stops:

Access version should be odd when acquiring lock

Measured in the crash-time log:

Flood begins at byte offset ~1,364,796
Total log size 20,444,189,922 bytes (20.4 GB)
Share of the log that is this one line >99.99%

Sampled at 25%, 50% and 75% of the file — every window is that line back to back. Disk space was not the trigger; 1,227 GB remained free.

It accumulates over a session — a leaked capturer per timed-out capture

Symptomatically this bites hardest in long-running sessions; the scrolling message has appeared several times and restarting the Editor clears it. There is a concrete mechanism.

ScreenshotCapturer.Begin spawns a hidden GameObject that is destroyed only after WaitForEndOfFrame resumes:

private System.Collections.IEnumerator Start()
{
    yield return new WaitForEndOfFrame();   // never resumes if no frame is rendered
    ...
    _onComplete?.Invoke(tex);
    Destroy(gameObject);                     // so this is never reached
}

WaitForEndOfFrame does not fire when the frame is not rendered — paused play mode, unfocused Editor, minimized player. The __MCP_ScreenshotCapturer__ object is then never destroyed and its coroutine stays suspended for the life of the session, while the synchronous caller has already given up after timeoutSteps.

The two defects compound, and #1230 supplies the link:

  1. Step() pauses play mode (per fix(screenshot): restore pause state after composited capture #1230's own description).
  2. On any version without fix(screenshot): restore pause state after composited capture #1230, that pause is never restored.
  3. A paused Editor renders no frames, so every subsequent capture's WaitForEndOfFrame can never fire — each one times out and leaks its capturer.

So the first screenshot of a session can put the Editor into a state where every later screenshot is guaranteed to leak.

Suggested diagnostic: take several play-mode screenshots, then count objects named __MCP_ScreenshotCapturer__. They should be zero.

Secondary symptom: captures silently return the wrong thing

The for loop exits on timeoutSteps whether or not done was set, so callers can receive a null or half-composited texture with no error. Three identical calls in one session returned a correct 640x419 image, then a blank 512x512, then a 448x448 frame with all Screen Space - Overlay UI missing.

Steps to reproduce

  1. Open a project and enter play mode. (Set Application.runInBackground = true if the Editor will be unfocused; otherwise frames don't tick.)
  2. Call manage_camera(action="screenshot", include_image=true).
  3. Observe An abnormal situation has occurred: the PlayerLoop internal function has been called recursively.
  4. Exit play mode, leave the Editor idle.
  5. Access version should be odd when acquiring lock repeats without limit; Editor.log grows without bound.

Environment

  • MCP for Unity 10.0.0 (4ee62b097584), installed from git?path=/MCPForUnity#main
  • Verified still present on main = c14de1e6dc01 (v10.1.0)
  • Unity 6000.4.3f1 (39d1a88d4dd1)
  • Windows 11 Pro 10.0.26200

Prior work in this area

Both left the re-entrancy in place.

Suggested fixes

  • Don't pump the Editor from a context already inside the PlayerLoop. If the request arrives during ExecuteTasks, defer the capture to a later editor tick instead of calling EditorApplication.Step().
  • Better, make the play-mode path fully async: return the pending MCP command's result from the WaitForEndOfFrame callback rather than blocking the dispatcher until done.
  • Give ScreenshotCapturer a timeout/lifetime so it destroys itself if the end-of-frame never arrives.
  • At minimum, treat !done after timeoutSteps as an error returned to the caller, rather than returning a partial texture as success.

A crash.dmp (3 MB) and log extracts can be supplied if useful.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions