Skip to content

fix: harden localhost resolution and reload transport resilience on Windows#688

Open
dsarno wants to merge 3 commits intoCoplayDev:betafrom
dsarno:fix/localhost-ipv6-resolution
Open

fix: harden localhost resolution and reload transport resilience on Windows#688
dsarno wants to merge 3 commits intoCoplayDev:betafrom
dsarno:fix/localhost-ipv6-resolution

Conversation

@dsarno
Copy link
Collaborator

@dsarno dsarno commented Feb 5, 2026

Summary

Fix 1: IPv4/IPv6 localhost ambiguity (#672)

  • Replace default localhost references with 127.0.0.1 in bind/connect defaults across Python server and Unity plugin.
  • On Windows, localhost can resolve to ::1 (IPv6) first, causing server/client address-family mismatch and intermittent disconnects.
  • Explicit user-configured localhost values are still honored.

Fix 2: Duplicate TCP listeners after domain reload (#692)

  • On Windows, domain reload could orphan stdio sockets; permissive socket options then allowed duplicate listeners on the same port.
  • Incoming connections could round-robin between live and dead listeners, causing handshake hangs and intermittent failures.
  • Root-cause hardening in stdio host/reload path:
    • Stop before domain reload destroys managed state.
    • Centralize stdio teardown sequencing in StdioBridgeReloadHandler to avoid resume-flag races.
    • Remove duplicate beforeAssemblyReload stop hook from StdioBridgeHost.
    • Tighten socket option behavior by platform.
    • Dispose listener socket eagerly during stop.
    • Increase stop/rebind tolerance windows for reload timing.

Fix 3: HTTP/WebSocket reload resilience hardening

  • Domain reload HTTP resume is now bounded-retry (0s, 1s, 3s, 5s, 10s, 30s) instead of one-shot start.
  • Before assembly reload, HTTP transport uses synchronous force-stop to avoid leaving orphaned WebSocket resources due to un-awaited async shutdown.
  • WebSocket connect now includes localhost loopback fallbacks (localhost, then 127.0.0.1, then ::1) to handle resolver/address-family mismatches during reconnect/resume.
  • Reconnect behavior remains finite (no infinite retry loop).
  • Added per-attempt diagnostic logs for HTTP post-reload resume.

Files changed

  • Server/src/core/config.pyunity_host default
  • Server/src/main.py--http-url arg default, host fallbacks, help text
  • MCPForUnity/Editor/Helpers/HttpEndpointUtility.csDefaultLocalBaseUrl
  • MCPForUnity/Editor/Services/Transport/Transports/WebSocketTransportClient.cs — bind-only host mapping + localhost candidate fallbacks + reconnect handling
  • MCPForUnity/Editor/Services/Transport/Transports/StdioBridgeHost.cs — socket options, stop disposal, retry params, remove duplicate reload stop hook
  • MCPForUnity/Editor/Services/StdioBridgeReloadHandler.cs — centralized stdio pre-reload stop sequencing and resume flag handling
  • MCPForUnity/Editor/Services/Transport/TransportManager.cs — synchronous ForceStop(TransportMode) bridge helper
  • MCPForUnity/Editor/Services/HttpBridgeReloadHandler.cs — synchronous pre-reload stop + bounded post-reload resume retries
  • Server/tests/test_core_infrastructure_characterization.py — updated assertion + HTTP default/fallback coverage
  • TestProjects/UnityMCPTests/Assets/Tests/EditMode/Services/WebSocketTransportClientTests.cs — new tests for localhost fallback candidate generation

Relationship to #687

PR #687 by @whatevertogo independently identified the same IPv4/IPv6 root cause. This PR keeps direct default-path changes and also includes reload hardening behavior in the Unity editor-side transport lifecycle.

Test plan

  • All 563 Unity EditMode tests pass (0 failures) on baseline scope before this follow-up hardening
  • Python tests pass
  • Added/updated Python tests for HTTP default host/URL fallback behavior
  • Added Unity EditMode tests for WebSocket localhost fallback candidate generation
  • Verify on Windows: single LISTENING socket on port 6400 after multiple domain reloads
  • Verify Unity HTTP session resumes after domain reload with bounded retries (no manual reconnect)
  • Verify explicit localhost configs still work
  • Verify bind-only fallback behavior: 0.0.0.0 -> 127.0.0.1, :: -> ::1

Repro for duplicate listener bug (Windows)

# 1. Before fix — check after each domain reload (save a .cs file):
netstat -ano | findstr :6400 | findstr LISTENING
# Bug: count increases each reload (2, 3, 4...)

# 2. After fix — same test:
netstat -ano | findstr :6400 | findstr LISTENING
# Fixed: always exactly 1 LISTENING socket

Addresses #672, #692

…on Windows

On Windows machines where localhost resolves to ::1 (IPv6), the server
and Unity plugin could end up on different address families, causing
WinError 64 disconnects. Hardcode 127.0.0.1 in all default bind/connect
paths to eliminate the ambiguity. Users can still explicitly set localhost
if desired.

Addresses CoplayDev#672

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@sourcery-ai
Copy link
Contributor

sourcery-ai bot commented Feb 5, 2026

Reviewer's guide (collapsed on small PRs)

Reviewer's Guide

This PR standardizes default loopback usage on the MCP server and Unity plugin by replacing localhost-based defaults with 127.0.0.1 and tightening up HTTP/WebSocket host resolution to avoid IPv4/IPv6 mismatches on Windows, while keeping explicit localhost configurations working as-is.

Sequence diagram for default Unity WebSocket connection to MCP server

sequenceDiagram
    actor UnityDeveloper
    participant UnityEditor
    participant HttpEndpointUtility
    participant WebSocketTransportClient
    participant MCPServer_main

    UnityDeveloper->>UnityEditor: Start play mode with default settings
    UnityEditor->>HttpEndpointUtility: GetLocalBaseUrl()
    HttpEndpointUtility-->>UnityEditor: DefaultLocalBaseUrl = http://127.0.0.1:8080
    UnityEditor->>WebSocketTransportClient: Connect(baseUrl = http://127.0.0.1:8080)

    par Server_startup
        UnityDeveloper->>MCPServer_main: python -m src.server --transport http
        MCPServer_main->>MCPServer_main: Parse args
        MCPServer_main->>MCPServer_main: http_url default = http://127.0.0.1:8080
        MCPServer_main->>MCPServer_main: http_host = UNITY_MCP_HTTP_HOST or parsed_url.hostname or 127.0.0.1
        MCPServer_main->>MCPServer_main: Bind HTTP server on 127.0.0.1:8080
    and Client_connect
        WebSocketTransportClient->>WebSocketTransportClient: BuildWebSocketUri(baseUrl)
        WebSocketTransportClient->>WebSocketTransportClient: host = httpUri.Host
        alt Bind_only_host
            WebSocketTransportClient->>WebSocketTransportClient: if host == 0.0.0.0 or ::
            WebSocketTransportClient->>WebSocketTransportClient: host = 127.0.0.1
        end
        WebSocketTransportClient->>MCPServer_main: Open WebSocket to ws://127.0.0.1:8080
    end

    MCPServer_main-->>WebSocketTransportClient: WebSocket connection established
    WebSocketTransportClient-->>UnityEditor: Ready
Loading

Class diagram for updated server and Unity networking defaults

classDiagram
    class ServerConfig {
        +str unity_host = 127.0.0.1
        +int unity_port
        +int mcp_port
    }

    class HttpEndpointUtility {
        -string LocalPrefKey
        -string RemotePrefKey
        -string DefaultLocalBaseUrl = http://127.0.0.1:8080
        -string DefaultRemoteBaseUrl
        +string GetLocalBaseUrl()
        +void SetLocalBaseUrl(string url)
        +string GetRemoteBaseUrl()
        +void SetRemoteBaseUrl(string url)
    }

    class WebSocketTransportClient {
        +Uri BuildWebSocketUri(string baseUrl)
        -Uri NormalizeBaseUrl(string baseUrl)
        -void LogConnection(string url)
    }

    HttpEndpointUtility ..> WebSocketTransportClient : provides_baseUrl
    ServerConfig ..> WebSocketTransportClient : server_bind_host
    ServerConfig ..> HttpEndpointUtility : aligns_default_host
Loading

File-Level Changes

Change Details Files
Update Python server HTTP defaults and host resolution to use 127.0.0.1 instead of localhost.
  • Change CLI example HTTP URL in main() docstring/help text to http://127.0.0.1:8080.
  • Set --http-url argument default to http://127.0.0.1:8080 and update its help string accordingly.
  • Adjust http_host resolution fallbacks to use 127.0.0.1 when no CLI/env/URL host is provided, in both initial computation and HTTP transport startup.
  • Change the log guard to compare against the new default http://127.0.0.1:8080 so logging behavior remains the same relative to defaults.
Server/src/main.py
Change server config and Unity editor defaults from localhost to 127.0.0.1 to ensure IPv4 loopback is used.
  • Set ServerConfig.unity_host default to 127.0.0.1 instead of localhost.
  • Update the Unity HttpEndpointUtility default local base URL to http://127.0.0.1:8080.
  • Adjust the characterization test to assert the new unity_host default value.
Server/src/core/config.py
MCPForUnity/Editor/Helpers/HttpEndpointUtility.cs
Server/tests/test_core_infrastructure_characterization.py
Ensure Unity WebSocket client replaces bind-only hosts with 127.0.0.1 instead of localhost when constructing client URIs.
  • Update WebSocketTransportClient bind-only host handling to swap 0.0.0.0/:: to 127.0.0.1.
  • Adjust the associated warning message to reference 127.0.0.1 rather than localhost.
MCPForUnity/Editor/Services/Transport/Transports/WebSocketTransportClient.cs

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Feb 5, 2026

📝 Walkthrough

Walkthrough

Replaced many implicit "localhost" references with explicit loopback IPs (127.0.0.1 and ::1) across client and server code; added granular handling for bind-only addresses in WebSocket URI construction; widened stdio bridge retry/cleanup and added an assembly-reload stop hook; added/updated tests covering HTTP host defaults.

Changes

Cohort / File(s) Summary
Client Endpoint Configuration
MCPForUnity/Editor/Helpers/HttpEndpointUtility.cs
Changed default local base URL from http://localhost:8080 to http://127.0.0.1:8080.
Client Transport Logic
MCPForUnity/Editor/Services/Transport/Transports/WebSocketTransportClient.cs
BuildWebSocketUri now treats bind-only addresses separately: 0.0.0.0127.0.0.1 (with warning) and ::::1 (with warning); removed the prior combined branch. URI construction otherwise unchanged.
Stdio Bridge Host (Unity Editor)
MCPForUnity/Editor/Services/Transport/Transports/StdioBridgeHost.cs
Added AssemblyReloadEvents.beforeAssemblyReload hook with OnBeforeAssemblyReload to stop/cleanup sockets before reload; increased bind retry attempts and sleep, inverted OSX ReuseAddress guard, removed Windows ExclusiveAddressUse block, dispose listener.Server on stop, and increased thread wait timeout.
Server Core Configuration
Server/src/core/config.py
ServerConfig.unity_host default changed from "localhost" to "127.0.0.1".
Server Initialization & Defaults
Server/src/main.py
Updated help text, CLI/env defaults, comparisons, and startup messages to use http://127.0.0.1:8080 and adjusted host fallback logic accordingly.
Server Tests
Server/tests/test_core_infrastructure_characterization.py
Updated assertion for ServerConfig.unity_host default; added TestHttpDefaultHostFallbacks covering HTTP URL/host defaulting and environment-based host resolution (including explicit localhost).

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

Suggested reviewers

  • msanatan

Poem

🐰 I swapped names for numbers, neat and spry,

127 hops where "localhost" used to lie.
Bind-only whispers nudged to loopback light,
The bridge now sleeps before the reload night,
Rabbit skips — connections snug and spry. 🥕

🚥 Pre-merge checks | ✅ 2 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 66.67% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title aligns with the main changes across multiple files: replacing localhost with 127.0.0.1 to resolve IPv4/IPv6 ambiguity on Windows, and improving StdioBridgeHost resilience and cleanup on reload.
Description check ✅ Passed Pull request description is detailed and comprehensive, covering all required sections with clear changes, test plan, and related issues.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

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.

Copy link
Contributor

@sourcery-ai sourcery-ai bot left a comment

Choose a reason for hiding this comment

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

Hey - I've found 2 issues

Prompt for AI Agents
Please address the comments from this code review:

## Individual Comments

### Comment 1
<location> `MCPForUnity/Editor/Services/Transport/Transports/WebSocketTransportClient.cs:727-728` </location>
<code_context>
             {
-                McpLog.Warn($"[WebSocket] Base URL host '{host}' is bind-only; using 'localhost' for client connection.");
-                host = "localhost";
+                McpLog.Warn($"[WebSocket] Base URL host '{host}' is bind-only; using '127.0.0.1' for client connection.");
+                host = "127.0.0.1";
             }

</code_context>

<issue_to_address>
**issue:** Mapping bind-only host '::' to 127.0.0.1 may break IPv6-only setups

This change rewrites both `0.0.0.0` and `"::"` to `127.0.0.1`. On IPv6-only systems where the server binds to `"::"`, this IPv4-only loopback may fail. Consider mapping `"::"` to `"::1"` and `0.0.0.0` to `127.0.0.1` to avoid bind-only hosts while keeping IPv6-only setups working.
</issue_to_address>

### Comment 2
<location> `Server/tests/test_core_infrastructure_characterization.py:661` </location>
<code_context>
         config = ServerConfig()

-        assert config.unity_host == "localhost"
+        assert config.unity_host == "127.0.0.1"
         assert config.unity_port == 6400
         assert config.mcp_port == 6500
</code_context>

<issue_to_address>
**suggestion (testing):** Add tests to cover the new HTTP default host/URL behavior in `main.py`.

This now covers the updated `unity_host` default, but there are still no tests for the new `http_url`/`http_host` defaults and fallbacks in `main.py` (default `--http-url` of `http://127.0.0.1:8080`, `UNITY_MCP_HTTP_URL`/`UNITY_MCP_HTTP_HOST`, and the `127.0.0.1` fallback). Please add tests that:

- Run the argument parser (or `main`) with no HTTP flags and assert host `127.0.0.1` and port `8080`.
- Set `UNITY_MCP_HTTP_URL` and/or `UNITY_MCP_HTTP_HOST` to `localhost` and verify they are honored without being rewritten.
- Set only `UNITY_MCP_HTTP_PORT` and verify the host still falls back to `127.0.0.1`.

This will lock in the behavior described in the PR and help detect regressions if defaults change again.

Suggested implementation:

```python
        assert config.unity_host == "127.0.0.1"
        assert config.unity_port == 6400
        assert config.mcp_port == 6500
        assert config.connection_timeout == 30.0


def _parse_http_args(monkeypatch, env_vars, argv=None):
    """Helper to exercise the HTTP argument / env var handling in main.py.

    This uses the same argument parser as `main()` so that defaults and
    fallbacks are exercised exactly as in production.
    """
    from Server import main as server_main

    # Clear any pre-existing env that could affect the parser.
    for key in ("UNITY_MCP_HTTP_URL", "UNITY_MCP_HTTP_HOST", "UNITY_MCP_HTTP_PORT"):
        monkeypatch.delenv(key, raising=False)

    # Apply the env vars for this scenario.
    for key, value in env_vars.items():
        monkeypatch.setenv(key, value)

    # The parser is expected to match what `main()` uses.
    if hasattr(server_main, "build_argument_parser"):
        parser = server_main.build_argument_parser()
    else:
        # Fall back to a module-level parser if present.
        parser = server_main.PARSER  # type: ignore[attr-defined]

    args = parser.parse_args([] if argv is None else argv)

    # Some implementations derive http_host/port from http_url only.
    # Normalize them here so the tests are robust to that detail.
    http_host = getattr(args, "http_host", None)
    http_port = getattr(args, "http_port", None)

    http_url = getattr(args, "http_url", None)
    if http_url and (http_host is None or http_port is None):
        from urllib.parse import urlparse

        parsed = urlparse(http_url)
        if http_host is None:
            http_host = parsed.hostname
        if http_port is None:
            http_port = parsed.port

    return http_host, http_port


def test_http_defaults_no_flags_uses_127_0_0_1_8080(monkeypatch):
    """With no HTTP flags or env, default URL should be http://127.0.0.1:8080."""
    http_host, http_port = _parse_http_args(monkeypatch, env_vars={}, argv=None)

    assert http_host == "127.0.0.1"
    assert http_port == 8080


def test_http_env_url_and_host_localhost_are_honored(monkeypatch):
    """UNITY_MCP_HTTP_URL / UNITY_MCP_HTTP_HOST set to localhost should be honored."""
    http_host, http_port = _parse_http_args(
        monkeypatch,
        env_vars={
            "UNITY_MCP_HTTP_URL": "http://localhost:8080",
            "UNITY_MCP_HTTP_HOST": "localhost",
        },
        argv=None,
    )

    # The explicit localhost values must not be rewritten to 127.0.0.1
    assert http_host == "localhost"
    assert http_port == 8080


def test_http_env_only_port_falls_back_to_127_0_0_1(monkeypatch):
    """UNITY_MCP_HTTP_PORT alone should keep the host fallback of 127.0.0.1."""
    http_host, http_port = _parse_http_args(
        monkeypatch,
        env_vars={"UNITY_MCP_HTTP_PORT": "9090"},
        argv=None,
    )

    assert http_host == "127.0.0.1"
    assert http_port == 9090

```

To make these tests pass, ensure the following in `Server/main.py` (or adjust the test helper accordingly):

1. There is an argument parser used by `main()` that supports the HTTP options and env defaults, exposed either as:
   - `build_argument_parser()` returning an `argparse.ArgumentParser`, or
   - a module-level `PARSER` object.
2. The parser must:
   - Default to `http_url="http://127.0.0.1:8080"` (or equivalent `http_host="127.0.0.1", http_port=8080`) when no HTTP args/env are provided.
   - Respect `UNITY_MCP_HTTP_URL` and `UNITY_MCP_HTTP_HOST` when they are set to `localhost` without rewriting them to `127.0.0.1`.
   - When only `UNITY_MCP_HTTP_PORT` is set, keep/fall back to a host of `127.0.0.1`.
3. If your parser uses different attribute names than `http_url`, `http_host`, and `http_port`, update `_parse_http_args` to map to the actual attribute names.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

config = ServerConfig()

assert config.unity_host == "localhost"
assert config.unity_host == "127.0.0.1"
Copy link
Contributor

Choose a reason for hiding this comment

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

suggestion (testing): Add tests to cover the new HTTP default host/URL behavior in main.py.

This now covers the updated unity_host default, but there are still no tests for the new http_url/http_host defaults and fallbacks in main.py (default --http-url of http://127.0.0.1:8080, UNITY_MCP_HTTP_URL/UNITY_MCP_HTTP_HOST, and the 127.0.0.1 fallback). Please add tests that:

  • Run the argument parser (or main) with no HTTP flags and assert host 127.0.0.1 and port 8080.
  • Set UNITY_MCP_HTTP_URL and/or UNITY_MCP_HTTP_HOST to localhost and verify they are honored without being rewritten.
  • Set only UNITY_MCP_HTTP_PORT and verify the host still falls back to 127.0.0.1.

This will lock in the behavior described in the PR and help detect regressions if defaults change again.

Suggested implementation:

        assert config.unity_host == "127.0.0.1"
        assert config.unity_port == 6400
        assert config.mcp_port == 6500
        assert config.connection_timeout == 30.0


def _parse_http_args(monkeypatch, env_vars, argv=None):
    """Helper to exercise the HTTP argument / env var handling in main.py.

    This uses the same argument parser as `main()` so that defaults and
    fallbacks are exercised exactly as in production.
    """
    from Server import main as server_main

    # Clear any pre-existing env that could affect the parser.
    for key in ("UNITY_MCP_HTTP_URL", "UNITY_MCP_HTTP_HOST", "UNITY_MCP_HTTP_PORT"):
        monkeypatch.delenv(key, raising=False)

    # Apply the env vars for this scenario.
    for key, value in env_vars.items():
        monkeypatch.setenv(key, value)

    # The parser is expected to match what `main()` uses.
    if hasattr(server_main, "build_argument_parser"):
        parser = server_main.build_argument_parser()
    else:
        # Fall back to a module-level parser if present.
        parser = server_main.PARSER  # type: ignore[attr-defined]

    args = parser.parse_args([] if argv is None else argv)

    # Some implementations derive http_host/port from http_url only.
    # Normalize them here so the tests are robust to that detail.
    http_host = getattr(args, "http_host", None)
    http_port = getattr(args, "http_port", None)

    http_url = getattr(args, "http_url", None)
    if http_url and (http_host is None or http_port is None):
        from urllib.parse import urlparse

        parsed = urlparse(http_url)
        if http_host is None:
            http_host = parsed.hostname
        if http_port is None:
            http_port = parsed.port

    return http_host, http_port


def test_http_defaults_no_flags_uses_127_0_0_1_8080(monkeypatch):
    """With no HTTP flags or env, default URL should be http://127.0.0.1:8080."""
    http_host, http_port = _parse_http_args(monkeypatch, env_vars={}, argv=None)

    assert http_host == "127.0.0.1"
    assert http_port == 8080


def test_http_env_url_and_host_localhost_are_honored(monkeypatch):
    """UNITY_MCP_HTTP_URL / UNITY_MCP_HTTP_HOST set to localhost should be honored."""
    http_host, http_port = _parse_http_args(
        monkeypatch,
        env_vars={
            "UNITY_MCP_HTTP_URL": "http://localhost:8080",
            "UNITY_MCP_HTTP_HOST": "localhost",
        },
        argv=None,
    )

    # The explicit localhost values must not be rewritten to 127.0.0.1
    assert http_host == "localhost"
    assert http_port == 8080


def test_http_env_only_port_falls_back_to_127_0_0_1(monkeypatch):
    """UNITY_MCP_HTTP_PORT alone should keep the host fallback of 127.0.0.1."""
    http_host, http_port = _parse_http_args(
        monkeypatch,
        env_vars={"UNITY_MCP_HTTP_PORT": "9090"},
        argv=None,
    )

    assert http_host == "127.0.0.1"
    assert http_port == 9090

To make these tests pass, ensure the following in Server/main.py (or adjust the test helper accordingly):

  1. There is an argument parser used by main() that supports the HTTP options and env defaults, exposed either as:
    • build_argument_parser() returning an argparse.ArgumentParser, or
    • a module-level PARSER object.
  2. The parser must:
    • Default to http_url="http://127.0.0.1:8080" (or equivalent http_host="127.0.0.1", http_port=8080) when no HTTP args/env are provided.
    • Respect UNITY_MCP_HTTP_URL and UNITY_MCP_HTTP_HOST when they are set to localhost without rewriting them to 127.0.0.1.
    • When only UNITY_MCP_HTTP_PORT is set, keep/fall back to a host of 127.0.0.1.
  3. If your parser uses different attribute names than http_url, http_host, and http_port, update _parse_http_args to map to the actual attribute names.

Address review feedback:
- Split bind-only fallback so 0.0.0.0 maps to 127.0.0.1 and :: maps
  to ::1, preserving address family for IPv6-only setups
- Add tests for HTTP host/URL defaults and env var overrides in main.py
- Fix remaining localhost reference in epilog help text

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@whatevertogo
Copy link
Contributor

I'd like to suggest #687 as an alternative approach.

Main difference: instead of changing defaults everywhere, I added a HostAddress helper that centralizes the logic
currently scattered across ServerManagementService, WebSocketTransportClient, and HttpEndpointUtility. It also
provides an IPv6 fallback toggle for IPv6-only networks, and aligns Python/C# behavior (domain symmetry pattern).

Why #687:

  1. Easier maintenance - future host-related changes only touch one place
  2. IPv6 fallback toggle - helps users on IPv6-only networks without hurting Windows users
  3. Domain symmetry - Python (_get_default_unity_host()) and C# (HostAddress.DefaultHost) share the same
    platform-aware policy
  4. 30 comprehensive tests

Re: main.py HTTP defaults — Unity explicitly passes --http-url when launching the server (via platform-aware
HttpEndpointUtility.GetLocalBaseUrl()), so the argparse defaults are only used for manual terminal launches. I kept
them as localhost since that's more conventional for standalone CLI tools.

Ultimately, you know the project best — I trust your judgment on which approach fits better here.

On Windows, domain reload orphans the OS socket (managed C# reference is
lost) but ExclusiveAddressUse=false + ReuseAddress=true allowed new
listeners to bind alongside the orphaned one. Incoming connections then
round-robin between live and dead listeners, causing WELCOME hangs.

Changes:
- Register AssemblyReloadEvents.beforeAssemblyReload to call Stop()
  before managed state is destroyed (root cause fix)
- Remove ExclusiveAddressUse=false on Windows
- Restrict ReuseAddress=true to macOS only (where it is needed)
- Add listener.Server.Dispose() in Stop() for immediate socket release
- Increase listener task wait from 100ms to 2000ms
- Widen retry window (10 attempts, 200ms) for post-reload rebind

Addresses CoplayDev#692

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In `@MCPForUnity/Editor/Services/Transport/Transports/StdioBridgeHost.cs`:
- Line 131: StdioBridgeHost and StdioBridgeReloadHandler both subscribe to
AssemblyReloadEvents.beforeAssemblyReload causing a race where
StdioBridgeHost.OnBeforeAssemblyReload calls Stop() (clearing isRunning) before
StdioBridgeReloadHandler.OnBeforeAssemblyReload checks StdioBridgeHost.IsRunning
and thus prevents setting EditorPrefs to resume; fix by removing the
beforeAssemblyReload subscription from StdioBridgeHost (do not add
AssemblyReloadEvents.beforeAssemblyReload += OnBeforeAssemblyReload in
StdioBridgeHost), and instead move any socket disposal logic into
StdioBridgeReloadHandler.OnBeforeAssemblyReload after it invokes its stop logic,
or expose a dedicated socket disposal method on StdioBridgeHost (e.g.,
DisposeSockets) and call that from StdioBridgeReloadHandler so only the reload
handler controls assembly-reload sequencing and restart preference updates.
🧹 Nitpick comments (2)
MCPForUnity/Editor/Services/Transport/Transports/StdioBridgeHost.cs (2)

270-271: Increased retry budget blocks the main thread for up to 2 seconds.

maxImmediateRetries=10 × retrySleepMs=200 = 2 s of Thread.Sleep on the main thread before falling back to port discovery. The previous values (3 × 75 ms ≈ 225 ms) were much lighter. This is acceptable if the goal is to survive longer socket linger times on Windows, but worth noting as a UX trade-off during editor startup.


420-420: Wait timeout increased from 100 ms to 2000 ms — verify this won't stall the editor on quit.

Stop() is called from EditorApplication.quitting and now from OnBeforeAssemblyReload. A 2-second synchronous Task.Wait on the main thread during these callbacks could cause a noticeable pause. This is likely fine for quitting, but during assembly reload it compounds with the StdioBridgeReloadHandler's own 500 ms wait.

}
}
EditorApplication.quitting += Stop;
AssemblyReloadEvents.beforeAssemblyReload += OnBeforeAssemblyReload;
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

# Search for StdioBridgeReloadHandler and its beforeAssemblyReload subscription
fd -t f "StdioBridgeReloadHandler" --extension cs

Repository: CoplayDev/unity-mcp

Length of output: 118


🏁 Script executed:

# Search for all beforeAssemblyReload subscriptions in the codebase
rg "beforeAssemblyReload" --type cs -B2 -A2

Repository: CoplayDev/unity-mcp

Length of output: 2088


🏁 Script executed:

# Check the Stop() method and IsRunning property in StdioBridgeHost
rg -n "public.*Stop\(\)|IsRunning|void Stop" MCPForUnity/Editor/Services/Transport/Transports/StdioBridgeHost.cs -A5

Repository: CoplayDev/unity-mcp

Length of output: 503


🏁 Script executed:

# Look for the status file deletion and understanding the flow
rg -n "statusFile|Delete\|Remove" MCPForUnity/Editor/Services/Transport/Transports/StdioBridgeHost.cs -B2 -A2

Repository: CoplayDev/unity-mcp

Length of output: 593


🏁 Script executed:

# Read StdioBridgeReloadHandler.cs to see the OnBeforeAssemblyReload implementation
cat MCPForUnity/Editor/Services/StdioBridgeReloadHandler.cs

Repository: CoplayDev/unity-mcp

Length of output: 4817


🏁 Script executed:

# Read the new OnBeforeAssemblyReload method and the Stop() method in full from StdioBridgeHost.cs
sed -n '376,395p' MCPForUnity/Editor/Services/Transport/Transports/StdioBridgeHost.cs
sed -n '448,454p' MCPForUnity/Editor/Services/Transport/Transports/StdioBridgeHost.cs

Repository: CoplayDev/unity-mcp

Length of output: 958


Duplicate beforeAssemblyReload handler creates a race condition with StdioBridgeReloadHandler.

Both StdioBridgeReloadHandler (in StdioBridgeReloadHandler.cs) and the new handler in StdioBridgeHost subscribe to the same event via [InitializeOnLoad] static constructors. Unity does not guarantee invocation order across different static initializers. If StdioBridgeHost.OnBeforeAssemblyReload() runs first, it calls Stop() which sets isRunning = false and deletes the status file. When StdioBridgeReloadHandler.OnBeforeAssemblyReload() then runs, it checks StdioBridgeHost.IsRunning (line 34 in StdioBridgeReloadHandler.cs), finds it false, and skips the EditorPrefs.SetBool(EditorPrefKeys.ResumeStdioAfterReload, true) call — so the bridge will not auto-restart after the domain reload.

The socket disposal concern (preventing orphaned OS listeners) is valid, but it should not be implemented as a separate handler on the same event. Instead, move socket disposal into StdioBridgeReloadHandler.OnBeforeAssemblyReload() after its own stop logic completes, or call a dedicated socket disposal method from the reload handler.

🤖 Prompt for AI Agents
In `@MCPForUnity/Editor/Services/Transport/Transports/StdioBridgeHost.cs` at line
131, StdioBridgeHost and StdioBridgeReloadHandler both subscribe to
AssemblyReloadEvents.beforeAssemblyReload causing a race where
StdioBridgeHost.OnBeforeAssemblyReload calls Stop() (clearing isRunning) before
StdioBridgeReloadHandler.OnBeforeAssemblyReload checks StdioBridgeHost.IsRunning
and thus prevents setting EditorPrefs to resume; fix by removing the
beforeAssemblyReload subscription from StdioBridgeHost (do not add
AssemblyReloadEvents.beforeAssemblyReload += OnBeforeAssemblyReload in
StdioBridgeHost), and instead move any socket disposal logic into
StdioBridgeReloadHandler.OnBeforeAssemblyReload after it invokes its stop logic,
or expose a dedicated socket disposal method on StdioBridgeHost (e.g.,
DisposeSockets) and call that from StdioBridgeReloadHandler so only the reload
handler controls assembly-reload sequencing and restart preference updates.

@dsarno dsarno changed the title fix: use 127.0.0.1 instead of localhost to avoid IPv4/IPv6 ambiguity on Windows fix: harden localhost resolution and reload transport resilience on Windows Feb 7, 2026
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