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: 2 additions & 0 deletions .agents/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
30 changes: 30 additions & 0 deletions .agents/evidence/verify/2026-10-18-distinct-local-server-ports.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
17 changes: 16 additions & 1 deletion client-sdk/tests/test_api/server_e2e/_live.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
49 changes: 49 additions & 0 deletions client-sdk/tests/test_api/server_e2e/test_live_ports.py
Original file line number Diff line number Diff line change
@@ -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)
6 changes: 5 additions & 1 deletion docs/en/guide/testing-e2e.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

Expand Down Expand Up @@ -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.
6 changes: 5 additions & 1 deletion docs/zh/guide/testing-e2e.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` | **是** | 相同 | 更久 |

Expand Down Expand Up @@ -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 端口。这种本地分配重选不重放请求,也不替代真实云验收。确定性复用回归启动真实服务、检查两个监听器,再由正常夹具清理将它们停止。
Loading