Skip to content

feat: optional out-of-process broker transport (survives domain reloads)#13

Open
dehuaichendragonplus wants to merge 1 commit into
FunplayAI:mainfrom
dehuaichendragonplus:feat/broker-transport-domain-reload-survival
Open

feat: optional out-of-process broker transport (survives domain reloads)#13
dehuaichendragonplus wants to merge 1 commit into
FunplayAI:mainfrom
dehuaichendragonplus:feat/broker-transport-domain-reload-survival

Conversation

@dehuaichendragonplus
Copy link
Copy Markdown
Contributor

funplay's HttpMCPTransport binds a TcpListener inside Unity's managed AppDomain. Every domain reload (script recompile / entering Play Mode) destroys that AppDomain, so the listener dies and the port is released; MCP client requests landing in the reload window fail with "connection refused", and the tool call that triggered the reload loses its response. A socket in the reloadable AppDomain fundamentally cannot survive an AppDomain unload — the only robust fix is to move the client-facing socket out of process.

This adds an opt-in out-of-process broker, realizing the IsAttachedToExistingServer seam already present in the codebase. No external runtime is required: the broker runs under the Unity-bundled Mono.

  • keepalive-broker.cs: standalone broker (TcpListener, raw HTTP, conservative C# / Mono 4.5 profile). Owns the client-facing port; holds in-flight client requests across the reload gap and re-queues a request that was pulled-but-unanswered when the plugin reloaded mid-call. Header framing (X-Broker-ReqId) keeps it a dumb byte forwarder — no JSON parsing on the broker side.
  • BrokerClientTransport (IMCPTransport, IsAttachedToExistingServer => true): instead of binding a listener, long-polls the broker (GET /_b/pull), dispatches through the existing MCPRequestHandler, and pushes responses (POST /_b/push). Uses HttpWebRequest (editor BCL, no new asmdef refs).
  • BrokerProcessManager: launches the broker under the bundled Mono and kills it on switch-to-direct / editor quit (EditorApplication.quitting). Resolves Mono dynamically because its path differs by Unity version (Contents/MonoBleedingEdge vs Unity 6's Contents/Resources/Scripting/ MonoBleedingEdge) — known candidates + bounded recursive search, never a hard-coded path. If the prebuilt .exe is absent it compiles keepalive-broker.cs once with the bundled mcs (cached under Library/). Tracks the process via a Library/funplay-broker.pid file so it can be reacquired/ stopped after a domain reload. A port-open guard adopts an already-listening broker instead of launching a duplicate, so the broker is reused across reloads and editor restarts (stable PID, no churn / no orphan).
  • MCPServerService: selects BrokerClientTransport when broker mode is on and the broker started, else kills any stale broker (freeing the port) and uses HttpMCPTransport. If Mono is missing or the broker fails to launch it logs a warning and falls back to the in-process transport.
  • Settings (BrokerModeEnabled / BrokerMonoPath, persisted) + a "Broker mode" toggle, optional Mono path override, and status line in the server controls panel. The broker binds the same port as the server, so MCP clients need no config change — the switch is transparent.

Default (broker mode off) is byte-for-byte the existing in-process behavior.

Verified on macOS / Apple Silicon:

  • Compatibility: the bundled Mono exists in every Unity 2019+ editor; the same broker .exe (built with Unity 6 mcs) runs under both Unity 6000 and Unity 2022.3 Mono (6.13.0).
  • Domain-reload survival: an MCP client driven by a continuous request loop through a real RequestScriptReload kept 100% success (zero connection-refused); the request spanning the reload was held ~13s and returned 200; the broker process PID stayed stable across the reload (reused, not restarted).
  • Editor restart: closing Unity kills the broker (quitting hook); reopening auto-launches a fresh broker (persisted broker mode -> server auto-start -> EnsureRunning), confirmed end-to-end with a real editor restart.

Windows (path-verified against Unity docs/community; not yet run on a Windows editor): the bundled Mono is mono.exe at \Data\MonoBleedingEdge\bin\ — applicationContentsPath (=\Data) plus the resolver's first candidate resolves it directly. Unity 6.3's Mono->.NET relocation moved MonoBleedingEdge only on macOS (to Contents/Resources/Scripting/MonoBleedingEdge); on Windows it stays at Data\MonoBleedingEdge. mcs for the compile-on-demand fallback is at ...\MonoBleedingEdge\lib\mono\4.5\mcs.exe. If Mono is ever absent the plugin falls back to the in-process HttpMCPTransport.

What changed

  • Describe the change briefly
  • Explain the user impact or motivation

Checklist

  • I tested the package in a clean Unity 2022.3+ project
  • I verified Funplay > MCP Server opens and starts correctly
  • If I changed setup, update, or config flows, I verified the affected flow end-to-end
  • I updated docs for any user-facing behavior changes
  • I did not commit local junk such as .idea/ or .DS_Store
  • I updated CHANGELOG.md when the change affects users

funplay's HttpMCPTransport binds a TcpListener inside Unity's managed AppDomain. Every domain
reload (script recompile / entering Play Mode) destroys that AppDomain, so the listener dies and
the port is released; MCP client requests landing in the reload window fail with "connection
refused", and the tool call that triggered the reload loses its response. A socket in the
reloadable AppDomain fundamentally cannot survive an AppDomain unload — the only robust fix is to
move the client-facing socket out of process.

This adds an opt-in out-of-process broker, realizing the IsAttachedToExistingServer seam already
present in the codebase. No external runtime is required: the broker runs under the Unity-bundled
Mono.

- keepalive-broker.cs: standalone broker (TcpListener, raw HTTP, conservative C# / Mono 4.5
  profile). Owns the client-facing port; holds in-flight client requests across the reload gap and
  re-queues a request that was pulled-but-unanswered when the plugin reloaded mid-call. Header
  framing (X-Broker-ReqId) keeps it a dumb byte forwarder — no JSON parsing on the broker side.
- BrokerClientTransport (IMCPTransport, IsAttachedToExistingServer => true): instead of binding a
  listener, long-polls the broker (GET /_b/pull), dispatches through the existing MCPRequestHandler,
  and pushes responses (POST /_b/push). Uses HttpWebRequest (editor BCL, no new asmdef refs).
- BrokerProcessManager: launches the broker under the bundled Mono and kills it on switch-to-direct
  / editor quit (EditorApplication.quitting). Resolves Mono dynamically because its path differs by
  Unity version (Contents/MonoBleedingEdge vs Unity 6's Contents/Resources/Scripting/
  MonoBleedingEdge) — known candidates + bounded recursive search, never a hard-coded path. If the
  prebuilt .exe is absent it compiles keepalive-broker.cs once with the bundled mcs (cached under
  Library/). Tracks the process via a Library/funplay-broker.pid file so it can be reacquired/
  stopped after a domain reload. A port-open guard adopts an already-listening broker instead of
  launching a duplicate, so the broker is reused across reloads and editor restarts (stable PID,
  no churn / no orphan).
- MCPServerService: selects BrokerClientTransport when broker mode is on and the broker started,
  else kills any stale broker (freeing the port) and uses HttpMCPTransport. If Mono is missing or
  the broker fails to launch it logs a warning and falls back to the in-process transport.
- Settings (BrokerModeEnabled / BrokerMonoPath, persisted) + a "Broker mode" toggle, optional Mono
  path override, and status line in the server controls panel. The broker binds the same port as
  the server, so MCP clients need no config change — the switch is transparent.

Default (broker mode off) is byte-for-byte the existing in-process behavior.

Verified on macOS / Apple Silicon:
- Compatibility: the bundled Mono exists in every Unity 2019+ editor; the same broker .exe (built
  with Unity 6 mcs) runs under both Unity 6000 and Unity 2022.3 Mono (6.13.0).
- Domain-reload survival: an MCP client driven by a continuous request loop through a real
  RequestScriptReload kept 100% success (zero connection-refused); the request spanning the reload
  was held ~13s and returned 200; the broker process PID stayed stable across the reload (reused,
  not restarted).
- Editor restart: closing Unity kills the broker (quitting hook); reopening auto-launches a fresh
  broker (persisted broker mode -> server auto-start -> EnsureRunning), confirmed end-to-end with a
  real editor restart.

Windows (path-verified against Unity docs/community; not yet run on a Windows editor): the bundled
Mono is mono.exe at <Editor>\Data\MonoBleedingEdge\bin\ — applicationContentsPath (=<Editor>\Data)
plus the resolver's first candidate resolves it directly. Unity 6.3's Mono->.NET relocation moved
MonoBleedingEdge only on macOS (to Contents/Resources/Scripting/MonoBleedingEdge); on Windows it
stays at Data\MonoBleedingEdge. mcs for the compile-on-demand fallback is at
...\MonoBleedingEdge\lib\mono\4.5\mcs.exe. If Mono is ever absent the plugin falls back to the
in-process HttpMCPTransport.
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.

1 participant