@@ -34,6 +34,12 @@ pub struct ContainerInfo {
3434 pub status : ContainerStatus ,
3535 /// Unix epoch seconds when metadata was created.
3636 pub created_unix_secs : u64 ,
37+ /// Unix epoch seconds when the container was started, if applicable.
38+ #[ serde( default , skip_serializing_if = "Option::is_none" ) ]
39+ pub started_unix_secs : Option < u64 > ,
40+ /// Unix epoch seconds when the container stopped, if applicable.
41+ #[ serde( default , skip_serializing_if = "Option::is_none" ) ]
42+ pub stopped_unix_secs : Option < u64 > ,
3743 /// Assembled rootfs path for this container, when known.
3844 pub rootfs_path : Option < PathBuf > ,
3945 /// Host process ID currently managing this container, if running.
@@ -86,6 +92,56 @@ impl ContainerStore {
8692 self . write_all ( & containers)
8793 }
8894
95+ /// Find a single container by ID.
96+ pub fn find ( & self , id : & str ) -> io:: Result < Option < ContainerInfo > > {
97+ let containers = self . load_all ( ) ?;
98+ Ok ( containers. into_iter ( ) . find ( |c| c. id == id) )
99+ }
100+
101+ /// Reconcile stale containers whose host PID is no longer alive.
102+ ///
103+ /// Containers in `Running` or `Created` state whose `host_pid` no longer
104+ /// exists are transitioned to `Stopped { exit_code: -1 }` with their
105+ /// rootfs cleaned up. Returns the IDs of reconciled containers.
106+ pub fn reconcile_stale ( & self ) -> io:: Result < Vec < String > > {
107+ let mut containers = self . load_all ( ) ?;
108+ let mut reconciled = Vec :: new ( ) ;
109+
110+ let now_secs = SystemTime :: now ( )
111+ . duration_since ( UNIX_EPOCH )
112+ . map_or ( 0 , |d| d. as_secs ( ) ) ;
113+
114+ for container in & mut containers {
115+ let is_active = matches ! (
116+ container. status,
117+ ContainerStatus :: Running | ContainerStatus :: Created
118+ ) ;
119+ if !is_active {
120+ continue ;
121+ }
122+
123+ let pid_alive = container. host_pid . is_some_and ( is_process_alive) ;
124+
125+ if !pid_alive {
126+ container. status = ContainerStatus :: Stopped { exit_code : -1 } ;
127+ container. stopped_unix_secs = Some ( now_secs) ;
128+ container. host_pid = None ;
129+
130+ if let Some ( rootfs) = container. rootfs_path . take ( ) {
131+ let _ = fs:: remove_dir_all ( rootfs) ;
132+ }
133+
134+ reconciled. push ( container. id . clone ( ) ) ;
135+ }
136+ }
137+
138+ if !reconciled. is_empty ( ) {
139+ self . write_all ( & containers) ?;
140+ }
141+
142+ Ok ( reconciled)
143+ }
144+
89145 /// Remove a container metadata record by ID.
90146 pub fn remove ( & self , id : & str ) -> io:: Result < ( ) > {
91147 let mut containers = self . load_all ( ) ?;
@@ -129,6 +185,16 @@ fn write_atomic(destination: &Path, bytes: &[u8]) -> io::Result<()> {
129185 fs:: rename ( & tmp, destination)
130186}
131187
188+ /// Check if a process with the given PID is alive.
189+ fn is_process_alive ( pid : u32 ) -> bool {
190+ std:: process:: Command :: new ( "kill" )
191+ . args ( [ "-0" , & pid. to_string ( ) ] )
192+ . stdout ( std:: process:: Stdio :: null ( ) )
193+ . stderr ( std:: process:: Stdio :: null ( ) )
194+ . status ( )
195+ . is_ok_and ( |s| s. success ( ) )
196+ }
197+
132198fn unique_temp_path ( path : & Path ) -> PathBuf {
133199 let timestamp = SystemTime :: now ( )
134200 . duration_since ( UNIX_EPOCH )
@@ -189,6 +255,8 @@ mod tests {
189255 image_id : "sha256:base" . to_string ( ) ,
190256 status : ContainerStatus :: Created ,
191257 created_unix_secs : 1700 ,
258+ started_unix_secs : None ,
259+ stopped_unix_secs : None ,
192260 rootfs_path : None ,
193261 host_pid : None ,
194262 } )
@@ -201,6 +269,8 @@ mod tests {
201269 image_id : "sha256:base" . to_string ( ) ,
202270 status : ContainerStatus :: Stopped { exit_code : 0 } ,
203271 created_unix_secs : 1700 ,
272+ started_unix_secs : None ,
273+ stopped_unix_secs : None ,
204274 rootfs_path : None ,
205275 host_pid : None ,
206276 } )
@@ -228,6 +298,8 @@ mod tests {
228298 image_id : "sha256:base" . to_string ( ) ,
229299 status : ContainerStatus :: Created ,
230300 created_unix_secs : 1700 ,
301+ started_unix_secs : None ,
302+ stopped_unix_secs : None ,
231303 rootfs_path : Some ( PathBuf :: from ( "/tmp/example" ) ) ,
232304 host_pid : Some ( 12345 ) ,
233305 } )
@@ -239,4 +311,164 @@ mod tests {
239311 assert ! ( remaining. is_empty( ) ) ;
240312 assert ! ( store. remove( "container-1" ) . is_err( ) ) ;
241313 }
314+
315+ #[ test]
316+ fn find_returns_matching_container ( ) {
317+ let root = unique_temp_dir ( "find" ) ;
318+ let store = ContainerStore :: new ( root) ;
319+
320+ store
321+ . upsert ( ContainerInfo {
322+ id : "ctr-a" . to_string ( ) ,
323+ image : "ubuntu:24.04" . to_string ( ) ,
324+ image_id : "sha256:a" . to_string ( ) ,
325+ status : ContainerStatus :: Running ,
326+ created_unix_secs : 100 ,
327+ started_unix_secs : Some ( 101 ) ,
328+ stopped_unix_secs : None ,
329+ rootfs_path : None ,
330+ host_pid : Some ( std:: process:: id ( ) ) ,
331+ } )
332+ . unwrap ( ) ;
333+
334+ let found = store. find ( "ctr-a" ) . unwrap ( ) ;
335+ assert ! ( found. is_some( ) ) ;
336+ assert_eq ! ( found. unwrap( ) . id, "ctr-a" ) ;
337+
338+ let missing = store. find ( "ctr-none" ) . unwrap ( ) ;
339+ assert ! ( missing. is_none( ) ) ;
340+ }
341+
342+ #[ test]
343+ fn reconcile_stale_transitions_dead_pid_containers ( ) {
344+ let root = unique_temp_dir ( "reconcile" ) ;
345+ let store = ContainerStore :: new ( root) ;
346+
347+ // Container with a PID that definitely doesn't exist.
348+ store
349+ . upsert ( ContainerInfo {
350+ id : "stale-running" . to_string ( ) ,
351+ image : "ubuntu:24.04" . to_string ( ) ,
352+ image_id : "sha256:a" . to_string ( ) ,
353+ status : ContainerStatus :: Running ,
354+ created_unix_secs : 100 ,
355+ started_unix_secs : Some ( 101 ) ,
356+ stopped_unix_secs : None ,
357+ rootfs_path : None ,
358+ host_pid : Some ( 999_999_999 ) ,
359+ } )
360+ . unwrap ( ) ;
361+
362+ // Container with our own PID — should remain running.
363+ store
364+ . upsert ( ContainerInfo {
365+ id : "alive-running" . to_string ( ) ,
366+ image : "ubuntu:24.04" . to_string ( ) ,
367+ image_id : "sha256:b" . to_string ( ) ,
368+ status : ContainerStatus :: Running ,
369+ created_unix_secs : 200 ,
370+ started_unix_secs : Some ( 201 ) ,
371+ stopped_unix_secs : None ,
372+ rootfs_path : None ,
373+ host_pid : Some ( std:: process:: id ( ) ) ,
374+ } )
375+ . unwrap ( ) ;
376+
377+ // Already stopped container — should be untouched.
378+ store
379+ . upsert ( ContainerInfo {
380+ id : "already-stopped" . to_string ( ) ,
381+ image : "ubuntu:24.04" . to_string ( ) ,
382+ image_id : "sha256:c" . to_string ( ) ,
383+ status : ContainerStatus :: Stopped { exit_code : 0 } ,
384+ created_unix_secs : 50 ,
385+ started_unix_secs : Some ( 51 ) ,
386+ stopped_unix_secs : Some ( 60 ) ,
387+ rootfs_path : None ,
388+ host_pid : None ,
389+ } )
390+ . unwrap ( ) ;
391+
392+ let reconciled = store. reconcile_stale ( ) . unwrap ( ) ;
393+
394+ assert_eq ! ( reconciled, vec![ "stale-running" . to_string( ) ] ) ;
395+
396+ let containers = store. load_all ( ) . unwrap ( ) ;
397+ let stale = containers. iter ( ) . find ( |c| c. id == "stale-running" ) . unwrap ( ) ;
398+ assert ! ( matches!(
399+ stale. status,
400+ ContainerStatus :: Stopped { exit_code: -1 }
401+ ) ) ;
402+ assert ! ( stale. stopped_unix_secs. is_some( ) ) ;
403+ assert ! ( stale. host_pid. is_none( ) ) ;
404+
405+ let alive = containers. iter ( ) . find ( |c| c. id == "alive-running" ) . unwrap ( ) ;
406+ assert ! ( matches!( alive. status, ContainerStatus :: Running ) ) ;
407+ assert_eq ! ( alive. host_pid, Some ( std:: process:: id( ) ) ) ;
408+ }
409+
410+ #[ test]
411+ fn reconcile_stale_cleans_up_rootfs ( ) {
412+ let root = unique_temp_dir ( "reconcile-rootfs" ) ;
413+ let store = ContainerStore :: new ( root. clone ( ) ) ;
414+
415+ let rootfs_dir = root. join ( "stale-rootfs" ) ;
416+ fs:: create_dir_all ( & rootfs_dir) . unwrap ( ) ;
417+
418+ store
419+ . upsert ( ContainerInfo {
420+ id : "stale-with-rootfs" . to_string ( ) ,
421+ image : "ubuntu:24.04" . to_string ( ) ,
422+ image_id : "sha256:a" . to_string ( ) ,
423+ status : ContainerStatus :: Running ,
424+ created_unix_secs : 100 ,
425+ started_unix_secs : Some ( 101 ) ,
426+ stopped_unix_secs : None ,
427+ rootfs_path : Some ( rootfs_dir. clone ( ) ) ,
428+ host_pid : Some ( 999_999_999 ) ,
429+ } )
430+ . unwrap ( ) ;
431+
432+ let reconciled = store. reconcile_stale ( ) . unwrap ( ) ;
433+ assert_eq ! ( reconciled. len( ) , 1 ) ;
434+ assert ! ( !rootfs_dir. exists( ) ) ;
435+ }
436+
437+ #[ test]
438+ fn serde_round_trip_with_new_timestamp_fields ( ) {
439+ let original = ContainerInfo {
440+ id : "ctr-serde" . to_string ( ) ,
441+ image : "alpine:3.22" . to_string ( ) ,
442+ image_id : "sha256:serde" . to_string ( ) ,
443+ status : ContainerStatus :: Stopped { exit_code : 42 } ,
444+ created_unix_secs : 1000 ,
445+ started_unix_secs : Some ( 1001 ) ,
446+ stopped_unix_secs : Some ( 1010 ) ,
447+ rootfs_path : None ,
448+ host_pid : None ,
449+ } ;
450+
451+ let json = serde_json:: to_string ( & original) . unwrap ( ) ;
452+ let deserialized: ContainerInfo = serde_json:: from_str ( & json) . unwrap ( ) ;
453+ assert_eq ! ( deserialized, original) ;
454+ }
455+
456+ #[ test]
457+ fn serde_backward_compat_missing_timestamp_fields ( ) {
458+ // Simulate old JSON without started_unix_secs/stopped_unix_secs.
459+ let json = r#"{
460+ "id": "old-ctr",
461+ "image": "ubuntu:24.04",
462+ "image_id": "sha256:old",
463+ "status": "Created",
464+ "created_unix_secs": 500,
465+ "rootfs_path": null,
466+ "host_pid": null
467+ }"# ;
468+
469+ let info: ContainerInfo = serde_json:: from_str ( json) . unwrap ( ) ;
470+ assert_eq ! ( info. id, "old-ctr" ) ;
471+ assert ! ( info. started_unix_secs. is_none( ) ) ;
472+ assert ! ( info. stopped_unix_secs. is_none( ) ) ;
473+ }
242474}
0 commit comments