Зависание 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")
- Запуск по
stdio → spawn_worker возвращает TIMEOUT (HANG); воркер застрял в Py_InitializeFromConfig.
- Запуск по
streamable-http → spawn_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
- Поддержать
mcp 2.x (снять ограничение mcp<2) — тогда фикс #3117 уберёт зависание нативно, без обходного патча.
- Либо зафиксировать
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)
Либо:
- Отвязать/перенаправить stdio sandbox-воркера при спавне (например, NUL-хэндлы через
STARTF_USESTDHANDLES), либо
- Обновить
mcp до 2.0.0 (нативное исправление stdio_server()).
Примечания
- Не связано с venv vs глобальной установкой (воспроизводится и на базовом Python).
- Не связано с
PYTHONLEGACYWINDOWSSTDIO (не помогает).
- Std-хэндлы воркера в PEB изначально читались как
0x0, но это была ошибка layout-структуры в диагностике; на самом деле воркер наследует хэндлы родителя.
Зависание sandbox-воркера в
Py_InitializeFromConfigпри MCP stdio-транспорте на Windows (Python 3.14)Потратил несколько часов, пытаясь заставить работать рлм через stdio на пару с ИИ. Нашли причину, пофиксили, а потом уже обнаружили что это бага MCP уже известна, но поправили только в 2.0. Поэтому сделали такой отчет.
Окружение
RLM_SANDBOX_MODE=processstdio(MCP)Суть проблемы
rlm_startзависает в process-режиме песочницы, когда сервер работает по MCP stdio-транспорту. Sandbox-воркер спавнится черезmultiprocessingspawn, но никогда не присылаетinit_ok. Тот же сервер +RLM_SANDBOX_MODE=processпо HTTP-транспорту (streamable-http) работает нормально (воркер стартует за ~1.2s). То есть зависание специфично именно для stdio-транспорта.Симптом
rlm_startне возвращается (таймаут поRLM_SANDBOX_START_TIMEOUT_SECONDS).py-spy dump --nativeна воркере показывает блокировку в инициализации интерпретатора, до выполнения любого кода воркера (нетsitecustomize, нет кода модуля):sandbox_worker_main/_detach_stdio.Причина (гипотеза, подтверждена минимальным репро)
mcp.server.stdio.stdio_server()оборачиваетsys.stdin.buffer/sys.stdout.bufferв новыеTextIOWrapperи запускает поверх них anyio-задачи чтения/записи:Когда сервер затем спавнит sandbox-воркера через
multiprocessingspawn, воркер наследует активные std-хэндлы родителя (стандартные хэндлы наследуются даже приbInheritHandles=False, если они валидны и наследуемы). При инициализации интерпретатора воркер вызываетfflush(NULL)(«сбросить все буферы вывода»), который блокируется на унаследованном хэндле, активно используемом stdio-транспортом родителя.HTTP-транспорт не оборачивает std-буферы, поэтому воркер наследует «спокойные» хэндлы и инициализируется нормально.
Минимальное воспроизведение
Stdio FastMCP-сервер с одним инструментом, который спавнит
multiprocessing-воркера:stdio→spawn_workerвозвращаетTIMEOUT (HANG); воркер застрял вPy_InitializeFromConfig.streamable-http→spawn_workerвозвращаетready: {'ready': True}за ~0.6s.Известный upstream-баг (важно!)
Этот баг уже зафиксирован в MCP python-SDK:
bInheritHandles=FALSE».mcp2.0.0:stdio_server()теперь обслуживает протокол с приватных дубликатов fd 0/1 и перенаправляет fd 0 → null, fd 1 → stderr на время сессии, так что дети наследуют «отводы», а не протокольные pipe.mcp<2, поэтому он использует багованныйmcp1.29.0; фикс есть только вmcp2.0.0.Что нужно от rlm-tools-bsl
mcp2.x (снять ограничениеmcp<2) — тогда фикс #3117 уберёт зависание нативно, без обходного патча.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) — либо один раз на уровне модуля после импортов.После этого
rlm_startработает в process-режиме по stdio (~1.5s) без правок транспорта.Предлагаемое исправление (если это делается на стороне rlm)
Либо:
STARTF_USESTDHANDLES), либоmcpдо 2.0.0 (нативное исправлениеstdio_server()).Примечания
PYTHONLEGACYWINDOWSSTDIO(не помогает).0x0, но это была ошибка layout-структуры в диагностике; на самом деле воркер наследует хэндлы родителя.