fix(plugin): honor display_plugin_debug_url port for managed Runtime (#2782)

Stdio and Windows launches always bound the SDK default debug port 5401,
so changing plugin.display_plugin_debug_url only updated the console URL
and a second Core instance hit Address already in use. Pass the configured
debug (and Windows control) ports into the Runtime CLI, and document the
behavior.

Fixes #2713

Co-authored-by: Li-john1021 <[email protected]>
This commit is contained in:
Li-john1021
2026-10-27 20:56:26 +08:00
committed by GitHub
co-authored by Li-john1021
parent 12debe2747
commit bd4e408d7e
5 changed files with 222 additions and 9 deletions
+1 -1
View File
@@ -111,7 +111,7 @@ You need to first deploy and start LangBot, ensuring that Plugin Runtime is runn
<Info title="Runtime has two startup modes:">
- `Stdio` mode: When you start LangBot using source code without the startup parameter `--standalone-runtime`, LangBot will automatically start Plugin Runtime as a subprocess and communicate with Plugin Runtime through `stdio` (standard input/output streams). In this case, Runtime will load plugins from the `data/plugins` directory in the LangBot root directory and listen on port `5401` of the LangBot host as a debug port.
- `Stdio` mode: When you start LangBot using source code without the startup parameter `--standalone-runtime`, LangBot will automatically start Plugin Runtime as a subprocess and communicate with Plugin Runtime through `stdio` (standard input/output streams). In this case, Runtime will load plugins from the `data/plugins` directory in the LangBot root directory and listen on the LangBot host debug port (default `5401`, overridable via the port in `plugin.display_plugin_debug_url`).
- `WebSocket` mode:
- Production environment: When you start LangBot using the official `docker-compose.yaml`, Plugin Runtime will run in a separate container, and LangBot will communicate with Plugin Runtime in WebSocket mode due to the startup parameter `--standalone-runtime`. The 5401 port of the Runtime container will be mapped to the 5401 port of the host as default.
+1 -1
View File
@@ -111,7 +111,7 @@ cd HelloPlugin
<Info title="Runtime 有两种启动方式:">
- `Stdio` 模式:当您使用源代码启动 LangBot 并未携带启动参数`--standalone-runtime`时,LangBot 会自动以子进程的形式启动 Plugin Runtime,并通过 `stdio`(标准输入输出流)模式与 Plugin Runtime 通信。此时 Runtime 会加载 LangBot 根目录的`data/plugins`目录下的插件。并监听 LangBot 运行主机的`5401`端口作为调试端口。
- `Stdio` 模式:当您使用源代码启动 LangBot 并未携带启动参数`--standalone-runtime`时,LangBot 会自动以子进程的形式启动 Plugin Runtime,并通过 `stdio`(标准输入输出流)模式与 Plugin Runtime 通信。此时 Runtime 会加载 LangBot 根目录的`data/plugins`目录下的插件。并监听 LangBot 运行主机上的调试端口(默认`5401`,可由配置项`plugin.display_plugin_debug_url`中的端口覆盖)。
- `WebSocket` 模式:
- 生产环境:当您使用官方提供的`docker-compose.yaml`启动 LangBot 时,会在独立的容器运行 Plugin Runtime,LangBot 也会因为携带启动参数`--standalone-runtime`而以 WebSocket 模式与 Plugin Runtime 通信。默认情况下,Runtime 容器的 5401 端口会被映射到宿主机的 5401 端口。
+49 -7
View File
@@ -1121,13 +1121,18 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
# a managed runtime and authenticate its WebSocket controller.
self.ap.logger.info('(windows) use cmd to launch plugin runtime and communicate via ws')
control_headers = self._control_headers(allow_generate=True)
control_port, debug_port = self._managed_runtime_ports()
await self._start_runtime_subprocess(
'-m',
'langbot_plugin.cli.__init__',
'rt',
'--ws-control-port',
str(control_port),
'--ws-debug-port',
str(debug_port),
env_overrides={PLUGIN_RUNTIME_CONTROL_TOKEN_ENV: self._control_token},
)
ws_url = 'ws://localhost:5400/control/ws'
ws_url = f'ws://localhost:{control_port}/control/ws'
async def connection_failed(
ctrl: ws_client_controller.WebSocketClientController,
@@ -1145,9 +1150,17 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
task_coro = self.ctrl.run(new_connection_callback)
else:
self.ap.logger.info('use stdio to connect to plugin runtime')
_, debug_port = self._managed_runtime_ports()
self.ctrl = stdio_client_controller.StdioClientController(
command=sys.executable,
args=['-m', 'langbot_plugin.cli.__init__', 'rt', '-s'],
args=[
'-m',
'langbot_plugin.cli.__init__',
'rt',
'-s',
'--ws-debug-port',
str(debug_port),
],
env=os.environ.copy(),
capture_stderr=False,
)
@@ -1235,14 +1248,43 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
await self.runtime_ops_reporter.stop()
await self._close_managed_subprocess()
@staticmethod
def _runtime_port_from_url(url: str, *, default: int) -> int:
"""Extract a TCP port from a WebSocket/HTTP URL, falling back to *default*."""
try:
parsed = urlparse(url if '://' in url else f'//{url}')
return parsed.port or default
except (TypeError, ValueError):
return default
@staticmethod
def _runtime_debug_port_from_url(debug_url: str) -> int:
"""Extract the local plugin runtime debug port from its display URL."""
try:
parsed = urlparse(debug_url if '://' in debug_url else f'//{debug_url}')
return parsed.port or 5401
except (TypeError, ValueError):
return 5401
return PluginRuntimeConnector._runtime_port_from_url(debug_url, default=5401)
@staticmethod
def _runtime_control_port_from_url(control_url: str) -> int:
"""Extract the local plugin runtime control port from its WebSocket URL."""
return PluginRuntimeConnector._runtime_port_from_url(control_url, default=5400)
def _managed_runtime_ports(self) -> tuple[int, int]:
"""Resolve control/debug ports for a Core-managed Runtime subprocess.
Docker/standalone deployments keep listening on their own configured
ports; this helper only affects the stdio and Windows launch paths,
where LangBot starts Runtime itself. The debug port follows
``plugin.display_plugin_debug_url`` so a second Core instance can avoid
``Address already in use`` on the default 5401 without editing CLI
flags by hand. The control port follows ``plugin.runtime_ws_url`` for
the Windows WebSocket path.
"""
plugin_config = self.ap.instance_config.data.get('plugin') or {}
control_url = plugin_config.get('runtime_ws_url') or 'ws://localhost:5400/control/ws'
debug_url = plugin_config.get('display_plugin_debug_url') or 'ws://localhost:5401/plugin/debug/ws'
return (
self._runtime_control_port_from_url(str(control_url)),
self._runtime_debug_port_from_url(str(debug_url)),
)
async def initialize_plugins(self):
pass
+3
View File
@@ -289,6 +289,9 @@ plugin:
connect_timeout_seconds: 180.0
runtime_ws_url: 'ws://langbot_plugin_runtime:5400/control/ws'
enable_marketplace: true
# Console debug endpoint; its port is also passed to managed Runtime
# subprocesses (stdio / Windows) as --ws-debug-port so a second Core
# instance can listen elsewhere without Address already in use.
display_plugin_debug_url: 'ws://localhost:5401/plugin/debug/ws'
certification:
# Non-secret Ed25519 issuer key ring used to verify the SDK ZIP-comment
@@ -460,3 +460,171 @@ async def test_explicit_cloud_binding_is_revalidated_against_projection():
expected_generation=7,
)
connector._synchronize_workspace.assert_awaited_once_with(configured)
def test_runtime_debug_port_from_url_defaults_and_parses():
assert PluginRuntimeConnector._runtime_debug_port_from_url('') == 5401
assert PluginRuntimeConnector._runtime_debug_port_from_url('ws://localhost:5411/plugin/debug/ws') == 5411
assert PluginRuntimeConnector._runtime_debug_port_from_url('localhost:5412/plugin/debug/ws') == 5412
assert PluginRuntimeConnector._runtime_control_port_from_url('ws://langbot_plugin_runtime:5500/control/ws') == 5500
assert PluginRuntimeConnector._runtime_control_port_from_url('') == 5400
def test_managed_runtime_ports_follow_plugin_config_urls():
connector = make_connector()
connector.ap.instance_config.data['plugin']['runtime_ws_url'] = 'ws://127.0.0.1:5500/control/ws'
connector.ap.instance_config.data['plugin']['display_plugin_debug_url'] = 'ws://127.0.0.1:5411/plugin/debug/ws'
assert connector._managed_runtime_ports() == (5500, 5411)
def test_managed_runtime_ports_default_when_urls_empty():
connector = make_connector()
connector.ap.instance_config.data['plugin']['runtime_ws_url'] = ''
connector.ap.instance_config.data['plugin']['display_plugin_debug_url'] = ''
assert connector._managed_runtime_ports() == (5400, 5401)
@pytest.mark.asyncio
async def test_stdio_runtime_passes_debug_port_from_display_url(
monkeypatch: pytest.MonkeyPatch,
):
connector = make_connector()
connector.ap.instance_config.data['plugin']['display_plugin_debug_url'] = 'ws://localhost:5411/plugin/debug/ws'
connector._prepare_connected_runtime = AsyncMock()
created = {}
monkeypatch.setattr(connector_module.constants, 'instance_id', 'instance-a')
class FakeRuntimeHandler:
def __init__(self, connection, disconnect_callback, ap):
self.release = asyncio.Event()
async def ping(self):
return None
async def set_runtime_config(self, **kwargs):
return None
async def run(self):
await self.release.wait()
async def close(self):
self.release.set()
class FakeController:
def __init__(self, **kwargs):
created.update(kwargs)
async def run(self, callback):
await callback(object())
async def close(self):
return None
monkeypatch.setattr(connector_module.platform, 'get_platform', lambda: 'linux')
monkeypatch.setattr(
connector_module.platform,
'use_websocket_to_connect_plugin_runtime',
lambda: False,
)
monkeypatch.setattr(
connector_module.handler,
'RuntimeConnectionHandler',
FakeRuntimeHandler,
)
monkeypatch.setattr(
connector_module.stdio_client_controller,
'StdioClientController',
FakeController,
)
await connector.initialize()
assert created['args'] == [
'-m',
'langbot_plugin.cli.__init__',
'rt',
'-s',
'--ws-debug-port',
'5411',
]
assert connector._connected.is_set()
await connector.aclose()
@pytest.mark.asyncio
async def test_windows_runtime_passes_control_and_debug_ports(
monkeypatch: pytest.MonkeyPatch,
):
connector = make_connector()
connector.ap.instance_config.data['plugin']['runtime_ws_url'] = 'ws://localhost:5500/control/ws'
connector.ap.instance_config.data['plugin']['display_plugin_debug_url'] = 'ws://localhost:5411/plugin/debug/ws'
connector._prepare_connected_runtime = AsyncMock()
start_args: list[object] = []
created = {}
monkeypatch.setattr(connector_module.constants, 'instance_id', 'instance-a')
class FakeRuntimeHandler:
def __init__(self, connection, disconnect_callback, ap):
self.release = asyncio.Event()
async def ping(self):
return None
async def set_runtime_config(self, **kwargs):
return None
async def run(self):
await self.release.wait()
async def close(self):
self.release.set()
class FakeController:
def __init__(self, **kwargs):
created.update(kwargs)
async def run(self, callback):
await callback(object())
async def close(self):
return None
async def fake_start(*args, env_overrides=None):
start_args.extend(args)
start_args.append(env_overrides)
monkeypatch.setattr(connector_module.platform, 'get_platform', lambda: 'win32')
monkeypatch.setattr(
connector_module.platform,
'use_websocket_to_connect_plugin_runtime',
lambda: False,
)
monkeypatch.setattr(
connector_module.handler,
'RuntimeConnectionHandler',
FakeRuntimeHandler,
)
monkeypatch.setattr(
connector_module.ws_client_controller,
'WebSocketClientController',
FakeController,
)
monkeypatch.setattr(connector, '_start_runtime_subprocess', fake_start)
await connector.initialize()
assert start_args[:-1] == [
'-m',
'langbot_plugin.cli.__init__',
'rt',
'--ws-control-port',
'5500',
'--ws-debug-port',
'5411',
]
assert start_args[-1] == {PLUGIN_RUNTIME_CONTROL_TOKEN_ENV: connector._control_token}
assert created['ws_url'] == 'ws://localhost:5500/control/ws'
assert connector._connected.is_set()
await connector.aclose()