mirror of
https://github.com/apache/airflow.git
synced 2026-11-04 07:12:48 +00:00
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
This commit is contained in:
1 parent
78187c9373
commit
435b35ac07
2 files changed
+253
-4
No files matched your search
@@ -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)
|
||||
@@ -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
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def harness(tmp_path):
|
||||
harness = DevScriptHarness(tmp_path)
|
||||
yield harness
|
||||
harness.kill_process_group()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"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")
|
||||
Reference in new issue
Block a user