Skip to content

Зависание sandbox-воркера в Py_InitializeFromConfig при MCP **stdio**-транспорте на Windows (Python 3.14) #25

Description

@EugeneVl

Зависание sandbox-воркера в Py_InitializeFromConfig при MCP stdio-транспорте на Windows (Python 3.14)

Потратил несколько часов, пытаясь заставить работать рлм через stdio на пару с ИИ. Нашли причину, пофиксили, а потом уже обнаружили что это бага MCP уже известна, но поправили только в 2.0. Поэтому сделали такой отчет.

Окружение

  • ОС: Windows (10/11), 64-bit
  • Python: 3.14.5
  • rlm-tools-bsl: 1.32.0 (воспроизводится и на 1.31.0)
  • mcp (Python SDK): 1.29.0
  • Режим песочницы: RLM_SANDBOX_MODE=process
  • Транспорт: stdio (MCP)

Суть проблемы

rlm_start зависает в process-режиме песочницы, когда сервер работает по MCP stdio-транспорту. Sandbox-воркер спавнится через multiprocessing spawn, но никогда не присылает init_ok. Тот же сервер + RLM_SANDBOX_MODE=process по HTTP-транспорту (streamable-http) работает нормально (воркер стартует за ~1.2s). То есть зависание специфично именно для stdio-транспорта.

Симптом

  • rlm_start не возвращается (таймаут по RLM_SANDBOX_START_TIMEOUT_SECONDS).
  • Процесс-воркер жив, но застрял.
  • py-spy dump --native на воркере показывает блокировку в инициализации интерпретатора, до выполнения любого кода воркера (нет sitecustomize, нет кода модуля):
    NtQueryInformationFile (ntdll.dll)
    SetFilePointerEx (KERNELBASE.dll)
    fflush_nolock (ucrtbase.dll)
    lseeki64 (ucrtbase.dll)
    ...
    Py_InitializeFromConfig (python314.dll)
    Py_Main (python314.dll)
    
  • Воркер не доходит до sandbox_worker_main / _detach_stdio.

Причина (гипотеза, подтверждена минимальным репро)

mcp.server.stdio.stdio_server() оборачивает sys.stdin.buffer / sys.stdout.buffer в новые TextIOWrapper и запускает поверх них anyio-задачи чтения/записи:

stdin  = anyio.wrap_file(TextIOWrapper(sys.stdin.buffer, encoding="utf-8", errors="replace"))
stdout = anyio.wrap_file(TextIOWrapper(sys.stdout.buffer, encoding="utf-8"))

Когда сервер затем спавнит sandbox-воркера через multiprocessing spawn, воркер наследует активные std-хэндлы родителя (стандартные хэндлы наследуются даже при bInheritHandles=False, если они валидны и наследуемы). При инициализации интерпретатора воркер вызывает fflush(NULL) («сбросить все буферы вывода»), который блокируется на унаследованном хэндле, активно используемом stdio-транспортом родителя.

HTTP-транспорт не оборачивает std-буферы, поэтому воркер наследует «спокойные» хэндлы и инициализируется нормально.

Минимальное воспроизведение

Stdio FastMCP-сервер с одним инструментом, который спавнит multiprocessing-воркера:

# server.py
import multiprocessing, time
from mcp.server.fastmcp import FastMCP

mcp = FastMCP("repro", stateless_http=True)

def worker_main(conn):
    conn.send({"ready": True})   # в stdio-режиме сюда не доходит
    time.sleep(60)

@mcp.tool()
def spawn_worker() -> str:
    ctx = multiprocessing.get_context("spawn")
    parent, child = ctx.Pipe(duplex=True)
    p = ctx.Process(target=worker_main, args=(child,), daemon=True)
    p.start()
    child.close()
    deadline = time.monotonic() + 30
    while time.monotonic() < deadline:
        if parent.poll(0.5):
            return f"ready: {parent.recv()}"
    return "TIMEOUT (HANG)"

if __name__ == "__main__":
    mcp.run(transport="stdio")
  • Запуск по stdiospawn_worker возвращает TIMEOUT (HANG); воркер застрял в Py_InitializeFromConfig.
  • Запуск по streamable-httpspawn_worker возвращает ready: {'ready': True} за ~0.6s.

