From 24aa77f07ee8e23c7691f6a2698afa7cbaf42718 Mon Sep 17 00:00:00 2001 From: Zijian Zhang <35801754+futrime@users.noreply.github.com> Date: Sat, 3 Oct 2026 03:13:53 +0000 Subject: [PATCH 1/3] fix: run the suite on macOS as it runs on Linux Mac-specific failures, each fixed where it lives: - A workdir that is not there is said to be unavailable on a Mac too: the target names it by its folded spelling (/var for /private/var), and the comparison is now made the way both halves route paths. - `hmz internal anchor` on a host that cannot intercept syscalls says so in a line rather than a traceback. - Tests: the stand-in docker's containers say they are Linux, as every docker daemon's do; CPUs and memory are counted the way the host does; a daemon leaves its session with Python's setsid, which a Mac has; a turned-away connection may be reset; the tests of the Linux seccomp and Landlock bindings skip where those will not import. Co-Authored-By: Claude Opus 5.5 --- src/hmz/cli/anchor.py | 4 +- src/hmz/runtime/flowing/environing.py | 10 ++- tests/integration/agents/test_agy_stream.py | 13 ++-- .../integration/coganchor/test_fence_proxy.py | 10 ++- tests/integration/flows/test_local_envs.py | 14 ++++- tests/integration/flows/test_ssh_envs.py | 16 ++++- tests/integration/flows/test_swarm_envs.py | 3 + tests/integration/machines/test_runtimes.py | 3 +- tests/integration/tui/test_runtimes.py | 4 +- tests/machines/fixtures.py | 62 +++++++++++++++++++ tests/stubs.py | 11 +++- tests/supervising.py | 5 ++ tests/unit/coganchor/test_anchor.py | 2 + tests/unit/coganchor/test_fence.py | 2 + tests/unit/coganchor/test_fence_abroad.py | 2 + tests/unit/coganchor/test_loopback.py | 5 ++ 16 files changed, 147 insertions(+), 19 deletions(-) diff --git a/src/hmz/cli/anchor.py b/src/hmz/cli/anchor.py index cfd98e57d..1fd31f131 100644 --- a/src/hmz/cli/anchor.py +++ b/src/hmz/cli/anchor.py @@ -135,7 +135,9 @@ def anchor(argv: list[str]) -> int: # this machine has not got, is a different thing gone wrong and is answered below. print(f"hmz: {exc.strerror or exc}", file=sys.stderr) return 127 - except (ConnectionError, ProtocolError, OSError, ValueError) as exc: + except (ConnectionError, ProtocolError, OSError, RuntimeError, ValueError) as exc: + # RuntimeError for a host that cannot intercept syscalls at all -- a Mac -- which is + # said in a sentence of its own rather than a traceback ending in one. print(f"hmz: {exc}", file=sys.stderr) return 1 else: diff --git a/src/hmz/runtime/flowing/environing.py b/src/hmz/runtime/flowing/environing.py index 2c8ff15aa..1b46ef644 100644 --- a/src/hmz/runtime/flowing/environing.py +++ b/src/hmz/runtime/flowing/environing.py @@ -454,7 +454,15 @@ def started_error( `EnvUnavailable` for a workdir that is not there, and otherwise what :func:`env_error` makes of it. """ - if error.filename == cwd and error.errno in (errno.ENOENT, errno.ENOTDIR): + from hmz.coganchor.proto import path_key + + # Matched as the machine would name it: a Mac's `/private/var` comes back as `/var`. + named = error.filename + if ( + isinstance(named, str) + and path_key(named) == path_key(cwd) + and error.errno in (errno.ENOENT, errno.ENOTDIR) + ): return EnvUnavailable(f"the workdir {cwd} is not there") return env_error(error, f"could not run {argv[0]!r} in {cwd}", reported=reported) diff --git a/tests/integration/agents/test_agy_stream.py b/tests/integration/agents/test_agy_stream.py index 1dec784ca..2717536b6 100644 --- a/tests/integration/agents/test_agy_stream.py +++ b/tests/integration/agents/test_agy_stream.py @@ -25,7 +25,7 @@ Failed, ) from hmz.coganchor.agents.event import Usage -from hmz.coganchor.providers import Provider +from hmz.coganchor.providers import Provider, redirect from tests.agents import standins from tests.stubs import HereAnchor @@ -479,14 +479,17 @@ def node() -> Provider: def swaps(_provider: Provider) -> tuple[tuple[str, str], ...]: return (("/native/agy/token.json", str(token)),) - def command(_provider: Provider, argv: list[str] | tuple[str, ...]) -> list[str]: - # The supervisor answering those paths is tested where it lives; what is in - # question here is only whether the file's contents reach the restart decision. + def command( + _swaps: object, argv: list[str] | tuple[str, ...], *_: object + ) -> list[str]: + # The supervisor answering those paths is tested where it lives -- and only where a + # ptrace supervisor can run; what is in question here is only whether the file's + # contents reach the restart decision. return list(argv) monkeypatch.setattr(agy.agent, "node", node) monkeypatch.setattr(Provider, "swaps", swaps) - monkeypatch.setattr(Provider, "command", command) + monkeypatch.setattr(redirect, "command", command) session = agy.agent.new() session("first") token.write_text("refreshed credential") diff --git a/tests/integration/coganchor/test_fence_proxy.py b/tests/integration/coganchor/test_fence_proxy.py index 3ff1e434b..fb2fc2a2b 100644 --- a/tests/integration/coganchor/test_fence_proxy.py +++ b/tests/integration/coganchor/test_fence_proxy.py @@ -182,7 +182,13 @@ def test_connections_past_the_limit_are_turned_away(upstream: int) -> None: with Proxy(["localhost"], ports=[upstream], limit=1) as proxy: held, answer = _ask(proxy, f"CONNECT localhost:{upstream} HTTP/1.1\r\n\r\n") assert answer.startswith(b"HTTP/1.1 200") - extra, answer = _ask(proxy, f"CONNECT localhost:{upstream} HTTP/1.1\r\n\r\n") + # Closed unanswered: an end of file where the request had not yet arrived when it + # was, a reset where it had -- which is what a Mac's kernel says of unread bytes. + with socket.create_connection(("127.0.0.1", proxy.port), timeout=10) as extra: + extra.sendall(f"CONNECT localhost:{upstream} HTTP/1.1\r\n\r\n".encode()) + try: + answer = extra.recv(4096) + except ConnectionResetError: + answer = b"" assert answer == b"" - extra.close() held.close() diff --git a/tests/integration/flows/test_local_envs.py b/tests/integration/flows/test_local_envs.py index 8c5188f4d..750e8c01b 100644 --- a/tests/integration/flows/test_local_envs.py +++ b/tests/integration/flows/test_local_envs.py @@ -14,8 +14,10 @@ import asyncio import fcntl import os +import shlex import stat import subprocess +import sys import time from pathlib import Path, PurePosixPath @@ -50,6 +52,7 @@ from hmz.runtime.flowing.specs import parse_envs from hmz.runtime.flowing.spi import ENV_CAPABILITIES, Placement from tests.flows.contracts import check_env_driver +from tests.stubs import CPUS #: Whether this process may read and write whatever it likes, which makes a permission test #: a test of nothing. @@ -196,7 +199,7 @@ def test_a_directory_that_is_not_there_is_unavailable(tmp_path: Path) -> None: async def test_what_the_machine_has_is_what_the_kernel_says(tmp_path: Path) -> None: driver = _driver(tmp_path) await probe(driver) - assert driver.cpu_count == len(os.sched_getaffinity(0)) + assert driver.cpu_count == CPUS assert driver.memory == psutil.virtual_memory().total assert driver.gpu_count >= 0 assert driver.gpu_memory >= 0 @@ -280,8 +283,13 @@ async def test_a_timeout_kills_what_outlived_the_command_in_its_group( assert await _gone(child), "what the command left holding its output outlived it" -#: A daemon that leaves the command's group and session, and keeps its output open. -_DAEMON = "setsid sh -c 'echo $$ > daemon; exec sleep 30' & echo started" +#: A daemon that leaves the command's group and session, and keeps its output open. Python's +#: `setsid` rather than the command, which a Mac has not got. +_DAEMON = ( + f"{shlex.quote(sys.executable)} -c 'import os; os.setsid(); " + 'os.write(os.open("daemon", os.O_WRONLY | os.O_CREAT), b"%d" % os.getpid()); ' + 'os.execvp("sleep", ["sleep", "30"])\' & echo started' +) async def test_a_daemon_holding_a_command_s_output_does_not_hold_its_timeout( diff --git a/tests/integration/flows/test_ssh_envs.py b/tests/integration/flows/test_ssh_envs.py index 2ae174912..0eff9aae7 100644 --- a/tests/integration/flows/test_ssh_envs.py +++ b/tests/integration/flows/test_ssh_envs.py @@ -23,6 +23,7 @@ from hmz.coganchor.machines import AnchoredConfig, store from hmz.coganchor.machines.store import SSHRuntime +from hmz.coganchor.proto import path_key from hmz.flows import ( EnvBackendKind, EnvCommandTimeout, @@ -36,6 +37,7 @@ from hmz.runtime.flowing.environments import open_env, probe from hmz.runtime.flowing.specs import parse_envs from tests.flows.contracts import check_env_driver +from tests.stubs import CPUS if TYPE_CHECKING: from hmz.runtime.flowing.spi import EnvDriver @@ -103,7 +105,12 @@ def _open(host: str, workdir: Path | str) -> MachineEnvDriver: def _mem_total() -> int: - """The memory this machine's kernel says it has, as `/proc/meminfo` counts it.""" + """The memory this machine's kernel says it has, as `/proc/meminfo` counts it. + + Or as `sysctl hw.memsize` does where there is no `/proc` (macOS), which is psutil's count. + """ + if not Path("/proc/meminfo").exists(): + return int(psutil.virtual_memory().total) meminfo = Path("/proc/meminfo").read_text().splitlines() (total,) = [line.split()[1] for line in meminfo if line.startswith("MemTotal:")] return int(total) * 1024 @@ -192,7 +199,7 @@ async def test_what_a_host_has_is_learned_when_it_is_probed( try: await probe(driver) assert driver.available - assert driver.cpu_count == len(os.sched_getaffinity(0)) + assert driver.cpu_count == CPUS assert driver.memory == _mem_total() assert abs(driver.memory - psutil.virtual_memory().total) < 1024 * 1024 assert driver.gpu_count >= 0 @@ -212,7 +219,10 @@ async def test_a_workdir_under_home_is_found_where_home_is_there( assert isinstance(before, AnchoredConfig) assert before.anchor.remote_path == "~/work" await probe(driver) - assert await driver.exec(["pwd"], timeout=30) == (0, f"{far}/work\n", "") + # Whichever name for it the host's `pwd` prints: a Mac's says `/var` for `/private/var`. + status, out, err = await driver.exec(["pwd"], timeout=30) + assert (status, err) == (0, "") + assert path_key(out.strip()) == path_key(f"{far}/work") await driver.write("made.txt", b"made") assert (far / "work/made.txt").read_bytes() == b"made" assert await driver.read("~/work/made.txt") == b"made" diff --git a/tests/integration/flows/test_swarm_envs.py b/tests/integration/flows/test_swarm_envs.py index 4568ab8a4..3e5ac76bf 100644 --- a/tests/integration/flows/test_swarm_envs.py +++ b/tests/integration/flows/test_swarm_envs.py @@ -42,6 +42,7 @@ from hmz.runtime.flowing.specs import parse_envs from hmz.sdk import Hmz from tests.flows.contracts import check_env_driver +from tests.machines.fixtures import containered if TYPE_CHECKING: from pathlib import Path @@ -108,6 +109,7 @@ class Envs(EnvCollection): elif command == "run" and "--rm" in rest: print(said("STANDIN_OWNER", "4242 4343")) elif command == "exec": + os.environ["PATH"] = os.environ["STANDIN_CONTAINER"] + os.pathsep + os.environ["PATH"] moved = os.environ["STANDIN_ROOT"] words = [word.replace("/tmp/humanize", moved) for word in rest[2:]] os.execvp(words[0], words) @@ -198,6 +200,7 @@ def swarm(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> _Swarm: monkeypatch.setenv("PATH", f"{bin_}{os.pathsep}{os.environ['PATH']}") monkeypatch.setenv("STANDIN_LOG", str(tmp_path / "docker.log")) monkeypatch.setenv("STANDIN_ROOT", str(tmp_path / "container-tmp")) + containered(tmp_path / "container-bin", monkeypatch) monkeypatch.setenv( "STANDIN_NODES", json.dumps( diff --git a/tests/integration/machines/test_runtimes.py b/tests/integration/machines/test_runtimes.py index 91af77ce8..1e018e0ef 100644 --- a/tests/integration/machines/test_runtimes.py +++ b/tests/integration/machines/test_runtimes.py @@ -18,6 +18,7 @@ from hmz.coganchor.machines import store from hmz.coganchor.machines.store import DockerRuntime, SSHRuntime from hmz.sdk import Hmz +from tests.stubs import CPUS if TYPE_CHECKING: from pathlib import Path @@ -167,7 +168,7 @@ def test_an_ssh_host_checked_says_what_a_run_would_learn(standins: Path) -> None assert checked.reached, checked.said assert checked.home == os.environ["HOME"] - assert checked.cpus == len(os.sched_getaffinity(0)) + assert checked.cpus == CPUS assert checked.memory > 0 asked = standins.read_text() # one ssh, carrying the probe a run makes assert asked.count("-T -o BatchMode=no") == 1 diff --git a/tests/integration/tui/test_runtimes.py b/tests/integration/tui/test_runtimes.py index 32a5d4349..ad91835e8 100644 --- a/tests/integration/tui/test_runtimes.py +++ b/tests/integration/tui/test_runtimes.py @@ -66,7 +66,7 @@ picks, rows, ) -from tests.stubs import written +from tests.stubs import CPUS, written from tests.tui.fixtures import until if TYPE_CHECKING: @@ -275,7 +275,7 @@ async def test_an_ssh_host_is_added_on_one_form_and_asked_what_it_has( ) # Asked what a run would ask it, down the road a run takes: its home, its CPUs. assert f"home {os.environ['HOME']}" in _under(app) - assert f"{len(os.sched_getaffinity(0))} CPUs" in _under(app) + assert f"{CPUS} CPUs" in _under(app) assert sheet.under() == "ssh/gpu" assert "me@gpu.example:2200 ยท key ~/.ssh/gpu" in _drawn(app) assert "ssh/gpu saved to" in "\n".join(sheet._told) diff --git a/tests/machines/fixtures.py b/tests/machines/fixtures.py index d7a3844dc..b948facaf 100644 --- a/tests/machines/fixtures.py +++ b/tests/machines/fixtures.py @@ -21,6 +21,7 @@ import contextlib import json import os +import shlex import shutil import socket import subprocess @@ -315,6 +316,7 @@ def context(daemon: None, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> st elif command == "exec": if "STANDIN_STOPPED" in os.environ: sys.exit("Error response from daemon: container c0ffee is not running") + os.environ["PATH"] = os.environ["STANDIN_CONTAINER"] + os.pathsep + os.environ["PATH"] moved = os.environ["STANDIN_ROOT"] words = [word.replace("/tmp/humanize", moved) for word in rest[2:]] os.execvp(words[0], words) @@ -335,6 +337,65 @@ def context(daemon: None, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> st """ +#: The `python3` a stand-in container runs the bundle with: this machine's, except that it says +#: it is Linux at the handshake, as a container does -- Docker Desktop's on a Mac included -- +#: whatever the machine running the tests is. Anything that is not the bundle is let through. +_CONTAINER_PYTHON = """\ +#!/bin/sh +case $1 in + *.pyz) exec {python} {linux} "$@" ;; +esac +exec {python} "$@" +""" + +#: What runs the bundle there, with the handshake's platform the one thing changed. +_LINUX = """\ +import sys, types + +sys.argv.pop(0) +sys.path.insert(0, sys.argv[0]) + +from hmz.coganchor.serve import server + + +class _Linux(types.ModuleType): + def __getattr__(self, name): + return getattr(sys, name) + + +server.sys = _Linux("sys") +server.sys.platform = "linux" + +from hmz.cli import main + +raise SystemExit(main()) +""" + + +def containered(at: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """Has a stand-in `docker exec` run what it is handed as a container would. + + Here rather than in a container, so on this machine, and so on a Mac when the tests run + on one -- where a target would otherwise say `darwin`, and be refused by a runtime whose + containers are Linux, as every docker daemon's are. + + Args: + at: A directory of the test's own to put the container's `python3` in. + monkeypatch: What the stand-in is told where it is by. + """ + at.mkdir() + linux = at / "linux.py" + linux.write_text(_LINUX) + python = at / "python3" + python.write_text( + _CONTAINER_PYTHON.format( + python=shlex.quote(sys.executable), linux=shlex.quote(str(linux)) + ) + ) + python.chmod(0o755) + monkeypatch.setenv("STANDIN_CONTAINER", str(at)) + + @dataclass class Standin: """The stand-in on `PATH`, and what it was asked.""" @@ -373,6 +434,7 @@ def standin(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Standin: monkeypatch.setenv("PATH", f"{bin_}{os.pathsep}{os.environ['PATH']}") monkeypatch.setenv("STANDIN_LOG", str(tmp_path / "docker.log")) monkeypatch.setenv("STANDIN_ROOT", str(tmp_path / "container-tmp")) + containered(tmp_path / "container-bin", monkeypatch) # Docker's default here is this machine's, whatever the machine running the tests says. monkeypatch.delenv("DOCKER_HOST", raising=False) # And a fresh memo of which machines hold the bundle, so each test pays for its own. diff --git a/tests/stubs.py b/tests/stubs.py index fd0e811dc..b0184ab09 100644 --- a/tests/stubs.py +++ b/tests/stubs.py @@ -15,6 +15,7 @@ from __future__ import annotations import json +import os from dataclasses import dataclass, field from typing import TYPE_CHECKING, Any @@ -25,10 +26,18 @@ from hmz.runtime.flowing.skills import SKILLS if TYPE_CHECKING: - import os from collections.abc import Mapping, Sequence from pathlib import Path +#: The CPUs a command started on this machine may run on, as `nproc` and humanize's own probe +#: count them: those the process is pinned to where the kernel says (Linux), every one online +#: where it does not (macOS). +CPUS = ( + len(os.sched_getaffinity(0)) + if hasattr(os, "sched_getaffinity") + else os.cpu_count() or 1 +) + @dataclass(frozen=True, kw_only=True, slots=True) class HereAnchor(AnchorConfig): diff --git a/tests/supervising.py b/tests/supervising.py index 36fd34e93..43b53f997 100644 --- a/tests/supervising.py +++ b/tests/supervising.py @@ -92,6 +92,11 @@ def _cannot_import() -> str: #: can. A module gated on this skips itself before reaching for the bindings. WITHOUT_BINDINGS = _cannot_import() +#: The mark that leaves out a test reaching for those bindings where they will not import. +bound = pytest.mark.skipif( + bool(WITHOUT_BINDINGS), reason=WITHOUT_BINDINGS or "the bindings import here" +) + def _cannot_trace() -> str: """Why a redirected run cannot be watched on this machine, or "" where one can. diff --git a/tests/unit/coganchor/test_anchor.py b/tests/unit/coganchor/test_anchor.py index ff409d04e..131cd5870 100644 --- a/tests/unit/coganchor/test_anchor.py +++ b/tests/unit/coganchor/test_anchor.py @@ -23,6 +23,7 @@ from hmz.coganchor import AnchorConfig from hmz.coganchor.argv import parser from hmz.coganchor.fence import ALL, READ, Fence +from tests.supervising import bound if TYPE_CHECKING: from pathlib import Path @@ -154,6 +155,7 @@ def test_a_harness_elsewhere_answers_the_workspaces_own_path_with_its_mirror() - assert _aliases(peer, mirror, f"{mirror}/project") == () +@bound def test_a_mirror_that_cannot_be_made_here_says_so_rather_than_what_it_failed_with( tmp_path: Path, capsys: pytest.CaptureFixture[str] ) -> None: diff --git a/tests/unit/coganchor/test_fence.py b/tests/unit/coganchor/test_fence.py index 8f3b2942a..d1e1d161c 100644 --- a/tests/unit/coganchor/test_fence.py +++ b/tests/unit/coganchor/test_fence.py @@ -36,6 +36,7 @@ ) from hmz.coganchor.fence.wrap import CACHES, PROXIES, environ from hmz.coganchor.linux import landlock +from tests.supervising import bound HOME = "/home/someone" WORK = "/home/someone/work" @@ -191,6 +192,7 @@ def prctl(self, *_: object) -> int: return 0 +@bound @pytest.mark.parametrize(("online", "bound"), [(False, (0, 41234)), (True, ())]) def test_the_wrapper_binds_only_the_ports_it_is_told_and_only_offline( monkeypatch: pytest.MonkeyPatch, online: bool, bound: tuple[int, ...] diff --git a/tests/unit/coganchor/test_fence_abroad.py b/tests/unit/coganchor/test_fence_abroad.py index edef045ee..98764272a 100644 --- a/tests/unit/coganchor/test_fence_abroad.py +++ b/tests/unit/coganchor/test_fence_abroad.py @@ -22,6 +22,7 @@ from hmz.coganchor.fence.abroad import drawn, installed, told from hmz.coganchor.machines import AnchoredConfig from hmz.coganchor.proto import hello_fences +from tests.supervising import bound if TYPE_CHECKING: from pathlib import Path @@ -480,6 +481,7 @@ def test_an_anchored_agent_is_let_what_its_driver_grants_it() -> None: assert held.scopes == (ALL, READ, READ) +@bound @pytest.mark.usefixtures("target") def test_an_agent_walled_in_here_offline_listens_as_the_wrapper_lets_it( tmp_path: Path, diff --git a/tests/unit/coganchor/test_loopback.py b/tests/unit/coganchor/test_loopback.py index 143109566..d0222b48a 100644 --- a/tests/unit/coganchor/test_loopback.py +++ b/tests/unit/coganchor/test_loopback.py @@ -13,6 +13,11 @@ import pytest +from tests.supervising import WITHOUT_BINDINGS + +if WITHOUT_BINDINGS: + pytest.skip(WITHOUT_BINDINGS, allow_module_level=True) + from hmz.coganchor.fence.loopback import loopback From 7227a6595666610ea8eef9345371c25b0b6a0103 Mon Sep 17 00:00:00 2001 From: Zijian Zhang <35801754+futrime@users.noreply.github.com> Date: Sat, 3 Oct 2026 03:21:39 +0000 Subject: [PATCH 2/3] fix(rendezvous): do not wait on a hostname that will not resolve A half offered the broker the addresses its hostname resolves to, and asked the resolver after connecting. On a Mac whose `.local` no one answers for, that lookup takes 35 s to fail -- past the 30 s the broker waits for a half's first word -- so every meeting ended with the broker gone. The lookup is now given 1 s and left out after that. Co-Authored-By: Claude Opus 5.5 --- docs/reference/remote-execution.md | 4 ++- src/hmz/coganchor/rendezvous.py | 39 ++++++++++++++++++++++++------ 2 files changed, 35 insertions(+), 8 deletions(-) diff --git a/docs/reference/remote-execution.md b/docs/reference/remote-execution.md index cfa371266..fb60a6630 100644 --- a/docs/reference/remote-execution.md +++ b/docs/reference/remote-execution.md @@ -600,7 +600,9 @@ machine, humanize books a meeting: else `$HUMANIZE_RENDEZVOUS`, else this machine's outward-facing IPv4 address (the source address of a route to `192.0.2.1`; `127.0.0.1` if there is none). 3. It spawns `hmz internal anchor --target peer://TICKET@HOST:PORT` on the harness machine. -4. Both halves dial the broker with the ticket and their role (`anchor` or `serve`). +4. Both halves dial the broker with the ticket and their role (`anchor` or `serve`), and offer + the address they dialled from plus the IPv4 addresses their hostname resolves to -- left + out where resolving it takes longer than 1 s, as an unanswered macOS `.local` name does. | Phase | Behaviour | Time limit | | --- | --- | --- | diff --git a/src/hmz/coganchor/rendezvous.py b/src/hmz/coganchor/rendezvous.py index 182f1d40d..d00d5344c 100644 --- a/src/hmz/coganchor/rendezvous.py +++ b/src/hmz/coganchor/rendezvous.py @@ -95,6 +95,9 @@ #: connection to something that is not listening for us. GREETING = 3.0 +#: How long a half waits for its own machine's name to resolve before going without it. +_NAMING = 1.0 + #: How long a half waits on the broker's own answers, which are sent as soon as it has them. _ANSWERING = 30.0 @@ -1008,16 +1011,38 @@ def _mine(home: socket.socket, port: int) -> list[tuple[str, int]]: where the broker is somewhere else entirely and the two halves are neighbours. """ addresses = [str(home.getsockname()[0])] - with contextlib.suppress(OSError): - for found in socket.getaddrinfo( - socket.gethostname(), port, socket.AF_INET, socket.SOCK_STREAM - ): - address = str(found[4][0]) - if address not in addresses: - addresses.append(address) + for address in _named(port): + if address not in addresses: + addresses.append(address) return [(address, port) for address in addresses] +def _named(port: int) -> list[str]: + """This machine's addresses by its own name, or none if the name is slow to say them. + + Asked of the resolver, which may not know the name at all: a Mac's `.local` is + looked up over multicast DNS and, where nothing answers, takes over half a minute to fail + -- longer than the broker waits for this half's first word, so a half that waited for it + would arrive to find the broker gone. The names are a nicety for neighbours, and are left + out rather than waited for. + """ + found: list[str] = [] + done = threading.Event() + + def asking() -> None: + with contextlib.suppress(OSError): + found.extend( + str(one[4][0]) + for one in socket.getaddrinfo( + socket.gethostname(), port, socket.AF_INET, socket.SOCK_STREAM + ) + ) + done.set() + + threading.Thread(target=asking, name="rendezvous-names", daemon=True).start() + return list(found) if done.wait(_NAMING) else [] + + def _pairs(said: Any) -> list[tuple[str, int]]: """Reads a candidate list off the wire, keeping only the addresses that can be punched to. From 62c0c2cdb50da8a6d2a498c5b402fb3a4a4eeca7 Mon Sep 17 00:00:00 2001 From: Zijian Zhang <35801754+futrime@users.noreply.github.com> Date: Sat, 3 Oct 2026 03:55:00 +0000 Subject: [PATCH 3/3] test(watchdog): look at a stopped process once the kernel says it is A SIGSTOP is taken when the process next runs, which on a loaded macOS runner came after the watchdog's first look, so the wedge was read as idle rather than stopped. Co-Authored-By: Claude Opus 5.5 --- tests/integration/agents/test_watchdog.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/tests/integration/agents/test_watchdog.py b/tests/integration/agents/test_watchdog.py index db1bcfe8f..42e555929 100644 --- a/tests/integration/agents/test_watchdog.py +++ b/tests/integration/agents/test_watchdog.py @@ -272,7 +272,13 @@ def test_a_process_that_was_stopped_is_named_as_stopped() -> None: session = agent.new() assert isinstance(session, _Stream) proc = session._start(session._command()) - psutil.Process(proc.pid).suspend() + stopped = psutil.Process(proc.pid) + stopped.suspend() + # Stopped once the kernel says so, not once it was asked: a signal is taken when the + # process next runs, which on a loaded Mac can be past the first look. + deadline = time.monotonic() + 10 + while stopped.status() != psutil.STATUS_STOPPED and time.monotonic() < deadline: + time.sleep(0.05) watch = Watchdog(session, riding=lambda: proc, window=0.2) try: with watch: