Skip to content

Commit 23d215d

Browse files
committed
Make all service launches bulletproof with zero port conflicts
Port conflict prevention (ports.py): - Added controller_port() and is_controller_port() helpers - reserved_ports() now always includes the controller's own port - next_available_port() can never return the controller's port Import conflict auto-resolution (registry_import.py): - Import now auto-reassigns ports that collide with the controller, other registered services, or ports already in use — instead of silently allowing the conflict and only logging a warning - local_url and healthcheck_url are automatically updated when a port is reassigned during import - local_url is auto-populated from port when not explicitly provided Start-time self-healing (process_manager.py): - start_service() now guards against the controller port in addition to the existing port-in-use check, with automatic reassignment Conflict resolver (api_ports.py): - resolve-conflicts endpoint now detects and fixes controller port collisions with a distinct "controller_port_collision" reason - Auto-populates local_url for any service that has a port but is missing a URL Sample data fixes: - Fixed Job Tracker port from 3000→3002 to avoid duplicate with Eicher Monorepo Backend (both were on 3000) Pre-existing bug fix (api_import.py): - Fixed f-string backslash syntax error on line 1036 that prevented the entire module from loading (broke imports and FastAPI startup) https://claude.ai/code/session_01BX7BsftaQ3WdmdGGqKjf7n
1 parent 72eb253 commit 23d215d

File tree

6 files changed

+98
-38
lines changed

6 files changed

+98
-38
lines changed

local_nexus_controller/routers/api_import.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1033,9 +1033,11 @@ def github_localize_pr(req: GitHubLocalizePrRequest) -> dict:
10331033
if not str(svc.get("working_directory") or "").strip():
10341034
svc["working_directory"] = "{REPO_ROOT}"
10351035
if not str(svc.get("start_command") or "").strip():
1036-
svc["start_command"] = f"powershell -ExecutionPolicy Bypass -File {start_ps1_path.replace('/', '\\\\')}"
1036+
win_start = start_ps1_path.replace("/", "\\")
1037+
svc["start_command"] = f"powershell -ExecutionPolicy Bypass -File {win_start}"
10371038
if not str(svc.get("stop_command") or "").strip():
1038-
svc["stop_command"] = f"powershell -ExecutionPolicy Bypass -File {stop_ps1_path.replace('/', '\\\\')}"
1039+
win_stop = stop_ps1_path.replace("/", "\\")
1040+
svc["stop_command"] = f"powershell -ExecutionPolicy Bypass -File {win_stop}"
10391041

10401042
tech = svc.get("tech_stack")
10411043
if not isinstance(tech, list):

local_nexus_controller/routers/api_ports.py

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
from local_nexus_controller.db import get_session
77
from local_nexus_controller.models import Service
88
from local_nexus_controller.security import require_token
9-
from local_nexus_controller.services.ports import is_port_in_use, next_available_port, port_map
9+
from local_nexus_controller.services.ports import is_controller_port, is_port_in_use, next_available_port, port_map
1010

1111

1212
router = APIRouter()
@@ -99,24 +99,25 @@ def _update_urls(svc: Service, old_port: int | None, new_port: int) -> None:
9999
# Re-load after commits so subsequent checks see updated ports
100100
services = list(session.exec(select(Service).order_by(Service.created_at)))
101101