Известный upstream-баг (важно!)

Этот баг уже зафиксирован в MCP python-SDK:

  • Issue: modelcontextprotocol/python-sdk#671 — MCP Tool hangs in stdio mode when spawning external Python.
  • Исправлен PR modelcontextprotocol/python-sdk#3117 (merged 25.07.2026).
  • Корень (цитата из PR): CPython gh-78961 — Windows сериализует операции на синхронном pipe; stdio-сервер всегда держит блокирующее чтение на stdin; Python-ребёнок, унаследовавший этот pipe, замерзает внутри старта интерпретатора, до первой строки кода. «Windows hands a console child the parent's standard handles even with bInheritHandles=FALSE».
  • Фикс в mcp 2.0.0: stdio_server() теперь обслуживает протокол с приватных дубликатов fd 0/1 и перенаправляет fd 0 → null, fd 1 → stderr на время сессии, так что дети наследуют «отводы», а не протокольные pipe.
  • Почему у нас баг: rlm-tools-bsl 1.32.0 требует mcp<2, поэтому он использует багованный mcp 1.29.0; фикс есть только в mcp 2.0.0.

Что нужно от rlm-tools-bsl

  1. Поддержать mcp 2.x (снять ограничение mcp<2) — тогда фикс #3117 уберёт зависание нативно, без обходного патча.
  2. Либо зафиксировать mcp версии с фиксом, если он будет бэкпортирован в 1.x.

Обходной путь для пакета (готовый патч для автора)

Минимальное исправление, которое можно внести в rlm-tools-bsl без перехода на mcp 2.x: дать спавнящемуся воркеру NUL-хэндлы через STARTF_USESTDHANDLES (аналог stdin=DEVNULL из обсуждения #671, но на все три потока). Воркер и так отвязывает свой stdio (_detach_stdio), поэтому NUL безвреден.

Файл: rlm_tools_bsl/sandbox_process.py
Метод: ProcessSandboxBackend._start_worker (в 1.32.0 — строка 641)
Куда вставить: в начало _start_worker, до ctx = multiprocessing.get_context("spawn") (строка ~651) — либо один раз на уровне модуля после импортов.

# Добавить в _start_worker перед spawn (или на уровне модуля):
import msvcrt
import _winapi
from subprocess import STARTF_FORCEOFFFEEDBACK, STARTF_USESTDHANDLES

nul_handle = msvcrt.get_osfhandle(os.open("NUL", os.O_RDWR))
_orig_create_process = _winapi.CreateProcess

def _create_process_nul_stdio(app, cmd, pa, ta, inherit, flags, env, cwd, si):
    # Срабатывает ТОЛЬКО на multiprocessing-спавн воркера (флаг STARTF_FORCEOFFFEEDBACK
    # + python.exe). git/subprocess-дети не затрагиваются.
    if si is not None and (si.dwFlags & STARTF_FORCEOFFFEEDBACK) and app and app.lower().endswith("python.exe"):
        si.dwFlags |= STARTF_USESTDHANDLES
        si.hStdInput = nul_handle
        si.hStdOutput = nul_handle
        si.hStdError = nul_handle
    return _orig_create_process(app, cmd, pa, ta, inherit, flags, env, cwd, si)

_winapi.CreateProcess = _create_process_nul_stdio

После этого rlm_start работает в process-режиме по stdio (~1.5s) без правок транспорта.

Предлагаемое исправление (если это делается на стороне rlm)

Либо:

  1. Отвязать/перенаправить stdio sandbox-воркера при спавне (например, NUL-хэндлы через STARTF_USESTDHANDLES), либо
  2. Обновить mcp до 2.0.0 (нативное исправление stdio_server()).

Примечания

  • Не связано с venv vs глобальной установкой (воспроизводится и на базовом Python).
  • Не связано с PYTHONLEGACYWINDOWSSTDIO (не помогает).
  • Std-хэндлы воркера в PEB изначально читались как 0x0, но это была ошибка layout-структуры в диагностике; на самом деле воркер наследует хэндлы родителя.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions