diff --git a/CHANGELOG.md b/CHANGELOG.md
index 30503ff..467c61a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,6 +9,16 @@ in the main repo).
## [Unreleased]
+### Fixed — Windows artifact validation
+
+- Keep proxy startup compatible with redirected cp1252 consoles.
+- Classify fragmented experimental native prefixes without the Windows-
+ incompatible combination of socket peek and wait-all flags; retain a bounded
+ deadline and leave HTTP-only service behavior unchanged.
+- Correct Windows test fixtures for executable discovery, home directories,
+ service-path quoting and corrupt trust-store rejection. Measure streaming
+ flush behavior after input delivery rather than including client setup.
+
### Security — bounded stable HTTP task path
- Enforce the protocol's 1 MiB encoded request and response boundary for
diff --git a/src/iicp_client/cli.py b/src/iicp_client/cli.py
index 66fdb86..7ef7e89 100644
--- a/src/iicp_client/cli.py
+++ b/src/iicp_client/cli.py
@@ -2783,7 +2783,7 @@ def _cmd_proxy(args: argparse.Namespace) -> int:
# Flag/env precedence: explicit --host/--port override the TOML/env-loaded config.
cfg.host = args.host
cfg.port = args.port
- print(f"iicp-node proxy → http://{cfg.host}:{cfg.port} (OpenAI/Ollama/Anthropic compat; no directory registration)")
+ print(f"iicp-node proxy -> http://{cfg.host}:{cfg.port} (OpenAI/Ollama/Anthropic compat; no directory registration)")
uvicorn.run(create_app(cfg), host=cfg.host, port=cfg.port, server_header=False)
return 0
diff --git a/src/iicp_client/native_preface.py b/src/iicp_client/native_preface.py
new file mode 100644
index 0000000..cb6b11f
--- /dev/null
+++ b/src/iicp_client/native_preface.py
@@ -0,0 +1,18 @@
+"""Bounded, non-consuming classification for experimental native multiplexing."""
+import socket
+import time
+
+
+def peek_protocol_prefix(conn: socket.socket, magic: bytes, *, timeout: float) -> bytes:
+ deadline = time.monotonic() + timeout
+ while True:
+ remaining = deadline - time.monotonic()
+ if remaining <= 0:
+ raise TimeoutError("incomplete protocol prefix")
+ conn.settimeout(remaining)
+ prefix = conn.recv(len(magic), socket.MSG_PEEK)
+ if not prefix or len(prefix) == len(magic) or not magic.startswith(prefix):
+ return prefix
+ # Peeking leaves partial bytes readable, so select alone would spin.
+ # Bound both the wait and retry rate until the entire magic arrives.
+ time.sleep(min(0.005, remaining))
diff --git a/src/iicp_client/node.py b/src/iicp_client/node.py
index 31aaffb..13261cf 100644
--- a/src/iicp_client/node.py
+++ b/src/iicp_client/node.py
@@ -40,6 +40,7 @@
from iicp_client.effective_capability import EffectiveCapability, effective_capability_to_dict
from iicp_client.idempotency import IdempotencyGuard
from iicp_client.iicp_tcp import IICP_MAGIC, IicpTcpServer # #457 single-port multiplexer
+from iicp_client.native_preface import peek_protocol_prefix
from iicp_client.peer_manager import PeerManager
from iicp_client.scheduler import QUEUE_WAIT_S, is_queue_eligible
@@ -1942,10 +1943,9 @@ async def _handle_native_conn(conn: socket.socket) -> None:
def _route_conn(conn: socket.socket, addr: Any) -> None:
try:
- conn.settimeout(10.0)
- # Wait for the full 4-byte prefix without consuming it; the chosen consumer
- # then parses from the start. MSG_WAITALL avoids misrouting on a fragmented magic.
- prefix = conn.recv(4, socket.MSG_PEEK | socket.MSG_WAITALL)
+ # Preserve fragmented magic without the non-portable
+ # MSG_PEEK|MSG_WAITALL combination (WSAEOPNOTSUPP on Windows).
+ prefix = peek_protocol_prefix(conn, IICP_MAGIC, timeout=10.0)
except OSError:
try:
conn.close()
diff --git a/tests/test_backends.py b/tests/test_backends.py
index da6ba09..faca464 100644
--- a/tests/test_backends.py
+++ b/tests/test_backends.py
@@ -97,8 +97,12 @@ async def test_openai_compat_streaming_handler_flushes_at_utf8_byte_bound():
class _DelayedSseStream(httpx.AsyncByteStream):
+ def __init__(self, delivered):
+ self.delivered = delivered
+
async def __aiter__(self):
yield b'data: {"choices":[{"delta":{"content":"timed"}}]}\n\n'
+ self.delivered.set()
# 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.
@@ -108,8 +112,9 @@ async def __aiter__(self):
@respx.mock
async def test_openai_compat_streaming_handler_flushes_after_25_ms():
+ delivered = asyncio.Event()
respx.post("http://localhost:11434/v1/chat/completions").mock(
- return_value=httpx.Response(200, stream=_DelayedSseStream())
+ return_value=httpx.Response(200, stream=_DelayedSseStream(delivered))
)
handler = openai_compat_streaming_handler(model="qwen")
events = handler(
@@ -119,9 +124,16 @@ async def test_openai_compat_streaming_handler_flushes_after_25_ms():
"payload": {"messages": []},
}
)
- first = await asyncio.wait_for(anext(events), timeout=0.25)
- assert first == {"status": "partial", "result": "timed"}
- await events.aclose()
+ # Measure flushing after input delivery, not Windows TLS/client setup.
+ first_task = asyncio.create_task(anext(events))
+ try:
+ await asyncio.wait_for(delivered.wait(), timeout=5.0)
+ first = await asyncio.wait_for(first_task, timeout=0.25)
+ assert first == {"status": "partial", "result": "timed"}
+ finally:
+ first_task.cancel()
+ await asyncio.gather(first_task, return_exceptions=True)
+ await events.aclose()
@respx.mock
diff --git a/tests/test_dispatch_ticket_trust_store.py b/tests/test_dispatch_ticket_trust_store.py
index 3466f33..1a874bc 100644
--- a/tests/test_dispatch_ticket_trust_store.py
+++ b/tests/test_dispatch_ticket_trust_store.py
@@ -87,13 +87,9 @@ 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)
- 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
+ # JSON corruption is invalid on every OS, independently of ACL semantics.
+ with pytest.raises(TrustBundleStoreCorrupt):
+ store.load()
recovered = store.recover(
_bundle("v1"), AdminRecoveryAuthorization("repair-corrupt-test", 1)
diff --git a/tests/test_native_preface.py b/tests/test_native_preface.py
new file mode 100644
index 0000000..d431b21
--- /dev/null
+++ b/tests/test_native_preface.py
@@ -0,0 +1,43 @@
+import socket
+import threading
+import time
+from unittest.mock import Mock, patch
+
+import pytest
+
+from iicp_client.native_preface import peek_protocol_prefix
+
+
+def test_fragmented_magic_is_not_consumed(monkeypatch):
+ monkeypatch.delattr(socket, 'MSG_WAITALL', raising=False)
+ reader, writer = socket.socketpair()
+ def send():
+ for byte in b'IICP':
+ writer.sendall(bytes([byte]))
+ time.sleep(0.01)
+ thread = threading.Thread(target=send)
+ thread.start()
+ try:
+ assert peek_protocol_prefix(reader, b'IICP', timeout=2) == b'IICP'
+ assert reader.recv(4) == b'IICP'
+ finally:
+ thread.join()
+ reader.close()
+ writer.close()
+
+
+def test_partial_prefix_timeout_is_bounded():
+ conn = Mock()
+ conn.recv.return_value = b'I'
+ with patch('iicp_client.native_preface.time.monotonic', side_effect=[0, 0, 2]):
+ with pytest.raises(TimeoutError):
+ peek_protocol_prefix(conn, b'IICP', timeout=1)
+ assert conn.recv.call_count == 1
+
+
+@pytest.mark.parametrize('prefix', [b'', b'GET ', b'P'])
+def test_eof_and_http_prefixes_return_without_wait(prefix):
+ conn = Mock()
+ conn.recv.return_value = prefix
+ assert peek_protocol_prefix(conn, b'IICP', timeout=1) == prefix
+ conn.recv.assert_called_once_with(4, socket.MSG_PEEK)
diff --git a/tests/test_proxy_cli_encoding.py b/tests/test_proxy_cli_encoding.py
new file mode 100644
index 0000000..2f76ab6
--- /dev/null
+++ b/tests/test_proxy_cli_encoding.py
@@ -0,0 +1,18 @@
+"""Redirected Windows consoles must not prevent proxy startup."""
+import io
+from argparse import Namespace
+from unittest.mock import Mock
+
+from iicp_client import cli
+
+
+def test_proxy_startup_supports_cp1252_stdout(monkeypatch, tmp_path):
+ run = Mock()
+ monkeypatch.setattr('uvicorn.run', run)
+ buffer = io.BytesIO()
+ output = io.TextIOWrapper(buffer, encoding='cp1252', errors='strict')
+ monkeypatch.setattr(cli.sys, 'stdout', output)
+ assert cli._cmd_proxy(Namespace(config=str(tmp_path / "absent.toml"), host='127.0.0.1', port=9484)) == 0
+ output.flush()
+ assert b'iicp-node proxy ->' in buffer.getvalue()
+ run.assert_called_once()
diff --git a/tests/test_runtime_health.py b/tests/test_runtime_health.py
index adf99f9..2e3eb49 100644
--- a/tests/test_runtime_health.py
+++ b/tests/test_runtime_health.py
@@ -28,6 +28,7 @@ def test_healthcheck_cli_exit_semantics(tmp_path, monkeypatch, capsys):
from iicp_client.cli import _cmd_healthcheck
monkeypatch.setenv("HOME", str(tmp_path))
+ monkeypatch.setenv("USERPROFILE", str(tmp_path))
health = RuntimeHealth()
health.mark_running()
health.advance_runtime()
diff --git a/tests/test_service.py b/tests/test_service.py
index d3d775b..c7e567c 100644
--- a/tests/test_service.py
+++ b/tests/test_service.py
@@ -1,5 +1,7 @@
from __future__ import annotations
+import shlex
+
from iicp_client import cli
from iicp_client.service import render_launchd, render_systemd
@@ -97,7 +99,7 @@ def test_service_preserves_only_explicit_tunnel_policy_and_resolved_binary(monke
resolved = str(binary.resolve())
assert f"IICP_CLOUDFLARED_PATH{resolved}" in launchd.content
assert "IICP_TUNNEL1" in launchd.content
- assert f"Environment=IICP_CLOUDFLARED_PATH={resolved}" in systemd.content
+ assert f"Environment=IICP_CLOUDFLARED_PATH={shlex.quote(resolved)}" in systemd.content
assert "Environment=IICP_TUNNEL=1" in systemd.content
assert "IICP_ENABLE_EXPERIMENTAL_NATIVE_TCP1" in launchd.content
assert "Environment=IICP_ENABLE_EXPERIMENTAL_NATIVE_TCP=1" in systemd.content
diff --git a/tests/test_tunnel.py b/tests/test_tunnel.py
index d84931e..0cf9dd2 100644
--- a/tests/test_tunnel.py
+++ b/tests/test_tunnel.py
@@ -53,7 +53,7 @@
def test_cloudflared_override_is_absolute_executable_and_authoritative(monkeypatch, tmp_path):
- binary = tmp_path / "cloudflared"
+ binary = tmp_path / ("cloudflared.exe" if sys.platform == "win32" else "cloudflared")
binary.write_text("#!/bin/sh\nexit 0\n")
binary.chmod(0o700)