diff --git a/comfy_cli/cmdline.py b/comfy_cli/cmdline.py index 57c6f0d6..6bb9673c 100644 --- a/comfy_cli/cmdline.py +++ b/comfy_cli/cmdline.py @@ -1169,22 +1169,47 @@ def validate_comfyui(_env_checker): @app.command(help="Stop background ComfyUI") @tracking.track_command() def stop(): - if constants.CONFIG_KEY_BACKGROUND not in ConfigManager().config["DEFAULT"]: - rprint("[bold red]No ComfyUI is running in the background.[/bold red]\n") - raise typer.Exit(code=1) - - bg_info = ConfigManager().background + renderer = get_renderer() + config = ConfigManager() + bg_info = config.background if constants.CONFIG_KEY_BACKGROUND in config.config["DEFAULT"] else None if not bg_info: rprint("[bold red]No ComfyUI is running in the background.[/bold red]\n") + if renderer.is_json(): + renderer.error( + code="no_background_server", + message="No ComfyUI is running in the background.", + command="stop", + ) raise typer.Exit(code=1) + is_killed = utils.kill_all(bg_info[2]) if not is_killed: + # The kill failed (permissions, a race, or the process is still alive). + # Do NOT remove the background record — it is the only handle a retried + # `comfy stop` has to target this PID/host/port; dropping it here would + # orphan a live server. Surface a non-zero exit in BOTH pretty and JSON + # mode so `$?`-checking scripts see the failure (pretty mode otherwise + # printed the error yet returned 0). rprint("[bold red]Failed to stop ComfyUI in the background.[/bold red]\n") - else: - rprint(f"[bold yellow]Background ComfyUI is stopped.[/bold yellow] ({bg_info[0]}:{bg_info[1]})") + if renderer.is_json(): + renderer.error( + code="stop_failed", + message="Failed to stop ComfyUI in the background.", + command="stop", + details={"pid": bg_info[2]}, + ) + raise typer.Exit(code=1) + + rprint(f"[bold yellow]Background ComfyUI is stopped.[/bold yellow] ({bg_info[0]}:{bg_info[1]})") + config.remove_background() - ConfigManager().remove_background() + if renderer.is_json(): + renderer.emit( + {"stopped": True, "host": bg_info[0], "port": bg_info[1], "pid": bg_info[2]}, + command="stop", + changed=True, + ) @app.command(help="Launch ComfyUI: ?[--background] ?[-- ]") diff --git a/comfy_cli/command/launch.py b/comfy_cli/command/launch.py index 1f015367..3fb6c49b 100644 --- a/comfy_cli/command/launch.py +++ b/comfy_cli/command/launch.py @@ -16,7 +16,8 @@ from comfy_cli import constants, utils from comfy_cli.command.custom_nodes.cm_cli_util import find_cm_cli, resolve_manager_gui_mode from comfy_cli.config_manager import ConfigManager -from comfy_cli.env_checker import check_comfy_server_running +from comfy_cli.env_checker import _bracket_host, check_comfy_server_running +from comfy_cli.output import get_renderer from comfy_cli.output import rprint as print # context-aware print: stderr in JSON mode from comfy_cli.resolve_python import resolve_workspace_python from comfy_cli.workspace_manager import WorkspaceManager, WorkspaceType @@ -51,6 +52,38 @@ def _get_manager_flags() -> list[str]: return ["--enable-manager"] # fallback to default +def _format_url(listen, port) -> str: + """Build the ComfyUI ``http://host:port`` URL, bracketing IPv6 literals. + + An IPv6 ``--listen`` (e.g. ``::1`` or ``::``) must be wrapped in brackets or + the bare ``host:port`` join yields an invalid URL like ``http://::1:8188``. + Delegates the bracketing to the shared ``_bracket_host`` choke point. + """ + return f"http://{_bracket_host(str(listen))}:{port}" + + +def _emit_launch_success(listen, port, pid) -> None: + """Emit the background-launch success ``envelope/1`` for ``--json`` callers. + + Extracted from ``launch_and_monitor``'s success handler (which ``os._exit``s + immediately after) so the terminal success envelope is unit-testable. No-op + in pretty mode, keeping human output unchanged. + """ + renderer = get_renderer() + if renderer.is_json(): + renderer.emit( + { + "background": True, + "listen": listen, + "port": port, + "url": _format_url(listen, port), + "pid": pid, + }, + command="launch", + changed=True, + ) + + def launch_comfyui(extra, frontend_pr=None, python=sys.executable): reboot_path = None @@ -83,14 +116,38 @@ def launch_comfyui(extra, frontend_pr=None, python=sys.executable): if "COMFY_CLI_BACKGROUND" not in os.environ: # If not running in background mode, there's no need to use popen. This can prevent the issue of linefeeds occurring with tqdm. + # Under --json the child inherits stdout by default, so ComfyUI's raw + # non-JSON output would land on stdout ahead of our envelope and break + # the single-envelope-on-stdout contract for machine callers. Redirect + # the child's stdout to stderr in JSON mode; leave it inherited in pretty + # mode so humans still see ComfyUI's output on the terminal. + child_stdout = sys.stderr if get_renderer().is_json() else None while True: - res = subprocess.run([python, "main.py"] + extra, env=new_env, check=False) + res = subprocess.run([python, "main.py"] + extra, env=new_env, check=False, stdout=child_stdout) if reboot_path is None: print("[bold red]ComfyUI is not installed.[/bold red]\n") exit(res.returncode) if not os.path.exists(reboot_path): + # Foreground server exited (Ctrl-C, crash, or clean stop). Emit + # the lifecycle envelope so a `--json` caller gets a terminal + # verdict instead of nothing; no-op in pretty mode. + renderer = get_renderer() + if renderer.is_json(): + if res.returncode == 0: + renderer.emit( + {"background": False, "returncode": res.returncode}, + command="launch", + changed=True, + ) + else: + renderer.error( + code="launch_failed", + message=f"ComfyUI exited with code {res.returncode}", + command="launch", + details={"returncode": res.returncode}, + ) exit(res.returncode) os.remove(reboot_path) @@ -159,6 +216,14 @@ def launch( "\nComfyUI is not available.\nTo install ComfyUI, you can run:\n\n\tcomfy install\n\n", file=sys.stderr, ) + renderer = get_renderer() + if renderer.is_json(): + renderer.error( + code="not_in_workspace", + message="ComfyUI is not available.", + hint="run `comfy install`, or pass `--workspace`", + command="launch", + ) raise typer.Exit(code=1) if (extra is None or len(extra) == 0) and workspace_manager.workspace_type == WorkspaceType.DEFAULT: @@ -189,39 +254,73 @@ def launch( def background_launch(extra, frontend_pr=None): + renderer = get_renderer() config_background = ConfigManager().background if config_background is not None and utils.is_running(config_background[2]): print( "[bold red]ComfyUI is already running in background.\nYou cannot start more than one background service.[/bold red]\n" ) + if renderer.is_json(): + renderer.error( + code="server_already_running", + message="ComfyUI is already running in background.", + hint="run `comfy stop` before launching another background service", + command="launch", + ) raise typer.Exit(code=1) port = 8188 listen = "127.0.0.1" if extra is not None: - for i in range(len(extra) - 1): - if extra[i] == "--port": + # Accept both the two-token (``--port 9000``) and the ``--port=9000`` + # forms; the latter is common and was previously ignored, leaving port + # at the 8188 default and silently disagreeing with what ComfyUI binds. + for i, tok in enumerate(extra): + if tok == "--port" and i + 1 < len(extra): port = extra[i + 1] - if extra[i] == "--listen": + elif tok.startswith("--port="): + port = tok[len("--port=") :] + elif tok == "--listen" and i + 1 < len(extra): listen = extra[i + 1] + elif tok.startswith("--listen="): + listen = tok[len("--listen=") :] if len(extra) > 0: extra = ["--"] + extra else: extra = [] - # Validate --port as an integer. It flows into the log path - # (``comfyui_.log``); a non-integer value like ``../../etc/x`` would - # otherwise escape the workspace when the logfile is created. + # Validate --port as an integer in the valid TCP range. It flows into the log + # path (``comfyui_.log``); a non-integer value like ``../../etc/x`` + # would otherwise escape the workspace when the logfile is created, and an + # out-of-range value (e.g. -1 or 70000) would degrade to a generic + # launch_failed instead of this precise port_invalid verdict. try: - port = int(port) + port_int = int(port) except (TypeError, ValueError): - print(f"[bold red]Invalid --port value {port!r}; expected an integer.[/bold red]\n") + port_int = None + if port_int is None or not (1 <= port_int <= 65535): + print(f"[bold red]Invalid --port value {port!r}; expected an integer in 1-65535.[/bold red]\n") + if renderer.is_json(): + renderer.error( + code="port_invalid", + message=f"Invalid --port value {port!r}; expected an integer in 1-65535.", + command="launch", + details={"port": port}, + ) raise typer.Exit(code=1) + port = port_int if check_comfy_server_running(port): print(f"[bold red]The {port} port is already in use. A new ComfyUI server cannot be launched.\n[bold red]\n") + if renderer.is_json(): + renderer.error( + code="port_in_use", + message=f"The {port} port is already in use. A new ComfyUI server cannot be launched.", + command="launch", + details={"port": port}, + ) raise typer.Exit(code=1) cmd = [ @@ -238,6 +337,8 @@ def background_launch(extra, frontend_pr=None): log = asyncio.run(launch_and_monitor(cmd, listen, port)) + # Reaching here means the monitor returned without seeing the success line + # (the success path emits its envelope and os._exit(0)s inside the monitor). if log is not None: print( Panel( @@ -248,6 +349,13 @@ def background_launch(extra, frontend_pr=None): ) print("\n[bold red]Execution error: failed to launch ComfyUI[/bold red]\n") + if renderer.is_json(): + renderer.error( + code="launch_failed", + message="Execution error: failed to launch ComfyUI", + command="launch", + details={"log": _bounded_log(log)} if log else None, + ) # NOTE: os.exit(0) doesn't work os._exit(1) @@ -318,6 +426,14 @@ async def launch_and_monitor(cmd, listen, port): logfh = _open_log_for_write(log_path) except OSError as e: print(f"[bold red]Could not open background log file {log_path}: {e}[/bold red]\n") + renderer = get_renderer() + if renderer.is_json(): + renderer.error( + code="launch_failed", + message=f"Could not open background log file {log_path}: {e}", + command="launch", + details={"log_path": log_path}, + ) os._exit(1) # Record the log path up front so `comfy logs` can surface a crash log even @@ -356,7 +472,7 @@ def _handle(line): logging_flag = True if "To see the GUI go to:" in line: print( - f"[bold yellow]ComfyUI is successfully launched in the background.[/bold yellow]\nTo see the GUI go to: http://{listen}:{port}" + f"[bold yellow]ComfyUI is successfully launched in the background.[/bold yellow]\nTo see the GUI go to: {_format_url(listen, port)}" ) # CONFIG_KEY_BACKGROUND_LOG was already recorded before launch; here # we add the running background info now that startup succeeded. @@ -365,6 +481,8 @@ def _handle(line): cfg.config["DEFAULT"][constants.CONFIG_KEY_BACKGROUND_LOG] = log_path cfg.write_config() + _emit_launch_success(listen, port, process.pid) + # NOTE: os.exit(0) doesn't work. os._exit(0) if logging_flag: @@ -445,6 +563,23 @@ def read_log_tail( return lines, truncated +def _bounded_log(lines, *, max_lines: int = LOGS_MAX_LINES, max_bytes: int = LOGS_MAX_BYTES) -> str: + """Join an in-memory captured log (list of lines) for a JSON envelope, + applying the same ``LOGS_MAX_LINES``/``LOGS_MAX_BYTES`` caps as + ``read_log_tail``. A verbose failing launch can otherwise produce an + unbounded JSON payload (and leak any secrets present in the log) to machine + consumers. Keeps the last ``max_lines`` lines, then trims from the top until + within ``max_bytes``. + """ + tail = list(lines)[-max_lines:] + size = sum(len(s.encode("utf-8")) for s in tail) + while len(tail) > 1 and size > max_bytes: + size -= len(tail.pop(0).encode("utf-8")) + if tail and size > max_bytes: + tail[-1] = tail[-1].encode("utf-8")[-max_bytes:].decode("utf-8", errors="replace") + return "".join(tail) + + def resolve_background_log_path() -> str | None: """Locate the background logfile: the path recorded at launch, else the default derived from the resolved workspace and background/default port. @@ -467,7 +602,6 @@ def resolve_background_log_path() -> str | None: def logs(tail: int = 200, where: str | None = None): """Print the tail of the background ComfyUI log captured by `comfy launch`.""" from comfy_cli import where as where_mod - from comfy_cli.output import get_renderer renderer = get_renderer() diff --git a/comfy_cli/discovery.py b/comfy_cli/discovery.py index 8c6b2a44..d0aa9389 100644 --- a/comfy_cli/discovery.py +++ b/comfy_cli/discovery.py @@ -87,6 +87,9 @@ "comfy templates show": "templates", "comfy templates fetch": "templates", "comfy templates refresh": "templates", + # lifecycle + "comfy launch": "launch", + "comfy stop": "stop", # file transfer "comfy upload": "transfer", "comfy download": "transfer", diff --git a/comfy_cli/error_codes.py b/comfy_cli/error_codes.py index f0729744..10c878d2 100644 --- a/comfy_cli/error_codes.py +++ b/comfy_cli/error_codes.py @@ -47,6 +47,38 @@ class ErrorCode: "Resolved no workspace where one was required (e.g. `comfy which`).", "run `comfy install`, or pass `--workspace`", ), + # --- launch / stop lifecycle --------------------------------------------- + ErrorCode( + "server_already_running", + "`comfy launch --background` found a background ComfyUI already running.", + "run `comfy stop` before launching another background service", + ), + ErrorCode( + "port_invalid", + "`comfy launch --background` got a non-integer `--port`. `details.port` carries the offending value.", + "pass an integer `--port` (e.g. `--port 8188`)", + ), + ErrorCode( + "port_in_use", + "`comfy launch --background` found the target port already in use. `details.port` carries the port.", + "stop the process on that port or pass a different `--port`", + ), + ErrorCode( + "launch_failed", + "ComfyUI failed to launch (background monitor saw no success line) or a " + "foreground launch exited non-zero. `details` carries the log / returncode.", + "check the error log for the underlying failure", + ), + ErrorCode( + "no_background_server", + "`comfy stop` found no background ComfyUI recorded as running.", + "run `comfy launch --background` first", + ), + ErrorCode( + "stop_failed", + "`comfy stop` could not kill the recorded background ComfyUI process. `details.pid` carries the process id.", + "kill the process manually if it is still running", + ), # --- workflow loading ---------------------------------------------------- ErrorCode( "workflow_not_found", diff --git a/comfy_cli/schemas/launch.json b/comfy_cli/schemas/launch.json new file mode 100644 index 00000000..c4da7705 --- /dev/null +++ b/comfy_cli/schemas/launch.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "comfy launch", + "description": "Output shape for the launch command's success envelope. Background launches carry the server address; a foreground launch reports the subprocess return code on exit.", + "type": "object", + "properties": { + "background": { "type": "boolean" }, + "listen": { "type": "string" }, + "port": { "type": ["integer", "string"] }, + "url": { "type": "string" }, + "pid": { "type": "integer" }, + "returncode": { "type": "integer" } + }, + "required": ["background"] +} diff --git a/comfy_cli/schemas/stop.json b/comfy_cli/schemas/stop.json new file mode 100644 index 00000000..60895b18 --- /dev/null +++ b/comfy_cli/schemas/stop.json @@ -0,0 +1,13 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "comfy stop", + "description": "Output shape for the stop command's success envelope.", + "type": "object", + "properties": { + "stopped": { "type": "boolean" }, + "host": { "type": "string" }, + "port": { "type": ["integer", "string"] }, + "pid": { "type": "integer" } + }, + "required": ["stopped"] +} diff --git a/tests/comfy_cli/command/test_launch_stop_envelope.py b/tests/comfy_cli/command/test_launch_stop_envelope.py new file mode 100644 index 00000000..a0350808 --- /dev/null +++ b/tests/comfy_cli/command/test_launch_stop_envelope.py @@ -0,0 +1,292 @@ +"""Envelope emission for the ``launch`` / ``stop`` lifecycle commands (BE-4273). + +Unlike other subcommands, ``comfy launch`` / ``comfy stop`` historically emitted +no ``envelope/1`` on stdout under ``--json``, so programmatic callers (e.g. +comfy-local-mcp, which parses the versioned envelope) got nothing machine-readable +back. These tests pin the envelope on the reachable success and error paths and +assert pretty (human) output is untouched. +""" + +from __future__ import annotations + +import json +from unittest.mock import MagicMock, patch + +import pytest +import typer + +from comfy_cli import constants +from comfy_cli.cmdline import stop +from comfy_cli.command import launch as launch_mod +from comfy_cli.output import Renderer, set_renderer +from comfy_cli.output.renderer import OutputMode, reset_renderer_for_testing + + +@pytest.fixture(autouse=True) +def reset_renderer(): + reset_renderer_for_testing() + yield + reset_renderer_for_testing() + + +def _json_renderer(command: str) -> None: + set_renderer(Renderer(mode=OutputMode.JSON, command=command)) + + +def _pretty_renderer(command: str) -> None: + set_renderer(Renderer(mode=OutputMode.PRETTY, command=command)) + + +def _envelopes(captured_out: str) -> list[dict]: + lines = [ln for ln in captured_out.splitlines() if ln.strip()] + return [json.loads(ln) for ln in lines] + + +# --------------------------------------------------------------------------- # +# stop +# --------------------------------------------------------------------------- # + + +class TestStopEnvelope: + def _config(self, bg_info): + cfg = MagicMock() + cfg.config = {"DEFAULT": {constants.CONFIG_KEY_BACKGROUND: "x"} if bg_info else {}} + cfg.background = bg_info + return cfg + + def test_success_emits_envelope(self, capsys): + _json_renderer("stop") + cfg = self._config(("127.0.0.1", 8188, 4242)) + with ( + patch("comfy_cli.cmdline.ConfigManager", return_value=cfg), + patch("comfy_cli.cmdline.utils.kill_all", return_value=True), + ): + stop() + + cfg.remove_background.assert_called_once() + env = _envelopes(capsys.readouterr().out)[-1] + assert env["schema"] == "envelope/1" + assert env["type"] == "envelope" + assert env["ok"] is True + assert env["command"] == "stop" + assert env["data"] == {"stopped": True, "host": "127.0.0.1", "port": 8188, "pid": 4242} + + def test_no_background_emits_error_envelope(self, capsys): + _json_renderer("stop") + cfg = self._config(None) + with patch("comfy_cli.cmdline.ConfigManager", return_value=cfg): + with pytest.raises(typer.Exit) as exc: + stop() + assert exc.value.exit_code == 1 + + env = _envelopes(capsys.readouterr().out)[-1] + assert env["ok"] is False + assert env["error"]["code"] == "no_background_server" + + def test_kill_failure_emits_error_envelope(self, capsys): + _json_renderer("stop") + cfg = self._config(("127.0.0.1", 8188, 4242)) + with ( + patch("comfy_cli.cmdline.ConfigManager", return_value=cfg), + patch("comfy_cli.cmdline.utils.kill_all", return_value=False), + ): + with pytest.raises(typer.Exit) as exc: + stop() + assert exc.value.exit_code == 1 + + # A failed kill must NOT drop the record — else a still-alive server is + # orphaned and a retried `comfy stop` can no longer target it. + cfg.remove_background.assert_not_called() + + env = _envelopes(capsys.readouterr().out)[-1] + assert env["ok"] is False + assert env["error"]["code"] == "stop_failed" + assert env["error"]["details"]["pid"] == 4242 + + def test_kill_failure_pretty_mode_exits_nonzero(self, capsys): + # In pretty mode the failure previously printed but returned 0, so + # `$?`-checking scripts saw success. It must exit non-zero. + _pretty_renderer("stop") + cfg = self._config(("127.0.0.1", 8188, 4242)) + with ( + patch("comfy_cli.cmdline.ConfigManager", return_value=cfg), + patch("comfy_cli.cmdline.utils.kill_all", return_value=False), + ): + with pytest.raises(typer.Exit) as exc: + stop() + assert exc.value.exit_code == 1 + cfg.remove_background.assert_not_called() + assert "Failed to stop" in capsys.readouterr().out + + def test_pretty_mode_stdout_has_no_json(self, capsys): + _pretty_renderer("stop") + cfg = self._config(("127.0.0.1", 8188, 4242)) + with ( + patch("comfy_cli.cmdline.ConfigManager", return_value=cfg), + patch("comfy_cli.cmdline.utils.kill_all", return_value=True), + ): + stop() + out = capsys.readouterr().out + assert "envelope" not in out + assert "Background ComfyUI is stopped" in out + + +# --------------------------------------------------------------------------- # +# launch +# --------------------------------------------------------------------------- # + + +class TestLaunchEnvelope: + def test_background_success_helper_emits_envelope(self, capsys): + _json_renderer("launch") + launch_mod._emit_launch_success("127.0.0.1", 8188, 9999) + + env = _envelopes(capsys.readouterr().out)[-1] + assert env["schema"] == "envelope/1" + assert env["ok"] is True + assert env["command"] == "launch" + assert env["changed"] is True + assert env["data"] == { + "background": True, + "listen": "127.0.0.1", + "port": 8188, + "url": "http://127.0.0.1:8188", + "pid": 9999, + } + + def test_background_success_helper_pretty_is_noop(self, capsys): + _pretty_renderer("launch") + launch_mod._emit_launch_success("127.0.0.1", 8188, 9999) + assert capsys.readouterr().out == "" + + def test_workspace_unavailable_emits_error_envelope(self, capsys): + _json_renderer("launch") + with patch.object(launch_mod.workspace_manager, "workspace_path", None): + with pytest.raises(typer.Exit) as exc: + launch_mod.launch(background=False) + assert exc.value.exit_code == 1 + + env = _envelopes(capsys.readouterr().out)[-1] + assert env["ok"] is False + assert env["command"] == "launch" + assert env["error"]["code"] == "not_in_workspace" + + def test_background_already_running_emits_error_envelope(self, capsys): + _json_renderer("launch") + cfg = MagicMock() + cfg.background = ("127.0.0.1", 8188, 4242) + with ( + patch("comfy_cli.command.launch.ConfigManager", return_value=cfg), + patch("comfy_cli.command.launch.utils.is_running", return_value=True), + ): + with pytest.raises(typer.Exit) as exc: + launch_mod.background_launch(extra=[]) + assert exc.value.exit_code == 1 + + env = _envelopes(capsys.readouterr().out)[-1] + assert env["ok"] is False + assert env["error"]["code"] == "server_already_running" + + def test_background_port_in_use_emits_error_envelope(self, capsys): + _json_renderer("launch") + cfg = MagicMock() + cfg.background = None + with ( + patch("comfy_cli.command.launch.ConfigManager", return_value=cfg), + patch("comfy_cli.command.launch.check_comfy_server_running", return_value=True), + ): + with pytest.raises(typer.Exit) as exc: + launch_mod.background_launch(extra=["--port", "1234"]) + assert exc.value.exit_code == 1 + + env = _envelopes(capsys.readouterr().out)[-1] + assert env["ok"] is False + assert env["error"]["code"] == "port_in_use" + assert env["error"]["details"]["port"] == 1234 + + def test_background_invalid_port_emits_error_envelope(self, capsys): + _json_renderer("launch") + cfg = MagicMock() + cfg.background = None + with patch("comfy_cli.command.launch.ConfigManager", return_value=cfg): + with pytest.raises(typer.Exit) as exc: + launch_mod.background_launch(extra=["--port", "notaport"]) + assert exc.value.exit_code == 1 + + env = _envelopes(capsys.readouterr().out)[-1] + assert env["ok"] is False + assert env["error"]["code"] == "port_invalid" + assert env["error"]["details"]["port"] == "notaport" + + def test_background_launch_failed_emits_error_envelope(self, capsys): + _json_renderer("launch") + cfg = MagicMock() + cfg.background = None + with ( + patch("comfy_cli.command.launch.ConfigManager", return_value=cfg), + patch("comfy_cli.command.launch.check_comfy_server_running", return_value=False), + patch("comfy_cli.command.launch.launch_and_monitor", return_value=["boom\n"]), + patch("comfy_cli.command.launch.os._exit", side_effect=SystemExit(1)), + ): + with pytest.raises(SystemExit): + launch_mod.background_launch(extra=[]) + + env = _envelopes(capsys.readouterr().out)[-1] + assert env["ok"] is False + assert env["command"] == "launch" + assert env["error"]["code"] == "launch_failed" + assert env["error"]["details"]["log"] == "boom\n" + + def test_format_url_brackets_ipv6(self): + assert launch_mod._format_url("::1", 8188) == "http://[::1]:8188" + assert launch_mod._format_url("::", 8188) == "http://[::]:8188" + # Already-bracketed and plain IPv4/hostnames are left as-is. + assert launch_mod._format_url("[::1]", 8188) == "http://[::1]:8188" + assert launch_mod._format_url("127.0.0.1", 8188) == "http://127.0.0.1:8188" + + def test_background_success_helper_ipv6_url(self, capsys): + _json_renderer("launch") + launch_mod._emit_launch_success("::1", 8188, 9999) + env = _envelopes(capsys.readouterr().out)[-1] + assert env["data"]["url"] == "http://[::1]:8188" + + def test_background_port_eq_form_parsed(self, capsys): + # The ``--port=9000`` form must be honored (was previously ignored). + _json_renderer("launch") + cfg = MagicMock() + cfg.background = None + with ( + patch("comfy_cli.command.launch.ConfigManager", return_value=cfg), + patch("comfy_cli.command.launch.check_comfy_server_running", return_value=True), + ): + with pytest.raises(typer.Exit) as exc: + launch_mod.background_launch(extra=["--port=9000"]) + assert exc.value.exit_code == 1 + env = _envelopes(capsys.readouterr().out)[-1] + assert env["error"]["code"] == "port_in_use" + assert env["error"]["details"]["port"] == 9000 + + def test_background_out_of_range_port_is_invalid(self, capsys): + _json_renderer("launch") + cfg = MagicMock() + cfg.background = None + with patch("comfy_cli.command.launch.ConfigManager", return_value=cfg): + with pytest.raises(typer.Exit) as exc: + launch_mod.background_launch(extra=["--port", "70000"]) + assert exc.value.exit_code == 1 + env = _envelopes(capsys.readouterr().out)[-1] + assert env["error"]["code"] == "port_invalid" + assert env["error"]["details"]["port"] == "70000" + + def test_bounded_log_caps_bytes(self): + # A verbose failing launch must not embed an unbounded log payload. + huge = [f"line {i}\n" for i in range(100)] + out = launch_mod._bounded_log(huge, max_lines=1000, max_bytes=20) + assert len(out.encode("utf-8")) <= 20 + # Keeps the tail (most recent lines), trimming from the top. + assert out.endswith("line 99\n") + + def test_bounded_log_caps_lines(self): + huge = [f"line {i}\n" for i in range(100)] + out = launch_mod._bounded_log(huge, max_lines=3, max_bytes=1_000_000) + assert out.splitlines() == ["line 97", "line 98", "line 99"]