Skip to content
Merged
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
2 changes: 1 addition & 1 deletion scripts/test_pre1_qualification_case.py
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
26 changes: 16 additions & 10 deletions src/iicp_client/dispatch_ticket_trust.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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()
Expand Down
49 changes: 47 additions & 2 deletions src/iicp_client/instance_lock.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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):
Expand All @@ -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)
Expand Down
33 changes: 26 additions & 7 deletions src/iicp_client/node.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -1954,15 +1960,28 @@ 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.
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:
Expand Down Expand Up @@ -2136,7 +2155,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
Expand Down
1 change: 1 addition & 0 deletions src/iicp_client/tunnel.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.")


Expand Down
7 changes: 6 additions & 1 deletion tests/proxy/test_e2e.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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"
7 changes: 5 additions & 2 deletions tests/test_backends.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"


Expand All @@ -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()

Expand Down
8 changes: 5 additions & 3 deletions tests/test_cip_arcp_fixture.py
Original file line number Diff line number Diff line change
Expand Up @@ -102,21 +102,23 @@ 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"]
assert hmac.new(vector["hmac_key_utf8"].encode(), vector["canonical_message"].encode(), hashlib.sha256).hexdigest() == vector["signature_hmac_sha256"]


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"]
4 changes: 2 additions & 2 deletions tests/test_consumer_cosignature_fixture.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand Down Expand Up @@ -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"]

Expand Down
2 changes: 1 addition & 1 deletion tests/test_consumer_cosignature_transcript_fixture.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
3 changes: 2 additions & 1 deletion tests/test_dispatch_admission.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
35 changes: 30 additions & 5 deletions tests/test_dispatch_ticket_trust_store.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import os
import threading
from pathlib import Path
from unittest import mock

import pytest

Expand All @@ -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"
)
)


Expand Down Expand Up @@ -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)
Expand All @@ -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:
Expand Down
Loading
Loading