102-
# 2) Resolve "port in use but service not running" conflicts
102+
# 2) Resolve controller-port collisions and "port in use but service not running" conflicts
103103
for svc in services:
104104
if svc.port is None:
105105
continue
106106
port = int(svc.port)
107-
if svc.status == "running" and svc.process_pid is not None:
108-
continue
109-
if is_port_in_use(host, port):
107+
if is_controller_port(port) or (
108+
is_port_in_use(host, port) and not (svc.status == "running" and svc.process_pid is not None)
109+
):
110110
old_port = port
111111
new_port = next_available_port(session, host=host)
112112
svc.port = new_port
113113
_update_urls(svc, old_port=old_port, new_port=new_port)
114114
session.add(svc)
115+
reason = "controller_port_collision" if is_controller_port(old_port) else "port_in_use_on_host"
115116
changes.append(
116117
{
117118
"service_id": svc.id,
118119
"name": svc.name,
119-
"reason": "port_in_use_on_host",
120+
"reason": reason,
120121
"old_port": old_port,
121122
"new_port": new_port,
122123
"local_url": svc.local_url,
@@ -125,6 +126,14 @@ def _update_urls(svc: Service, old_port: int | None, new_port: int) -> None:
125126

126127
session.commit()
127128

129+
# 2b) Auto-populate local_url for services that have a port but no URL
130+
services = list(session.exec(select(Service).order_by(Service.created_at)))
131+
for svc in services:
132+
if svc.port is not None and not svc.local_url:
133+
svc.local_url = f"http://{host}:{svc.port}"
134+
session.add(svc)
135+
session.commit()
136+
128137
# 3) Update dependent Vite apps: VITE_API_BASE_URL -> dependency local_url
129138
services = list(session.exec(select(Service).order_by(Service.name)))
130139
by_name = {s.name: s for s in services}

local_nexus_controller/services/ports.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,14 +22,26 @@ def is_port_in_use(host: str, port: int, timeout_s: float = 0.25) -> bool:
2222
return False
2323

2424

25+
def controller_port() -> int:
26+
"""Return the port the Nexus Controller itself is running on."""
27+
return settings.port
28+
29+
2530
def reserved_ports(session: Session) -> set[int]:
2631
ports: set[int] = set()
32+
# Always reserve the controller's own port so no service collides with it.
33+
ports.add(controller_port())
2734
for p in session.exec(select(Service.port).where(Service.port.is_not(None))):
2835
if p is not None:
2936
ports.add(int(p))
3037
return ports
3138

3239

40+
def is_controller_port(port: int) -> bool:
41+
"""Check if a port is the Nexus Controller's own port."""
42+
return port == controller_port()
43+
44+
3345
def next_available_port(
3446
session: Session,
3547
host: str = "127.0.0.1",

local_nexus_controller/services/process_manager.py

Lines changed: 17 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010

1111
from local_nexus_controller.models import Service
1212
from local_nexus_controller.settings import settings
13-
from local_nexus_controller.services.ports import is_port_in_use, next_available_port
13+
from local_nexus_controller.services.ports import is_controller_port, is_port_in_use, next_available_port
1414

1515

1616
def _now_utc() -> datetime:
@@ -59,18 +59,22 @@ def start_service(session: Session, service: Service) -> Service:
5959
if service.status == "running":
6060
return service
6161

62-
# Self-heal: if the reserved port is currently in use, reassign.
63-
if service.port is not None and is_port_in_use("127.0.0.1", int(service.port)) and service.status != "running":
64-
old_port = int(service.port)
65-
new_port = next_available_port(session, host="127.0.0.1")
66-
service.port = new_port
67-
if service.local_url and f":{old_port}" in service.local_url:
68-
service.local_url = service.local_url.replace(f":{old_port}", f":{new_port}")
69-
if service.healthcheck_url and f":{old_port}" in service.healthcheck_url:
70-
service.healthcheck_url = service.healthcheck_url.replace(f":{old_port}", f":{new_port}")
71-
session.add(service)
72-
session.commit()
73-
session.refresh(service)
62+
# Self-heal: reassign port if it conflicts with the controller or is already in use.
63+
if service.port is not None:
64+
needs_reassign = is_controller_port(int(service.port))
65+
if not needs_reassign and is_port_in_use("127.0.0.1", int(service.port)) and service.status != "running":
66+
needs_reassign = True
67+
if needs_reassign:
68+
old_port = int(service.port)
69+
new_port = next_available_port(session, host="127.0.0.1")
70+
service.port = new_port
71+
if service.local_url and f":{old_port}" in service.local_url:
72+
service.local_url = service.local_url.replace(f":{old_port}", f":{new_port}")
73+
if service.healthcheck_url and f":{old_port}" in service.healthcheck_url:
74+
service.healthcheck_url = service.healthcheck_url.replace(f":{old_port}", f":{new_port}")
75+
session.add(service)
76+
session.commit()
77+
session.refresh(service)
7478

7579
log_path = _service_log_path(service)
7680
log_path.parent.mkdir(parents=True, exist_ok=True)

local_nexus_controller/services/registry_import.py

Lines changed: 46 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
KeyRef,
1313
Service,
1414
)
15-
from local_nexus_controller.services.ports import is_port_in_use, next_available_port
15+
from local_nexus_controller.services.ports import is_controller_port, is_port_in_use, next_available_port, reserved_ports
1616

1717

1818
def _now_utc() -> datetime:
@@ -75,27 +75,60 @@ def import_bundle(session: Session, bundle: ImportBundle, host_for_port_checks:
7575
db = _upsert_database(session, bundle.database)
7676
database_id = db.id
7777

78-
# Port assignment
78+
# Port assignment with automatic conflict resolution
7979
port: int | None = bundle.service.port
8080
if bundle.requested_port is not None:
8181
port = int(bundle.requested_port)
82-
if port is None and bundle.auto_assign_port:
83-
port = next_available_port(session, host=host_for_port_checks)
8482

85-
# Check conflicts
83+
# Resolve port conflicts automatically instead of silently allowing them
8684
if port is not None:
87-
# Only warn if the port is reserved by a *different* service.
88-
conflicting = session.exec(
89-
select(Service).where(Service.port == int(port), Service.name != bundle.service.name)
90-
).first()
91-
if conflicting:
92-
warnings.append(f"Port {port} is already reserved in the registry (by '{conflicting.name}').")
93-
if is_port_in_use(host_for_port_checks, port):
94-
warnings.append(f"Port {port} appears to be in use on {host_for_port_checks}.")
85+
needs_reassign = False
86+
87+
# Never allow a service to use the controller's own port
88+
if is_controller_port(port):
89+
warnings.append(f"Port {port} is the Nexus Controller port; auto-reassigning.")
90+
needs_reassign = True
91+
92+
# Check if another service already has this port
93+
if not needs_reassign:
94+
conflicting = session.exec(
95+
select(Service).where(Service.port == int(port), Service.name != bundle.service.name)
96+
).first()
97+
if conflicting:
98+
warnings.append(f"Port {port} already reserved by '{conflicting.name}'; auto-reassigning.")
99+
needs_reassign = True
100+
101+
# Check if the port is physically in use by something else
102+
if not needs_reassign and is_port_in_use(host_for_port_checks, port):
103+
warnings.append(f"Port {port} in use on {host_for_port_checks}; auto-reassigning.")
104+
needs_reassign = True
105+
106+
if needs_reassign:
107+
old_port = port
108+
port = next_available_port(session, host=host_for_port_checks)
109+
warnings.append(f"Reassigned from port {old_port} to {port}.")
110+
111+
if port is None and bundle.auto_assign_port:
112+
port = next_available_port(session, host=host_for_port_checks)
95113

96114
# Upsert service
97115
svc_dict = bundle.service.model_dump() # type: ignore[attr-defined]
98116
svc_dict["port"] = port
117+
118+
# Auto-populate local_url from port when missing
119+
if not svc_dict.get("local_url") and port is not None:
120+
svc_dict["local_url"] = f"http://127.0.0.1:{port}"
121+
# If port changed from what was in the bundle, update local_url to match
122+
elif svc_dict.get("local_url") and bundle.service.port is not None and port != bundle.service.port:
123+
old_port_str = f":{bundle.service.port}"
124+
if old_port_str in svc_dict["local_url"]:
125+
svc_dict["local_url"] = svc_dict["local_url"].replace(old_port_str, f":{port}")
126+
# Same for healthcheck_url
127+
if svc_dict.get("healthcheck_url") and bundle.service.port is not None and port != bundle.service.port:
128+
old_port_str = f":{bundle.service.port}"
129+
if old_port_str in svc_dict["healthcheck_url"]:
130+
svc_dict["healthcheck_url"] = svc_dict["healthcheck_url"].replace(old_port_str, f":{port}")
131+
99132
if database_id is not None:
100133
svc_dict["database_id"] = database_id
101134
svc_dict.setdefault("status", "stopped")

sample_data/import_existing_bundle.json

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -104,9 +104,9 @@
104104
"tech_stack": ["nodejs", "nextjs", "typescript", "tailwind", "prisma", "postgres"],
105105
"dependencies": [],
106106
"config_paths": ["C:/Users/nedpe/job-tracker/.env"],
107-
"port": 3000,
108-
"local_url": "http://127.0.0.1:3000",
109-
"healthcheck_url": "http://127.0.0.1:3000/api/version",
107+
"port": 3002,
108+
"local_url": "http://127.0.0.1:3002",
109+
"healthcheck_url": "http://127.0.0.1:3002/api/version",
110110
"working_directory": "C:/Users/nedpe/job-tracker",
111111
"start_command": "npm run dev -- -p {PORT}",
112112
"stop_command": "",
@@ -139,7 +139,7 @@
139139
{ "key_name": "PG password", "env_var": "PGPASSWORD", "description": "Optional alternative DB config (Railway-style)" },
140140
{ "key_name": "PG database", "env_var": "PGDATABASE", "description": "Optional alternative DB config (Railway-style)" }
141141
],
142-
"requested_port": 3000,
142+
"requested_port": 3002,
143143
"auto_assign_port": false,
144144
"auto_create_db": true,
145145
"meta": { "source": "C:/Users/nedpe/job-tracker" }

0 commit comments

Comments
 (0)