@@ -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 } ;
77use bollard:: models:: { HostConfig , Mount , MountTypeEnum , MountTmpfsOptions } ;
88use bollard:: image:: CreateImageOptions ;
99use bollard:: exec:: { CreateExecOptions , StartExecResults } ;
@@ -16,6 +16,116 @@ use tower_sessions::Session;
1616use crate :: state:: { AppState , SessionContext } ;
1717use 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+
19129pub 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+
50237async 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