@@ -623,4 +623,118 @@ fn which(prog: &str) -> bool {
623623 std:: env:: var_os ( "PATH" ) . map_or ( false , |paths| {
624624 std:: env:: split_paths ( & paths) . any ( |d| d. join ( prog) . is_file ( ) )
625625 } )
626+ }
627+
628+ /// Path to the prebuilt static `rootfs-helper` (compiled by sandlock-core's
629+ /// build.rs). It is a self-contained, busybox-style binary the chroot
630+ /// integration tests drop into a rootfs; building `sandlock-oci` pulls in
631+ /// `sandlock-core`, so the binary is available here too.
632+ fn rootfs_helper ( ) -> std:: path:: PathBuf {
633+ std:: path:: PathBuf :: from ( env ! ( "CARGO_MANIFEST_DIR" ) ) . join ( "../../tests/rootfs-helper" )
634+ }
635+
636+ /// Regression test for process-group collapse on container stop. sandlock has
637+ /// no PID namespace, so when the container's main process exits the supervisor
638+ /// must explicitly SIGKILL the process group; otherwise background children (or
639+ /// exec'd siblings) outlive the container with a dead supervisor.
640+ ///
641+ /// The container's main process (`rootfs-helper spawn-loop`) forks a worker that
642+ /// advances `/child.cnt`, then `pause`s. We confirm the worker is running,
643+ /// `kill` the main process, and assert the worker stops advancing. Without the
644+ /// `reap_and_collapse` fix the orphaned worker keeps writing and this test fails.
645+ #[ tokio:: test( flavor = "multi_thread" ) ]
646+ async fn oci_stop_collapses_process_group ( ) {
647+ if sandlock_core:: landlock_abi_version ( ) . is_err ( ) {
648+ eprintln ! ( "skipping: Landlock unavailable on this host" ) ;
649+ return ;
650+ }
651+ let helper = rootfs_helper ( ) ;
652+ if !helper. exists ( ) {
653+ eprintln ! ( "skipping: rootfs-helper not built (needs musl-gcc or cc -static)" ) ;
654+ return ;
655+ }
656+
657+ let tmp = std:: env:: temp_dir ( ) . join ( format ! ( "sandlock-oci-pgroup-{}" , std:: process:: id( ) ) ) ;
658+ fs:: create_dir_all ( & tmp) . unwrap ( ) ;
659+
660+ // The container chroots to rootfs, so the worker's in-sandbox path
661+ // `/child.cnt` resolves to `rootfs/child.cnt` on the host. Drop the static
662+ // rootfs-helper into the rootfs and run its `spawn-loop` worker.
663+ let bundle = tmp. join ( "bundle" ) ;
664+ let rootfs = bundle. join ( "rootfs" ) ;
665+ fs:: create_dir_all ( & rootfs) . unwrap ( ) ;
666+ fs:: copy ( & helper, rootfs. join ( "rootfs-helper" ) ) . unwrap ( ) ;
667+ {
668+ use std:: os:: unix:: fs:: PermissionsExt ;
669+ fs:: set_permissions ( rootfs. join ( "rootfs-helper" ) , fs:: Permissions :: from_mode ( 0o755 ) ) . unwrap ( ) ;
670+ }
671+ create_bundle ( & bundle, & [ "/rootfs-helper" , "spawn-loop" , "/child.cnt" ] ) ;
672+
673+ let host_child = rootfs. join ( "child.cnt" ) ;
674+ let host_child_s = host_child. to_str ( ) . unwrap ( ) . to_string ( ) ;
675+ let read_counter = |path : & str | -> Option < u64 > {
676+ fs:: read_to_string ( path) . ok ( ) . and_then ( |s| s. trim ( ) . parse :: < u64 > ( ) . ok ( ) )
677+ } ;
678+
679+ let root = tempdir ( ) . unwrap ( ) ;
680+ let root_s = root. path ( ) . to_str ( ) . unwrap ( ) . to_string ( ) ;
681+ let id = "oci-pgroup-e2e" ;
682+
683+ // create (daemonizes a supervisor that inherits stdio; redirect + .status()).
684+ let create_log = tmp. join ( "create.log" ) ;
685+ let create_status = Command :: new ( oci_bin ( ) )
686+ . args ( [ "--root" , & root_s, "create" , id, "-b" , bundle. to_str ( ) . unwrap ( ) ] )
687+ . stdout ( std:: process:: Stdio :: from ( fs:: File :: create ( & create_log) . unwrap ( ) ) )
688+ . stderr ( std:: process:: Stdio :: from (
689+ fs:: OpenOptions :: new ( ) . append ( true ) . open ( & create_log) . unwrap ( ) ,
690+ ) )
691+ . status ( )
692+ . expect ( "run create" ) ;
693+ assert ! ( create_status. success( ) , "create failed: {}" , fs:: read_to_string( & create_log) . unwrap_or_default( ) ) ;
694+
695+ let start_out = Command :: new ( oci_bin ( ) )
696+ . args ( [ "--root" , & root_s, "start" , id] )
697+ . output ( )
698+ . expect ( "run start" ) ;
699+ assert ! ( start_out. status. success( ) , "start failed: {}" , String :: from_utf8_lossy( & start_out. stderr) ) ;
700+
701+ // Wait until the forked worker is genuinely running.
702+ let deadline = std:: time:: Instant :: now ( ) + std:: time:: Duration :: from_secs ( 5 ) ;
703+ let mut worker_running = false ;
704+ while std:: time:: Instant :: now ( ) < deadline {
705+ if read_counter ( & host_child_s) . map ( |v| v > 2 ) . unwrap_or ( false ) {
706+ worker_running = true ;
707+ break ;
708+ }
709+ tokio:: time:: sleep ( std:: time:: Duration :: from_millis ( 50 ) ) . await ;
710+ }
711+
712+ // Kill ONLY the main process (default SIGTERM to state.pid, not the group).
713+ // The supervisor's group-collapse is what must take the worker down.
714+ let kill_out = Command :: new ( oci_bin ( ) )
715+ . args ( [ "--root" , & root_s, "kill" , id, "SIGTERM" ] )
716+ . output ( )
717+ . expect ( "run kill" ) ;
718+ let kill_ok = kill_out. status . success ( ) ;
719+
720+ // Give the supervisor time to observe the exit and collapse the group.
721+ tokio:: time:: sleep ( std:: time:: Duration :: from_millis ( 600 ) ) . await ;
722+ let sample_a = read_counter ( & host_child_s) ;
723+ tokio:: time:: sleep ( std:: time:: Duration :: from_millis ( 400 ) ) . await ;
724+ let sample_b = read_counter ( & host_child_s) ;
725+
726+ // clean up before asserting so a failure never leaks the worker.
727+ let _ = Command :: new ( oci_bin ( ) )
728+ . args ( [ "--root" , & root_s, "delete" , id, "--force" ] )
729+ . output ( ) ;
730+ let _ = fs:: remove_dir_all ( & tmp) ;
731+
732+ assert ! ( worker_running, "forked worker never started; create_log: {}" , fs:: read_to_string( & create_log) . unwrap_or_default( ) ) ;
733+ assert ! ( kill_ok, "kill failed: {}" , String :: from_utf8_lossy( & kill_out. stderr) ) ;
734+ assert_eq ! (
735+ sample_a, sample_b,
736+ "worker must stop advancing after the container's main process is killed \
737+ (process group was not collapsed); samples {:?} -> {:?}",
738+ sample_a, sample_b
739+ ) ;
626740}
0 commit comments