Skip to content
Open
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
47 changes: 43 additions & 4 deletions packages/ai/src/ai/modules/task/task_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@

import time
import asyncio
import socket
import uuid
from typing import List
from fastapi import WebSocket
Expand Down Expand Up @@ -695,22 +696,60 @@ def get_task(self, token: str) -> Task:
# Extract and return the task instance
return control.task

@staticmethod
def _is_port_free(port: int) -> bool:
"""
Check whether a TCP port is actually free on the host.

The in-memory ``_allocated_ports`` list only knows about ports handed
out by *this* engine process. It cannot see ports held by another
engine (e.g. a second workspace, or an IDE engine alongside a CLI
engine) or any unrelated process. Two engines both defaulting to
``base_port=20000`` would otherwise both hand out 20000/20001, and the
second engine's node host would silently dial the first engine's data
plane (see #1558).

Attempt a real bind on ``127.0.0.1:port`` to confirm availability.

Args:
port: TCP port number to probe.

Returns:
True if the port could be bound (free), False if it is in use.
"""
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
try:
# No SO_REUSEADDR: we want the bind to fail if another
# process (or engine) is already holding this port.
sock.bind(('127.0.0.1', port))
return True
except OSError:
return False
Comment on lines +699 to +727

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Locate the relevant file and inspect the surrounding port-allocation code.
python3 - <<'PY'
from pathlib import Path
p = Path("packages/ai/src/ai/modules/task/task_server.py")
print("exists:", p.exists())
print("lines:", sum(1 for _ in p.open()))
PY

sed -n '640,780p' packages/ai/src/ai/modules/task/task_server.py

# Find the assign_port implementation and nearby callers/usages.
rg -n "def assign_port|_is_port_free|allocated_ports|base_port|socket\.bind|port" packages/ai/src/ai/modules/task/task_server.py

Repository: rocketride-org/rocketride-server

Length of output: 9074


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Show where assign_port() is called and how the returned port is used.
rg -n "assign_port\(" packages/ai/src/ai/modules/task

# Inspect the launch/startup path around the call sites.
python3 - <<'PY'
from pathlib import Path
p = Path("packages/ai/src/ai/modules/task/task_server.py")
lines = p.read_text().splitlines()
for needle in ["assign_port(", "release_port(", "socket.bind(", "port = self.assign_port", "self.assign_port()"]:
    hits = [i+1 for i,l in enumerate(lines) if needle in l]
    print(needle, hits)
PY

Repository: rocketride-org/rocketride-server

Length of output: 586


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the task engine code around the port assignment sites.
sed -n '1460,1535p' packages/ai/src/ai/modules/task/task_engine.py

# Show any direct socket/server binds in the task engine file.
rg -n "bind\\(|listen\\(|serve|start_server|create_server|open_connection|_data_port|_debug_port" packages/ai/src/ai/modules/task/task_engine.py

Repository: rocketride-org/rocketride-server

Length of output: 6022


Hold the port reservation until the child binds
_is_port_free() only probes and closes the socket, so assign_port() can still hand out the same port to two concurrent engines before either child process binds it. Reserving the socket through startup or handing the fd to the listener is the safer fix.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/ai/src/ai/modules/task/task_server.py` around lines 699 - 727,
Replace the probe-and-close behavior in _is_port_free and the associated
assign_port flow with a reservation that remains held until the child listener
binds, or pass the reserved socket file descriptor to that listener. Ensure
concurrent engines cannot receive the same port between checking availability
and child startup, while preserving the existing port allocation behavior for
unavailable ports.

Comment on lines +720 to +727

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Map the relevant file and nearby symbols first.
ast-grep outline packages/ai/src/ai/modules/task/task_server.py --view expanded

# Read the relevant region around the port probe and allocator.
sed -n '700,780p' packages/ai/src/ai/modules/task/task_server.py

# Inspect the referenced test region.
sed -n '180,260p' packages/ai/src/ai/modules/task/test_task_server.py

# Search for other call sites or guards around port allocation.
rg -n "_is_port_free|assign_port|base_port|65535|OverflowError" packages/ai/src/ai/modules/task

Repository: rocketride-org/rocketride-server

Length of output: 5692


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Find the test file and inspect port-allocation tests.
fd -a "test_task_server.py" packages
rg -n "assign_port|_is_port_free|base_port|ephemeral|port" packages/ai/src/ai/modules/task -g '*test*'

# Read the likely test file if found.
test_file="$(fd -a "test_task_server.py" packages | head -n 1 || true)"
if [ -n "$test_file" ]; then
  wc -l "$test_file"
  sed -n '1,280p' "$test_file"
fi

# Probe socket behavior for out-of-range ports.
python3 - <<'PY'
import socket

for port in [65535, 65536, 70000, -1]:
    try:
        with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
            s.bind(("127.0.0.1", port))
        print(port, "OK")
    except Exception as e:
        print(port, type(e).__name__, e)
PY

Repository: rocketride-org/rocketride-server

Length of output: 243


🏁 Script executed:

#!/bin/bash
set -euo pipefail

test_file="packages/ai/tests/ai/modules/task/test_task_server.py"

# Inspect the relevant test area.
wc -l "$test_file"
sed -n '1,280p' "$test_file"

# Confirm the socket exception type for out-of-range ports.
python3 - <<'PY'
import socket

for port in [65535, 65536, 70000, -1]:
    try:
        with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
            s.bind(("127.0.0.1", port))
        print(port, "OK")
    except Exception as e:
        print(port, type(e).__name__, str(e))
PY

Repository: rocketride-org/rocketride-server

Length of output: 10327


Guard _is_port_free against OverflowError
packages/ai/src/ai/modules/task/task_server.py:700-727 can walk past 65535 when base_port is high, and socket.bind() raises OverflowError there. Catch it (or clamp the search range) so port allocation still falls through to RuntimeError('No available ports...') instead of crashing.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/ai/src/ai/modules/task/task_server.py` around lines 720 - 727,
Update _is_port_free and the surrounding port-allocation loop to handle
out-of-range ports above 65535: catch OverflowError alongside OSError or clamp
the search range before socket.bind. Ensure exhausted searches continue to the
existing RuntimeError('No available ports...') path rather than propagating the
overflow.


