fix: More graceful handling of port conflicts

This commit is contained in:
Eric Hare 2025-11-25 17:20:30 -08:00
parent 1cb70ae704
commit 63d6979eb1
No known key found for this signature in database
GPG key ID: A73DF73724270AB7
3 changed files with 182 additions and 1 deletions

View file

@ -43,6 +43,7 @@ class ServiceInfo:
image: Optional[str] = None
image_digest: Optional[str] = None
created: Optional[str] = None
error_message: Optional[str] = None
def __post_init__(self):
if self.ports is None:
@ -135,6 +136,96 @@ class ContainerManager:
return self.platform_detector.get_compose_installation_instructions()
return self.platform_detector.get_installation_instructions()
def _extract_ports_from_compose(self) -> Dict[str, List[int]]:
"""Extract port mappings from compose files.
Returns:
Dict mapping service name to list of host ports
"""
service_ports: Dict[str, List[int]] = {}
compose_files = [self.compose_file]
if hasattr(self, 'cpu_compose_file') and self.cpu_compose_file and self.cpu_compose_file.exists():
compose_files.append(self.cpu_compose_file)
for compose_file in compose_files:
if not compose_file.exists():
continue
try:
import re
content = compose_file.read_text()
current_service = None
in_ports_section = False
for line in content.splitlines():
# Detect service names
service_match = re.match(r'^ (\w[\w-]*):$', line)
if service_match:
current_service = service_match.group(1)
in_ports_section = False
if current_service not in service_ports:
service_ports[current_service] = []
continue
# Detect ports section
if current_service and re.match(r'^ ports:$', line):
in_ports_section = True
continue
# Exit ports section on new top-level key
if in_ports_section and re.match(r'^ \w+:', line):
in_ports_section = False
# Extract port mappings
if in_ports_section and current_service:
# Match patterns like: - "3000:3000", - "9200:9200", - 7860:7860
port_match = re.search(r'["\']?(\d+):\d+["\']?', line)
if port_match:
host_port = int(port_match.group(1))
if host_port not in service_ports[current_service]:
service_ports[current_service].append(host_port)
except Exception as e:
logger.debug(f"Error parsing {compose_file} for ports: {e}")
continue
return service_ports
async def check_ports_available(self) -> tuple[bool, List[tuple[str, int, str]]]:
"""Check if required ports are available.
Returns:
Tuple of (all_available, conflicts) where conflicts is a list of
(service_name, port, error_message) tuples
"""
import socket
service_ports = self._extract_ports_from_compose()
conflicts: List[tuple[str, int, str]] = []
for service_name, ports in service_ports.items():
for port in ports:
try:
# Try to bind to the port to check if it's available
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.settimeout(0.5)
result = sock.connect_ex(('127.0.0.1', port))
sock.close()
if result == 0:
# Port is in use
conflicts.append((
service_name,
port,
f"Port {port} is already in use"
))
except Exception as e:
logger.debug(f"Error checking port {port}: {e}")
continue
return (len(conflicts) == 0, conflicts)
async def _run_compose_command(
self, args: List[str], cpu_mode: Optional[bool] = None
) -> tuple[bool, str, str]:
@ -655,6 +746,17 @@ class ContainerManager:
yield False, f"ERROR: Compose file not found at {compose_file.absolute()}", False
return
# Check for port conflicts before starting
yield False, "Checking port availability...", False
ports_available, conflicts = await self.check_ports_available()
if not ports_available:
yield False, "ERROR: Port conflicts detected:", False
for service_name, port, error_msg in conflicts:
yield False, f" - {service_name}: {error_msg}", False
yield False, "Please stop the conflicting services and try again.", False
yield False, "Services not started due to port conflicts.", False
return
yield False, "Starting OpenRAG services...", False
missing_images: List[str] = []
@ -677,13 +779,37 @@ class ContainerManager:
yield False, "Creating and starting containers...", False
up_success = {"value": True}
error_messages = []
async for message, replace_last in self._stream_compose_command(["up", "-d"], up_success, cpu_mode):
# Detect error patterns in the output
import re
lower_msg = message.lower()
# Check for common error patterns
if any(pattern in lower_msg for pattern in [
"port.*already.*allocated",
"address already in use",
"bind.*address already in use",
"port is already allocated"
]):
error_messages.append("Port conflict detected")
up_success["value"] = False
elif "error" in lower_msg or "failed" in lower_msg:
# Generic error detection
if message not in error_messages:
error_messages.append(message)
yield False, message, replace_last
if up_success["value"]:
yield True, "Services started successfully", False
else:
yield False, "Failed to start services. See output above for details.", False
if error_messages:
yield False, "\nDetected errors:", False
for err in error_messages[:5]: # Limit to first 5 errors
yield False, f" - {err}", False
async def stop_services(self) -> AsyncIterator[tuple[bool, str]]:
"""Stop all services and yield progress updates."""

