Skip to content

Commit 148cdda

Browse files
fix(multiplexer): restore session proxies on daemon restart
Fixes Docker container connectivity issue when daemon restarts by: - Persisting port allocations from database to PortAllocator on startup - Recreating session proxy listeners for running Docker sessions - Enhanced reconciliation with orphaned proxy cleanup and auto-healing Key changes: - Added restore_allocations() to PortAllocator for state recovery - Added restore_session_proxies() to ProxyManager with health checks - Wired up restoration logic in daemon startup (server.rs) - Enhanced reconcile() to clean up zombies and auto-heal missing proxies This enables iterative development by allowing frequent daemon restarts without breaking container network connectivity. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
1 parent 4c861e1 commit 148cdda

4 files changed

Lines changed: 254 additions & 0 deletions

File tree

packages/multiplexer/src/api/server.rs

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
use std::sync::Arc;
22
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
33
use tokio::net::{UnixListener, UnixStream};
4+
use uuid::Uuid;
45

56
use crate::backends::{DockerBackend, DockerProxyConfig};
67
use crate::core::SessionManager;
@@ -97,6 +98,34 @@ pub async fn run_daemon_with_http(enable_proxy: bool, http_port: Option<u16>) ->
9798
};
9899
tracing::info!("Session manager initialized");
99100

101+
// Restore session proxies for active sessions (if proxy manager is enabled)
102+
if let Some(ref pm) = proxy_manager {
103+
tracing::info!("Restoring session proxies for active sessions...");
104+
105+
// Get all sessions from database
106+
let sessions = manager.list_sessions().await;
107+
108+
// Extract port allocations for PortAllocator restoration
109+
let port_allocations: Vec<(u16, Uuid)> = sessions
110+
.iter()
111+
.filter_map(|s| s.proxy_port.map(|port| (port, s.id)))
112+
.collect();
113+
114+
// Restore port allocations in PortAllocator
115+
if !port_allocations.is_empty() {
116+
if let Err(e) = pm.port_allocator().restore_allocations(port_allocations).await {
117+
tracing::error!("Failed to restore port allocations: {}", e);
118+
tracing::warn!("New sessions may experience port conflicts");
119+
}
120+
}
121+
122+
// Restore session proxies (creates new proxy tasks on allocated ports)
123+
if let Err(e) = pm.restore_session_proxies(&sessions).await {
124+
tracing::error!("Failed to restore session proxies: {}", e);
125+
tracing::warn!("Existing sessions may not have network connectivity");
126+
}
127+
}
128+
100129
// Spawn both Unix socket and HTTP servers concurrently
101130
let unix_socket_future = run_unix_socket_server(Arc::clone(&manager));
102131

packages/multiplexer/src/core/manager.rs

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -430,6 +430,63 @@ impl SessionManager {
430430

431431
if !exists {
432432
report.missing_backends.push(session.id);
433+
434+
// Clean up orphaned session proxy
435+
if session.backend == BackendType::Docker {
436+
if let Some(ref proxy_manager) = self.proxy_manager {
437+
tracing::info!(
438+
session_id = %session.id,
439+
"Destroying proxy for session with missing container"
440+
);
441+
let _ = proxy_manager.destroy_session_proxy(session.id).await;
442+
}
443+
}
444+
} else {
445+
// Container exists but session is archived/failed - clean up zombie
446+
if matches!(session.status, SessionStatus::Archived | SessionStatus::Failed) {
447+
tracing::warn!(
448+
session_id = %session.id,
449+
status = ?session.status,
450+
"Found zombie container for non-active session, cleaning up"
451+
);
452+
453+
match session.backend {
454+
BackendType::Docker => {
455+
let _ = self.docker.delete(backend_id).await;
456+
if let Some(ref proxy_manager) = self.proxy_manager {
457+
let _ = proxy_manager.destroy_session_proxy(session.id).await;
458+
}
459+
}
460+
BackendType::Zellij => {
461+
let _ = self.zellij.delete(backend_id).await;
462+
}
463+
}
464+
}
465+
466+
// Verify proxy exists for running Docker sessions
467+
if session.backend == BackendType::Docker
468+
&& session.status == SessionStatus::Running
469+
{
470+
if let Some(ref proxy_manager) = self.proxy_manager {
471+
if let Some(port) = session.proxy_port {
472+
// Check if proxy is actually listening
473+
if tokio::net::TcpStream::connect(format!("127.0.0.1:{}", port))
474+
.await
475+
.is_err()
476+
{
477+
tracing::warn!(
478+
session_id = %session.id,
479+
port = port,
480+
"Session proxy not responding - attempting recreation"
481+
);
482+
// Attempt auto-recreation
483+
let _ = proxy_manager
484+
.restore_session_proxies(&[session.clone()])
485+
.await;
486+
}
487+
}
488+
}
489+
}
433490
}
434491
}
435492
}

