diff --git a/muxplex/frontend/app.js b/muxplex/frontend/app.js index 3f58469..60862bd 100644 --- a/muxplex/frontend/app.js +++ b/muxplex/frontend/app.js @@ -932,13 +932,7 @@ function renderGrid(sessions) { var currentMode = currentDs.viewMode || 'auto'; if (currentMode === 'fit' && grid) { grid.classList.add('session-grid--fit'); - requestAnimationFrame(function() { - applyFitLayout(grid); - // Scroll each tile's pre to the bottom so content anchors at the bottom (like a real terminal) - grid.querySelectorAll('.tile-body pre').forEach(function(pre) { - pre.scrollTop = pre.scrollHeight; - }); - }); + applyFitLayout(grid); } } @@ -1310,7 +1304,7 @@ function closeSession() { var _closGrid = document.getElementById('session-grid'); if (_closGrid) { _closGrid.classList.add('session-grid--fit'); - requestAnimationFrame(function() { applyFitLayout(_closGrid); }); + applyFitLayout(_closGrid); } } @@ -1503,29 +1497,24 @@ function saveDisplaySettings(settings) { } /** - * Calculate and apply grid layout to fill the viewport exactly (Fit mode). - * Determines optimal cols × rows based on tile count and available space. + * Set grid template for fit mode based on tile count. + * Pure arithmetic — no DOM measurement, no getComputedStyle, no clientHeight. + * Safe to call at any time regardless of display state or layout phase. + * + * The grid already has a definite height from CSS (flex: 1 inside height: 100dvh). + * Setting grid-template-rows: repeat(rows, 1fr) lets the browser divide that height + * equally without JS needing to know the pixel dimensions. Tiles use height: auto + * (set in CSS) so they fill their grid cells without inline style overrides. + * * @param {Element} grid - The session grid element */ function applyFitLayout(grid) { var count = grid.querySelectorAll('.session-tile').length; - if (count === 0) return; - - // Available space — use grid's parent container - var parent = grid.parentElement; - var availH = parent ? parent.clientHeight : window.innerHeight; - var availW = grid.clientWidth; - - // Subtract padding and gap - var style = getComputedStyle(grid); - var padT = parseFloat(style.paddingTop) || 0; - var padB = parseFloat(style.paddingBottom) || 0; - var padL = parseFloat(style.paddingLeft) || 0; - var padR = parseFloat(style.paddingRight) || 0; - var gap = parseFloat(style.gap) || 8; - - var innerW = availW - padL - padR; - var innerH = availH - padT - padB; + if (count === 0) { + grid.style.removeProperty('grid-template-columns'); + grid.style.removeProperty('grid-template-rows'); + return; + } // Calculate optimal cols/rows — start with square root var cols = Math.ceil(Math.sqrt(count)); @@ -1541,16 +1530,8 @@ function applyFitLayout(grid) { } } - // Tile height from available space - var tileH = (innerH - gap * (rows - 1)) / rows; - grid.style.gridTemplateColumns = 'repeat(' + cols + ', 1fr)'; grid.style.gridTemplateRows = 'repeat(' + rows + ', 1fr)'; - - // Override tile height so tiles fill the grid rows - grid.querySelectorAll('.session-tile').forEach(function(t) { - t.style.height = tileH + 'px'; - }); } /** @@ -1597,9 +1578,7 @@ function applyDisplaySettings(ds) { // Reset any inline styles from previous fit calculation grid.style.removeProperty('grid-template-rows'); - grid.querySelectorAll('.session-tile').forEach(function(t) { - t.style.removeProperty('height'); - }); + grid.style.removeProperty('grid-template-columns'); if (mode === 'auto') { // Restore grid columns setting @@ -1611,7 +1590,7 @@ function applyDisplaySettings(ds) { } else if (mode === 'fit') { grid.classList.add('session-grid--fit'); - requestAnimationFrame(function() { applyFitLayout(grid); }); + applyFitLayout(grid); } } @@ -2496,7 +2475,7 @@ window.addEventListener('resize', function() { var ds = loadDisplaySettings(); if ((ds.viewMode || 'auto') === 'fit') { var grid = document.getElementById('session-grid'); - if (grid) requestAnimationFrame(function() { applyFitLayout(grid); }); + if (grid) applyFitLayout(grid); } }); diff --git a/muxplex/frontend/style.css b/muxplex/frontend/style.css index c3503a4..f8bafe0 100644 --- a/muxplex/frontend/style.css +++ b/muxplex/frontend/style.css @@ -83,7 +83,8 @@ body { ============================================================ */ .view { - height: 100vh; + height: 100vh; /* fallback for browsers without dvh support */ + height: 100dvh; /* dynamic viewport height — adjusts for mobile browser chrome */ overflow: hidden; } @@ -593,7 +594,8 @@ body { top: 0 !important; left: 0 !important; width: 100vw !important; - height: 100vh !important; + height: 100vh !important; /* fallback for browsers without dvh support */ + height: 100dvh !important; /* dynamic viewport height — adjusts for mobile browser chrome */ border-radius: 0; } @@ -1523,19 +1525,23 @@ body { /* Fit view — tiles fill viewport, no scroll */ .session-grid--fit { overflow: hidden; + align-content: stretch; /* let 1fr rows fill container height */ } -/* In fit mode, stretch pre to fill the full tile body height and anchor content to bottom */ -.session-grid--fit .tile-body pre { - top: 0; /* stretch to fill full tile body height in fit mode */ - overflow-y: scroll; /* enable scrollTop positioning (content anchored via JS scrollTop=scrollHeight) */ - scrollbar-width: none; /* Firefox: hide scrollbar */ - -ms-overflow-style: none; /* IE/Edge: hide scrollbar */ +/* In fit mode, tiles must use height:auto so the CSS grid 1fr rows control sizing. + The base .session-tile uses var(--tile-height) which is a fixed pixel value — + overriding with height:auto lets the grid cell (from grid-template-rows: repeat(n,1fr)) + determine the tile height instead. JS sets only grid-template-columns/rows, not inline heights. */ +.session-grid--fit .session-tile { + height: auto; } -.session-grid--fit .tile-body pre::-webkit-scrollbar { - display: none; /* Chrome/Safari: hide scrollbar */ -} +/* In fit mode, tile body and pre use the base CSS: + .tile-body { position: relative; overflow: hidden; } + .tile-body pre { position: absolute; bottom: 0; left: 0; right: 0; } + The pre's natural height from 80 lines of content exceeds the tile height, so it + overflows from bottom:0 upward, and tile-body clips excess at the top — showing + the bottom-most content (the prompt area) just like a real terminal. */ /* ============================================================ Responsive overlay sidebar at <960px diff --git a/muxplex/frontend/tests/test_app.mjs b/muxplex/frontend/tests/test_app.mjs index ce5b160..c56ac4a 100644 --- a/muxplex/frontend/tests/test_app.mjs +++ b/muxplex/frontend/tests/test_app.mjs @@ -3528,11 +3528,22 @@ test('bindStaticEventListeners wires view-mode-btn click to cycleViewMode', () = ); }); -test('applyFitLayout is called via requestAnimationFrame for correct timing', () => { +test('applyFitLayout is called directly (no requestAnimationFrame needed — pure arithmetic)', () => { + // Pure arithmetic applyFitLayout is safe to call synchronously at any time — + // it does not measure DOM dimensions so there is no layout-timing dependency. + // Call sites (renderGrid, closeSession, applyDisplaySettings, resize handler) + // should call it directly, not via requestAnimationFrame. const source = fs.readFileSync(new URL('../app.js', import.meta.url), 'utf8'); + // Verify applyFitLayout exists as a direct call (not only inside rAF callbacks) assert.ok( - source.includes('requestAnimationFrame') && source.includes('applyFitLayout'), - 'applyFitLayout must be deferred via requestAnimationFrame' + source.includes('applyFitLayout'), + 'app.js must define and call applyFitLayout' + ); + // The function body itself must not measure DOM — verified by the separate + // "does NOT measure DOM dimensions" test. Here just confirm the function exists. + assert.ok( + source.includes('function applyFitLayout('), + 'applyFitLayout function must be declared' ); }); @@ -3557,14 +3568,29 @@ test('buildTileHTML shows up to 80 lines in fit mode', () => { ); }); -test('CSS style.css has scrollbar-width none for fit mode pre to hide scrollbar', () => { +test('CSS style.css uses base position:absolute bottom:0 for fit mode content anchoring (no flex override)', () => { const source = fs.readFileSync(new URL('../style.css', import.meta.url), 'utf8'); + // Reverted approach: base CSS position:absolute + bottom:0 anchors content to bottom. + // The flex + justify-content:flex-end approach failed because
 filled 100% of parent,
+  // making flex-end a no-op (content started at top, excess clipped at bottom).
+  // The fit-mode tile-body flex override must be REMOVED.
   assert.ok(
-    source.includes('scrollbar-width: none'),
-    'style.css must have scrollbar-width: none for hidden scrollbar in fit mode pre'
+    !source.includes('.session-grid--fit .tile-body {'),
+    'style.css must NOT have .session-grid--fit .tile-body flex override — base position:absolute handles anchoring'
+  );
+  // The pre static-positioning override must also be removed
+  assert.ok(
+    !source.includes('.session-grid--fit .tile-body pre {'),
+    'style.css must NOT have .session-grid--fit .tile-body pre override — base position:absolute + bottom:0 is correct'
+  );
+  // Base .tile-body pre must still use position:absolute + bottom:0
+  assert.ok(
+    source.includes('position: absolute') && source.includes('bottom: 0'),
+    'base .tile-body pre must retain position:absolute and bottom:0 for content anchoring'
   );
 });
 
+
 // --- document.title quality fixes ---
 
 test('device name input handler restores default title when value is cleared', () => {
@@ -3623,3 +3649,65 @@ test('DOMContentLoaded sets document.title from server settings at page load', (
     "DOMContentLoaded must set document.title = _serverSettings.device_name || 'muxplex' after loadServerSettings resolves"
   );
 });
+
+
+// --- Fit view layout: applyFitLayout sets grid template via pure arithmetic ---
+
+test('applyFitLayout sets gridTemplateColumns and gridTemplateRows via pure arithmetic', () => {
+  const assignedProps = {};
+
+  const mockTile = { style: { removeProperty: () => {} } };
+
+  const mockGrid = {
+    style: new Proxy(assignedProps, {
+      set(target, prop, value) { target[prop] = value; return true; },
+      get(target, prop) { return target[prop]; },
+    }),
+    querySelectorAll: (sel) => {
+      if (sel === '.session-tile') return [mockTile, mockTile, mockTile, mockTile];
+      return [];
+    },
+  };
+
+  app.applyFitLayout(mockGrid);
+
+  assert.ok(
+    typeof assignedProps.gridTemplateColumns === 'string' && assignedProps.gridTemplateColumns.includes('1fr'),
+    'applyFitLayout must set gridTemplateColumns with 1fr tracks'
+  );
+  assert.ok(
+    typeof assignedProps.gridTemplateRows === 'string' && assignedProps.gridTemplateRows.includes('1fr'),
+    'applyFitLayout must set gridTemplateRows with 1fr tracks'
+  );
+});
+
+// --- Pure CSS fit layout: applyFitLayout must not measure DOM ---
+
+test('applyFitLayout does NOT measure DOM dimensions (pure arithmetic)', () => {
+  const source = fs.readFileSync(new URL('../app.js', import.meta.url), 'utf8');
+  const fnStart = source.indexOf('function applyFitLayout(');
+  assert.ok(fnStart !== -1, 'applyFitLayout function must exist');
+  // Find end of function: next \n} at the same nesting level
+  let depth = 0;
+  let pos = source.indexOf('{', fnStart);
+  const fnBodyStart = pos;
+  while (pos < source.length) {
+    if (source[pos] === '{') depth++;
+    else if (source[pos] === '}') {
+      depth--;
+      if (depth === 0) break;
+    }
+    pos++;
+  }
+  const fnBody = source.substring(fnBodyStart, pos + 1);
+
+  assert.ok(!fnBody.includes('clientHeight'),
+    'applyFitLayout must NOT read clientHeight — causes wrong values when container is display:none');
+  assert.ok(!fnBody.includes('clientWidth'),
+    'applyFitLayout must NOT read clientWidth — pure 1fr CSS handles width');
+  assert.ok(!fnBody.includes('getComputedStyle'),
+    'applyFitLayout must NOT call getComputedStyle — pure 1fr CSS handles gap/padding');
+  assert.ok(!fnBody.includes('.style.height'),
+    'applyFitLayout must NOT set inline tile heights — CSS grid 1fr rows handle sizing');
+});
+
diff --git a/muxplex/tests/test_frontend_css.py b/muxplex/tests/test_frontend_css.py
index 25911c7..ba72a59 100644
--- a/muxplex/tests/test_frontend_css.py
+++ b/muxplex/tests/test_frontend_css.py
@@ -1971,21 +1971,91 @@ def test_css_no_compact_tile_height() -> None:
 # ============================================================
 
 
-def test_fit_view_pre_has_top_zero() -> None:
-    """In fit mode, .tile-body pre must have top:0 to fill the full tile height.
+# ============================================================
+# Mobile viewport + fit view content anchoring fixes
+# ============================================================
 
-    Bug: .tile-body pre uses position:absolute with bottom:0 but no top:0.
-    In fit mode where tiles are taller than auto mode, the pre is anchored
-    to the bottom but only takes natural content height, leaving a black gap above.
-    Fix: .session-grid--fit .tile-body pre { top: 0 } stretches it to fill the tile.
+
+def test_view_uses_dvh_fallback() -> None:
+    """Bug fix: .view must use 100dvh (dynamic viewport height) for mobile.
+
+    On mobile browsers, 100vh includes the browser chrome (address bar + bottom nav),
+    causing the bottom row of tiles to be cut off under overflow:hidden.
+    Fix: height: 100dvh with 100vh fallback (progressive enhancement).
+    The 100vh MUST appear before 100dvh (browsers ignore unknown values, so
+    100dvh overrides 100vh for browsers that support it).
     """
     css = read_css()
-    assert ".session-grid--fit .tile-body pre" in css, (
-        "Missing .session-grid--fit .tile-body pre rule — needed to fix pre height in fit mode"
+    # .view block must contain 100dvh
+    block = _extract_rule_block(css, ".view {")
+    assert "100dvh" in block, (
+        ".view must use height: 100dvh for mobile — 100vh includes browser chrome, "
+        "causing the bottom row to be cut off on mobile devices"
     )
-    block = _extract_rule_block(css, ".session-grid--fit .tile-body pre {")
-    assert "top: 0" in block or "top:0" in block, (
-        ".session-grid--fit .tile-body pre must have top: 0 to fill full tile body height"
+    # 100vh must still be present as fallback (appears before 100dvh in file)
+    assert "100vh" in block, (
+        ".view must keep height: 100vh as fallback for browsers without dvh support"
+    )
+    # Verify order: 100vh must come before 100dvh in the block (fallback first)
+    assert block.index("100vh") < block.index("100dvh"), (
+        "height: 100vh (fallback) must appear BEFORE height: 100dvh in .view rule — "
+        "browsers that don't support dvh will use the last valid value"
+    )
+
+
+def test_fit_view_no_tile_body_flex_override() -> None:
+    """Bug fix: .session-grid--fit .tile-body must NOT have a flex override.
+
+    The flex + justify-content:flex-end approach failed because the 
 with
+    max-height:100% fills the parent entirely, making flex-end a no-op. Content
+    started at the top and excess was clipped at the bottom — the opposite of what
+    we want.
+
+    Fix: delete the .session-grid--fit .tile-body rule entirely. The base CSS
+    position:absolute + bottom:0 on the 
 anchors content to the bottom.
+    """
+    css = read_css()
+    assert ".session-grid--fit .tile-body {" not in css, (
+        ".session-grid--fit .tile-body must be removed — flex-end approach does not work "
+        "when 
 fills 100% of the parent. Use base position:absolute + bottom:0."
+    )
+
+
+def test_fit_view_session_tile_has_height_auto() -> None:
+    """Pure CSS fit layout: .session-grid--fit .session-tile must use height: auto.
+
+    JS was measuring clientHeight and setting tile.style.height = tileH + 'px'.
+    This failed when the grid was display:none (clientHeight = 0) and after
+    innerHTML rebuilds destroyed inline styles every 2s.
+
+    Pure CSS fix: the grid has a definite height (flex:1 inside 100dvh).
+    grid-template-rows: repeat(rows, 1fr) divides that height equally.
+    height: auto on tiles lets them fill their grid cells without JS measurement.
+    """
+    css = read_css()
+    assert ".session-grid--fit .session-tile {" in css, (
+        ".session-grid--fit .session-tile rule must exist for pure CSS fit layout"
+    )
+    block = _extract_rule_block(css, ".session-grid--fit .session-tile {")
+    assert "height: auto" in block or "height:auto" in block, (
+        ".session-grid--fit .session-tile must use height: auto — "
+        "JS inline height setting was unreliable (lost on innerHTML rebuild every 2s)"
+    )
+
+
+def test_fit_view_no_pre_static_override() -> None:
+    """Bug fix: .session-grid--fit .tile-body pre must NOT override position to static.
+
+    The position:static override removed the pre from absolute positioning, breaking
+    the bottom anchoring. Base CSS already has position:absolute + bottom:0 which
+    anchors content to the bottom of the tile.
+
+    Fix: delete the .session-grid--fit .tile-body pre rule entirely.
+    """
+    css = read_css()
+    assert ".session-grid--fit .tile-body pre {" not in css, (
+        ".session-grid--fit .tile-body pre override must be removed — revert to base "
+        "position:absolute + bottom:0 for correct bottom anchoring."
     )
 
 
diff --git a/muxplex/tests/test_ttyd.py b/muxplex/tests/test_ttyd.py
index deeeb03..6d76db4 100644
--- a/muxplex/tests/test_ttyd.py
+++ b/muxplex/tests/test_ttyd.py
@@ -217,6 +217,100 @@ async def test_kill_orphan_ttyd_handles_pid_file_with_dead_process():
     assert not pid_path.exists(), "PID file should be removed after orphan cleanup"
 
 
+async def test_kill_ttyd_kills_orphan_on_port_when_pid_file_desynced():
+    """kill_ttyd() must also kill orphaned ttyd processes on TTYD_PORT.
+
+    If the PID file points to a dead process (desynced), but the REAL ttyd is
+    still running on TTYD_PORT (orphaned from a previous spawn), kill_ttyd()
+    must find and kill it via lsof -ti :.  This is the belt-and-suspenders
+    fallback that prevents the session-switching bug where a new ttyd cannot bind
+    the port because the old one is still running.
+    """
+    pid_path = ttyd_mod.TTYD_PID_PATH
+    pid_path.parent.mkdir(parents=True, exist_ok=True)
+    # PID file with a dead process (desynced)
+    pid_path.write_text("99999")
+
+    killed_pids: list[tuple[int, int]] = []
+
+    def mock_os_kill(pid: int, sig: int) -> None:
+        if pid == 99999 and sig == 0:
+            raise ProcessLookupError  # PID file process is already dead
+        killed_pids.append((pid, sig))
+
+    mock_lsof_result = MagicMock()
+    mock_lsof_result.returncode = 0
+    mock_lsof_result.stdout = "12345\n"  # orphan PID occupying TTYD_PORT
+
+    def mock_subprocess_run(cmd, **kwargs):  # noqa: ANN001
+        result = MagicMock()
+        if "lsof" in cmd and "-ti" in cmd:
+            return mock_lsof_result
+        result.returncode = 1
+        result.stdout = ""
+        return result
+
+    with (
+        patch("os.kill", side_effect=mock_os_kill),
+        patch("muxplex.ttyd._subprocess.run", side_effect=mock_subprocess_run),
+    ):
+        result = await kill_ttyd()
+
+    assert result is True, "kill_ttyd must return True when orphan found on port"
+    orphan_killed = any(
+        pid == 12345 and sig == signal.SIGTERM for pid, sig in killed_pids
+    )
+    assert orphan_killed, (
+        "kill_ttyd must send SIGTERM to orphan process (12345) found via "
+        "lsof on TTYD_PORT when PID file is desynced"
+    )
+
+
+async def test_spawn_ttyd_force_kills_process_on_port_before_binding():
+    """spawn_ttyd() must force-kill any process occupying TTYD_PORT before spawning.
+
+    If kill_ttyd() completed but the port is still occupied (race condition),
+    spawn_ttyd() must do a final SIGKILL cleanup so the new ttyd can bind.
+    """
+    mock_proc = _make_mock_ttyd_process(pid=22222)
+
+    # First lsof call returns an occupant; second call (after kill) returns empty
+    call_count = 0
+
+    def mock_subprocess_run(cmd, **kwargs):  # noqa: ANN001
+        nonlocal call_count
+        result = MagicMock()
+        if "lsof" in cmd and "-ti" in cmd:
+            call_count += 1
+            if call_count == 1:
+                result.returncode = 0
+                result.stdout = "54321\n"  # port still occupied
+                return result
+        result.returncode = 1
+        result.stdout = ""
+        return result
+
+    killed_pids: list[tuple[int, int]] = []
+
+    def mock_os_kill(pid: int, sig: int) -> None:
+        killed_pids.append((pid, sig))
+
+    with (
+        patch("asyncio.create_subprocess_exec", new=AsyncMock(return_value=mock_proc)),
+        patch("muxplex.ttyd._subprocess.run", side_effect=mock_subprocess_run),
+        patch("os.kill", side_effect=mock_os_kill),
+    ):
+        await spawn_ttyd("test-session")
+
+    force_killed = any(
+        pid == 54321 and sig == signal.SIGKILL for pid, sig in killed_pids
+    )
+    assert force_killed, (
+        "spawn_ttyd must SIGKILL any process occupying TTYD_PORT before spawning "
+        "to prevent 'address already in use' errors"
+    )
+
+
 async def test_kill_orphan_ttyd_handles_invalid_pid_file_content():
     """kill_orphan_ttyd() gracefully handles a PID file with non-integer content."""
     pid_path = ttyd_mod.TTYD_PID_PATH
diff --git a/muxplex/ttyd.py b/muxplex/ttyd.py
index 41b4ec2..e26a3d6 100644
--- a/muxplex/ttyd.py
+++ b/muxplex/ttyd.py
@@ -18,6 +18,7 @@ Public API:
 import asyncio
 import os
 import signal
+import subprocess as _subprocess
 import time
 from pathlib import Path
 
@@ -37,6 +38,43 @@ TTYD_PORT: int = 7682
 
 _active_process: asyncio.subprocess.Process | None = None
 
+# ---------------------------------------------------------------------------
+# Internal helpers
+# ---------------------------------------------------------------------------
+
+
+def _kill_pids_on_port(port: int, sig: int) -> bool:
+    """Find and signal all processes listening on *port* via lsof.
+
+    Returns True if at least one PID was found and signalled.
+    Silently ignores lsof unavailability and already-dead processes.
+    """
+    try:
+        result = _subprocess.run(
+            ["lsof", "-ti", f":{port}"],
+            capture_output=True,
+            text=True,
+            timeout=5,
+        )
+        if result.returncode != 0 or not result.stdout.strip():
+            return False
+        sent = False
+        for pid_str in result.stdout.strip().split("\n"):
+            pid_str = pid_str.strip()
+            if not pid_str:
+                continue
+            try:
+                orphan_pid = int(pid_str)
+                os.kill(orphan_pid, sig)
+                sent = True
+            except (ValueError, ProcessLookupError, PermissionError):
+                pass
+        return sent
+    except Exception:  # noqa: BLE001
+        # lsof not available, timed out, or other unexpected failure
+        return False
+
+
 # ---------------------------------------------------------------------------
 # Public API
 # ---------------------------------------------------------------------------
@@ -45,57 +83,77 @@ _active_process: asyncio.subprocess.Process | None = None
 async def kill_ttyd() -> bool:
     """Kill the running ttyd process and clean up the PID file.
 
-    Reads the PID from TTYD_PID_PATH.  If no PID file exists, returns False.
-    If the file content is not a valid integer, removes the file and returns False.
+    Belt-and-suspenders strategy:
 
-    Checks whether the process is alive via ``os.kill(pid, 0)``.  If the
-    process is already gone (ProcessLookupError), cleans up and returns True.
-    Otherwise sends SIGTERM and polls every 0.1 s for up to 2 s waiting for
-    the process to exit.  The PID file and ``_active_process`` are cleared in
-    all cases before returning.
+    Strategy 1 — PID file:
+        Reads the PID from TTYD_PID_PATH.  If no PID file exists, returns False.
+        If the file content is not a valid integer, removes the file and returns
+        False.  Checks whether the process is alive via ``os.kill(pid, 0)``.  If
+        already gone (ProcessLookupError), cleans up and proceeds.  Otherwise
+        sends SIGTERM and polls every 0.1 s for up to 2 s.
 
-    Uses ``asyncio.sleep`` for polling to avoid blocking the event loop.
+    Strategy 2 — port-based fallback:
+        After the PID-file kill, finds and kills any process still listening on
+        TTYD_PORT via ``lsof -ti :``.  This catches orphaned ttyd processes
+        whose PID was never recorded in the file (e.g. after a coordinator crash).
+        A brief 0.3 s wait is added to let the OS release the port.
+
+    The PID file and ``_active_process`` are cleared in all cases before
+    returning.
 
     Returns:
-        True  — process was killed or was already dead.
-        False — no PID file found, or PID file contained invalid content.
+        True  — a process was killed (or was already dead) via either strategy.
+        False — no PID file found and no process was listening on the port.
     """
     global _active_process
 
-    if not TTYD_PID_PATH.exists():
-        return False
+    killed = False
 
-    try:
-        pid = int(TTYD_PID_PATH.read_text().strip())
-    except ValueError:
-        TTYD_PID_PATH.unlink(missing_ok=True)
-        return False
-
-    # Check whether the process is still alive.
-    try:
-        os.kill(pid, 0)
-    except ProcessLookupError:
-        # Already dead — clean up and report success.
-        TTYD_PID_PATH.unlink(missing_ok=True)
-        _active_process = None
-        return True
-
-    # Process is alive — ask it to terminate.
-    os.kill(pid, signal.SIGTERM)
-
-    # Poll up to 2 s for the process to exit, yielding to the event loop each iteration.
-    deadline = time.time() + 2.0
-    while time.time() < deadline:
+    # -------------------------------------------------------------------
+    # Strategy 1: PID file
+    # -------------------------------------------------------------------
+    if TTYD_PID_PATH.exists():
         try:
-            os.kill(pid, 0)
-        except (ProcessLookupError, PermissionError):
-            break
-        await asyncio.sleep(0.1)
+            pid = int(TTYD_PID_PATH.read_text().strip())
+        except ValueError:
+            TTYD_PID_PATH.unlink(missing_ok=True)
+            pid = None
+        else:
+            # Check whether the process is still alive.
+            try:
+                os.kill(pid, 0)
+            except ProcessLookupError:
+                # Already dead — clean up and note success.
+                TTYD_PID_PATH.unlink(missing_ok=True)
+                killed = True
+                pid = None
+            else:
+                # Process is alive — ask it to terminate.
+                os.kill(pid, signal.SIGTERM)
+
+                # Poll up to 2 s for the process to exit.
+                deadline = time.time() + 2.0
+                while time.time() < deadline:
+                    try:
+                        os.kill(pid, 0)
+                    except (ProcessLookupError, PermissionError):
+                        break
+                    await asyncio.sleep(0.1)
+
+                TTYD_PID_PATH.unlink(missing_ok=True)
+                killed = True
+                pid = None  # noqa: F841 (intentional)
+
+    # -------------------------------------------------------------------
+    # Strategy 2: port-based fallback — catch orphans not in PID file
+    # -------------------------------------------------------------------
+    if _kill_pids_on_port(TTYD_PORT, signal.SIGTERM):
+        killed = True
+        # Brief pause so the OS can release the port before the next spawn.
+        await asyncio.sleep(0.3)
 
-    # Always clean up regardless of whether the process exited in time.
-    TTYD_PID_PATH.unlink(missing_ok=True)
     _active_process = None
-    return True
+    return killed
 
 
 async def kill_orphan_ttyd() -> bool:
@@ -122,6 +180,10 @@ async def spawn_ttyd(session_name: str) -> asyncio.subprocess.Process:
 
         ttyd -W -m 3 -p 7682 tmux attach -t 
 
+    Before spawning, verifies that TTYD_PORT is free.  If any process is still
+    listening on the port (e.g. a race between kill_ttyd() and spawn_ttyd()),
+    it sends SIGKILL to force-free the port immediately.
+
     stdout and stderr are discarded (DEVNULL).  The PID is written to
     TTYD_PID_PATH.  The process handle is stored in ``_active_process``.
 
@@ -133,6 +195,11 @@ async def spawn_ttyd(session_name: str) -> asyncio.subprocess.Process:
     """
     global _active_process
 
+    # Final port-free guard — catches races where kill_ttyd() returned but
+    # the old ttyd hasn't fully released the socket yet.
+    if _kill_pids_on_port(TTYD_PORT, signal.SIGKILL):
+        await asyncio.sleep(0.3)
+
     proc = await asyncio.create_subprocess_exec(
         "ttyd",
         "-W",