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 435b35ac07b Fail loudly when a UI dev server dies in breeze dev mode
(#71784)
435b35ac07b is described below
commit 435b35ac07bf6b6b6ccf8b0cdf497086a8334518
Author: Andrew Chang <[email protected]>
AuthorDate: Thu Aug 20 18:51:13 2026 +0400
Fail loudly when a UI dev server dies in breeze dev mode (#71784)
* Fail loudly when a UI dev server dies in breeze dev mode
Follow-up to #71241. The dev-mode hook discarded the Popen handles of
the two Vite dev servers, so a server dying later (e.g. losing a port
race) went unnoticed. Worse, on macOS the SIGCHLD from the dead child
woke signal.pause(), so the script fell off its end and reported
success - the supervisor itself silently disappeared, orphaning the
surviving server and leaving the browser on a blank page with no error
anywhere.
* Name the pnpm stub's orphan self-destruct timeout in tests
---
scripts/ci/prek/compile_ui_assets_dev.py | 49 ++++-
.../tests/ci/prek/test_compile_ui_assets_dev.py | 208 +++++++++++++++++++++
2 files changed, 253 insertions(+), 4 deletions(-)
diff --git a/scripts/ci/prek/compile_ui_assets_dev.py
b/scripts/ci/prek/compile_ui_assets_dev.py
index 8f7760f0bc8..a75e2ed44c2 100755
--- a/scripts/ci/prek/compile_ui_assets_dev.py
+++ b/scripts/ci/prek/compile_ui_assets_dev.py
@@ -17,9 +17,11 @@
# under the License.
from __future__ import annotations
+import contextlib
import os
-import signal
import subprocess
+import sys
+import time
from common_prek_utils import AIRFLOW_CORE_SOURCES_PATH, AIRFLOW_ROOT_PATH
@@ -52,6 +54,8 @@ SIMPLE_AUTH_MANAGER_UI_ASSET_OUT_DEV_MODE_FILE = (
UI_CACHE_DIR / "simple_auth_manager_asset_compile_dev_mode.out"
)
+DEV_SERVER_POLL_INTERVAL_SECONDS = 1.0
+
if __name__ == "__main__":
UI_CACHE_DIR.mkdir(parents=True, exist_ok=True)
@@ -77,7 +81,7 @@ if __name__ == "__main__":
stderr=subprocess.STDOUT,
)
- subprocess.Popen(
+ ui_dev_server = subprocess.Popen(
["pnpm", "dev"],
cwd=os.fspath(UI_DIRECTORY),
env=env,
@@ -94,7 +98,7 @@ if __name__ == "__main__":
stderr=subprocess.STDOUT,
)
- subprocess.Popen(
+ simple_auth_manager_ui_dev_server = subprocess.Popen(
["pnpm", "dev"],
cwd=os.fspath(SIMPLE_AUTH_MANAGER_UI_DIRECTORY),
env=env,
@@ -102,6 +106,43 @@ if __name__ == "__main__":
stderr=subprocess.STDOUT,
)
+ dev_servers = {
+ "airflow-ui": (ui_dev_server, UI_ASSET_OUT_DEV_MODE_FILE),
+ "simple-auth-manager-ui": (
+ simple_auth_manager_ui_dev_server,
+ SIMPLE_AUTH_MANAGER_UI_ASSET_OUT_DEV_MODE_FILE,
+ ),
+ }
+
# Keep script alive so child processes stay in the same process group.
# When breeze exits, kill_process_group() will terminate all processes
together.
- signal.pause()
+ try:
+ exited_server_name = None
+ while exited_server_name is None:
+ for server_name, (dev_server, _) in dev_servers.items():
+ if dev_server.poll() is not None:
+ exited_server_name = server_name
+ break
+ else:
+ time.sleep(DEV_SERVER_POLL_INTERVAL_SECONDS)
+ except KeyboardInterrupt:
+ for dev_server, _ in dev_servers.values():
+ with contextlib.suppress(OSError):
+ dev_server.terminate()
+ raise SystemExit(130)
+
+ exited_server, exited_server_out_file = dev_servers[exited_server_name]
+ print(
+ f"\nThe {exited_server_name} dev server exited unexpectedly "
+ f"with code {exited_server.returncode}. "
+ f"Check {exited_server_out_file} for details. "
+ "Terminating the remaining UI dev servers.",
+ file=sys.stderr,
+ flush=True,
+ )
+ for server_name, (dev_server, _) in dev_servers.items():
+ if server_name != exited_server_name and dev_server.poll() is None:
+ dev_server.terminate()
+ with contextlib.suppress(subprocess.TimeoutExpired):
+ dev_server.wait(timeout=5)
+ raise SystemExit(1)
diff --git a/scripts/tests/ci/prek/test_compile_ui_assets_dev.py
b/scripts/tests/ci/prek/test_compile_ui_assets_dev.py
new file mode 100644
index 00000000000..750b9d5a28d
--- /dev/null
+++ b/scripts/tests/ci/prek/test_compile_ui_assets_dev.py
@@ -0,0 +1,208 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+"""Tests for the dev-server supervision in compile_ui_assets_dev.py.
+
+The script refuses to be imported as a module, so these tests run the real
+script as a subprocess against a stubbed ``pnpm`` and a stubbed
+``common_prek_utils`` that points all paths into a temporary directory.
+"""
+
+from __future__ import annotations
+
+import os
+import shutil
+import signal
+import subprocess
+import sys
+import textwrap
+import time
+from pathlib import Path
+
+import pytest
+
+SCRIPT_PATH = Path(__file__).resolve().parents[3] / "ci" / "prek" /
"compile_ui_assets_dev.py"
+
+UI_RELATIVE_PATH = Path("airflow-core/src/airflow/ui")
+SIMPLE_AUTH_MANAGER_UI_RELATIVE_PATH =
Path("airflow-core/src/airflow/api_fastapi/auth/managers/simple/ui")
+
+PNPM_STUB = textwrap.dedent(
+ """\
+ #!/usr/bin/env python3
+ import signal
+ import sys
+ import time
+ from pathlib import Path
+
+ ORPHAN_SELF_DESTRUCT_SECONDS = 60
+
+ if sys.argv[1] == "install":
+ print("pnpm install stub")
+ sys.exit(0)
+
+ print("pnpm dev stub starting", flush=True)
+ Path("dev_server.pid").write_text("started")
+
+ def handle_sigterm(signum, frame):
+ Path("terminated.txt").write_text("terminated")
+ sys.exit(0)
+
+ signal.signal(signal.SIGTERM, handle_sigterm)
+
+ behavior_file = Path("dev_behavior.txt")
+ behavior = behavior_file.read_text().strip() if behavior_file.exists()
else "sleep"
+ if behavior.startswith("exit:"):
+ deadline = time.monotonic() + 30
+ while not Path("exit_now.txt").exists() and time.monotonic() <
deadline:
+ time.sleep(0.05)
+ sys.exit(int(behavior.removeprefix("exit:")))
+ time.sleep(ORPHAN_SELF_DESTRUCT_SECONDS)
+ """
+)
+
+
+def wait_for_file(path: Path, timeout: float = 15.0) -> None:
+ deadline = time.monotonic() + timeout
+ while not path.exists():
+ if time.monotonic() > deadline:
+ raise AssertionError(f"{path} did not appear within {timeout}
seconds")
+ time.sleep(0.1)
+
+
+class DevScriptHarness:
+ def __init__(self, tmp_path: Path):
+ airflow_root = tmp_path / "airflow_root"
+ self.ui_directory = airflow_root / UI_RELATIVE_PATH
+ self.simple_auth_manager_ui_directory = airflow_root /
SIMPLE_AUTH_MANAGER_UI_RELATIVE_PATH
+ self.ui_directory.mkdir(parents=True)
+ self.simple_auth_manager_ui_directory.mkdir(parents=True)
+ self.ui_out_file = airflow_root / ".build" / "ui" /
"asset_compile_dev_mode.out"
+ self.simple_auth_manager_ui_out_file = (
+ airflow_root / ".build" / "ui" /
"simple_auth_manager_asset_compile_dev_mode.out"
+ )
+
+ script_dir = tmp_path / "prek"
+ script_dir.mkdir()
+ self.script_path = script_dir / SCRIPT_PATH.name
+ shutil.copy(SCRIPT_PATH, self.script_path)
+ (script_dir / "common_prek_utils.py").write_text(
+ textwrap.dedent(
+ f"""\
+ from pathlib import Path
+
+ AIRFLOW_ROOT_PATH = Path({os.fspath(airflow_root)!r})
+ AIRFLOW_CORE_SOURCES_PATH = AIRFLOW_ROOT_PATH / "airflow-core"
/ "src"
+ """
+ )
+ )
+
+ bin_dir = tmp_path / "bin"
+ bin_dir.mkdir()
+ pnpm_stub = bin_dir / "pnpm"
+ pnpm_stub.write_text(PNPM_STUB)
+ pnpm_stub.chmod(0o755)
+ self.env = {**os.environ, "PATH":
f"{bin_dir}{os.pathsep}{os.environ['PATH']}"}
+ self.process: subprocess.Popen | None = None
+
+ def set_dev_behavior(self, ui_directory: Path, behavior: str) -> None:
+ (ui_directory / "dev_behavior.txt").write_text(behavior)
+
+ def run_script(self) -> subprocess.Popen:
+ self.process = subprocess.Popen(
+ [sys.executable, os.fspath(self.script_path)],
+ env=self.env,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE,
+ text=True,
+ start_new_session=True,
+ )
+ return self.process
+
+ def wait_for_dev_servers_started(self) -> None:
+ wait_for_file(self.ui_directory / "dev_server.pid")
+ wait_for_file(self.simple_auth_manager_ui_directory / "dev_server.pid")
+
+ def kill_process_group(self) -> None:
+ if self.process is None:
+ return
+ for kill_signal in (signal.SIGTERM, signal.SIGKILL):
+ if self.process.poll() is None:
+ try:
+ os.killpg(self.process.pid, kill_signal)
+ self.process.wait(timeout=10)
+ except (OSError, subprocess.TimeoutExpired):
+ continue
+
+
[email protected]
+def harness(tmp_path):
+ harness = DevScriptHarness(tmp_path)
+ yield harness
+ harness.kill_process_group()
+
+
[email protected](
+ "exiting_server_name, exit_code",
+ [
+ pytest.param("airflow-ui", 7, id="airflow-ui"),
+ pytest.param("airflow-ui", 0, id="airflow-ui-clean-exit"),
+ pytest.param("simple-auth-manager-ui", 3, id="simple-auth-manager-ui"),
+ ],
+)
+def test_reports_dev_server_exit_and_terminates_the_other_one(harness,
exiting_server_name, exit_code):
+ servers = {
+ "airflow-ui": (harness.ui_directory, harness.ui_out_file),
+ "simple-auth-manager-ui": (
+ harness.simple_auth_manager_ui_directory,
+ harness.simple_auth_manager_ui_out_file,
+ ),
+ }
+ exiting_directory, exiting_out_file = servers[exiting_server_name]
+ ((surviving_directory, _),) = (server for name, server in servers.items()
if name != exiting_server_name)
+ harness.set_dev_behavior(exiting_directory, f"exit:{exit_code}")
+
+ process = harness.run_script()
+ harness.wait_for_dev_servers_started()
+ (exiting_directory / "exit_now.txt").write_text("exit")
+ _, stderr = process.communicate(timeout=90)
+
+ assert process.returncode == 1
+ assert f"The {exiting_server_name} dev server exited unexpectedly with
code {exit_code}." in stderr
+ assert os.fspath(exiting_out_file) in stderr
+ wait_for_file(surviving_directory / "terminated.txt")
+
+
+def test_keeps_running_while_dev_servers_are_alive(harness):
+ process = harness.run_script()
+ harness.wait_for_dev_servers_started()
+
+ time.sleep(2.5)
+
+ assert process.poll() is None
+
+
+def test_terminates_dev_servers_on_keyboard_interrupt(harness):
+ process = harness.run_script()
+ harness.wait_for_dev_servers_started()
+ time.sleep(0.5)
+
+ os.kill(process.pid, signal.SIGINT)
+ _, stderr = process.communicate(timeout=30)
+
+ assert process.returncode == 130
+ assert "Traceback" not in stderr
+ wait_for_file(harness.ui_directory / "terminated.txt")
+ wait_for_file(harness.simple_auth_manager_ui_directory / "terminated.txt")