Skip to content

fix(ai): verify OS port availability in assign_port to prevent cross-engine data-plane crosstalk (#1558)#1566

Open
meetp06 wants to merge 1 commit into
rocketride-org:developfrom
meetp06:fix/gh-1558-assign-port-os-check
Open

fix(ai): verify OS port availability in assign_port to prevent cross-engine data-plane crosstalk (#1558)#1566
meetp06 wants to merge 1 commit into
rocketride-org:developfrom
meetp06:fix/gh-1558-assign-port-os-check

Conversation

@meetp06

@meetp06 meetp06 commented Jul 13, 2026

Copy link
Copy Markdown
Contributor

Fixes #1558

Problem

TaskServer.assign_port() allocated data/debug ports by consulting only its own in-memory _allocated_ports list — it never verified the port was free on the host. When two engines run on one machine (two workspaces, or an IDE engine + a CLI engine), both default to base_port=20000 and both hand out 20000/20001 first. The second engine's node host then dials ws://localhost:{data_port}/task/data and connects to the first engine's data server — node hosts silently talk to the wrong engine's data plane.

It surfaces as a misleading downstream error (in the report, a vector-store/DNS failure at store.py:440), not a port collision — costing hours of misdirected debugging. Any machine running two engines is exposed.

Fix

Add a real bind test before accepting a port (the approach suggested in the issue):

  • _is_port_free(port) — attempts to bind 127.0.0.1:port. No SO_REUSEADDR, so a port already held by another process/engine fails the bind and is reported busy.
  • assign_port() — a candidate is now skipped unless it is free both in _allocated_ports and on the host OS.

Two engines on the same machine can no longer be handed the same port, so the cross-engine data-plane connection can't happen.

for port in range(base_port, base_port + 10000):
    if port in self._allocated_ports:
        continue
    if not self._is_port_free(port):   # NEW: real host check
        continue
    self._allocated_ports.append(port)
    return port

Tests

  • Existing allocation-logic tests stub the host check via the test factory so they stay deterministic (no reliance on real host ports).
  • New coverage:
    • test_assign_port_skips_os_busy_port — a port free in _allocated_ports but busy on the host is skipped.
    • test_is_port_free_detects_bound_port — the real bind-check reports a bound port busy and a released port free.
    • test_assign_port_real_bind_skips_held_port — end-to-end: assign_port skips a port held by a live socket.

Verification (run locally)

  • ruff check — All checks passed
  • ruff format --check — already formatted
  • Fix logic + real bind behavior verified against a standalone harness (held socket → skipped, released → reusable, existing scenarios unchanged). Full pytest suite needs the ai package environment; covered by CI.

Notes

  • There is an unavoidable small TOCTOU window between the bind-test and the real server bind; this fix closes the practical cross-engine collision (the other engine already holds the port for its lifetime, so the bind-test reliably fails). The issue's alternative — child-host authentication to its parent engine — is a larger, complementary hardening left as follow-up.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes
    • Improved task server port allocation to detect ports already in use by other processes.
    • Reduced the risk of port conflicts when multiple engine processes share the same port range.
    • Added validation to ensure allocated ports are genuinely available on the host.

)

TaskServer.assign_port() allocated data/debug ports by consulting only its
own in-memory `_allocated_ports` list, never checking whether the port was
actually free on the host. When two engines run on one machine (two
workspaces, or an IDE engine plus a CLI engine), both default to
`base_port=20000` and both hand out 20000/20001 first. The second engine's
node host then dials `ws://localhost:{data_port}/task/data` and connects to
the *first* engine's data server, so node hosts silently talk to the wrong
engine's data plane. It surfaces as a misleading downstream error (e.g. a
vector-store/DNS failure), not a port collision.

Add a real bind test: `_is_port_free()` attempts to bind `127.0.0.1:port`
(no SO_REUSEADDR, so an in-use port fails), and `assign_port()` now skips a
candidate unless it is free both in `_allocated_ports` and on the host. Two
engines on the same machine can no longer hand out the same port.

Tests: stub the host check in the existing logic tests so they stay
deterministic, and add coverage for the new behaviour — an OS-busy port is
skipped, the real bind-check reports bound/free correctly, and end-to-end
assign_port skips a port held by a live socket.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor
🤖 Internal: Discord sync marker

Auto-managed by the Discord notification workflow. Stores the linked Discord message ID and forum thread ID. Do not edit or delete.

@coderabbitai

coderabbitai Bot commented Jul 13, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

TaskServer port allocation now checks both in-process allocations and actual TCP bind availability on 127.0.0.1. Tests cover deterministic stubbing, simulated busy ports, and real sockets.

Changes

Task server port allocation

Layer / File(s) Summary
Host-aware port allocator
packages/ai/src/ai/modules/task/task_server.py
assign_port() probes host bind availability and skips ports that are already allocated or bound.
Port allocation validation
packages/ai/tests/ai/modules/task/test_task_server.py
Tests cover deterministic availability stubbing, simulated busy ports, real bound ports, and allocation skipping.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Sequence Diagram(s)

sequenceDiagram
  participant TaskServer
  participant HostSocket
  participant AllocatedPorts
  TaskServer->>HostSocket: Probe 127.0.0.1:<port>
  HostSocket-->>TaskServer: Report bind availability
  TaskServer->>AllocatedPorts: Record available port
  TaskServer-->>TaskServer: Return port
Loading

Suggested reviewers: jmaionchi, stepmikhaylov, rod-christensen, dsapandora

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly states the main change: verifying OS port availability in assign_port to avoid cross-engine crosstalk.
Linked Issues check ✅ Passed The code adds a host bind check in assign_port and tests the busy-port behavior, matching issue #1558's requested fix.
Out of Scope Changes check ✅ Passed Changes stay focused on port allocation logic and its tests, with no obvious unrelated code changes.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

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.

Actionable comments posted: 2

🤖 Prompt for all review comments with 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.

Inline comments:
In `@packages/ai/src/ai/modules/task/task_server.py`:
- Around line 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.
- Around line 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.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 0a3d9be2-b348-4c69-aee3-54740e962681

📥 Commits

Reviewing files that changed from the base of the PR and between cbe5d12 and a67154c.

📒 Files selected for processing (2)
  • packages/ai/src/ai/modules/task/task_server.py
  • packages/ai/tests/ai/modules/task/test_task_server.py

Comment on lines +699 to +727
@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

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
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

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

module:ai AI/ML modules

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Engine port allocator (assign_port) never checks OS port availability → cross-engine data-plane crosstalk

1 participant