From 4737c79386bb27348c247e57453685f513b158a5 Mon Sep 17 00:00:00 2001 From: RobLe3 Date: Sat, 5 Sep 2026 22:28:08 +0200 Subject: [PATCH 1/2] fix: stop node accept loop cleanly on Windows --- src/iicp_client/node.py | 16 ++++++++++------ tests/test_serve.py | 17 +++++------------ 2 files changed, 15 insertions(+), 18 deletions(-) diff --git a/src/iicp_client/node.py b/src/iicp_client/node.py index 874b4eb..837e00d 100644 --- a/src/iicp_client/node.py +++ b/src/iicp_client/node.py @@ -1916,7 +1916,13 @@ def _json_response(self, status: int, body: bytes, cors: bool = True) -> None: pass listener.bind((host, port)) listener.listen(128) - listener.settimeout(0.5) # so the accept loop notices shutdown promptly + # Let the active asyncio implementation own accept readiness. The + # former run_in_executor loop left non-daemon executor workers blocked + # in socket.accept() during Windows interpreter shutdown, even after + # the serve task had been cancelled. A non-blocking socket keeps the + # accept lifecycle attached to this coroutine and therefore makes + # cancellation deterministic on every supported event loop. + listener.setblocking(False) mux_stop = threading.Event() async def _handle_native_conn(conn: socket.socket) -> None: @@ -1954,12 +1960,10 @@ def _route_conn(conn: socket.socket, addr: Any) -> None: # connection from the start (MSG_PEEK left the bytes in the kernel buffer). server.process_request(conn, addr) - def _accept_loop() -> None: + async def _accept_loop() -> None: while not mux_stop.is_set(): try: - conn, addr = listener.accept() - except TimeoutError: - continue + conn, addr = await loop.sock_accept(listener) except OSError: break # Peek+route off the accept thread so a slow client can't block new connections. @@ -2136,7 +2140,7 @@ async def _relay_task_handler(task: dict[str, Any]) -> dict[str, Any]: try: # #457 — run the single-port accept/route loop (replaces server.serve_forever; # the HTTP server never binds its own socket — we feed it routed connections). - await loop.run_in_executor(None, _accept_loop) + await _accept_loop() finally: self._runtime_health.mark_stopping() # BUG-3 fix: cancel background tasks BEFORE teardown so the gossip/heartbeat diff --git a/tests/test_serve.py b/tests/test_serve.py index f6c9163..ae5baac 100644 --- a/tests/test_serve.py +++ b/tests/test_serve.py @@ -38,16 +38,9 @@ async def _echo_handler(task: dict) -> dict: class _ServerHandle: """Runs IicpNode.serve in a background asyncio loop + thread. - Shutdown discipline (iter-1447 fix): teardown CANCELS the serve task - instead of calling loop.stop(). loop.stop() exits run_until_complete - without unwinding the coroutine through its `finally:` block, so the - underlying http.server.serve_forever in run_in_executor never gets - server.shutdown() called → executor thread leaks. On macOS the daemon - thread is reaped at process exit so the fixture appears to work; on - Linux (github-hosted runners) pytest's exit-handler waits indefinitely. - - Cancelling the task triggers the coroutine's finally block → calls - server.shutdown() → serve_forever exits cleanly → no thread leak. + Teardown cancels the serve task instead of stopping the event loop. The + accept loop is asyncio-owned, so cancellation unwinds the coroutine and + closes the listener without leaving a default-executor worker behind. """ def __init__(self, config: NodeConfig): @@ -76,10 +69,10 @@ def stop(self) -> None: loop = self._loop task = self._task # Cancel the serve coroutine on its own loop. This unwinds through - # node.serve()'s `finally: server.shutdown()` which exits serve_forever - # and lets the executor thread terminate cleanly. + # node.serve()'s cleanup and closes its asyncio-owned listener. loop.call_soon_threadsafe(task.cancel) self._thread.join(timeout=5) + assert not self._thread.is_alive(), "IicpNode.serve did not stop within five seconds" def _run(self) -> None: self._loop = asyncio.new_event_loop() From 74c0fdc3cf88fdb698a052f4663d5e5a6da814ed Mon Sep 17 00:00:00 2001 From: RobLe3 Date: Sat, 5 Sep 2026 23:10:28 +0200 Subject: [PATCH 2/2] fix: make Python artifact suite portable on Windows --- scripts/test_pre1_qualification_case.py | 2 +- src/iicp_client/dispatch_ticket_trust.py | 26 ++++++---- src/iicp_client/instance_lock.py | 49 ++++++++++++++++++- src/iicp_client/node.py | 17 ++++++- src/iicp_client/tunnel.py | 1 + tests/proxy/test_e2e.py | 7 ++- tests/test_backends.py | 7 ++- tests/test_cip_arcp_fixture.py | 8 +-- tests/test_consumer_cosignature_fixture.py | 4 +- ...consumer_cosignature_transcript_fixture.py | 2 +- tests/test_dispatch_admission.py | 3 +- tests/test_dispatch_ticket_trust_store.py | 35 +++++++++++-- tests/test_instance_lock.py | 19 ++++++- tests/test_operator_rename_cli.py | 4 +- tests/test_relay_http_poll.py | 12 +++-- tests/test_serve_multiplex.py | 8 ++- tests/test_service_lifecycle.py | 3 +- tests/test_service_lifecycle_identity.py | 6 ++- tests/test_tunnel.py | 12 ++++- 19 files changed, 185 insertions(+), 40 deletions(-) diff --git a/scripts/test_pre1_qualification_case.py b/scripts/test_pre1_qualification_case.py index 73b4c59..4cecc71 100755 --- a/scripts/test_pre1_qualification_case.py +++ b/scripts/test_pre1_qualification_case.py @@ -62,7 +62,7 @@ def test_referenced_test_files_exist(self) -> None: source = ROOT / node_id.split("::", 1)[0] marker = f"def {assertion}(" self.assertTrue(source.is_file(), source) - self.assertIn(marker, source.read_text()) + self.assertIn(marker, source.read_text(encoding="utf-8")) def test_every_scenario_has_one_unique_exact_assertion(self) -> None: self.assertEqual(set(module.SCENARIO_CASES), set(module.SCENARIO_COMMANDS)) diff --git a/src/iicp_client/dispatch_ticket_trust.py b/src/iicp_client/dispatch_ticket_trust.py index 3fa1858..e13fa9b 100644 --- a/src/iicp_client/dispatch_ticket_trust.py +++ b/src/iicp_client/dispatch_ticket_trust.py @@ -23,6 +23,7 @@ PROFILE = "dispatch_ticket_v2" DOMAIN = b"IICP-DISPATCH-TICKET-V2\0" +_POSIX_MODE_SEMANTICS = os.name == "posix" def _decode(value: str) -> bytes: @@ -150,9 +151,12 @@ def __init__(self, path: str | Path, *, lock_timeout_s: float = 2.0) -> None: def _prepare_directory(self) -> None: self.path.parent.mkdir(mode=0o700, parents=True, exist_ok=True) - mode = stat.S_IMODE(self.path.parent.stat().st_mode) - if mode & 0o077: - raise TrustBundleStoreError("trust store directory must be owner-only") + if self.path.parent.is_symlink() or not self.path.parent.is_dir(): + raise TrustBundleStoreError("trust store directory must be a directory, not a link") + if _POSIX_MODE_SEMANTICS: + mode = stat.S_IMODE(self.path.parent.stat().st_mode) + if mode & 0o077: + raise TrustBundleStoreError("trust store directory must be owner-only") def _acquire_lock(self) -> int: self._prepare_directory() @@ -181,7 +185,7 @@ def load(self) -> StoredTrustBundle | None: metadata = self.path.lstat() if stat.S_ISLNK(metadata.st_mode) or not stat.S_ISREG(metadata.st_mode): raise TrustBundleStoreCorrupt("trust store must be a regular file, not a link") - if stat.S_IMODE(metadata.st_mode) & 0o077: + if _POSIX_MODE_SEMANTICS and stat.S_IMODE(metadata.st_mode) & 0o077: raise TrustBundleStoreCorrupt("trust store file must be owner-only") try: raw = self.path.read_bytes() @@ -233,17 +237,19 @@ def _commit(self, bundle: TrustBundle, high_water: int) -> StoredTrustBundle: payload = json.dumps(state, sort_keys=True, separators=(",", ":")).encode() with NamedTemporaryFile(dir=self.path.parent, prefix=self.path.name + ".tmp-", delete=False) as tmp: tmp_path = Path(tmp.name) - os.fchmod(tmp.fileno(), 0o600) + if _POSIX_MODE_SEMANTICS: + os.fchmod(tmp.fileno(), 0o600) tmp.write(payload) tmp.flush() os.fsync(tmp.fileno()) try: os.replace(tmp_path, self.path) - dir_fd = os.open(self.path.parent, os.O_RDONLY) - try: - os.fsync(dir_fd) - finally: - os.close(dir_fd) + if _POSIX_MODE_SEMANTICS: + dir_fd = os.open(self.path.parent, os.O_RDONLY) + try: + os.fsync(dir_fd) + finally: + os.close(dir_fd) finally: try: tmp_path.unlink() diff --git a/src/iicp_client/instance_lock.py b/src/iicp_client/instance_lock.py index 97760bb..21cfec9 100644 --- a/src/iicp_client/instance_lock.py +++ b/src/iicp_client/instance_lock.py @@ -16,15 +16,60 @@ import os from pathlib import Path +_WINDOWS = os.name == "nt" + def _run_dir() -> Path: base = Path(os.environ.get("IICP_HOME") or (Path.home() / ".iicp")) return base / "run" +def _pid_alive_windows(pid: int) -> bool: + """Query a Windows process without using ``os.kill(pid, 0)``. + + Python maps every non-console-control ``os.kill`` signal on Windows, + including zero, to ``TerminateProcess``. A Unix-style liveness probe would + therefore kill the node it was checking. + """ + + import ctypes + from ctypes import wintypes + + process_query_limited_information = 0x1000 + still_active = 259 + error_invalid_parameter = 87 + # These APIs exist only on Windows, while mypy is normally run against the + # host platform's ctypes stubs. + kernel32 = ctypes.WinDLL("kernel32", use_last_error=True) # type: ignore[attr-defined] + kernel32.OpenProcess.argtypes = [wintypes.DWORD, wintypes.BOOL, wintypes.DWORD] + kernel32.OpenProcess.restype = wintypes.HANDLE + kernel32.GetExitCodeProcess.argtypes = [wintypes.HANDLE, ctypes.POINTER(wintypes.DWORD)] + kernel32.GetExitCodeProcess.restype = wintypes.BOOL + kernel32.CloseHandle.argtypes = [wintypes.HANDLE] + kernel32.CloseHandle.restype = wintypes.BOOL + + handle = kernel32.OpenProcess(process_query_limited_information, False, pid) + if not handle: + error = ctypes.get_last_error() # type: ignore[attr-defined] + if error == error_invalid_parameter: + return False + # A protected process is still alive; unknown query failures fail + # closed so a second node cannot start a token-rotation fight. + return True + try: + exit_code = wintypes.DWORD() + if not kernel32.GetExitCodeProcess(handle, ctypes.byref(exit_code)): + return True + return exit_code.value == still_active + finally: + kernel32.CloseHandle(handle) + + def _pid_alive(pid: int) -> bool: """True if a process with ``pid`` exists. PermissionError means it exists (we just may not signal it) — treat as alive to be safe.""" + if _WINDOWS: + return _pid_alive_windows(pid) try: os.kill(pid, 0) return True @@ -56,7 +101,7 @@ def acquire(cls, node_id: str, force: bool = False) -> InstanceLock: return cls(None) # fail open if not force and path.exists(): try: - pid = int(path.read_text().strip()) + pid = int(path.read_text(encoding="utf-8").strip()) except (ValueError, OSError): pid = None if pid is not None and pid != os.getpid() and _pid_alive(pid): @@ -65,7 +110,7 @@ def acquire(cls, node_id: str, force: bool = False) -> InstanceLock: f"Stop that process, choose a different --node, or pass --force to take over." ) try: - path.write_text(str(os.getpid())) + path.write_text(str(os.getpid()), encoding="utf-8") except OSError: return cls(None) return cls(path) diff --git a/src/iicp_client/node.py b/src/iicp_client/node.py index 837e00d..31aaffb 100644 --- a/src/iicp_client/node.py +++ b/src/iicp_client/node.py @@ -1966,7 +1966,22 @@ async def _accept_loop() -> None: conn, addr = await loop.sock_accept(listener) except OSError: break - # Peek+route off the accept thread so a slow client can't block new connections. + if not native_enabled: + # The supported HTTP-only path has nothing to classify. In + # particular, do not make ordinary HTTP service depend on + # MSG_WAITALL/MSG_PEEK behavior, which differs across socket + # implementations and caused Windows clients to be reset + # before BaseHTTPRequestHandler received the request. + try: + conn.setblocking(True) + except OSError: + conn.close() + continue + server.process_request(conn, addr) + continue + # Prefix inspection belongs only to the explicitly enabled + # experimental native multiplexer. Route it off the accept + # coroutine so a slow client cannot block new connections. threading.Thread(target=_route_conn, args=(conn, addr), daemon=True).start() if native_enabled: diff --git a/src/iicp_client/tunnel.py b/src/iicp_client/tunnel.py index b86d7ee..4d23ff6 100644 --- a/src/iicp_client/tunnel.py +++ b/src/iicp_client/tunnel.py @@ -691,6 +691,7 @@ def close(self) -> None: self.process.wait(timeout=5) except subprocess.TimeoutExpired: self.process.kill() + self.process.wait(timeout=5) logger.info("Quick Tunnel closed.") diff --git a/tests/proxy/test_e2e.py b/tests/proxy/test_e2e.py index e2fcdbb..81e4b23 100644 --- a/tests/proxy/test_e2e.py +++ b/tests/proxy/test_e2e.py @@ -120,7 +120,8 @@ def test_e2e_all_surfaces_through_real_proxy_process(): srv.node_endpoint = f"http://127.0.0.1:{mock_port}" # type: ignore[attr-defined] srv.directory_issuer = f"http://127.0.0.1:{mock_port}" # type: ignore[attr-defined] srv.ticket_private_key = Ed25519PrivateKey.generate() # type: ignore[attr-defined] - threading.Thread(target=srv.serve_forever, daemon=True).start() + server_thread = threading.Thread(target=srv.serve_forever, daemon=True) + server_thread.start() env = { "IICP_PROXY_DIRECTORY_URL": f"http://127.0.0.1:{mock_port}/api", @@ -194,4 +195,8 @@ def test_e2e_all_surfaces_through_real_proxy_process(): proc.wait(timeout=5) except subprocess.TimeoutExpired: proc.kill() + proc.wait(timeout=5) srv.shutdown() + srv.server_close() + server_thread.join(timeout=5) + assert not server_thread.is_alive(), "mock directory/node server did not stop" diff --git a/tests/test_backends.py b/tests/test_backends.py index 8a89a53..da6ba09 100644 --- a/tests/test_backends.py +++ b/tests/test_backends.py @@ -99,7 +99,10 @@ async def test_openai_compat_streaming_handler_flushes_at_utf8_byte_bound(): class _DelayedSseStream(httpx.AsyncByteStream): async def __aiter__(self): yield b'data: {"choices":[{"delta":{"content":"timed"}}]}\n\n' - await asyncio.sleep(0.1) + # Keep the next transport chunk well beyond the assertion deadline so + # the test proves timer-driven flushing without depending on a 75 ms + # scheduler window on loaded Windows builders. + await asyncio.sleep(1.0) yield b"data: [DONE]\n\n" @@ -116,7 +119,7 @@ async def test_openai_compat_streaming_handler_flushes_after_25_ms(): "payload": {"messages": []}, } ) - first = await asyncio.wait_for(anext(events), timeout=0.075) + first = await asyncio.wait_for(anext(events), timeout=0.25) assert first == {"status": "partial", "result": "timed"} await events.aclose() diff --git a/tests/test_cip_arcp_fixture.py b/tests/test_cip_arcp_fixture.py index a4f907c..cea6add 100644 --- a/tests/test_cip_arcp_fixture.py +++ b/tests/test_cip_arcp_fixture.py @@ -102,7 +102,7 @@ def _coordinator_transcript(case): def test_cip_conformance_fixture(): - fixture = json.loads((ROOT / "parity/cip-conformance-v0.json").read_text()) + fixture = json.loads((ROOT / "parity/cip-conformance-v0.json").read_text(encoding="utf-8")) assert all(_cip(case["input"]) == case["expected"] for case in fixture["cases"]) vector = fixture["canonical_receipt_vectors"][0] assert hashlib.sha256(vector["canonical_result_json"].encode()).hexdigest() == vector["response_hash"] @@ -110,13 +110,15 @@ def test_cip_conformance_fixture(): def test_arcp_evaluator_fixture(): - fixture = json.loads((ROOT / "parity/arcp-evaluator-v0.json").read_text()) + fixture = json.loads((ROOT / "parity/arcp-evaluator-v0.json").read_text(encoding="utf-8")) for case in fixture["cases"]: assert _evaluate(case) == case["expected"], case["name"] def test_arcp_coordinator_transcript_fixture(): - fixture = json.loads((ROOT / "parity/arcp-coordinator-transcript-v0.json").read_text()) + fixture = json.loads( + (ROOT / "parity/arcp-coordinator-transcript-v0.json").read_text(encoding="utf-8") + ) assert fixture["status"] == "pre-normative" for case in fixture["cases"]: assert _coordinator_transcript(case) == case["expected"], case["name"] diff --git a/tests/test_consumer_cosignature_fixture.py b/tests/test_consumer_cosignature_fixture.py index eff678b..a323c8c 100644 --- a/tests/test_consumer_cosignature_fixture.py +++ b/tests/test_consumer_cosignature_fixture.py @@ -48,7 +48,7 @@ def evaluate(value: dict[str, str]) -> dict[str, str]: def test_consumer_cosignature_fixture() -> None: - fixture = json.loads(FIXTURE.read_text()) + fixture = json.loads(FIXTURE.read_text(encoding="utf-8")) vector = fixture["canonical_vector"] encoded = canonicalize_jcs(vector["receipt"]) assert encoded.decode() == vector["canonical_json_utf8"] @@ -77,7 +77,7 @@ def test_consumer_cosignature_fixture() -> None: def test_full_jcs_vectors_and_invalid_number_domain() -> None: - fixture = json.loads(FIXTURE.read_text()) + fixture = json.loads(FIXTURE.read_text(encoding="utf-8")) for vector in fixture["jcs_vectors"]: assert canonicalize_jcs(vector["input"]).decode() == vector["canonical_json_utf8"], vector["name"] diff --git a/tests/test_consumer_cosignature_transcript_fixture.py b/tests/test_consumer_cosignature_transcript_fixture.py index ec2439d..765b29b 100644 --- a/tests/test_consumer_cosignature_transcript_fixture.py +++ b/tests/test_consumer_cosignature_transcript_fixture.py @@ -7,7 +7,7 @@ def test_consumer_cosignature_transcript_is_content_free_and_fail_closed() -> None: - data = json.loads(FIXTURE.read_text()) + data = json.loads(FIXTURE.read_text(encoding="utf-8")) messages = [step["message"] for step in data["transcript"]] assert [message["type"] for message in messages] == [ "receipt_offer", "receipt_acceptance", "settlement_request" diff --git a/tests/test_dispatch_admission.py b/tests/test_dispatch_admission.py index 17a0c82..480f1cb 100644 --- a/tests/test_dispatch_admission.py +++ b/tests/test_dispatch_admission.py @@ -125,7 +125,8 @@ def test_crash_boundaries_and_bounded_cleanup(tmp_path: Path) -> None: ).accepted assert store.cleanup(now=200, retention_s=100, limit=1) == 1 assert store.cleanup(now=200, retention_s=100, limit=1) == 1 - assert os.stat(path).st_mode & 0o077 == 0 + if os.name == "posix": + assert os.stat(path).st_mode & 0o077 == 0 def test_locked_and_corrupt_store_fail_closed(tmp_path: Path) -> None: diff --git a/tests/test_dispatch_ticket_trust_store.py b/tests/test_dispatch_ticket_trust_store.py index da596ba..3466f33 100644 --- a/tests/test_dispatch_ticket_trust_store.py +++ b/tests/test_dispatch_ticket_trust_store.py @@ -4,6 +4,7 @@ import os import threading from pathlib import Path +from unittest import mock import pytest @@ -20,7 +21,9 @@ def _fixture() -> dict: return json.loads( - (Path(__file__).parents[1] / "parity" / "dispatch-ticket-trust-store-v1.json").read_text() + (Path(__file__).parents[1] / "parity" / "dispatch-ticket-trust-store-v1.json").read_text( + encoding="utf-8" + ) ) @@ -63,6 +66,18 @@ def test_shared_store_sequence_and_explicit_recovery(tmp_path: Path) -> None: assert store.install(_bundle("v1")).status == "stale" +def test_store_uses_owner_directory_on_platforms_without_posix_modes(tmp_path: Path) -> None: + path = tmp_path / "trust" / "bundle.state" + with mock.patch( + "iicp_client.dispatch_ticket_trust._POSIX_MODE_SEMANTICS", False + ): + installed = FileTrustBundleStore(path).install(_bundle("v1")) + loaded = FileTrustBundleStore(path).load() + assert installed.status == "installed" + assert loaded is not None + assert loaded.bundle.bundle_version == 1 + + def test_corruption_permissions_and_orphan_temp_are_fail_closed(tmp_path: Path) -> None: path = tmp_path / "trust" / "bundle.state" store = FileTrustBundleStore(path) @@ -72,16 +87,26 @@ def test_corruption_permissions_and_orphan_temp_are_fail_closed(tmp_path: Path) path.write_text("{not-json", encoding="utf-8") os.chmod(path, 0o600) - with pytest.raises(TrustBundleStoreCorrupt): - store.load() + if os.name == "posix": + with pytest.raises(TrustBundleStoreCorrupt): + store.load() + else: + # Windows chmod cannot broaden an ACL; it only toggles the read-only + # attribute. The state remains protected by its owner-only directory. + assert store.load() is not None recovered = store.recover( _bundle("v1"), AdminRecoveryAuthorization("repair-corrupt-test", 1) ) assert recovered.status == "recovered" os.chmod(path, 0o644) - with pytest.raises(TrustBundleStoreCorrupt): - store.load() + if os.name == "posix": + with pytest.raises(TrustBundleStoreCorrupt): + store.load() + else: + # Windows chmod does not create a POSIX 0644 mode: owner write stays + # enabled and access remains governed by the containing directory ACL. + assert store.load() is not None def test_concurrent_writers_never_finish_below_highest_version(tmp_path: Path) -> None: diff --git a/tests/test_instance_lock.py b/tests/test_instance_lock.py index 30ec1a9..a5b891d 100644 --- a/tests/test_instance_lock.py +++ b/tests/test_instance_lock.py @@ -3,16 +3,33 @@ from __future__ import annotations import subprocess +import sys +from unittest import mock import pytest from iicp_client.instance_lock import InstanceLock, NodeAlreadyServingError +def test_windows_liveness_probe_never_calls_os_kill(): + with ( + mock.patch("iicp_client.instance_lock._WINDOWS", True), + mock.patch( + "iicp_client.instance_lock._pid_alive_windows", return_value=True + ) as windows_probe, + mock.patch("iicp_client.instance_lock.os.kill") as kill, + ): + from iicp_client.instance_lock import _pid_alive + + assert _pid_alive(1234) is True + windows_probe.assert_called_once_with(1234) + kill.assert_not_called() + + def test_live_foreign_pid_is_refused(tmp_path, monkeypatch): monkeypatch.setenv("IICP_HOME", str(tmp_path)) # a real, same-user, signalable live process holding the lock - child = subprocess.Popen(["sleep", "30"]) + child = subprocess.Popen([sys.executable, "-c", "import time; time.sleep(30)"]) try: run = tmp_path / "run" run.mkdir(parents=True, exist_ok=True) diff --git a/tests/test_operator_rename_cli.py b/tests/test_operator_rename_cli.py index 102de0a..38950b7 100644 --- a/tests/test_operator_rename_cli.py +++ b/tests/test_operator_rename_cli.py @@ -9,6 +9,7 @@ import base64 import json +import os import threading from http.server import BaseHTTPRequestHandler, HTTPServer @@ -114,7 +115,8 @@ def log_message(self, *_args): assert rc == 0 assert out.exists() assert json.loads(out.read_text()) == json.loads(export) - assert (out.stat().st_mode & 0o777) == 0o600 + if os.name == "posix": + assert (out.stat().st_mode & 0o777) == 0o600 payload = _captured["payload"] assert payload["operator_pub"] == op.operator_id assert "operator_secret" not in payload diff --git a/tests/test_relay_http_poll.py b/tests/test_relay_http_poll.py index d498cea..19c5c90 100644 --- a/tests/test_relay_http_poll.py +++ b/tests/test_relay_http_poll.py @@ -86,14 +86,18 @@ def __init__(self, config: NodeConfig): def start(self) -> _ServerHandle: self._thread.start() - self._ready.wait(timeout=5) + if not self._ready.wait(timeout=5): + raise RuntimeError("relay HTTP test server did not initialize") for _ in range(40): try: - with socket.create_connection(("127.0.0.1", self.port), timeout=0.1): - break + status, _body, _headers = self.request( + "GET", "/iicp/health", timeout=0.5 + ) + if status == 200: + return self except OSError: time.sleep(0.05) - return self + raise RuntimeError("relay HTTP test server did not become ready") def stop(self) -> None: if self._loop is None or self._task is None: diff --git a/tests/test_serve_multiplex.py b/tests/test_serve_multiplex.py index 8651795..0dad922 100644 --- a/tests/test_serve_multiplex.py +++ b/tests/test_serve_multiplex.py @@ -100,7 +100,7 @@ def test_derive_native_endpoint() -> None: assert derive_native_endpoint("not-a-url") is None -async def test_native_call_is_not_mounted_without_explicit_endpoint() -> None: +async def test_native_call_is_not_mounted_without_explicit_endpoint(monkeypatch) -> None: cfg = NodeConfig( node_id="http-only-node", endpoint="http://test-node.local", @@ -112,9 +112,15 @@ async def test_native_call_is_not_mounted_without_explicit_endpoint() -> None: assert cfg.transport_endpoint is None node = IicpNode(cfg) port = _free_port() + # HTTP-only operation must not inspect native framing flags. Removing the + # platform constant makes this a portable regression for the Windows reset + # seen when the old common path peeked at every accepted HTTP connection. + monkeypatch.delattr(socket, "MSG_WAITALL", raising=False) serve_task = asyncio.create_task(node.serve(_echo, host="127.0.0.1", port=port, node_token=None)) try: await _wait_port(port) + status = await asyncio.get_event_loop().run_in_executor(None, _http_health, port) + assert status == 200 async with IicpTcpClient("127.0.0.1", port) as client: with pytest.raises((asyncio.IncompleteReadError, TimeoutError, IicpTcpClientError)): await asyncio.wait_for(client.handshake(), timeout=2.0) diff --git a/tests/test_service_lifecycle.py b/tests/test_service_lifecycle.py index b46a868..7c3e781 100644 --- a/tests/test_service_lifecycle.py +++ b/tests/test_service_lifecycle.py @@ -254,7 +254,8 @@ def test_sqlite_persistence_is_opt_in_content_free_and_restart_safe(tmp_path: Pa restarted = SqliteLifecyclePersistence(path, max_events=3) assert restarted.status("durable").state == "streaming" assert [event.sequence for event in restarted.events_after("durable", 0)] == [1, 2] - assert stat.S_IMODE(path.stat().st_mode) == 0o600 + if os.name == "posix": + assert stat.S_IMODE(path.stat().st_mode) == 0o600 with pytest.raises(LifecycleConflict): restarted.transition("durable", "completed", {"response": "must-not-persist"}) database = path.read_bytes().lower() diff --git a/tests/test_service_lifecycle_identity.py b/tests/test_service_lifecycle_identity.py index 73f09b4..9d63f01 100644 --- a/tests/test_service_lifecycle_identity.py +++ b/tests/test_service_lifecycle_identity.py @@ -3,7 +3,11 @@ from iicp_client.service_lifecycle_identity import evaluate_lifecycle_identity -FIXTURE = json.loads((Path(__file__).parents[1] / "parity/service-lifecycle-identity-v1.json").read_text()) +FIXTURE = json.loads( + (Path(__file__).parents[1] / "parity/service-lifecycle-identity-v1.json").read_text( + encoding="utf-8" + ) +) def test_lifecycle_identity_fixture() -> None: diff --git a/tests/test_tunnel.py b/tests/test_tunnel.py index 50fc35f..d84931e 100644 --- a/tests/test_tunnel.py +++ b/tests/test_tunnel.py @@ -69,9 +69,17 @@ def test_cloudflared_override_is_absolute_executable_and_authoritative(monkeypat def _fake_bin(tmp_path, template: str, name: str = "fake-fox-1234", lifetime: float = 60.0) -> str: - p = tmp_path / "cloudflared" - p.write_text(template.format(python=sys.executable, name=name, lifetime=lifetime)) + suffix = ".py" if sys.platform == "win32" else "" + p = tmp_path / f"cloudflared{suffix}" + p.write_text( + template.format(python=sys.executable, name=name, lifetime=lifetime), + encoding="utf-8", + ) p.chmod(p.stat().st_mode | stat.S_IEXEC) + if sys.platform == "win32": + wrapper = tmp_path / "cloudflared.cmd" + wrapper.write_text(f'@"{sys.executable}" "{p}" %*\n', encoding="utf-8") + return str(wrapper) return str(p)