diff --git a/.agents/README.md b/.agents/README.md index 36901d2..668d659 100644 --- a/.agents/README.md +++ b/.agents/README.md @@ -55,3 +55,5 @@ Accepted engine recovery: [avoid uncertain mutation replay](notes/implemented/ar - [Legacy retry compatibility](notes/proposed/architecture/2026-10-18-retry-compatibility.md). Builtin transport: [direct HTTP](notes/implemented/architecture/2026-10-18-native-builtin-data-plane.md). + +- [Distinct local listener ports](notes/proposed/process/2026-10-18-distinct-local-server-ports.md). diff --git a/.agents/evidence/verify/2026-10-18-distinct-local-server-ports.md b/.agents/evidence/verify/2026-10-18-distinct-local-server-ports.md new file mode 100644 index 0000000..3eebb0c --- /dev/null +++ b/.agents/evidence/verify/2026-10-18-distinct-local-server-ports.md @@ -0,0 +1,30 @@ +# Verification: distinct local HTTP and PTY ports + +- Date:2026-10-18 +- Result:PARTIAL; client/cloud/quality/package PASS; six local server baseline restrictions, hosted pending. +- Base:ac3787a; production source unchanged. + +Actual failed CI job: [https://github.com/Alibaba-Agent-Sandbox/python-sdk/actions/runs/39109163353/job/117621358258](https://github.com/Alibaba-Agent-Sandbox/python-sdk/actions/runs/39109163353/job/117621358258). +It reports EADDRINUSE and HTTP1.0/404 for a WebSocket handshake. The exact job +URL is recorded from GitHub metadata rather than inferred from a run number. +Before fix:two deterministic default/explicit port-reuse cases FAIL, matching +the bind collision. After fix:20 actual local listener/policy cases PASS. +Only local allocation retries occur before any listener/request starts; no +mutation is replayed. + +Full client:2085 PASS /21 SKIP /10 deselected; two existing warning categories. +make lint typecheck format-check:PASS (80 client/56 server typed files). +Configured Express real cloud:116 PASS /4 SKIP /1 XFAIL /2 deselected; native +OpenSandbox/Cube/E2B/container prerequisites are absent and not called passes. +Known platform Jupyter expression behavior remains XFAIL. Independent janitor +exit0,zero candidates out of zero listed. No resources leaked. +No historical evidence is modified and no cloud pass is inferred from local IPC. + +Server full local suite:854 PASS /6 FAIL. The same exact six raw-socket +cases FAIL against accepted unchanged ac3787a in3.98s; their intercepted raw +requests receive the host501 audit-disabled response or no application response. +The production server tree is unchanged. This local suite is not called PASS; +required latest-head hosted Python3.10/3.11/3.12 checks must pass before merge. +make build dist-check:PASS for both sdists/wheels, twine, both py.typed markers +and fresh combined-wheel install/import. Health106 PASS/1 existing WARN/0 FAIL; +hygiene/whitespace PASS. Original local failures remain preserved. diff --git a/.agents/notes/proposed/process/2026-10-18-distinct-local-server-ports.md b/.agents/notes/proposed/process/2026-10-18-distinct-local-server-ports.md new file mode 100644 index 0000000..668b8fb --- /dev/null +++ b/.agents/notes/proposed/process/2026-10-18-distinct-local-server-ports.md @@ -0,0 +1,46 @@ +# Decision: Keep local HTTP and PTY test listeners on distinct ports +Status: proposed + +## Problem + +A released ephemeral port can be selected again. The client component fixture +chooses HTTP and PTY separately, so identical candidates cause EADDRINUSE and +misroute a WebSocket handshake to the HTTP listener. Python3.11 CI exposed this. +https://github.com/Alibaba-Agent-Sandbox/python-sdk/issues/63 + +## Decision + +Select a different candidate before creating listeners, with32 bounded attempts. +Preserve explicit PTY selection by moving the HTTP candidate when necessary. +Add deterministic default/explicit reuse regressions with actual server listeners +and normal cleanup. No production request retry or transport behavior change. + +## API Design + +Only the private test fixture changes. Existing product APIs are unchanged. + +## Alternatives considered + +- Rerun the failed job only: leaves the reproducible fixture race. +- Retry WebSocket requests: masks the wrong listener and does not fix allocation. +- Override an explicit PTY port: violates caller fixture configuration. + +## Dependencies + +This independently unblocks configuration refactor +https://github.com/Alibaba-Agent-Sandbox/python-sdk/pull/61. +No consumer repository or runtime dependency changes. + +## Test Strategy + +Force the candidate allocator to return the same released port. Both regression +cases fail before the fix, then actual HTTP/PTY listeners and policy cases pass. +Run full client/quality/hosted matrix and required real-cloud acceptance; preserve +initial failures and independently verify cleanup. + +## Acceptance criteria + +- Default and explicit PTY cases retain distinct live endpoints. +- No HTTP/PTY bind collision in the deterministic regression. +- Existing client/policy behavior and required CI/real cloud remain green. +- English/Chinese testing docs, changelog and owner evidence are complete. diff --git a/CHANGELOG.md b/CHANGELOG.md index 04223ee..9d7a29d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/). ### Fixed +- Keep HTTP and PTY component-test port candidates distinct, preserving explicit PTY settings and preventing intermittent WebSocket CI bind collisions. + - Verified native builtin Droid background installation, keyless SDK/HTTP runtime and owned cleanup; source-runtime evidence is distinct from template/image/model acceptance. - Python Node-provider engine recovery no longer repeats mutations after a lost connection, including SSE before its first event. Unknown outcomes raise `ProviderProtocolError`; safe reads and explicit authentication rejection retain bounded recovery. diff --git a/client-sdk/tests/test_api/server_e2e/_live.py b/client-sdk/tests/test_api/server_e2e/_live.py index 638179b..b66db28 100644 --- a/client-sdk/tests/test_api/server_e2e/_live.py +++ b/client-sdk/tests/test_api/server_e2e/_live.py @@ -22,12 +22,27 @@ def free_port() -> int: return port +def distinct_port(other: int) -> int: + """A released candidate can be reused; keep the two listeners separate.""" + for _ in range(32): + candidate = free_port() + if candidate != other: + return candidate + raise RuntimeError("could not select distinct local HTTP and PTY ports") + + class LiveServer: """Runs ``SandboxServer.serve`` in a daemon thread; ``close()`` stops it.""" def __init__(self, **kwargs: Any) -> None: self.port = free_port() - self.pty_port: int = kwargs.setdefault("pty_port", free_port()) + if "pty_port" in kwargs: + self.pty_port: int = kwargs["pty_port"] + if self.port == self.pty_port: + self.port = distinct_port(self.pty_port) + else: + self.pty_port = distinct_port(self.port) + kwargs["pty_port"] = self.pty_port self.registry: CommandRegistry = kwargs.pop("registry", None) or CommandRegistry() self.server = SandboxServer(host="127.0.0.1", registry=self.registry, **kwargs) self.thread = threading.Thread(target=self.server.serve, args=(self.port,), daemon=True) diff --git a/client-sdk/tests/test_api/server_e2e/test_live_ports.py b/client-sdk/tests/test_api/server_e2e/test_live_ports.py new file mode 100644 index 0000000..3c111e5 --- /dev/null +++ b/client-sdk/tests/test_api/server_e2e/test_live_ports.py @@ -0,0 +1,49 @@ +"""Regression for an OS allocator reusing a released ephemeral port.""" + +from __future__ import annotations + +import socket +import time +from typing import TYPE_CHECKING, Any + +import pytest + +from . import _live + +if TYPE_CHECKING: + from collections.abc import Callable + + from agent_sandbox.api.server_client import ServerClient + + +@pytest.mark.parametrize("explicit_pty", [False, True]) +def test_reused_candidate_keeps_real_http_and_pty_listeners_separate( + monkeypatch: pytest.MonkeyPatch, + server_factory: Callable[..., tuple[_live.LiveServer, ServerClient]], + explicit_pty: bool, +) -> None: + with socket.socket() as http, socket.socket() as pty: + http.bind(("127.0.0.1", 0)) + pty.bind(("127.0.0.1", 0)) + http_port, pty_port = http.getsockname()[1], pty.getsockname()[1] + + # A released candidate may be returned again. Explicit PTY selections must + # be preserved; the HTTP candidate must move in that case. + candidates = iter([pty_port, http_port] if explicit_pty else [http_port, http_port, pty_port]) + monkeypatch.setattr(_live, "free_port", lambda: next(candidates)) + options: dict[str, Any] = {"pty_port": pty_port} if explicit_pty else {} + live, _client = server_factory(**options) + assert (live.port, live.pty_port) == (http_port, pty_port) + + # PTY startup is asynchronous. Probe only local listener readiness, bounded + # independently of requests or shell execution. + for port in (http_port, pty_port): + deadline = time.monotonic() + 3 + while True: + try: + with socket.create_connection(("127.0.0.1", port), timeout=0.2): + break + except OSError: + if time.monotonic() >= deadline: + pytest.fail("the selected local listener did not start") + time.sleep(0.01) diff --git a/docs/en/guide/testing-e2e.md b/docs/en/guide/testing-e2e.md index 54d3393..286c018 100644 --- a/docs/en/guide/testing-e2e.md +++ b/docs/en/guide/testing-e2e.md @@ -8,7 +8,7 @@ How the Python SDK (`agent-sandbox-client`) and the in-sandbox server (`agent-sa | Layer | Tool | Real components? | Credentials | Typical time | |---|---|---|---|---| -| Unit tests | `make test` (pytest in `client-sdk/tests`, `server-sdk/tests`) | No (mocks) | none | seconds | +| Unit/component tests | `make test` (pytest in both packages) | Logic mocks and real local HTTP/PTY fixtures | none | seconds to minutes | | Real-cloud E2E | `make e2e` | **Yes**: real platform, envd, in-sandbox server | `.env` or environment | ~8 min | | E2E incl. slow cases | `make e2e-all` | **Yes** | same | longer | @@ -41,3 +41,7 @@ The full rules and the ADR and evidence templates: [`.agents/notes/DEFINITION-OF ## Cloud example practice Outside the test suite, the walkthrough that runs the example scripts on the default provider is [Cloud end-to-end practice](cloud-examples.md). The Agents API case is not on that page; see [Agents API](agents-api.md). + +## Local HTTP and PTY fixture ports + +The client component fixture selects distinct HTTP and PTY port candidates before starting either listener. A released ephemeral candidate may be returned again; selection retries are bounded and preserve an explicit PTY port. These local allocation retries do not replay a request or replace real-cloud acceptance. The deterministic reuse regression starts the actual server and verifies both listeners, then normal fixture cleanup shuts them down. diff --git a/docs/zh/guide/testing-e2e.md b/docs/zh/guide/testing-e2e.md index 76c550f..d9c935c 100644 --- a/docs/zh/guide/testing-e2e.md +++ b/docs/zh/guide/testing-e2e.md @@ -8,7 +8,7 @@ Python SDK(`agent-sandbox-client`)与沙箱内服务(`agent-sandbox-server`)如 | 层 | 工具 | 真实组件? | 凭据 | 典型耗时 | |---|---|---|---|---| -| 单元测试 | `make test`(`client-sdk/tests`、`server-sdk/tests` 下的 pytest) | 否(mock) | 无 | 几秒 | +| 单元/组件测试 | `make test`(两个包的 pytest) | 逻辑 mock 与真实本地 HTTP/PTY 夹具 | 无 | 几秒至几分钟 | | 真实云 E2E | `make e2e` | **是**:真实平台、envd、沙箱内服务 | `.env` 或环境变量 | 约 8 分钟 | | 含慢速用例的 E2E | `make e2e-all` | **是** | 相同 | 更久 | @@ -41,3 +41,7 @@ make e2e-cleanup # 清理遗留资源的 dry-run janitor;ARGS=--yes 才 ## 云上示例练习 测试套件之外,按脚本在默认提供方上走一遍的说明见 [云上端到端练习](cloud-examples.md)。Agents API 的完整案例不在那一页,见 [Agents API](agents-api.md)。 + +## 本地 HTTP 与 PTY 夹具端口 + +客户端组件夹具启动监听前会选择不同的 HTTP 与 PTY 端口候选。刚释放的临时端口可能被再次分配,候选重选有次数上限,并保留显式指定的 PTY 端口。这种本地分配重选不重放请求,也不替代真实云验收。确定性复用回归启动真实服务、检查两个监听器,再由正常夹具清理将它们停止。