test(gateway): make 7 macOS-failing tests host-honest (#107035)

scripts/run_tests.sh tests/gateway/ on a clean macOS checkout fails 7 tests
that pass on the Linux CI lane. The macOS CI lane runs only -m macos_only,
so these had never executed on a Mac. Each failure was diagnosed
individually, not assumed to share a cause:

* test_systemd_notify::test_notify_supports_systemd_abstract_socket
  binds a NUL-prefixed abstract AF_UNIX name, a Linux kernel feature.
  -> @pytest.mark.linux_only (the abstract socket IS the subject).

* test_shutdown_forensics::test_spawns_subprocess_and_writes_output
  spawn_async_diagnostic() wraps a Linux-only script (ps --sort,
  /proc/loadavg, dmesg, pstree) in GNU `timeout`, which macOS lacks.
  Popen raises, the producer returns None by design (fail-soft).
  -> @pytest.mark.linux_only, replacing a bare skipif(win32).

* test_scale_to_zero::test_suspend_self_{posts_suspend,non_2xx}
  bound the fake flaps socket under pytest tmp_path, which exceeds
  sun_path (104 B on macOS/BSD, 108 on Linux). Production connects to the
  fixed /.fly/api so the code is not length-exposed; only the fake needed
  a short home. -> short_sock_dir fixture (gettempdir -> /tmp fallback,
  skip if neither fits). Verified with a 130-char TMPDIR on Linux.

* test_buzz_adapter::test_live_media_redacts_long_path_before_bounding
  built 160-char path components; a single component is capped at 255 B
  on both hosts and the full path hit PATH_MAX (1024) on macOS. The
  invariant (path redacted BEFORE the 900-char bound) needs the path to
  exceed 900 chars, not 1000+. -> grow 80-char components until the path
  passes _MAX_CLI_MESSAGE_CHARS, assert < 1024. Mutation-checked: moving
  the redaction after the bound fails the test.

* test_wecom_callback::{test_build_event_extracts_text_message,
  test_poll_loop_dispatches_handle_message}
  NOT environmental: `_build_event` parses with defusedxml, which ships
  only via the `wecom` extra (or transitively via `youtube` -> [all] in
  CI). A dev venv without it gets ET=None. -> dependency skipif on
  DEFUSEDXML_AVAILABLE (not an OS marker; the 4 other tests in the file
  still run).

Verified: scripts/run_tests.sh on macOS: 226 passed, 4 skipped (2
linux_only, 2 defusedxml). python:3.11-slim container with [dev,wecom]:
-m "linux_only and not integration" selects and passes both marked tests;
all 5 files 229 passed.
This commit is contained in:
Ben Barclay
2026-09-10 12:08:51 +10:00
committed by GitHub
parent f6ddd89692
commit ecefb1e2a2
5 changed files with 66 additions and 13 deletions
+8 -2
View File
@@ -25,6 +25,7 @@ hex_to_npub = _buzz_mod.hex_to_npub
npub_to_hex = _buzz_mod.npub_to_hex
_normalize_user_ref = _buzz_mod._normalize_user_ref
_cli_error_message = _buzz_mod._cli_error_message
_MAX_CLI_MESSAGE_CHARS = _buzz_mod._MAX_CLI_MESSAGE_CHARS
_resolve_private_key = _buzz_mod._resolve_private_key
_resolve_auth_tag = _buzz_mod._resolve_auth_tag
_event_reply_parent_id = _buzz_mod._event_reply_parent_id
@@ -3062,14 +3063,19 @@ class TestInboundMediaAuthorizationGate:
@pytest.mark.asyncio
async def test_live_media_redacts_long_path_before_bounding(self, tmp_path):
# Invariant: the host path is redacted BEFORE the 900-char bound is applied,
# so a path long enough to straddle the cut never leaks in fragments. The
# path must therefore exceed the bound, but stay under PATH_MAX (1024 on
# macOS; 4096 on Linux) so the directory can actually be created.
parent = tmp_path
private_parts = []
for index in range(6):
part = f"private-{index}-" + ("x" * 150)
while len(str(parent)) < _MAX_CLI_MESSAGE_CHARS:
part = f"private-{len(private_parts)}-" + ("x" * 80)
private_parts.append(part)
parent = parent / part
parent.mkdir()
media = parent / "handoff.txt"
assert _MAX_CLI_MESSAGE_CHARS < len(str(media)) < 1024
media.write_text("safe handoff", encoding="utf-8")
adapter = _make_adapter()
adapter._run_cli = AsyncMock(
+38 -6
View File
@@ -108,8 +108,11 @@ def test_idle_exactly_at_threshold():
import os
import shutil
import socket as _socket
import tempfile
import threading
from pathlib import Path
from gateway.scale_to_zero import ( # noqa: E402 - grouped with their section
@@ -121,10 +124,39 @@ from gateway.scale_to_zero import ( # noqa: E402 - grouped with their section
_FLY_ENV = {FLY_APP_NAME_ENV: "hermes-agent-stg-test", FLY_MACHINE_ID_ENV: "d891234f"}
# sockaddr_un.sun_path is 104 bytes on macOS/BSD and 108 on Linux (incl. NUL).
_SUN_PATH_MAX = 100
def _fake_flaps(tmp_path, status_line, capture):
@pytest.fixture()
def short_sock_dir():
"""A directory short enough that ``<dir>/fly-api.sock`` fits in sun_path.
pytest's ``tmp_path`` nests deep enough on macOS and on CI runners that a
socket bound under it fails with ``OSError: AF_UNIX path too long``. The
production path is the fixed ``/.fly/api`` so the length limit never
applies there; only the fake flaps server needs a short home.
"""
candidates = [tempfile.gettempdir(), "/tmp"]
for base in candidates:
try:
path = Path(tempfile.mkdtemp(prefix="s2z-", dir=base))
except OSError:
continue
if len(str(path / "fly-api.sock").encode()) <= _SUN_PATH_MAX:
break
shutil.rmtree(path, ignore_errors=True)
else:
pytest.skip(f"no temp dir short enough for an AF_UNIX socket (tried {candidates})")
try:
yield path
finally:
shutil.rmtree(path, ignore_errors=True)
def _fake_flaps(sock_dir, status_line, capture):
"""One-shot unix-socket HTTP server standing in for flaps."""
sock_path = str(tmp_path / "fly-api.sock")
sock_path = str(sock_dir / "fly-api.sock")
server = _socket.socket(_socket.AF_UNIX, _socket.SOCK_STREAM)
server.bind(sock_path)
server.listen(1)
@@ -150,9 +182,9 @@ def _fake_flaps(tmp_path, status_line, capture):
return sock_path, t
def test_suspend_self_posts_suspend_for_this_machine(tmp_path):
def test_suspend_self_posts_suspend_for_this_machine(short_sock_dir):
captured: list[bytes] = []
sock_path, t = _fake_flaps(tmp_path, "200 OK", captured)
sock_path, t = _fake_flaps(short_sock_dir, "200 OK", captured)
assert suspend_self(_FLY_ENV, socket_path=sock_path) is True
t.join(timeout=5)
request = captured[0].decode()
@@ -164,9 +196,9 @@ def test_suspend_self_posts_suspend_for_this_machine(tmp_path):
assert "Host: flaps\r\n" in request
def test_suspend_self_non_2xx_is_false_not_raise(tmp_path):
def test_suspend_self_non_2xx_is_false_not_raise(short_sock_dir):
captured: list[bytes] = []
sock_path, t = _fake_flaps(tmp_path, "412 Precondition Failed", captured)
sock_path, t = _fake_flaps(short_sock_dir, "412 Precondition Failed", captured)
assert suspend_self(_FLY_ENV, socket_path=sock_path) is False
t.join(timeout=5)
+6 -2
View File
@@ -5,7 +5,6 @@ from __future__ import annotations
import json
import os
import signal
import sys
import time
from pathlib import Path
@@ -89,7 +88,12 @@ class TestFormatters:
# ---------------------------------------------------------------------------
class TestSpawnAsyncDiagnostic:
@pytest.mark.skipif(sys.platform == "win32", reason="POSIX-only diagnostic")
# The diagnostic wraps its script in GNU coreutils ``timeout`` and the script
# body is Linux-only (``ps auxf --sort``, ``/proc/loadavg``, ``dmesg``,
# ``pstree``). On hosts without ``timeout`` (macOS) Popen raises and the
# producer returns None by design (fail-soft), so the spawn can only be
# observed on Linux.
@pytest.mark.linux_only
def test_spawns_subprocess_and_writes_output(self, tmp_path):
log_path = tmp_path / "diag.log"
pid = sf.spawn_async_diagnostic(log_path, "SIGTERM", timeout_seconds=3.0)
+1 -3
View File
@@ -8,9 +8,7 @@ import socket
import pytest
@pytest.mark.skipif(
not hasattr(socket, "AF_UNIX"), reason="Unix datagram sockets are unavailable"
)
@pytest.mark.linux_only # abstract (NUL-prefixed) AF_UNIX names are a Linux kernel feature
def test_notify_supports_systemd_abstract_socket(monkeypatch):
name = "\0hermes-test-notify"
receiver = socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM)
+13
View File
@@ -6,9 +6,20 @@ from xml.etree import ElementTree as ET
import pytest
from gateway.config import PlatformConfig
from plugins.platforms.wecom import callback_adapter as _callback_mod
from plugins.platforms.wecom.callback_adapter import WecomCallbackAdapter
from plugins.platforms.wecom.wecom_crypto import WXBizMsgCrypt
# ``_build_event`` parses inbound XML with defusedxml, which ships with the
# ``wecom`` extra (and transitively with ``youtube``, hence ``[all]`` in CI). A
# dev venv without either extra cannot exercise the parser; the crypto,
# routing, token-refresh and body-size tests below do not touch it and run
# regardless. This is a dependency gate, not a host-OS gate.
requires_defusedxml = pytest.mark.skipif(
not _callback_mod.DEFUSEDXML_AVAILABLE,
reason="defusedxml not installed (uv sync --extra wecom)",
)
def _app(name="test-app", corp_id="ww1234567890", agent_id="1000002"):
return {
@@ -46,6 +57,7 @@ class TestWecomCrypto:
class TestWecomCallbackEventConstruction:
@requires_defusedxml
def test_build_event_extracts_text_message(self):
adapter = WecomCallbackAdapter(_config())
xml_text = """
@@ -140,6 +152,7 @@ class TestWecomCallbackSendTokenRefresh:
class TestWecomCallbackPollLoop:
@requires_defusedxml
@pytest.mark.asyncio
async def test_poll_loop_dispatches_handle_message(self, monkeypatch):
adapter = WecomCallbackAdapter(_config())