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" },