From bd4e408d7e9c83c63d82de4fa30bd278b6330ae4 Mon Sep 17 00:00:00 2001 From: Li-john1021 Date: Tue, 27 Oct 2026 20:56:26 +0800 Subject: [PATCH] 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 --- docs/en/plugin/dev/tutor.mdx | 2 +- docs/zh/plugin/dev/tutor.mdx | 2 +- src/langbot/pkg/plugin/connector.py | 56 +++++- src/langbot/templates/config.yaml | 3 + .../unit_tests/plugin/test_connector_ping.py | 168 ++++++++++++++++++ 5 files changed, 222 insertions(+), 9 deletions(-) diff --git a/docs/en/plugin/dev/tutor.mdx b/docs/en/plugin/dev/tutor.mdx index 9b8e26926..84626ba66 100644 --- a/docs/en/plugin/dev/tutor.mdx +++ b/docs/en/plugin/dev/tutor.mdx @@ -111,7 +111,7 @@ You need to first deploy and start LangBot, ensuring that Plugin Runtime is runn -- `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. diff --git a/docs/zh/plugin/dev/tutor.mdx b/docs/zh/plugin/dev/tutor.mdx index 089977061..612b7c4c0 100644 --- a/docs/zh/plugin/dev/tutor.mdx +++ b/docs/zh/plugin/dev/tutor.mdx @@ -111,7 +111,7 @@ cd HelloPlugin -- `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 端口。 diff --git a/src/langbot/pkg/plugin/connector.py b/src/langbot/pkg/plugin/connector.py index b880e86da..9a0b69886 100644 --- a/src/langbot/pkg/plugin/connector.py +++ b/src/langbot/pkg/plugin/connector.py @@ -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 diff --git a/src/langbot/templates/config.yaml b/src/langbot/templates/config.yaml index 9116b1040..34e219e5b 100644 --- a/src/langbot/templates/config.yaml +++ b/src/langbot/templates/config.yaml @@ -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 diff --git a/tests/unit_tests/plugin/test_connector_ping.py b/tests/unit_tests/plugin/test_connector_ping.py index 40d738936..8aa23e004 100644 --- a/tests/unit_tests/plugin/test_connector_ping.py +++ b/tests/unit_tests/plugin/test_connector_ping.py @@ -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()