You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@airflow.apache.org by po...@apache.org on 2026/09/07 19:31:13 UTC
(airflow) branch main updated: Show which processes hold the Breeze UI dev ports when start-airflow fails (#72564)
This is an automated email from the ASF dual-hosted git repository.
potiuk pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow.git
The following commit(s) were added to refs/heads/main by this push:
new ec71f28f2db Show which processes hold the Breeze UI dev ports when start-airflow fails (#72564)
ec71f28f2db is described below
commit ec71f28f2dba324d7398db3712d5764d774752b0
Author: Andrew Chang <69...@users.noreply.github.com>
AuthorDate: Tue Sep 8 03:31:01 2026 +0800
Show which processes hold the Breeze UI dev ports when start-airflow fails (#72564)
Since #71241 `breeze start-airflow --dev-mode` refuses to start when the
Vite ports are taken, but the message did not say by what. The usual
culprit is a stale dev server left behind by a previous breeze run, and
the user had to hunt for it with lsof before they could kill it. Printing
the PID and command line, plus a ready-to-paste kill hint, removes that
step. psutil is bumped to >=6.0.0 for Process.net_connections().
---
dev/breeze/pyproject.toml | 2 +-
dev/breeze/src/airflow_breeze/utils/run_utils.py | 53 +++++++++++++--
dev/breeze/tests/test_run_utils.py | 85 +++++++++++++++++++++++-
dev/breeze/uv.lock | 2 +-
uv.lock | 2 +-
5 files changed, 135 insertions(+), 9 deletions(-)
diff --git a/dev/breeze/pyproject.toml b/dev/breeze/pyproject.toml
index 8bfd12e1a0a..74f6b0222ad 100644
--- a/dev/breeze/pyproject.toml
+++ b/dev/breeze/pyproject.toml
@@ -62,7 +62,7 @@ dependencies = [
"jsonschema>=4.19.1",
"packaging>=25.0",
"prek>=0.4.14",
- "psutil>=5.9.6",
+ "psutil>=6.0.0",
"pygithub>=2.1.1",
"pytest-xdist>=3.3.1",
"pytest>=9.0.0",
diff --git a/dev/breeze/src/airflow_breeze/utils/run_utils.py b/dev/breeze/src/airflow_breeze/utils/run_utils.py
index c503841e97e..ef91d98a643 100644
--- a/dev/breeze/src/airflow_breeze/utils/run_utils.py
+++ b/dev/breeze/src/airflow_breeze/utils/run_utils.py
@@ -543,6 +543,52 @@ def _find_occupied_local_ports(ports: Iterable[str]) -> list[str]:
return occupied_ports
+def _find_local_port_listeners(ports: Iterable[str]) -> dict[str, tuple[int, str]]:
+ """
+ Finds processes listening on the given local ports.
+
+ :param ports: ports to look up
+ :return: mapping of port to (pid, command line) for each port whose listener could be found
+ """
+ # We import it locally so that click autocomplete works
+ try:
+ import psutil
+ except ImportError:
+ return {}
+
+ ports_by_number = {int(port): port for port in ports}
+ listeners: dict[str, tuple[int, str]] = {}
+ for process in psutil.process_iter():
+ with contextlib.suppress(psutil.Error):
+ for connection in process.net_connections(kind="tcp"):
+ if connection.status != psutil.CONN_LISTEN or connection.laddr.port not in ports_by_number:
+ continue
+ command_line = " ".join(process.cmdline()) or process.name()
+ listeners[ports_by_number[connection.laddr.port]] = (process.pid, command_line)
+ return listeners
+
+
+def _format_occupied_ports_error(occupied_ports: list[str], listeners: dict[str, tuple[int, str]]) -> str:
+ message = (
+ "[error]Cannot start UI development servers because the following local port(s) "
+ f"are already in use: {', '.join(occupied_ports)}.[/]\n"
+ )
+ pids: dict[int, None] = {}
+ for port in occupied_ports:
+ if port in listeners:
+ pid, command_line = listeners[port]
+ pids[pid] = None
+ message += f"[info]Port {port} is used by PID {pid}: {escape(command_line)}[/]\n"
+ if pids:
+ kill_hint = " ".join(str(pid) for pid in pids)
+ message += (
+ f"[info]Stop the processes using these ports (for example `kill {kill_hint}`) and try again.[/]"
+ )
+ else:
+ message += "[info]Stop the processes using these ports and try again.[/]"
+ return message
+
+
def run_compile_ui_assets(
dev: bool,
run_in_background: bool,
@@ -552,11 +598,8 @@ def run_compile_ui_assets(
if dev:
occupied_ports = _find_occupied_local_ports((VITE_DEV_PORT, SIMPLE_AUTH_MANAGER_VITE_DEV_PORT))
if occupied_ports:
- console_print(
- "[error]Cannot start UI development servers because the following local port(s) "
- f"are already in use: {', '.join(occupied_ports)}.[/]\n"
- "[info]Stop the processes using these ports and try again.[/]"
- )
+ listeners = _find_local_port_listeners(occupied_ports)
+ console_print(_format_occupied_ports_error(occupied_ports, listeners))
sys.exit(1)
if force_clean:
_clean_ui_assets(additional_ui_hooks)
diff --git a/dev/breeze/tests/test_run_utils.py b/dev/breeze/tests/test_run_utils.py
index 59ac4dd5292..1de3372bad2 100644
--- a/dev/breeze/tests/test_run_utils.py
+++ b/dev/breeze/tests/test_run_utils.py
@@ -16,16 +16,20 @@
# under the License.
from __future__ import annotations
+import os
import socket
import stat
import subprocess
from unittest import mock
+import psutil
import pytest
from airflow_breeze.global_constants import SIMPLE_AUTH_MANAGER_VITE_DEV_PORT, VITE_DEV_PORT
from airflow_breeze.utils.run_utils import (
+ _find_local_port_listeners,
_find_occupied_local_ports,
+ _format_occupied_ports_error,
change_directory_permission,
change_file_permission,
check_if_buildx_plugin_installed,
@@ -105,10 +109,11 @@ def test_run_compile_ui_assets_checks_both_dev_ports(mock_find_occupied_ports, m
],
)
@mock.patch("airflow_breeze.utils.run_utils._clean_ui_assets")
+@mock.patch("airflow_breeze.utils.run_utils._find_local_port_listeners", return_value={})
@mock.patch("airflow_breeze.utils.run_utils._find_occupied_local_ports")
@mock.patch("airflow_breeze.utils.run_utils.console_print")
def test_run_compile_ui_assets_exits_before_cleanup_when_dev_port_is_occupied(
- mock_console_print, mock_find_occupied_ports, mock_clean_ui_assets, occupied_ports
+ mock_console_print, mock_find_occupied_ports, mock_find_listeners, mock_clean_ui_assets, occupied_ports
):
mock_find_occupied_ports.return_value = occupied_ports
@@ -116,6 +121,7 @@ def test_run_compile_ui_assets_exits_before_cleanup_when_dev_port_is_occupied(
run_compile_ui_assets(dev=True, run_in_background=False, force_clean=True, additional_ui_hooks=[])
assert ctx.value.code == 1
+ mock_find_listeners.assert_called_once_with(occupied_ports)
mock_console_print.assert_called_once_with(
"[error]Cannot start UI development servers because the following local port(s) "
f"are already in use: {', '.join(occupied_ports)}.[/]\n"
@@ -124,6 +130,83 @@ def test_run_compile_ui_assets_exits_before_cleanup_when_dev_port_is_occupied(
mock_clean_ui_assets.assert_not_called()
+def test_find_local_port_listeners_reports_current_process():
+ with socket.create_server(("127.0.0.1", 0)) as server:
+ occupied_port = str(server.getsockname()[1])
+
+ listeners = _find_local_port_listeners([occupied_port])
+
+ assert list(listeners) == [occupied_port]
+ assert listeners[occupied_port] == (os.getpid(), " ".join(psutil.Process().cmdline()))
+
+
+@pytest.mark.parametrize(
+ "error",
+ [
+ pytest.param(psutil.AccessDenied(pid=1), id="access-denied"),
+ pytest.param(psutil.NoSuchProcess(pid=1), id="no-such-process"),
+ ],
+)
+@mock.patch("psutil.process_iter")
+def test_find_local_port_listeners_skips_processes_that_cannot_be_inspected(mock_process_iter, error):
+ failing_process = mock.Mock(spec=psutil.Process)
+ failing_process.net_connections.side_effect = error
+ listening_process = mock.Mock(spec=psutil.Process, pid=42)
+ listening_process.net_connections.return_value = [
+ mock.Mock(status=psutil.CONN_LISTEN, laddr=mock.Mock(port=5173)),
+ ]
+ listening_process.cmdline.return_value = ["node", "vite.js"]
+ mock_process_iter.return_value = [failing_process, listening_process]
+
+ assert _find_local_port_listeners(["5173"]) == {"5173": (42, "node vite.js")}
+
+
+@pytest.mark.parametrize(
+ ("listeners", "expected_lines"),
+ [
+ pytest.param(
+ {},
+ ["[info]Stop the processes using these ports and try again.[/]"],
+ id="no-listener-found",
+ ),
+ pytest.param(
+ {"5173": (42, "node vite.js --port 5173 [x]")},
+ [
+ "[info]Port 5173 is used by PID 42: node vite.js --port 5173 \\[x][/]",
+ "[info]Stop the processes using these ports (for example `kill 42`) and try again.[/]",
+ ],
+ id="one-listener",
+ ),
+ pytest.param(
+ {"5173": (42, "node"), "5174": (43, "node")},
+ [
+ "[info]Port 5173 is used by PID 42: node[/]",
+ "[info]Port 5174 is used by PID 43: node[/]",
+ "[info]Stop the processes using these ports (for example `kill 42 43`) and try again.[/]",
+ ],
+ id="two-listeners",
+ ),
+ pytest.param(
+ {"5174": (42, "node"), "5173": (42, "node")},
+ [
+ "[info]Port 5173 is used by PID 42: node[/]",
+ "[info]Port 5174 is used by PID 42: node[/]",
+ "[info]Stop the processes using these ports (for example `kill 42`) and try again.[/]",
+ ],
+ id="same-process-on-both-ports",
+ ),
+ ],
+)
+def test_format_occupied_ports_error(listeners, expected_lines):
+ message = _format_occupied_ports_error(["5173", "5174"], listeners)
+
+ assert message.split("\n") == [
+ "[error]Cannot start UI development servers because the following local port(s) "
+ "are already in use: 5173, 5174.[/]",
+ *expected_lines,
+ ]
+
+
@mock.patch("airflow_breeze.utils.run_utils._run_compile_internally")
@mock.patch("airflow_breeze.utils.run_utils._find_occupied_local_ports")
def test_run_compile_ui_assets_does_not_check_dev_ports_for_static_build(
diff --git a/dev/breeze/uv.lock b/dev/breeze/uv.lock
index 9961eb5e84f..ccf1baf92ef 100644
--- a/dev/breeze/uv.lock
+++ b/dev/breeze/uv.lock
@@ -81,7 +81,7 @@ requires-dist = [
{ name = "jsonschema", specifier = ">=4.19.1" },
{ name = "packaging", specifier = ">=25.0" },
{ name = "prek", specifier = ">=0.4.14" },
- { name = "psutil", specifier = ">=5.9.6" },
+ { name = "psutil", specifier = ">=6.0.0" },
{ name = "pygithub", specifier = ">=2.1.1" },
{ name = "pytest", specifier = ">=9.0.0" },
{ name = "pytest-xdist", specifier = ">=3.3.1" },
diff --git a/uv.lock b/uv.lock
index afe82113f14..7e52ab5f4e4 100644
--- a/uv.lock
+++ b/uv.lock
@@ -2072,7 +2072,7 @@ requires-dist = [
{ name = "jsonschema", specifier = ">=4.19.1" },
{ name = "packaging", specifier = ">=25.0" },
{ name = "prek", specifier = ">=0.4.14" },
- { name = "psutil", specifier = ">=5.9.6" },
+ { name = "psutil", specifier = ">=6.0.0" },
{ name = "pygithub", specifier = ">=2.1.1" },
{ name = "pytest", specifier = ">=9.0.0" },
{ name = "pytest-xdist", specifier = ">=3.3.1" },