View file

@ -311,17 +311,46 @@ class MonitorScreen(Screen):
"""Start services with progress updates."""
self.operation_in_progress = True
try:
# Check for port conflicts before attempting to start
ports_available, conflicts = await self.container_manager.check_ports_available()
if not ports_available:
# Show error notification instead of modal
conflict_msgs = []
for service_name, port, error_msg in conflicts[:3]: # Show first 3
conflict_msgs.append(f"{service_name} (port {port})")
conflict_str = ", ".join(conflict_msgs)
if len(conflicts) > 3:
conflict_str += f" and {len(conflicts) - 3} more"
self.notify(
f"Cannot start services: Port conflicts detected for {conflict_str}. "
f"Please stop the conflicting services first.",
severity="error",
timeout=10
)
# Refresh to show current state
await self._refresh_services()
return
# Show command output in modal dialog
command_generator = self.container_manager.start_services(cpu_mode)
modal = CommandOutputModal(
"Starting Services",
command_generator,
on_complete=None, # We'll refresh in on_screen_resume instead
on_complete=self._on_start_complete, # Refresh after completion
)
self.app.push_screen(modal)
except Exception as e:
self.notify(f"Error starting services: {str(e)}", severity="error")
await self._refresh_services()
finally:
self.operation_in_progress = False
async def _on_start_complete(self) -> None:
"""Callback after service start completes."""
await self._refresh_services()
async def _stop_services(self) -> None:
"""Stop services with progress updates."""
self.operation_in_progress = True

View file

@ -23,6 +23,7 @@ class CommandOutputModal(ModalScreen):
("p", "pause_waves", "Pause"),
("f", "speed_up", "Faster"),
("s", "speed_down", "Slower"),
("escape", "close_modal", "Close"),
]
DEFAULT_CSS = """
@ -188,6 +189,8 @@ class CommandOutputModal(ModalScreen):
self._output_lines: list[str] = []
self._layer_line_map: dict[str, int] = {} # Maps layer ID to line index
self._status_task: Optional[asyncio.Task] = None
self._error_detected = False
self._command_complete = False
def compose(self) -> ComposeResult:
"""Create the modal dialog layout."""
@ -254,6 +257,12 @@ class CommandOutputModal(ModalScreen):
for w in waves.wavelets:
w.speed = max(0.1, w.speed * 0.8)
def action_close_modal(self) -> None:
"""Close the modal (only if error detected or command complete)."""
close_btn = self.query_one("#close-btn", Button)
if not close_btn.disabled:
self.dismiss()
async def _run_command(self) -> None:
"""Run the command and update the output in real-time."""
output = self.query_one("#command-output", TextArea)
@ -273,8 +282,25 @@ class CommandOutputModal(ModalScreen):
# Move cursor to end to trigger scroll
output.move_cursor((len(self._output_lines), 0))
# Detect error patterns in messages
import re
lower_msg = message.lower() if message else ""
if not self._error_detected and any(pattern in lower_msg for pattern in [
"error:",
"failed",
"port.*already.*allocated",
"address already in use",
"not found",
"permission denied"
]):
self._error_detected = True
# Enable close button when error detected
close_btn = self.query_one("#close-btn", Button)
close_btn.disabled = False
# If command is complete, update UI
if is_complete:
self._command_complete = True
self._update_output("Command completed successfully", False)
output.text = "\n".join(self._output_lines)
output.move_cursor((len(self._output_lines), 0))