Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 33 additions & 8 deletions comfy_cli/cmdline.py
Original file line number Diff line number Diff line change
Expand Up @@ -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] ?[-- <extra args ...>]")
Expand Down
158 changes: 146 additions & 12 deletions comfy_cli/command/launch.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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():
Comment thread
mattmillerai marked this conversation as resolved.
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)
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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_<port>.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_<port>.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):
Comment thread
mattmillerai marked this conversation as resolved.
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 = [
Expand All @@ -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(
Expand All @@ -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)

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand All @@ -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:
Expand Down Expand Up @@ -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.
Expand All @@ -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()

Expand Down
3 changes: 3 additions & 0 deletions comfy_cli/discovery.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
32 changes: 32 additions & 0 deletions comfy_cli/error_codes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
15 changes: 15 additions & 0 deletions comfy_cli/schemas/launch.json
Original file line number Diff line number Diff line change
@@ -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"]
}
Loading
Loading