Skip to content

Commit 0280311

Browse files
authored
Merge pull request #142 from TryCli-Studio/copilot/sub-pr-141
Restore sessions from Docker container metadata after server restart
2 parents 6773a6b + 2d7d9a3 commit 0280311

2 files changed

Lines changed: 230 additions & 20 deletions

File tree

server/src/main.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ mod handlers {
1616
}
1717

1818
use services::docker::start_background_reaper;
19+
use services::websocket::restore_sessions_from_containers;
1920

2021
#[tokio::main]
2122
async fn main() -> Result<(), Box<dyn std::error::Error>> {
@@ -24,6 +25,10 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
2425
// Setup database and Docker
2526
let state = config::setup_database_and_docker().await?;
2627

28+
// Restore sessions from existing Docker containers (critical for reconnecting to pre-existing containers)
29+
tracing::info!("Restoring sessions from existing containers...");
30+
restore_sessions_from_containers(&state).await;
31+
2732
// Spawn background reaper
2833
let docker_reaper = state.docker.clone();
2934
let sessions_reaper = state.sessions.clone();

server/src/services/websocket.rs

Lines changed: 225 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ use axum::{
33
response::Response,
44
http::StatusCode,
55
};
6-
use bollard::container::{CreateContainerOptions, Config};
6+
use bollard::container::{CreateContainerOptions, Config, ListContainersOptions};
77
use bollard::models::{HostConfig, Mount, MountTypeEnum, MountTmpfsOptions};
88
use bollard::image::CreateImageOptions;
99
use bollard::exec::{CreateExecOptions, StartExecResults};
@@ -16,6 +16,116 @@ use tower_sessions::Session;
1616
use crate::state::{AppState, SessionContext};
1717
use crate::models::{User, AnalyticsEventType, log_analytics_event};
1818

19+
/// Helper function to create container labels with session metadata for restoration
20+
fn create_container_labels(
21+
session_id: &str,
22+
owner_id: Option<i64>,
23+
project_owner_id: Option<i64>,
24+
project_slug: Option<&str>,
25+
shell: &str,
26+
container_type: &str, // "builder" or "viewer"
27+
) -> HashMap<String, String> {
28+
let mut labels = HashMap::from([
29+
("managed_by".to_string(), "TryCli Studio".to_string()),
30+
("session_id".to_string(), session_id.to_string()),
31+
("shell".to_string(), shell.to_string()),
32+
("container_type".to_string(), container_type.to_string()),
33+
]);
34+
35+
if let Some(id) = owner_id {
36+
labels.insert("owner_id".to_string(), id.to_string());
37+
}
38+
39+
if let Some(id) = project_owner_id {
40+
labels.insert("project_owner_id".to_string(), id.to_string());
41+
}
42+
43+
if let Some(slug) = project_slug {
44+
labels.insert("project_slug".to_string(), slug.to_string());
45+
}
46+
47+
labels
48+
}
49+
50+
/// Restore sessions from existing Docker containers on server startup
51+
/// This allows pre-existing containers to be reconnected after server restart
52+
pub async fn restore_sessions_from_containers(state: &AppState) {
53+
let filters = HashMap::from([
54+
("label".to_string(), vec!["managed_by=TryCli Studio".to_string()])
55+
]);
56+
57+
let opts = ListContainersOptions {
58+
all: false, // Only running containers
59+
filters,
60+
..Default::default()
61+
};
62+
63+
match state.docker.list_containers(Some(opts)).await {
64+
Ok(containers) => {
65+
let mut restored = 0;
66+
for container in containers {
67+
if let Some(labels) = container.labels {
68+
// Extract session metadata from labels
69+
let session_id = labels.get("session_id").map(|s| s.clone());
70+
let shell = labels.get("shell").map(|s| s.clone()).unwrap_or_else(|| "/bin/bash".to_string());
71+
let owner_id = labels.get("owner_id").and_then(|s| s.parse::<i64>().ok());
72+
let project_owner_id = labels.get("project_owner_id").and_then(|s| s.parse::<i64>().ok());
73+
let project_slug = labels.get("project_slug").map(|s| s.clone());
74+
75+
if let (Some(session_id), Some(names)) = (session_id, container.names) {
76+
let container_name = names.first()
77+
.map(|n| n.trim_start_matches('/').to_string())
78+
.unwrap_or_default();
79+
80+
if !container_name.is_empty() {
81+
// Restore session to in-memory map
82+
let mut map = state.lock_sessions();
83+
84+
// Only restore if not already present (shouldn't happen, but be defensive)
85+
if !map.contains_key(&session_id) {
86+
// Calculate elapsed time from container creation
87+
let created_at = if let Some(created_ts) = container.created {
88+
// Container age in seconds
89+
let now = std::time::SystemTime::now()
90+
.duration_since(std::time::UNIX_EPOCH)
91+
.unwrap()
92+
.as_secs() as i64;
93+
let age_secs = now - created_ts;
94+
95+
// Set created_at to approximate original time
96+
std::time::Instant::now()
97+
.checked_sub(std::time::Duration::from_secs(age_secs.max(0) as u64))
98+
.unwrap_or_else(|| std::time::Instant::now())
99+
} else {
100+
std::time::Instant::now()
101+
};
102+
103+
map.insert(session_id.clone(), SessionContext {
104+
container_name: container_name.clone(),
105+
shell,
106+
pending_image_tag: None,
107+
owner_id,
108+
project_owner_id,
109+
is_publishing: false,
110+
project_slug,
111+
created_at,
112+
is_ws_connected: false, // Will be set to true on reconnection
113+
});
114+
restored += 1;
115+
tracing::info!("Restored session {} with container {}", session_id, container_name);
116+
}
117+
}
118+
}
119+
}
120+
}
121+
tracing::info!("Session restoration complete: {} sessions restored", restored);
122+
}
123+
Err(e) => {
124+
tracing::error!("Failed to restore sessions from containers: {}", e);
125+
}
126+
}
127+
}
128+
19129
pub async fn ws_handler(
20130
ws: WebSocketUpgrade,
21131
Path(session_id): Path<String>,
@@ -25,6 +135,17 @@ pub async fn ws_handler(
25135
let user: Option<User> = session.get("user").await.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
26136
let user_id = user.map(|u| u.id);
27137

138+
// Check if session exists in memory, if not, try to restore from Docker
139+
let session_exists = {
140+
let map = state.lock_sessions();
141+
map.contains_key(&session_id)
142+
};
143+
144+
if !session_exists {
145+
// Try to find and restore this specific session from Docker containers
146+
restore_specific_session(&state, &session_id).await;
147+
}
148+
28149
{
29150
let map = state.lock_sessions();
30151

@@ -47,6 +168,72 @@ pub async fn ws_handler(
47168
Ok(ws.on_upgrade(move |socket| handle_socket(socket, state, session_id, user_id)))
48169
}
49170

171+
/// Attempt to restore a specific session from Docker containers
172+
/// This is called when a client tries to connect to a session that isn't in memory
173+
async fn restore_specific_session(state: &AppState, session_id: &str) {
174+
let filters = HashMap::from([
175+
("label".to_string(), vec![
176+
"managed_by=TryCli Studio".to_string(),
177+
format!("session_id={}", session_id)
178+
])
179+
]);
180+
181+
let opts = ListContainersOptions {
182+
all: false, // Only running containers
183+
filters,
184+
..Default::default()
185+
};
186+
187+
if let Ok(containers) = state.docker.list_containers(Some(opts)).await {
188+
for container in containers {
189+
if let Some(labels) = container.labels {
190+
let shell = labels.get("shell").map(|s| s.clone()).unwrap_or_else(|| "/bin/bash".to_string());
191+
let owner_id = labels.get("owner_id").and_then(|s| s.parse::<i64>().ok());
192+
let project_owner_id = labels.get("project_owner_id").and_then(|s| s.parse::<i64>().ok());
193+
let project_slug = labels.get("project_slug").map(|s| s.clone());
194+
195+
if let Some(names) = container.names {
196+
let container_name = names.first()
197+
.map(|n| n.trim_start_matches('/').to_string())
198+
.unwrap_or_default();
199+
200+
if !container_name.is_empty() {
201+
// Calculate elapsed time from container creation
202+
let created_at = if let Some(created_ts) = container.created {
203+
let now = std::time::SystemTime::now()
204+
.duration_since(std::time::UNIX_EPOCH)
205+
.unwrap()
206+
.as_secs() as i64;
207+
let age_secs = now - created_ts;
208+
209+
std::time::Instant::now()
210+
.checked_sub(std::time::Duration::from_secs(age_secs.max(0) as u64))
211+
.unwrap_or_else(|| std::time::Instant::now())
212+
} else {
213+
std::time::Instant::now()
214+
};
215+
216+
let mut map = state.lock_sessions();
217+
map.insert(session_id.to_string(), SessionContext {
218+
container_name: container_name.clone(),
219+
shell,
220+
pending_image_tag: None,
221+
owner_id,
222+
project_owner_id,
223+
is_publishing: false,
224+
project_slug,
225+
created_at,
226+
is_ws_connected: false,
227+
});
228+
tracing::info!("Restored session {} from container {}", session_id, container_name);
229+
return;
230+
}
231+
}
232+
}
233+
}
234+
}
235+
}
236+
50237
async fn handle_socket(mut socket: WebSocket, state: AppState, session_id: String, user_id: Option<i64>) {
51238

52239
// Track if this is a first-time connection for view counting
@@ -103,7 +290,13 @@ async fn handle_socket(mut socket: WebSocket, state: AppState, session_id: Strin
103290
if let Some(ctx) = map.get(&session_id) {
104291
// If we have an image tag but no container name, it's a viewer waiting to start
105292
if ctx.container_name.is_empty() && ctx.pending_image_tag.is_some() {
106-
Some((ctx.pending_image_tag.clone().unwrap(), ctx.shell.clone()))
293+
Some((
294+
ctx.pending_image_tag.clone().unwrap(),
295+
ctx.shell.clone(),
296+
ctx.owner_id,
297+
ctx.project_owner_id,
298+
ctx.project_slug.clone(),
299+
))
107300
} else {
108301
None
109302
}
@@ -112,15 +305,22 @@ async fn handle_socket(mut socket: WebSocket, state: AppState, session_id: Strin
112305
}
113306
};
114307

115-
if let Some((image_tag, shell)) = pending_spawn {
308+
if let Some((image_tag, shell, owner_id, project_owner_id, project_slug)) = pending_spawn {
116309
// Perform the spawn that used to be in get_project
117310
let container_name = format!("trycli-studio-viewer-{}", Uuid::new_v4());
118311

312+
let labels = create_container_labels(
313+
&session_id,
314+
owner_id,
315+
project_owner_id,
316+
project_slug.as_deref(),
317+
&shell,
318+
"viewer",
319+
);
320+
119321
let config = Config {
120322
image: Some(image_tag),
121-
labels: Some(HashMap::from([
122-
("managed_by".to_string(), "TryCli Studio".to_string())
123-
])),
323+
labels: Some(labels),
124324
tty: Some(true),
125325
user: Some("root".to_string()),
126326
// FIX: Run sleep infinity as PID 1. This uses almost 0 CPU/RAM.
@@ -190,12 +390,10 @@ async fn handle_socket(mut socket: WebSocket, state: AppState, session_id: Strin
190390
if let Err(e) =
191391
state.docker.start_container::<String>(&container_name, None).await
192392
{
193-
// Surface detailed error to client and logs
194-
let msg = format!(
195-
"\r\n\x1b[31m[!] Failed to start viewer container: {}\x1b[0m\r\n",
196-
e
197-
);
198-
eprintln!("Viewer start error for session {}: {}", session_id, e);
393+
// Log detailed error server-side only
394+
tracing::error!("Viewer start error for session {}: {}", session_id, e);
395+
// Send generic error message to client
396+
let msg = "\r\n\x1b[31m[!] Failed to start viewer container. Please try again later.\x1b[0m\r\n";
199397
let _ = socket.send(Message::Text(msg.into())).await;
200398
return;
201399
}
@@ -208,11 +406,10 @@ async fn handle_socket(mut socket: WebSocket, state: AppState, session_id: Strin
208406
}
209407
}
210408
Err(e) => {
211-
let msg = format!(
212-
"\r\n\x1b[31m[!] Failed to create viewer container: {}\x1b[0m\r\n",
213-
e
214-
);
215-
eprintln!("Viewer create error for session {}: {}", session_id, e);
409+
// Log detailed error server-side only
410+
tracing::error!("Viewer create error for session {}: {}", session_id, e);
411+
// Send generic error message to client
412+
let msg = "\r\n\x1b[31m[!] Failed to create viewer container. Please try again later.\x1b[0m\r\n";
216413
let _ = socket.send(Message::Text(msg.into())).await;
217414
return;
218415
}
@@ -332,6 +529,16 @@ async fn run_setup_wizard(mut socket: WebSocket, state: AppState, session_id: St
332529
}
333530

334531
let container_name = format!("trycli-studio-session-{}", Uuid::new_v4());
532+
533+
let labels = create_container_labels(
534+
&session_id,
535+
_user_id,
536+
None, // Builder sessions don't have project context yet
537+
None,
538+
final_shell,
539+
"builder",
540+
);
541+
335542
let config = Config {
336543
image: Some(image.to_string()),
337544
tty: Some(true),
@@ -343,9 +550,7 @@ async fn run_setup_wizard(mut socket: WebSocket, state: AppState, session_id: St
343550
"LC_ALL=C.UTF-8".to_string(),
344551
"TERM=xterm-256color".to_string()
345552
]),
346-
labels: Some(HashMap::from([
347-
("managed_by".to_string(), "TryCli Studio".to_string())
348-
])),
553+
labels: Some(labels),
349554
host_config: Some(HostConfig {
350555
runtime: Some("runsc".to_string()),
351556
memory: Some(512 * 1024 * 1024),

0 commit comments

Comments
 (0)