def assign_port(self) -> int:
"""
Allocate available port from managed pool.

A candidate port must be free both in this engine's own
``_allocated_ports`` list *and* on the host OS (verified with a real
bind), so two engines on the same machine never hand out the same
port and cross-connect their data planes (#1558).

Returns:
Available port number (base_port to base_port+9999 range)

Raises:
RuntimeError: If no ports available
"""
base_port = self._config.get('base_port', 20000)
# Search for available port
# Search for a port free both in our pool and on the host
for port in range(base_port, base_port + 10000):
if port not in self._allocated_ports:
self._allocated_ports.append(port)
return port
if port in self._allocated_ports:
continue
if not self._is_port_free(port):
continue
self._allocated_ports.append(port)
return port

raise RuntimeError(f'No available ports in the range {base_port}-{base_port + 9999}')

Expand Down
52 changes: 52 additions & 0 deletions packages/ai/tests/ai/modules/task/test_task_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@

from __future__ import annotations

import socket
import sys
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock
Expand Down Expand Up @@ -54,6 +55,10 @@ def _make_server(*, config=None, web_server=None):
ts._config = config if config is not None else {}
ts._server = web_server or MagicMock()
ts.debug_message = MagicMock()
# Stub the host bind-check so port-allocation *logic* tests are
# deterministic and never touch real OS ports. Tests that exercise the
# real bind behaviour override or bypass this.
ts._is_port_free = lambda port: True
return ts


Expand Down Expand Up @@ -181,6 +186,53 @@ def test_assign_port_exhausts_after_10000():
ts.assign_port()


def test_assign_port_skips_os_busy_port():
"""
A port free in `_allocated_ports` but busy on the host is skipped (#1558).

Simulates another engine/process already holding 20000: even though this
engine has never allocated it, the OS bind-check must reject it so the two
engines cannot hand out the same port and cross-connect their data planes.
"""
ts = _make_server()
# 20000 is busy on the host, everything else is free.
ts._is_port_free = lambda port: port != 20000
assert ts.assign_port() == 20001
assert ts._allocated_ports == [20001]


def test_is_port_free_detects_bound_port():
"""The real bind-check reports a bound port as busy and a free one as free."""
# Bind a real socket to an ephemeral port and confirm the check sees it busy.
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as held:
held.bind(('127.0.0.1', 0))
held.listen(1)
busy_port = held.getsockname()[1]
assert TaskServer._is_port_free(busy_port) is False

# Once released, the same port is reported free again.
assert TaskServer._is_port_free(busy_port) is True


def test_assign_port_real_bind_skips_held_port():
"""
End-to-end: assign_port (using the real bind-check) skips a port held by
another socket and returns the next free one (#1558).
"""
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as held:
held.bind(('127.0.0.1', 0))
held.listen(1)
busy_port = held.getsockname()[1]

ts = _make_server(config={'base_port': busy_port})
# Use the real host bind-check, not the deterministic stub.
del ts._is_port_free
assigned = ts.assign_port()

assert assigned != busy_port
assert assigned == busy_port + 1 or assigned > busy_port


# ---------------------------------------------------------------------------
# get_task_control / get_task_control_by_public_key / get_task
# ---------------------------------------------------------------------------
Expand Down
Loading