Skip to content
Open
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
1 change: 1 addition & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ install:

test:
python -c 'import aw_qt'
python -m pytest tests/test_main.py

test-integration:
python ./tests/integration_tests.py --no-modules
Expand Down
29 changes: 29 additions & 0 deletions aw_qt/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import subprocess
import platform
import signal
import select
import threading
from typing import Optional
from time import sleep
Expand All @@ -26,6 +27,32 @@
logger = logging.getLogger(__name__)


def _ensure_output_streams() -> None:
# Desktop launchers can leave stdout/stderr on a disconnected terminal or pipe.
# Repair unusable descriptors before either aw-qt or its modules write.
for fd in (1, 2):
unusable = False
try:
os.write(fd, b"")
Comment thread
andreivince marked this conversation as resolved.
Resolved
if sys.platform != "win32":
poller = select.poll()
# A zero event mask still reports errors and hangups.
poller.register(fd, 0)
unusable = bool(poller.poll(0))
except OSError:
unusable = True
if not unusable:
continue
devnull = os.open(os.devnull, os.O_WRONLY)
if devnull == fd:
os.set_inheritable(fd, True)
else:
try:
os.dup2(devnull, fd)
finally:
os.close(devnull)


def _acquire_single_instance_lock(profile: str) -> QLockFile:
"""Ensure only one instance of aw-qt runs at a time.

Expand Down Expand Up @@ -92,6 +119,8 @@ def main(
no_gui: bool,
interactive_cli: bool,
) -> None:
_ensure_output_streams()

# Since the .app can crash when started from Finder for unknown reasons, we send a syslog message here to make debugging easier.
if platform.system() == "Darwin":
subprocess.call("syslog -s 'aw-qt started'", shell=True)
Expand Down
76 changes: 73 additions & 3 deletions poetry.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ PyQt6 = "6.5.3"
PyQt6-Qt6 = "6.5.3"

[tool.poetry.dev-dependencies]
pytest = "^8.3.5"
mypy = "*"
PyQt6-stubs = { git = "https://github.com/TilmanK/PyQt6-stubs", rev = "main" }
flake8 = "*"
Expand Down
99 changes: 98 additions & 1 deletion tests/test_main.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
"""Startup must use the effective CLI server selection, not the saved list."""
"""Tests for the launcher entry point."""

import importlib
import os
import subprocess
import sys
from unittest.mock import MagicMock, patch

import pytest
Expand Down Expand Up @@ -81,3 +84,97 @@ def test_cli_server_free_override_preserves_configured_port(
)
assert run.call_args.kwargs["port"] == 5678
manager.return_value.autostart_notify_if_enabled.assert_not_called()


@pytest.mark.skipif(sys.platform == "win32", reason="requires POSIX file descriptors")
@pytest.mark.parametrize(
"broken_output, broken_fd",
[("healthy", 0), ("terminal", 1), ("terminal", 2), ("pipe", 1), ("pipe", 2)],
ids=["healthy", "terminal-stdout", "terminal-stderr", "pipe-stdout", "pipe-stderr"],
)
def test_startup_preserves_modules_and_gui_actions(tmp_path, broken_output, broken_fd):
executable = tmp_path / "aw-test-output"
ready = tmp_path / "ready"
executable.write_text(
f"#!{sys.executable}\n"
"import os, signal\n"
"from pathlib import Path\n"
"os.write(1, b'child stdout\\n')\n"
"os.write(2, b'child stderr\\n')\n"
f"Path({str(ready)!r}).touch()\n"
"signal.pause()\n"
)
executable.chmod(0o755)

launcher = """
import os
import pty
import sys
import time
from pathlib import Path
from unittest.mock import patch
import platformdirs
from aw_qt import main, trayicon

root = Path(sys.argv[1])
broken_fd = int(sys.argv[2])
broken_output = sys.argv[3]
if broken_output == "terminal":
master, slave = pty.openpty()
os.close(master)
os.dup2(slave, broken_fd)
os.close(slave)
elif broken_output == "pipe":
reader, writer = os.pipe()
os.close(reader)
os.dup2(writer, broken_fd)
os.close(writer)
# A real terminal makes parent stdout line-buffered, so GUI action prints
# must complete before their browser and shutdown effects can happen.
sys.stdout.reconfigure(line_buffering=True)

# XDG variables do not isolate macOS application directories.
for name, kind in (
("user_config_dir", "config"), ("user_data_dir", "data"),
("user_cache_dir", "cache"), ("user_log_dir", "logs"),
):
setattr(platformdirs, name, lambda appname, *args, kind=kind, **kwargs: str(root / kind / appname))
platformdirs.user_cache_path = lambda appname, *args, **kwargs: root / "cache" / appname

def exercise_gui_actions(manager, **kwargs):
module = next(m for m in manager.modules if m.name == "aw-test-output")
try:
deadline = time.monotonic() + 5
while not (root / "ready").exists():
assert module.is_alive(), "child failed before completing its writes"
assert time.monotonic() < deadline, "child did not become ready"
time.sleep(0.01)
with patch.object(trayicon, "open_url") as open_url, patch.object(trayicon.QApplication, "quit") as quit_app:
trayicon.open_webui("http://example.invalid")
open_url.assert_called_once_with("http://example.invalid")
trayicon.exit(manager)
quit_app.assert_called_once_with()
assert not module.is_alive()
return 0
finally:
manager.stop_all()

trayicon.run = exercise_gui_actions
main(["--profile", "output-test", "--autostart-modules=aw-test-output"])
"""
env = dict(os.environ, PATH=str(tmp_path) + os.pathsep + os.environ["PATH"])
result = subprocess.run(
[sys.executable, "-c", launcher, str(tmp_path), str(broken_fd), broken_output],
env=env,
capture_output=True,
text=True,
timeout=15,
)

assert result.returncode == 0, result.stderr
if broken_fd != 1:
assert "child stdout\n" in result.stdout
assert "Opening dashboard\n" in result.stdout
assert "Shutdown initiated, stopping all services...\n" in result.stdout
if broken_fd != 2:
assert "child stderr\n" in result.stderr
Loading