packages/multiplexer/src/proxy/manager.rs

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -328,6 +328,138 @@ impl ProxyManager {
328328
anyhow::bail!("Session proxy not found for session {}", session_id)
329329
}
330330
}
331+
332+
/// Get reference to port allocator (for restoration)
333+
///
334+
/// Exposed to allow daemon initialization code to restore port allocations
335+
/// from database on startup.
336+
pub fn port_allocator(&self) -> &Arc<PortAllocator> {
337+
&self.port_allocator
338+
}
339+
340+
/// Restore session proxies from database
341+
///
342+
/// Called on daemon startup to recreate proxies for active sessions.
343+
/// Only restores proxies for sessions with Running status.
344+
///
345+
/// This enables containers to maintain network connectivity across daemon restarts
346+
/// by recreating the proxy listeners on their allocated ports.
347+
pub async fn restore_session_proxies(
348+
&self,
349+
sessions: &[crate::core::Session],
350+
) -> anyhow::Result<()> {
351+
use crate::core::{BackendType, SessionStatus};
352+
353+
tracing::info!("Restoring session proxies from database...");
354+
355+
let mut restored = 0;
356+
let mut skipped = 0;
357+
358+
for session in sessions {
359+
// Only restore proxies for active Docker sessions with allocated ports
360+
if session.backend != BackendType::Docker {
361+
continue;
362+
}
363+
364+
if session.status != SessionStatus::Running {
365+
tracing::debug!(
366+
session_id = %session.id,
367+
status = ?session.status,
368+
"Skipping proxy restore for non-running session"
369+
);
370+
skipped += 1;
371+
continue;
372+
}
373+
374+
let Some(port) = session.proxy_port else {
375+
tracing::debug!(
376+
session_id = %session.id,
377+
"Skipping proxy restore for session without allocated port"
378+
);
379+
skipped += 1;
380+
continue;
381+
};
382+
383+
// Create session proxy with the same port as before
384+
let access_mode_lock = Arc::new(RwLock::new(session.access_mode));
385+
386+
let authority = self.ca.to_rcgen_authority()?;
387+
let proxy = HttpAuthProxy::for_session(
388+
port,
389+
authority,
390+
Arc::clone(&self.credentials),
391+
Arc::clone(&self.audit_logger),
392+
session.id,
393+
Arc::clone(&access_mode_lock),
394+
);
395+
396+
// Spawn proxy task
397+
let session_id = session.id;
398+
let task = tokio::spawn(async move {
399+
if let Err(e) = proxy.run().await {
400+
tracing::error!(session_id = %session_id, "Session proxy error: {}", e);
401+
}
402+
});
403+
404+
// Wait for proxy to bind (health check)
405+
let mut bound = false;
406+
for attempt in 1..=10 {
407+
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
408+
if tokio::net::TcpStream::connect(format!("127.0.0.1:{}", port))
409+
.await
410+
.is_ok()
411+
{
412+
bound = true;
413+
break;
414+
}
415+
if attempt == 10 {
416+
tracing::warn!(
417+
port = port,
418+
session_id = %session.id,
419+
"Restored session proxy may not be ready (could not verify binding)"
420+
);
421+
}
422+
}
423+
424+
if !bound {
425+
tracing::error!(
426+
session_id = %session.id,
427+
port = port,
428+
"Failed to restore session proxy - port may be in use"
429+
);
430+
task.abort();
431+
skipped += 1;
432+
continue;
433+
}
434+
435+
// Store in session_proxies map
436+
self.session_proxies.write().await.insert(
437+
session.id,
438+
SessionProxyHandle {
439+
port,
440+
access_mode: access_mode_lock,
441+
task,
442+
},
443+
);
444+
445+
tracing::info!(
446+
session_id = %session.id,
447+
port = port,
448+
access_mode = ?session.access_mode,
449+
"Restored session proxy"
450+
);
451+
452+
restored += 1;
453+
}
454+
455+
tracing::info!(
456+
restored = restored,
457+
skipped = skipped,
458+
"Session proxy restoration complete"
459+
);
460+
461+
Ok(())
462+
}
331463
}
332464

333465
impl Drop for ProxyManager {

packages/multiplexer/src/proxy/port_allocator.rs

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,42 @@ impl PortAllocator {
5959
pub async fn get_session_id(&self, port: u16) -> Option<Uuid> {
6060
self.state.read().await.allocated.get(&port).copied()
6161
}
62+
63+
/// Restore port allocations from database
64+
///
65+
/// Called on daemon startup to restore in-memory state from persistent storage.
66+
/// Prevents port conflicts and maintains session-to-port mappings across restarts.
67+
pub async fn restore_allocations(&self, allocations: Vec<(u16, Uuid)>) -> anyhow::Result<()> {
68+
let mut state = self.state.write().await;
69+
70+
for (port, session_id) in allocations {
71+
// Validate port is in our range
72+
if port < Self::BASE_PORT || port >= Self::BASE_PORT + Self::MAX_SESSIONS {
73+
tracing::warn!(
74+
port,
75+
session_id = %session_id,
76+
"Skipping invalid port allocation from database (out of range)"
77+
);
78+
continue;
79+
}
80+
81+
state.allocated.insert(port, session_id);
82+
tracing::debug!(port, session_id = %session_id, "Restored port allocation");
83+
}
84+
85+
// Update next_port to avoid collisions with restored allocations
86+
if let Some(&max_port) = state.allocated.keys().max() {
87+
state.next_port = (max_port - Self::BASE_PORT + 1) % Self::MAX_SESSIONS;
88+
}
89+
90+
tracing::info!(
91+
count = state.allocated.len(),
92+
"Restored {} port allocations from database",
93+
state.allocated.len()
94+
);
95+
96+
Ok(())
97+
}
6298
}
6399

64100
impl Default for PortAllocator {

0 commit comments

Comments
 (0)