From 83dbdba2c196d893f10722e5dd07a4655a48dfb3 Mon Sep 17 00:00:00 2001 From: yiying-zhang Date: Fri, 10 Jul 2026 23:42:50 -0700 Subject: [PATCH 01/53] Stabilize live tfork source freezing --- crun/src/libcrun/criu.c | 32 +++- .../pkg/domain/infra/abi/container_tfork.go | 154 +++++++++++++++++- 2 files changed, 182 insertions(+), 4 deletions(-) diff --git a/crun/src/libcrun/criu.c b/crun/src/libcrun/criu.c index 8bf068d03..29feeee70 100644 --- a/crun/src/libcrun/criu.c +++ b/crun/src/libcrun/criu.c @@ -1353,12 +1353,13 @@ libcrun_container_restore_linux_criu (libcrun_container_status_t *status, libcru # define CRIU_TFORK_LOG_FILE "tfork.log" static int -read_source_state_pid (const char *path, pid_t *pid_out, libcrun_error_t *err) +read_source_state_pid_cgroup (const char *path, pid_t *pid_out, char **cgroup_path_out, libcrun_error_t *err) { cleanup_free char *buffer = NULL; char err_buffer[256]; yajl_val tree, tmp; const char *pid_path[] = { "pid", NULL }; + const char *cgroup_path[] = { "cgroup-path", NULL }; int ret; ret = read_all_file (path, &buffer, NULL, err); @@ -1377,6 +1378,15 @@ read_source_state_pid (const char *path, pid_t *pid_out, libcrun_error_t *err) } *pid_out = (pid_t) strtoull (YAJL_GET_NUMBER (tmp), NULL, 10); + + tmp = yajl_tree_get (tree, cgroup_path, yajl_t_string); + if (UNLIKELY (tmp == NULL)) + { + yajl_tree_free (tree); + return crun_make_error (err, 0, "`cgroup-path` missing in source state.json `%s`", path); + } + + *cgroup_path_out = xstrdup (YAJL_GET_STRING (tmp)); yajl_tree_free (tree); return 0; } @@ -1439,10 +1449,13 @@ libcrun_container_tfork_linux_criu (libcrun_container_t *container, libcrun_chec { runtime_spec_schema_config_schema *def = container->container_def; cleanup_wrapper struct libcriu_wrapper_s *wrapper = NULL; + cleanup_free char *freezer_path = NULL; cleanup_free char *rootfs_path = NULL; + cleanup_free char *source_cgroup_path = NULL; cleanup_close int image_fd = -1; cleanup_close int work_fd = -1; pid_t source_pid = 0; + int cgroup_mode; int ret; ret = load_wrapper (&wrapper, err); @@ -1470,7 +1483,7 @@ libcrun_container_tfork_linux_criu (libcrun_container_t *container, libcrun_chec if (UNLIKELY (cr_options->image_path == NULL)) return crun_make_error (err, 0, "--image-path is required"); - ret = read_source_state_pid (cr_options->source_state, &source_pid, err); + ret = read_source_state_pid_cgroup (cr_options->source_state, &source_pid, &source_cgroup_path, err); if (UNLIKELY (ret < 0)) return ret; if (UNLIKELY (source_pid <= 0)) @@ -1516,6 +1529,21 @@ libcrun_container_tfork_linux_criu (libcrun_container_t *container, libcrun_chec libcriu_wrapper->criu_set_leave_running (true); libcriu_wrapper->criu_set_file_locks (true); + cgroup_mode = libcrun_get_cgroup_mode (err); + if (UNLIKELY (cgroup_mode < 0)) + return cgroup_mode; + + if (cgroup_mode == CGROUP_MODE_UNIFIED) + ret = append_paths (&freezer_path, err, CGROUP_ROOT, source_cgroup_path, NULL); + else + ret = append_paths (&freezer_path, err, CGROUP_ROOT "/freezer", source_cgroup_path, NULL); + if (UNLIKELY (ret < 0)) + return ret; + + ret = libcriu_wrapper->criu_set_freeze_cgroup (freezer_path); + if (UNLIKELY (ret < 0)) + return crun_make_error (err, -ret, "CRIU: failed setting tfork freezer %d", ret); + if (def->root != NULL && def->root->path != NULL) { ret = append_paths (&rootfs_path, err, container->context ? container->context->bundle : ".", diff --git a/podman/pkg/domain/infra/abi/container_tfork.go b/podman/pkg/domain/infra/abi/container_tfork.go index 8aa6926d5..a14fd6208 100644 --- a/podman/pkg/domain/infra/abi/container_tfork.go +++ b/podman/pkg/domain/infra/abi/container_tfork.go @@ -69,6 +69,20 @@ func (ic *ContainerEngine) containerCloneLive(ctx context.Context, opts entities snapRO := filepath.Join(bundleDir, "snap-ro") + thawSource, err := tforkFreezeSourceCgroup(src, 10*time.Second) + if err != nil { + return nil, fmt.Errorf("freeze source cgroup before tfork snapshot: %w", err) + } + sourceThawed := false + defer func() { + if sourceThawed { + return + } + if err := thawSource(); err != nil { + logrus.Warnf("tfork: thaw source cgroup after clone setup: %v", err) + } + }() + if out, err := exec.Command("sync").CombinedOutput(); err != nil { return nil, fmt.Errorf("sync: %s: %w", out, err) } @@ -497,6 +511,7 @@ func (ic *ContainerEngine) containerCloneLive(ctx context.Context, opts entities logF.Close() return nil, fmt.Errorf("start crun tfork: %w", err) } + crunDone := make(chan error, 1) var crunAborted bool defer func() { if !crunAborted && retErr != nil { @@ -507,7 +522,7 @@ func (ic *ContainerEngine) containerCloneLive(ctx context.Context, opts entities _ = f.Close() } go func() { - _ = crunCmd.Wait() + crunDone <- crunCmd.Wait() logF.Close() }() perCopyAttachSocks := make([]*os.File, copies) @@ -559,7 +574,16 @@ func (ic *ContainerEngine) containerCloneLive(ctx context.Context, opts entities deadline := time.Now().Add(60 * time.Second) readyCopies := 0 stateReady := !needState + crunExited := false + var crunErr error for readyCopies < copies || !stateReady { + if !crunExited { + select { + case crunErr = <-crunDone: + crunExited = true + default: + } + } readyCopies = 0 for i := 0; i < copies; i++ { if _, err := os.Stat(pidFileFor(i)); err == nil { @@ -574,7 +598,7 @@ func (ic *ContainerEngine) containerCloneLive(ctx context.Context, opts entities if readyCopies >= copies && stateReady { break } - if crunCmd.ProcessState != nil && crunCmd.ProcessState.Exited() && readyCopies < copies { + if crunExited && readyCopies < copies { tforkAbortCrunCmd(crunCmd, src, bundleDir, copies) crunAborted = true return nil, fmt.Errorf("crun tfork exited before %d clones came up (got %d); see %s", @@ -588,9 +612,31 @@ func (ic *ContainerEngine) containerCloneLive(ctx context.Context, opts entities } time.Sleep(200 * time.Millisecond) } + if !crunExited { + select { + case crunErr = <-crunDone: + crunExited = true + case <-time.After(10 * time.Second): + tforkAbortCrunCmd(crunCmd, src, bundleDir, copies) + crunAborted = true + return nil, fmt.Errorf("timeout waiting for crun tfork to finish after %d clones came up; see %s", + copies, logPath) + } + } + if crunErr != nil { + tforkAbortCrunCmd(crunCmd, src, bundleDir, copies) + crunAborted = true + return nil, fmt.Errorf("crun tfork failed after %d clones came up: %w; see %s", + copies, crunErr, logPath) + } logrus.Infof("tfork: batch %s up (N=%d); crun-tfork.log at %s", batchID, copies, logPath) } + if err := thawSource(); err != nil { + return nil, fmt.Errorf("thaw source cgroup after tfork restore: %w", err) + } + sourceThawed = true + srcCfg := src.Config() if srcCfg == nil { return nil, fmt.Errorf("source %q: could not read libpod config", src.ID()) @@ -666,6 +712,110 @@ func (ic *ContainerEngine) containerCloneLive(ctx context.Context, opts entities return &entities.ContainerCreateReport{Id: strings.Join(cloneIDs, "\n")}, nil } +func tforkFreezeSourceCgroup(src *libpod.Container, timeout time.Duration) (func() error, error) { + if src == nil { + return nil, fmt.Errorf("source container is nil") + } + cgPath, err := src.CgroupPath() + if err != nil { + return nil, fmt.Errorf("read source cgroup path: %w", err) + } + cgPath = strings.TrimPrefix(filepath.Clean(cgPath), string(os.PathSeparator)) + if cgPath == "" || cgPath == "." { + return nil, fmt.Errorf("source cgroup path is empty") + } + cgFS := filepath.Join("/sys/fs/cgroup", cgPath) + freezePath := filepath.Join(cgFS, "cgroup.freeze") + eventsPath := filepath.Join(cgFS, "cgroup.events") + + originalFrozen, err := tforkCgroupFrozen(freezePath) + if err != nil { + return nil, fmt.Errorf("read %s: %w", freezePath, err) + } + if err := os.WriteFile(freezePath, []byte("1"), 0o644); err != nil { + return nil, fmt.Errorf("write %s=1: %w", freezePath, err) + } + if err := tforkWaitCgroupFrozen(eventsPath, timeout); err != nil { + _ = os.WriteFile(freezePath, []byte("0"), 0o644) + return nil, err + } + logrus.Infof("tfork: froze source cgroup for snapshot consistency: %s", cgFS) + + thaw := func() error { + if originalFrozen { + if err := os.WriteFile(freezePath, []byte("1"), 0o644); err != nil { + return fmt.Errorf("restore %s=1: %w", freezePath, err) + } + return nil + } + if err := os.WriteFile(freezePath, []byte("0"), 0o644); err != nil { + return fmt.Errorf("write %s=0: %w", freezePath, err) + } + if err := tforkWaitCgroupThawed(eventsPath, 10*time.Second); err != nil { + return err + } + logrus.Infof("tfork: thawed source cgroup after clone restore: %s", cgFS) + return nil + } + return thaw, nil +} + +func tforkCgroupFrozen(freezePath string) (bool, error) { + data, err := os.ReadFile(freezePath) + if err != nil { + return false, err + } + return strings.TrimSpace(string(data)) == "1", nil +} + +func tforkWaitCgroupFrozen(eventsPath string, timeout time.Duration) error { + return tforkWaitCgroupFrozenState(eventsPath, true, timeout) +} + +func tforkWaitCgroupThawed(eventsPath string, timeout time.Duration) error { + return tforkWaitCgroupFrozenState(eventsPath, false, timeout) +} + +func tforkWaitCgroupFrozenState(eventsPath string, wantFrozen bool, timeout time.Duration) error { + deadline := time.Now().Add(timeout) + for { + data, err := os.ReadFile(eventsPath) + if err != nil { + return fmt.Errorf("read %s: %w", eventsPath, err) + } + frozen, ok := tforkCgroupEventsFrozen(data) + if ok && frozen == wantFrozen { + return nil + } + if time.Now().After(deadline) { + return fmt.Errorf("timeout waiting for %s to report frozen %d", eventsPath, tforkBoolInt(wantFrozen)) + } + time.Sleep(50 * time.Millisecond) + } +} + +func tforkCgroupEventsFrozen(data []byte) (bool, bool) { + for _, line := range strings.Split(string(data), "\n") { + fields := strings.Fields(line) + if len(fields) == 2 && fields[0] == "frozen" { + switch fields[1] { + case "0": + return false, true + case "1": + return true, true + } + } + } + return false, false +} + +func tforkBoolInt(v bool) int { + if v { + return 1 + } + return 0 +} + func spawnTforkDumpdHolder() (int, uint64, error) { cmd := exec.Command("setsid", "bash", "-c", "sleep infinity & while wait -n 2>/dev/null; do :; done") From c7cd34888c9fc93521d59dbe084ae15077a10b5c Mon Sep 17 00:00:00 2001 From: yiying-zhang Date: Sat, 11 Jul 2026 11:09:19 -0700 Subject: [PATCH 02/53] Harden tfork source cgroup synchronization --- .../pkg/domain/infra/abi/container_tfork.go | 158 +++++++++++++----- 1 file changed, 119 insertions(+), 39 deletions(-) diff --git a/podman/pkg/domain/infra/abi/container_tfork.go b/podman/pkg/domain/infra/abi/container_tfork.go index a14fd6208..a3bc98e5a 100644 --- a/podman/pkg/domain/infra/abi/container_tfork.go +++ b/podman/pkg/domain/infra/abi/container_tfork.go @@ -27,6 +27,15 @@ import ( "golang.org/x/sys/unix" ) +const ( + tforkSourceFreezeTimeout = 10 * time.Second + tforkSourceThawTimeout = 10 * time.Second + tforkCloneReadyTimeout = 60 * time.Second + tforkCrunFinishTimeout = tforkCloneReadyTimeout + tforkCgroupPollInterval = 50 * time.Millisecond + tforkClonePollInterval = 200 * time.Millisecond +) + func (ic *ContainerEngine) containerCloneLive(ctx context.Context, opts entities.ContainerCloneOptions) (rep *entities.ContainerCreateReport, retErr error) { src, err := ic.Libpod.LookupContainer(opts.ID) if err != nil { @@ -69,7 +78,7 @@ func (ic *ContainerEngine) containerCloneLive(ctx context.Context, opts entities snapRO := filepath.Join(bundleDir, "snap-ro") - thawSource, err := tforkFreezeSourceCgroup(src, 10*time.Second) + thawSource, err := tforkFreezeSourceCgroup(src, tforkSourceFreezeTimeout) if err != nil { return nil, fmt.Errorf("freeze source cgroup before tfork snapshot: %w", err) } @@ -571,7 +580,7 @@ func (ic *ContainerEngine) containerCloneLive(ctx context.Context, opts entities } statePath := fmt.Sprintf("/run/crun/%s/status", cloneIDs[0]) needState := copies == 1 - deadline := time.Now().Add(60 * time.Second) + deadline := time.Now().Add(tforkCloneReadyTimeout) readyCopies := 0 stateReady := !needState crunExited := false @@ -610,17 +619,17 @@ func (ic *ContainerEngine) containerCloneLive(ctx context.Context, opts entities return nil, fmt.Errorf("timeout waiting for %d tfork.pid* files in %s (got %d, stateReady=%v); see %s", copies, imgDir, readyCopies, stateReady, logPath) } - time.Sleep(200 * time.Millisecond) + time.Sleep(tforkClonePollInterval) } if !crunExited { select { case crunErr = <-crunDone: crunExited = true - case <-time.After(10 * time.Second): + case <-time.After(tforkCrunFinishTimeout): tforkAbortCrunCmd(crunCmd, src, bundleDir, copies) crunAborted = true - return nil, fmt.Errorf("timeout waiting for crun tfork to finish after %d clones came up; see %s", - copies, logPath) + return nil, fmt.Errorf("timeout waiting %s for crun tfork to finish after %d clones came up; see %s", + tforkCrunFinishTimeout, copies, logPath) } } if crunErr != nil { @@ -633,6 +642,7 @@ func (ic *ContainerEngine) containerCloneLive(ctx context.Context, opts entities } if err := thawSource(); err != nil { + logrus.Warnf("tfork: clones are up, but thawing source cgroup after restore failed: %v", err) return nil, fmt.Errorf("thaw source cgroup after tfork restore: %w", err) } sourceThawed = true @@ -712,6 +722,13 @@ func (ic *ContainerEngine) containerCloneLive(ctx context.Context, opts entities return &entities.ContainerCreateReport{Id: strings.Join(cloneIDs, "\n")}, nil } +type tforkCgroupFreezer struct { + root string + statePath string + eventsPath string + version string +} + func tforkFreezeSourceCgroup(src *libpod.Container, timeout time.Duration) (func() error, error) { if src == nil { return nil, fmt.Errorf("source container is nil") @@ -724,77 +741,140 @@ func tforkFreezeSourceCgroup(src *libpod.Container, timeout time.Duration) (func if cgPath == "" || cgPath == "." { return nil, fmt.Errorf("source cgroup path is empty") } - cgFS := filepath.Join("/sys/fs/cgroup", cgPath) - freezePath := filepath.Join(cgFS, "cgroup.freeze") - eventsPath := filepath.Join(cgFS, "cgroup.events") + freezer, err := tforkSourceCgroupFreezer(cgPath) + if err != nil { + return nil, err + } - originalFrozen, err := tforkCgroupFrozen(freezePath) + originalFrozen, err := freezer.frozen() if err != nil { - return nil, fmt.Errorf("read %s: %w", freezePath, err) + return nil, err } - if err := os.WriteFile(freezePath, []byte("1"), 0o644); err != nil { - return nil, fmt.Errorf("write %s=1: %w", freezePath, err) + if err := freezer.freeze(); err != nil { + return nil, err } - if err := tforkWaitCgroupFrozen(eventsPath, timeout); err != nil { - _ = os.WriteFile(freezePath, []byte("0"), 0o644) + if err := freezer.waitFrozen(true, timeout); err != nil { + if !originalFrozen { + _ = freezer.thaw() + } return nil, err } - logrus.Infof("tfork: froze source cgroup for snapshot consistency: %s", cgFS) + logrus.Infof("tfork: froze source cgroup for snapshot consistency: %s (%s)", freezer.root, freezer.version) thaw := func() error { if originalFrozen { - if err := os.WriteFile(freezePath, []byte("1"), 0o644); err != nil { - return fmt.Errorf("restore %s=1: %w", freezePath, err) - } - return nil + return freezer.freeze() } - if err := os.WriteFile(freezePath, []byte("0"), 0o644); err != nil { - return fmt.Errorf("write %s=0: %w", freezePath, err) + if err := freezer.thaw(); err != nil { + return err } - if err := tforkWaitCgroupThawed(eventsPath, 10*time.Second); err != nil { + if err := freezer.waitFrozen(false, tforkSourceThawTimeout); err != nil { return err } - logrus.Infof("tfork: thawed source cgroup after clone restore: %s", cgFS) + logrus.Infof("tfork: thawed source cgroup after clone restore: %s (%s)", freezer.root, freezer.version) return nil } return thaw, nil } -func tforkCgroupFrozen(freezePath string) (bool, error) { - data, err := os.ReadFile(freezePath) +func tforkSourceCgroupFreezer(cgPath string) (*tforkCgroupFreezer, error) { + v2Root := filepath.Join("/sys/fs/cgroup", cgPath) + v2FreezePath := filepath.Join(v2Root, "cgroup.freeze") + v2EventsPath := filepath.Join(v2Root, "cgroup.events") + if _, err := os.Stat(v2FreezePath); err == nil { + return &tforkCgroupFreezer{ + root: v2Root, + statePath: v2FreezePath, + eventsPath: v2EventsPath, + version: "cgroup v2", + }, nil + } else if !os.IsNotExist(err) { + return nil, fmt.Errorf("stat %s: %w", v2FreezePath, err) + } + + v1Root := filepath.Join("/sys/fs/cgroup/freezer", cgPath) + v1StatePath := filepath.Join(v1Root, "freezer.state") + if _, err := os.Stat(v1StatePath); err == nil { + return &tforkCgroupFreezer{ + root: v1Root, + statePath: v1StatePath, + version: "cgroup v1 freezer", + }, nil + } else if !os.IsNotExist(err) { + return nil, fmt.Errorf("stat %s: %w", v1StatePath, err) + } + + return nil, fmt.Errorf("source cgroup freezer not found for %q; expected cgroup v2 %s or cgroup v1 %s", + cgPath, v2FreezePath, v1StatePath) +} + +func (f *tforkCgroupFreezer) frozen() (bool, error) { + data, err := os.ReadFile(f.statePath) if err != nil { - return false, err + return false, fmt.Errorf("read %s: %w", f.statePath, err) } - return strings.TrimSpace(string(data)) == "1", nil + frozen, ok := f.parseFrozen(data) + if !ok { + return false, fmt.Errorf("could not parse frozen state from %s", f.statePath) + } + return frozen, nil } -func tforkWaitCgroupFrozen(eventsPath string, timeout time.Duration) error { - return tforkWaitCgroupFrozenState(eventsPath, true, timeout) +func (f *tforkCgroupFreezer) freeze() error { + value := []byte("1") + if f.version == "cgroup v1 freezer" { + value = []byte("FROZEN") + } + if err := os.WriteFile(f.statePath, value, 0o644); err != nil { + return fmt.Errorf("write %s=%s: %w", f.statePath, value, err) + } + return nil } -func tforkWaitCgroupThawed(eventsPath string, timeout time.Duration) error { - return tforkWaitCgroupFrozenState(eventsPath, false, timeout) +func (f *tforkCgroupFreezer) thaw() error { + value := []byte("0") + if f.version == "cgroup v1 freezer" { + value = []byte("THAWED") + } + if err := os.WriteFile(f.statePath, value, 0o644); err != nil { + return fmt.Errorf("write %s=%s: %w", f.statePath, value, err) + } + return nil } -func tforkWaitCgroupFrozenState(eventsPath string, wantFrozen bool, timeout time.Duration) error { +func (f *tforkCgroupFreezer) waitFrozen(wantFrozen bool, timeout time.Duration) error { deadline := time.Now().Add(timeout) for { - data, err := os.ReadFile(eventsPath) + path := f.statePath + if f.eventsPath != "" { + path = f.eventsPath + } + data, err := os.ReadFile(path) if err != nil { - return fmt.Errorf("read %s: %w", eventsPath, err) + return fmt.Errorf("read %s: %w", path, err) } - frozen, ok := tforkCgroupEventsFrozen(data) + frozen, ok := f.parseFrozen(data) if ok && frozen == wantFrozen { return nil } if time.Now().After(deadline) { - return fmt.Errorf("timeout waiting for %s to report frozen %d", eventsPath, tforkBoolInt(wantFrozen)) + return fmt.Errorf("timeout waiting %s for %s to report frozen %d", timeout, path, tforkBoolInt(wantFrozen)) } - time.Sleep(50 * time.Millisecond) + time.Sleep(tforkCgroupPollInterval) } } -func tforkCgroupEventsFrozen(data []byte) (bool, bool) { +func (f *tforkCgroupFreezer) parseFrozen(data []byte) (bool, bool) { + if f.version == "cgroup v1 freezer" { + switch strings.TrimSpace(string(data)) { + case "FROZEN": + return true, true + case "THAWED": + return false, true + } + return false, false + } + for _, line := range strings.Split(string(data), "\n") { fields := strings.Fields(line) if len(fields) == 2 && fields[0] == "frozen" { From 156dcfab91bfe35d429ff9368b058483f77a4af6 Mon Sep 17 00:00:00 2001 From: yiying-zhang Date: Sat, 11 Jul 2026 11:31:13 -0700 Subject: [PATCH 03/53] Fix cgroup v2 freeze-state parsing --- podman/pkg/domain/infra/abi/container_tfork.go | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/podman/pkg/domain/infra/abi/container_tfork.go b/podman/pkg/domain/infra/abi/container_tfork.go index a3bc98e5a..734a3fcec 100644 --- a/podman/pkg/domain/infra/abi/container_tfork.go +++ b/podman/pkg/domain/infra/abi/container_tfork.go @@ -858,7 +858,7 @@ func (f *tforkCgroupFreezer) waitFrozen(wantFrozen bool, timeout time.Duration) return nil } if time.Now().After(deadline) { - return fmt.Errorf("timeout waiting %s for %s to report frozen %d", timeout, path, tforkBoolInt(wantFrozen)) + return fmt.Errorf("timeout waiting %s for %s to report frozen=%t", timeout, path, wantFrozen) } time.Sleep(tforkCgroupPollInterval) } @@ -875,6 +875,13 @@ func (f *tforkCgroupFreezer) parseFrozen(data []byte) (bool, bool) { return false, false } + switch strings.TrimSpace(string(data)) { + case "0": + return false, true + case "1": + return true, true + } + for _, line := range strings.Split(string(data), "\n") { fields := strings.Fields(line) if len(fields) == 2 && fields[0] == "frozen" { @@ -889,13 +896,6 @@ func (f *tforkCgroupFreezer) parseFrozen(data []byte) (bool, bool) { return false, false } -func tforkBoolInt(v bool) int { - if v { - return 1 - } - return 0 -} - func spawnTforkDumpdHolder() (int, uint64, error) { cmd := exec.Command("setsid", "bash", "-c", "sleep infinity & while wait -n 2>/dev/null; do :; done") From c422f42ea2fad21d2a69d356b103dc45f4537bac Mon Sep 17 00:00:00 2001 From: Yiying Zhang <14881222+yiying-zhang@users.noreply.github.com> Date: Sat, 11 Jul 2026 12:42:44 -0700 Subject: [PATCH 04/53] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 95ca25692..0b4845d72 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Tclone +# Tclone: Low-Latency Full-Workspace Forking for AI Agents Tclone is a workspace-versioning substrate built for computer-use agents. Tclone provides a versioned personal workspace that can be quickly forked, snapshotted, and rolledback. It forks a live, running container in milliseconds: clones share From 3f989ff42e4cdd99be79ca5e614971d014f5ac68 Mon Sep 17 00:00:00 2001 From: yiying-zhang Date: Fri, 17 Jul 2026 08:06:12 -0700 Subject: [PATCH 05/53] Use direct crun path for single-copy tfork --- .../pkg/domain/infra/abi/container_tfork.go | 40 +++++++++++++++++-- 1 file changed, 37 insertions(+), 3 deletions(-) diff --git a/podman/pkg/domain/infra/abi/container_tfork.go b/podman/pkg/domain/infra/abi/container_tfork.go index 734a3fcec..331838c7d 100644 --- a/podman/pkg/domain/infra/abi/container_tfork.go +++ b/podman/pkg/domain/infra/abi/container_tfork.go @@ -54,6 +54,15 @@ func (ic *ContainerEngine) containerCloneLive(ctx context.Context, opts entities if copies <= 0 { copies = 1 } + requestedCopies := copies + useSingleCopyConmon := requestedCopies == 1 && os.Getenv("PODMAN_TFORK_SINGLE_COPY_CONMON") == "1" + if requestedCopies == 1 && !useSingleCopyConmon { + // The CRIU/crun single-copy tfork path can abort before producing a + // clone PID (`free(): invalid pointer`). Run a two-copy batch internally + // to use the known-good batch path, then remove the hidden spare clone + // before returning to the caller. + copies = 2 + } var srcRootfs string if cfg := src.Config(); cfg != nil && cfg.ExternalSetup && cfg.Rootfs != "" { @@ -219,7 +228,11 @@ func (ic *ContainerEngine) containerCloneLive(ctx context.Context, opts entities baseName = src.Name() + "-clone" } cloneName := baseName - if copies > 1 { + if requestedCopies == 1 && copies > 1 { + if i > 0 { + cloneName = fmt.Sprintf("%s-tfork-spare-%d", baseName, i) + } + } else if copies > 1 { cloneName = fmt.Sprintf("%s-%d", baseName, i) } cloneNames[i] = cloneName @@ -423,7 +436,9 @@ func (ic *ContainerEngine) containerCloneLive(ctx context.Context, opts entities } }() hasTTY := len(ttySrcFds) > 0 - useConmon := copies == 1 + // Keep the legacy single-copy conmon bootstrap opt-in because it can fail + // before crun starts and report only `conmon reported pid=-1`. + useConmon := copies == 1 && useSingleCopyConmon if useConmon { conmonInheritFds := inheritFds @@ -651,6 +666,7 @@ func (ic *ContainerEngine) containerCloneLive(ctx context.Context, opts entities if srcCfg == nil { return nil, fmt.Errorf("source %q: could not read libpod config", src.ID()) } + visibleCloneIDs := make([]string, 0, requestedCopies) for i, cloneID := range cloneIDs { clonePID, err := readTforkClonePID(cloneID, imgDir, i, copies) if err != nil { @@ -717,9 +733,27 @@ func (ic *ContainerEngine) containerCloneLive(ctx context.Context, opts entities } } logrus.Infof("tfork: clone %s (%s) registered in libpod state, pid=%d", cloneID, cloneNames[i], clonePID) + if i >= requestedCopies { + if err := removeHiddenTforkClone(ctx, ic, ctr); err != nil { + logrus.Warnf("tfork: hidden spare clone %s cleanup failed: %v", cloneID, err) + } else { + logrus.Infof("tfork: hidden spare clone %s removed", cloneID) + } + continue + } + visibleCloneIDs = append(visibleCloneIDs, cloneID) } - return &entities.ContainerCreateReport{Id: strings.Join(cloneIDs, "\n")}, nil + return &entities.ContainerCreateReport{Id: strings.Join(visibleCloneIDs, "\n")}, nil +} + +func removeHiddenTforkClone(ctx context.Context, ic *ContainerEngine, ctr *libpod.Container) error { + oldNoReap, hadNoReap := os.LookupEnv("PODMAN_TFORK_NO_REAP") + if hadNoReap { + _ = os.Unsetenv("PODMAN_TFORK_NO_REAP") + defer os.Setenv("PODMAN_TFORK_NO_REAP", oldNoReap) + } + return ic.Libpod.RemoveContainer(ctx, ctr, true, true, nil) } type tforkCgroupFreezer struct { From 8001ff4090ba98d83f416f2da50d32f259096b7d Mon Sep 17 00:00:00 2001 From: yiying-zhang Date: Fri, 17 Jul 2026 10:32:38 -0700 Subject: [PATCH 06/53] Expand tfork CRIU failure logs --- crun/src/libcrun/criu.c | 36 +++++++++++++++++++++++++++++++----- 1 file changed, 31 insertions(+), 5 deletions(-) diff --git a/crun/src/libcrun/criu.c b/crun/src/libcrun/criu.c index 29feeee70..6a9a04ecc 100644 --- a/crun/src/libcrun/criu.c +++ b/crun/src/libcrun/criu.c @@ -545,6 +545,9 @@ show_criu_log (const char *work_path, const char *log) cleanup_free char *log_path = NULL; libcrun_error_t *tmp_err = NULL; char line[1024]; + char tail[200][1024]; + size_t tail_index = 0; + size_t tail_count = 0; FILE *f; if (UNLIKELY (append_paths (&log_path, tmp_err, work_path, log, NULL)) < 0) @@ -564,11 +567,34 @@ show_criu_log (const char *work_path, const char *log) /* Log with error verbosity as this is the default. */ libcrun_error (0, "--- excerpt from CRIU log `%s`", log_path); while (fgets (line, sizeof (line), f) != NULL) - if (strstr (line, "Error ") != NULL) - { - line[strcspn (line, "\n")] = '\0'; - libcrun_error (0, "%s", line); - } + { + strncpy (tail[tail_index], line, sizeof (tail[tail_index]) - 1); + tail[tail_index][sizeof (tail[tail_index]) - 1] = '\0'; + tail_index = (tail_index + 1) % 200; + if (tail_count < 200) + tail_count++; + + if (strstr (line, "Error ") != NULL || strstr (line, "Warn ") != NULL + || strstr (line, "failed") != NULL || strstr (line, "FAILED") != NULL + || strstr (line, "Unable") != NULL || strstr (line, "Can't") != NULL + || strstr (line, "No such") != NULL) + { + line[strcspn (line, "\n")] = '\0'; + libcrun_error (0, "%s", line); + } + } + + if (tail_count > 0) + { + size_t start = (tail_count == 200) ? tail_index : 0; + libcrun_error (0, "--- last %zu CRIU log lines", tail_count); + for (size_t i = 0; i < tail_count; i++) + { + char *entry = tail[(start + i) % 200]; + entry[strcspn (entry, "\n")] = '\0'; + libcrun_error (0, "%s", entry); + } + } fclose (f); libcrun_error (0, "--- end of excerpt"); From d682a64399c42fdc5129cd85fe131a17a16de7d9 Mon Sep 17 00:00:00 2001 From: yiying-zhang Date: Fri, 17 Jul 2026 12:15:25 -0700 Subject: [PATCH 07/53] Include tfork restore log on CRIU failure --- crun/src/libcrun/criu.c | 2 ++ 1 file changed, 2 insertions(+) diff --git a/crun/src/libcrun/criu.c b/crun/src/libcrun/criu.c index 6a9a04ecc..b6a2c86ae 100644 --- a/crun/src/libcrun/criu.c +++ b/crun/src/libcrun/criu.c @@ -1377,6 +1377,7 @@ libcrun_container_restore_linux_criu (libcrun_container_status_t *status, libcru } # define CRIU_TFORK_LOG_FILE "tfork.log" +# define CRIU_TFORK_RESTORE_LOG_FILE "tfork-restore.log" static int read_source_state_pid_cgroup (const char *path, pid_t *pid_out, char **cgroup_path_out, libcrun_error_t *err) @@ -1835,6 +1836,7 @@ libcrun_container_tfork_linux_criu (libcrun_container_t *container, libcrun_chec if (UNLIKELY (ret != 0)) { show_criu_log (cr_options->work_path, CRIU_TFORK_LOG_FILE); + show_criu_log (cr_options->image_path, CRIU_TFORK_RESTORE_LOG_FILE); return crun_make_error (err, 0, "criu_tfork failed: %d", ret); } From f9400ac64529c7dcf475e1a8673c4b59e6e4766b Mon Sep 17 00:00:00 2001 From: yiying-zhang Date: Fri, 17 Jul 2026 12:22:03 -0700 Subject: [PATCH 08/53] Avoid PID namespace collision in tfork ncopy --- criu/criu/crtools.c | 3 +-- crun/src/libcrun/criu.c | 13 +++++++++++++ 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/criu/criu/crtools.c b/criu/criu/crtools.c index df40ec14f..48ff7a2c5 100644 --- a/criu/criu/crtools.c +++ b/criu/criu/crtools.c @@ -339,8 +339,7 @@ int main(int argc, char *argv[], char *envp[]) int failed = 0; const char *base_log = opts.output; - const int ns_flags = - CLONE_NEWPID | CLONE_NEWNS; + const int ns_flags = CLONE_NEWNS; if (!opts.tfork.active) { pr_err("--tfork-copies>1 requires --tfork-restore " diff --git a/crun/src/libcrun/criu.c b/crun/src/libcrun/criu.c index b6a2c86ae..112f21f6b 100644 --- a/crun/src/libcrun/criu.c +++ b/crun/src/libcrun/criu.c @@ -1378,6 +1378,18 @@ libcrun_container_restore_linux_criu (libcrun_container_status_t *status, libcru # define CRIU_TFORK_LOG_FILE "tfork.log" # define CRIU_TFORK_RESTORE_LOG_FILE "tfork-restore.log" +# define CRIU_TFORK_MAX_COPY_LOGS 16 + +static void +show_criu_tfork_restore_copy_logs (const char *image_path) +{ + for (int i = 0; i < CRIU_TFORK_MAX_COPY_LOGS; i++) + { + char log[64]; + snprintf (log, sizeof (log), "%s.copy%d", CRIU_TFORK_RESTORE_LOG_FILE, i); + show_criu_log (image_path, log); + } +} static int read_source_state_pid_cgroup (const char *path, pid_t *pid_out, char **cgroup_path_out, libcrun_error_t *err) @@ -1837,6 +1849,7 @@ libcrun_container_tfork_linux_criu (libcrun_container_t *container, libcrun_chec { show_criu_log (cr_options->work_path, CRIU_TFORK_LOG_FILE); show_criu_log (cr_options->image_path, CRIU_TFORK_RESTORE_LOG_FILE); + show_criu_tfork_restore_copy_logs (cr_options->image_path); return crun_make_error (err, 0, "criu_tfork failed: %d", ret); } From 71a23f1c49d8fb440d56dde17ca5428c8d0a49ed Mon Sep 17 00:00:00 2001 From: yiying-zhang Date: Fri, 17 Jul 2026 12:28:27 -0700 Subject: [PATCH 09/53] Preserve PID hierarchy during tfork restore --- criu/criu/cr-tfork.c | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/criu/criu/cr-tfork.c b/criu/criu/cr-tfork.c index ea64d6627..07e697b80 100644 --- a/criu/criu/cr-tfork.c +++ b/criu/criu/cr-tfork.c @@ -1078,7 +1078,7 @@ int cr_tfork_tasks(pid_t pid) list_for_each_entry(cgo_iter, &opts.new_cgroup_roots, node) rpc_n_cg_root++; - rpc_max = 32 + 2 * (rpc_n_ifd + rpc_n_ext + rpc_n_cg_root + rpc_max = 33 + 2 * (rpc_n_ifd + rpc_n_ext + rpc_n_cg_root + opts.tfork.snap_mount_n) + rpc_n_copy_args + 2; rpc_argv = calloc(rpc_max, sizeof(*rpc_argv)); @@ -1098,6 +1098,7 @@ int cr_tfork_tasks(pid_t pid) rpc_argv[rpc_n++] = "-o"; rpc_argv[rpc_n++] = restore_log_arg; rpc_argv[rpc_n++] = "-v2"; + rpc_argv[rpc_n++] = "--keep-pid-hierarchy"; if (opts.root) { rpc_argv[rpc_n++] = "--root"; @@ -1267,7 +1268,7 @@ int cr_tfork_tasks(pid_t pid) buf[off] = '\0'; end = buf + off; - argv_max = 8 + 2; + argv_max = 8 + 3; for (p = buf; p < end; p++) if (*p == '\0') argv_max++; @@ -1312,6 +1313,7 @@ int cr_tfork_tasks(pid_t pid) argv_new[argc_new++] = "--pidfile"; argv_new[argc_new++] = pidfile_arg; } + argv_new[argc_new++] = "--keep-pid-hierarchy"; argv_new[argc_new] = NULL; execv("/proc/self/exe", argv_new); From ead824f0cc80ded6a7408e35b5223685406f08c5 Mon Sep 17 00:00:00 2001 From: yiying-zhang Date: Fri, 17 Jul 2026 12:46:03 -0700 Subject: [PATCH 10/53] Insert pstree PIDs after loading namespace IDs --- criu/criu/pstree.c | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/criu/criu/pstree.c b/criu/criu/pstree.c index c448c9516..576efb600 100644 --- a/criu/criu/pstree.c +++ b/criu/criu/pstree.c @@ -745,9 +745,8 @@ static struct pstree_item *get_or_create_pstree_item(pid_t real, pid_t local, in { struct pid *found; struct pstree_item *item; - struct rb_node **root_link, *root_parent; - found = __lookup_pid_root(&pid_root_rb[ALL_PID_NS_ID], real, &root_parent, &root_link); + found = __lookup_pid_root(&pid_root_rb[ALL_PID_NS_ID], real, NULL, NULL); if (found) { if (pidns_id != ALL_PID_NS_ID) { BUG_ON(found->leaf_ns_id != pidns_id || found->local != local); @@ -763,11 +762,6 @@ static struct pstree_item *get_or_create_pstree_item(pid_t real, pid_t local, in item->pid->local = local; item->pid->leaf_ns_id = pidns_id; - if (__pstree_insert_pid(item->pid, root_parent, root_link) < 0) { - xfree(item); - return NULL; - } - return item; } @@ -916,6 +910,16 @@ static int read_one_pstree_item(PstreeEntry *e) pi->pid->state = TASK_ALIVE; pi->pid->uid = e->uid; + /* note: we don't fail if we have empty ids */ + if (read_pstree_ids(pi) < 0) + goto err; + + if (pi->ids && pi->ids->has_pid_ns_id) + pi->pid->leaf_ns_id = pi->ids->pid_ns_id; + + if (__pstree_insert_pid(pi->pid, NULL, NULL) < 0) + goto err; + if (e->ppid == 0) { if (root_item) { pr_err("Parent missed on non-root task " @@ -977,10 +981,6 @@ static int read_one_pstree_item(PstreeEntry *e) task_entries->nr_threads += e->n_threads; task_entries->nr_tasks++; - /* note: we don't fail if we have empty ids */ - if (read_pstree_ids(pi) < 0) - goto err; - ret = 1; err: return ret; From bac21f94da0d7b92754eac2563c885ccbe9731e6 Mon Sep 17 00:00:00 2001 From: yiying-zhang Date: Fri, 17 Jul 2026 13:50:53 -0700 Subject: [PATCH 11/53] Fix tfork nested pid namespace ids --- criu/criu/crtools.c | 3 +- criu/criu/namespaces.c | 63 +++++++++++++++++++++++++++++++- criu/lib/pycriu/images/images.py | 2 +- 3 files changed, 65 insertions(+), 3 deletions(-) diff --git a/criu/criu/crtools.c b/criu/criu/crtools.c index 48ff7a2c5..df40ec14f 100644 --- a/criu/criu/crtools.c +++ b/criu/criu/crtools.c @@ -339,7 +339,8 @@ int main(int argc, char *argv[], char *envp[]) int failed = 0; const char *base_log = opts.output; - const int ns_flags = CLONE_NEWNS; + const int ns_flags = + CLONE_NEWPID | CLONE_NEWNS; if (!opts.tfork.active) { pr_err("--tfork-copies>1 requires --tfork-restore " diff --git a/criu/criu/namespaces.c b/criu/criu/namespaces.c index df224db68..e428741d7 100644 --- a/criu/criu/namespaces.c +++ b/criu/criu/namespaces.c @@ -592,6 +592,67 @@ static unsigned int get_ns_id(int pid, struct ns_desc *nd, protobuf_c_boolean *s return __get_ns_id(pid, nd, supported, NULL); } +static unsigned int add_nested_pid_leaf_ns_id(struct pstree_item *item) +{ + struct ns_id *nsid; + + nsid = xzalloc(sizeof(*nsid)); + if (!nsid) + return 0; + + nsid->type = NS_OTHER; + nsid->kid = 0; + nsid->ns_populated = true; + nsid_add(nsid, &pid_ns_desc, ns_next_id++, localpid(item)); + + pr_info("Add nested pid leaf ns %d for task %d(%d), level %d\n", + nsid->id, localpid(item), realpid(item), item->pid->ns_level); + return nsid->id; +} + +static unsigned int ensure_task_leaf_pid_ns_id(struct pstree_item *item); + +static unsigned int task_leaf_pid_ns_id(struct pstree_item *item, unsigned int proc_pid_ns_id) +{ + struct pstree_item *parent = item->parent; + + /* + * os4agent stores localpid as the innermost NSpid (pid->ns[0]). + * Keep pstree_entry.nsid at the same namespace level. Otherwise a + * nested pid namespace init such as bwrap can become (nsid=N, + * localpid=1) and collide with the container init in the same nsid. + */ + if (parent && ensure_task_leaf_pid_ns_id(parent) == 0) + return 0; + + if (parent && parent->pid->leaf_ns_id != ALL_PID_NS_ID) { + if (item->pid->ns_level == parent->pid->ns_level) + return parent->pid->leaf_ns_id; + if (item->pid->ns_level > parent->pid->ns_level && + proc_pid_ns_id != parent->pid->leaf_ns_id) + return proc_pid_ns_id; + if (item->pid->ns_level > parent->pid->ns_level) + return add_nested_pid_leaf_ns_id(item); + } + + return proc_pid_ns_id; +} + +static unsigned int ensure_task_leaf_pid_ns_id(struct pstree_item *item) +{ + unsigned int proc_pid_ns_id; + + if (item->pid->leaf_ns_id != ALL_PID_NS_ID) + return item->pid->leaf_ns_id; + + proc_pid_ns_id = get_ns_id(item->pid->real, &pid_ns_desc, NULL); + if (!proc_pid_ns_id) + return 0; + + item->pid->leaf_ns_id = task_leaf_pid_ns_id(item, proc_pid_ns_id); + return item->pid->leaf_ns_id; +} + int dump_one_ns_file(int lfd, u32 id, const struct fd_parms *p) { struct cr_img *img; @@ -773,7 +834,7 @@ int dump_task_ns_ids(struct pstree_item *item) TaskKobjIdsEntry *ids = item->ids; ids->has_pid_ns_id = true; - ids->pid_ns_id = get_ns_id(pid, &pid_ns_desc, NULL); + ids->pid_ns_id = ensure_task_leaf_pid_ns_id(item); if (!ids->pid_ns_id) { pr_err("Can't make pidns id\n"); return -1; diff --git a/criu/lib/pycriu/images/images.py b/criu/lib/pycriu/images/images.py index 9db506e1e..927eee972 100644 --- a/criu/lib/pycriu/images/images.py +++ b/criu/lib/pycriu/images/images.py @@ -502,7 +502,7 @@ def skip(self, f, pbuff): tcp_stream_extra_handler()), 'STATS': entry_handler(pb.stats_entry), 'PAGEMAP': pagemap_handler(), # Special one - 'PSTREE': entry_handler(pb.pstree_entry), + 'PSTREE': entry_handler(pb.pstree_file_entry), 'REG_FILES': entry_handler(pb.reg_file_entry), 'NS_FILES': entry_handler(pb.ns_file_entry), 'EVENTFD_FILE': entry_handler(pb.eventfd_file_entry), From c952db921ed149225f348181913c92f45f6d4556 Mon Sep 17 00:00:00 2001 From: yiying-zhang Date: Fri, 17 Jul 2026 14:10:38 -0700 Subject: [PATCH 12/53] Avoid tfork freezer wait timeout on transient tasks --- criu/criu/seize.c | 30 ++++++++++++++++++++++-------- 1 file changed, 22 insertions(+), 8 deletions(-) diff --git a/criu/criu/seize.c b/criu/criu/seize.c index f5cde74e9..5fc7c850e 100644 --- a/criu/criu/seize.c +++ b/criu/criu/seize.c @@ -379,7 +379,7 @@ static int seize_cgroup_tree(char *root_path, enum freezer_state state) */ static int freezer_wait_processes(void) { - int i; + int i, collected = 0; processes_to_wait_pids = xmalloc(sizeof(pid_t) * processes_to_wait); if (processes_to_wait_pids == NULL) @@ -388,23 +388,37 @@ static int freezer_wait_processes(void) for (i = 0; i < processes_to_wait; i++) { int status; pid_t pid; + int waited_ms = 0; /* * Here we are going to skip tasks which are already traced. * Ptraced tasks looks like children for us, so if * a task isn't ptraced yet, waitpid() will return a error. */ - pid = waitpid(-1, &status, 0); - if (pid < 0) { - pr_perror("Unable to wait processes"); - xfree(processes_to_wait_pids); - processes_to_wait_pids = NULL; - return -1; + while (1) { + pid = waitpid(-1, &status, opts.tfork.active ? WNOHANG : 0); + if (pid > 0) + break; + if (!opts.tfork.active || (pid < 0 && errno != ECHILD && errno != EINTR)) { + pr_perror("Unable to wait processes"); + xfree(processes_to_wait_pids); + processes_to_wait_pids = NULL; + return -1; + } + if (pid < 0 || waited_ms >= 500) { + pr_warn("tfork: collected %d/%d unexpected freezer processes; continuing\n", + collected, processes_to_wait); + processes_to_wait = collected; + return 0; + } + usleep(10 * 1000); + waited_ms += 10; } pr_warn("Unexpected process %d in the freezer cgroup (status 0x%x)\n", pid, status); - processes_to_wait_pids[i] = pid; + processes_to_wait_pids[collected++] = pid; } + processes_to_wait = collected; return 0; } From 3dea3a743d83a7683acc94be95fba5f842058af4 Mon Sep 17 00:00:00 2001 From: yiying-zhang Date: Fri, 17 Jul 2026 14:21:24 -0700 Subject: [PATCH 13/53] Recompute invalid tfork nested pid namespace cache --- criu/criu/namespaces.c | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/criu/criu/namespaces.c b/criu/criu/namespaces.c index e428741d7..0edb0d664 100644 --- a/criu/criu/namespaces.c +++ b/criu/criu/namespaces.c @@ -640,10 +640,22 @@ static unsigned int task_leaf_pid_ns_id(struct pstree_item *item, unsigned int p static unsigned int ensure_task_leaf_pid_ns_id(struct pstree_item *item) { + struct pstree_item *parent = item->parent; unsigned int proc_pid_ns_id; - if (item->pid->leaf_ns_id != ALL_PID_NS_ID) - return item->pid->leaf_ns_id; + if (parent && ensure_task_leaf_pid_ns_id(parent) == 0) + return 0; + + if (item->pid->leaf_ns_id != ALL_PID_NS_ID) { + if (!parent) + return item->pid->leaf_ns_id; + if (item->pid->ns_level == parent->pid->ns_level && + item->pid->leaf_ns_id == parent->pid->leaf_ns_id) + return item->pid->leaf_ns_id; + if (item->pid->ns_level > parent->pid->ns_level && + item->pid->leaf_ns_id != parent->pid->leaf_ns_id) + return item->pid->leaf_ns_id; + } proc_pid_ns_id = get_ns_id(item->pid->real, &pid_ns_desc, NULL); if (!proc_pid_ns_id) From 88fef5e0b5288930300bca43d065e37e7f99e64c Mon Sep 17 00:00:00 2001 From: yiying-zhang Date: Fri, 17 Jul 2026 14:39:07 -0700 Subject: [PATCH 14/53] Force nested pid leaf ids during tfork dump --- criu/criu/cr-tfork.c | 5 ++-- criu/criu/namespaces.c | 52 +++++++++++++++++++++++++++++++++++------- criu/criu/pstree.c | 5 +++- 3 files changed, 51 insertions(+), 11 deletions(-) diff --git a/criu/criu/cr-tfork.c b/criu/criu/cr-tfork.c index 07e697b80..ca5a5c61c 100644 --- a/criu/criu/cr-tfork.c +++ b/criu/criu/cr-tfork.c @@ -966,8 +966,9 @@ int cr_tfork_tasks(pid_t pid) opts.tfork.pidfd_map[opts.tfork.pidfd_map_nr].pidfd = pidfd; opts.tfork.pidfd_map[opts.tfork.pidfd_map_nr].memfd = -1; opts.tfork.pidfd_map_nr++; - pr_info("tfork: pidfd %d for pid %d (vpid %d uid %d)\n", - pidfd, item->pid->real, localpid(item), uid(item)); + pr_info("tfork: pidfd %d for pid %d (vpid %d uid %d nsid %d level %d)\n", + pidfd, item->pid->real, localpid(item), uid(item), + item->pid->leaf_ns_id, item->pid->ns_level); } ret = run_scripts(ACT_PRE_TFORK_RESTORE); diff --git a/criu/criu/namespaces.c b/criu/criu/namespaces.c index 0edb0d664..3cdf30c8b 100644 --- a/criu/criu/namespaces.c +++ b/criu/criu/namespaces.c @@ -615,6 +615,7 @@ static unsigned int ensure_task_leaf_pid_ns_id(struct pstree_item *item); static unsigned int task_leaf_pid_ns_id(struct pstree_item *item, unsigned int proc_pid_ns_id) { struct pstree_item *parent = item->parent; + unsigned int selected; /* * os4agent stores localpid as the innermost NSpid (pid->ns[0]). @@ -626,16 +627,29 @@ static unsigned int task_leaf_pid_ns_id(struct pstree_item *item, unsigned int p return 0; if (parent && parent->pid->leaf_ns_id != ALL_PID_NS_ID) { - if (item->pid->ns_level == parent->pid->ns_level) - return parent->pid->leaf_ns_id; + if (item->pid->ns_level == parent->pid->ns_level) { + selected = parent->pid->leaf_ns_id; + goto out; + } if (item->pid->ns_level > parent->pid->ns_level && - proc_pid_ns_id != parent->pid->leaf_ns_id) - return proc_pid_ns_id; - if (item->pid->ns_level > parent->pid->ns_level) - return add_nested_pid_leaf_ns_id(item); + proc_pid_ns_id != parent->pid->leaf_ns_id) { + selected = proc_pid_ns_id; + goto out; + } + if (item->pid->ns_level > parent->pid->ns_level) { + selected = add_nested_pid_leaf_ns_id(item); + goto out; + } } - return proc_pid_ns_id; + selected = proc_pid_ns_id; + +out: + pr_info("pid leaf ns task=%d(%d) uid=%d level=%d parent_level=%d proc_nsid=%u parent_nsid=%d selected=%u\n", + localpid(item), realpid(item), uid(item), item->pid->ns_level, + parent ? parent->pid->ns_level : -1, proc_pid_ns_id, + parent ? parent->pid->leaf_ns_id : -1, selected); + return selected; } static unsigned int ensure_task_leaf_pid_ns_id(struct pstree_item *item) @@ -844,15 +858,37 @@ int dump_task_ns_ids(struct pstree_item *item) int i; int pid = item->pid->real; TaskKobjIdsEntry *ids = item->ids; + struct pstree_item *parent = item->parent; + unsigned int proc_pid_ns_id; ids->has_pid_ns_id = true; - ids->pid_ns_id = ensure_task_leaf_pid_ns_id(item); + proc_pid_ns_id = get_ns_id(pid, &pid_ns_desc, NULL); + if (!proc_pid_ns_id) { + pr_err("Can't make pidns id\n"); + return -1; + } + + if (parent && ensure_task_leaf_pid_ns_id(parent) == 0) + return -1; + + ids->pid_ns_id = proc_pid_ns_id; + if (parent && item->pid->ns_level == parent->pid->ns_level) + ids->pid_ns_id = parent->pid->leaf_ns_id; + else if (parent && item->pid->ns_level > parent->pid->ns_level && + ids->pid_ns_id == parent->pid->leaf_ns_id) + ids->pid_ns_id = add_nested_pid_leaf_ns_id(item); + if (!ids->pid_ns_id) { pr_err("Can't make pidns id\n"); return -1; } item->pid->leaf_ns_id = ids->pid_ns_id; + pr_info("dump pid ns task=%d(%d) uid=%d level=%d parent_level=%d proc_nsid=%u parent_nsid=%d selected=%u\n", + localpid(item), realpid(item), uid(item), item->pid->ns_level, + parent ? parent->pid->ns_level : -1, proc_pid_ns_id, + parent ? parent->pid->leaf_ns_id : -1, ids->pid_ns_id); + for (i = 0; i < item->nr_threads; i++) item->threads[i].leaf_ns_id = ids->pid_ns_id; diff --git a/criu/criu/pstree.c b/criu/criu/pstree.c index 576efb600..2420c76bb 100644 --- a/criu/criu/pstree.c +++ b/criu/criu/pstree.c @@ -436,7 +436,10 @@ int dump_pstree(struct pstree_item *root_item) pstree_entry__init(e); tree_entries[nr_items++] = e; - pr_info("Process: %d(%d)\n", localpid(item), realpid(item)); + pr_info("Process: %d(%d) uid=%d nsid=%d level=%d parent=%d parent_nsid=%d\n", + localpid(item), realpid(item), uid(item), item->pid->leaf_ns_id, + item->pid->ns_level, item->parent ? realpid(item->parent) : 0, + item->parent ? item->parent->pid->leaf_ns_id : -1); e->realpid = realpid(item); e->ppid = item->parent ? realpid(item->parent) : 0; From d838fca8069d61ac7fe22fa4b54986f90cddc544 Mon Sep 17 00:00:00 2001 From: yiying-zhang Date: Fri, 17 Jul 2026 16:40:39 -0700 Subject: [PATCH 15/53] Finalize nested pid namespace ids before pstree dump --- criu/criu/cr-dump.c | 3 +++ criu/criu/include/namespaces.h | 1 + criu/criu/namespaces.c | 34 ++++++++++++++++++++++++++++++++++ criu/criu/pstree.c | 9 +++++++-- 4 files changed, 45 insertions(+), 2 deletions(-) diff --git a/criu/criu/cr-dump.c b/criu/criu/cr-dump.c index d196521fc..6728b8625 100644 --- a/criu/criu/cr-dump.c +++ b/criu/criu/cr-dump.c @@ -2360,6 +2360,9 @@ int cr_dump_tasks(pid_t pid) if (dump_zombies()) goto err; + if (finalize_nested_pid_ns_ids()) + goto err; + if (dump_pstree(root_item)) goto err; diff --git a/criu/criu/include/namespaces.h b/criu/criu/include/namespaces.h index e442e0a39..7a5aa6437 100644 --- a/criu/criu/include/namespaces.h +++ b/criu/criu/include/namespaces.h @@ -187,6 +187,7 @@ extern int restore_mnt_ns(int rst, int *cwd_fd); extern int dump_task_ns_ids(struct pstree_item *); extern int predump_task_ns_ids(struct pstree_item *); +extern int finalize_nested_pid_ns_ids(void); extern int rst_add_ns_id(unsigned int id, struct pstree_item *, struct ns_desc *nd); extern struct ns_id *lookup_ns_by_id(unsigned int id, struct ns_desc *nd); diff --git a/criu/criu/namespaces.c b/criu/criu/namespaces.c index 3cdf30c8b..11909cea2 100644 --- a/criu/criu/namespaces.c +++ b/criu/criu/namespaces.c @@ -974,6 +974,40 @@ int dump_task_ns_ids(struct pstree_item *item) return 0; } +int finalize_nested_pid_ns_ids(void) +{ + struct pstree_item *item; + + for_each_pstree_item(item) { + struct pstree_item *parent = item->parent; + unsigned int nsid; + int i; + + if (!parent) + continue; + if (item->pid->ns_level <= parent->pid->ns_level) + continue; + if (item->pid->leaf_ns_id != parent->pid->leaf_ns_id) + continue; + + nsid = add_nested_pid_leaf_ns_id(item); + if (!nsid) + return -1; + + item->pid->leaf_ns_id = nsid; + for (i = 0; i < item->nr_threads; i++) + item->threads[i].leaf_ns_id = nsid; + if (item->ids && item->ids->has_pid_ns_id) + item->ids->pid_ns_id = nsid; + + pr_info("finalize nested pid ns task=%d(%d) uid=%d level=%d parent_nsid=%d selected=%u\n", + localpid(item), realpid(item), uid(item), item->pid->ns_level, + parent->pid->leaf_ns_id, nsid); + } + + return 0; +} + static UsernsEntry userns_entry = USERNS_ENTRY__INIT; #define INVALID_ID (~0U) diff --git a/criu/criu/pstree.c b/criu/criu/pstree.c index 2420c76bb..de7f45b3f 100644 --- a/criu/criu/pstree.c +++ b/criu/criu/pstree.c @@ -917,8 +917,13 @@ static int read_one_pstree_item(PstreeEntry *e) if (read_pstree_ids(pi) < 0) goto err; - if (pi->ids && pi->ids->has_pid_ns_id) - pi->pid->leaf_ns_id = pi->ids->pid_ns_id; + if (pi->ids && pi->ids->has_pid_ns_id) { + if (pi->ids->pid_ns_id != pi->pid->leaf_ns_id) { + pr_warn("PID namespace id mismatch for uid %d: pstree=%d ids=%d, keeping pstree\n", + uid(pi), pi->pid->leaf_ns_id, pi->ids->pid_ns_id); + pi->ids->pid_ns_id = pi->pid->leaf_ns_id; + } + } if (__pstree_insert_pid(pi->pid, NULL, NULL) < 0) goto err; From 852c991466cecd0d4f9a71d81baa72df03683a8e Mon Sep 17 00:00:00 2001 From: yiying-zhang Date: Fri, 17 Jul 2026 16:48:12 -0700 Subject: [PATCH 16/53] Avoid ncopy helper consuming restored pid 1 --- criu/criu/crtools.c | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/criu/criu/crtools.c b/criu/criu/crtools.c index df40ec14f..8951acbe2 100644 --- a/criu/criu/crtools.c +++ b/criu/criu/crtools.c @@ -339,8 +339,13 @@ int main(int argc, char *argv[], char *envp[]) int failed = 0; const char *base_log = opts.output; - const int ns_flags = - CLONE_NEWPID | CLONE_NEWNS; + /* + * The n-copy helper must not create/occupy PID 1 in a new + * PID namespace. CRIU restores the real root task as PID 1 + * from the image; if the helper has already consumed it, + * restore fails with EEXIST ("Can't fork for 1"). + */ + const int ns_flags = CLONE_NEWNS; if (!opts.tfork.active) { pr_err("--tfork-copies>1 requires --tfork-restore " From 9f2fb9fd8555cb2d78b2f88025d196f79dc295b1 Mon Sep 17 00:00:00 2001 From: yiying-zhang Date: Fri, 17 Jul 2026 16:55:28 -0700 Subject: [PATCH 17/53] Drop source pid chain for tfork restore copies --- criu/criu/cr-restore.c | 9 ++++++++- criu/criu/crtools.c | 2 ++ criu/criu/pstree.c | 8 ++++++++ 3 files changed, 18 insertions(+), 1 deletion(-) diff --git a/criu/criu/cr-restore.c b/criu/criu/cr-restore.c index d85279a82..788d64187 100644 --- a/criu/criu/cr-restore.c +++ b/criu/criu/cr-restore.c @@ -1434,7 +1434,14 @@ static inline int fork_with_pid(struct pstree_item *item) strip |= CLONE_NEWUSER; if (kdat.has_clone3_set_tid) { - if (item->pid->ns_level == 1) + if (opts.tfork.active && item == root_item && + (ca.clone_flags & CLONE_NEWPID) && + item->pid->ns_level > 1) { + pr_info("tfork: restore root with local pid %d, dropping dumped outer pid chain level=%d\n", + pid, item->pid->ns_level); + ret = clone3_with_pid_noasan(restore_task_with_children, &ca, + ca.clone_flags & ~strip, SIGCHLD, pid); + } else if (item->pid->ns_level == 1) ret = clone3_with_pid_noasan(restore_task_with_children, &ca, ca.clone_flags & ~strip, SIGCHLD, pid); else diff --git a/criu/criu/crtools.c b/criu/criu/crtools.c index 8951acbe2..8f9067364 100644 --- a/criu/criu/crtools.c +++ b/criu/criu/crtools.c @@ -450,6 +450,8 @@ int main(int argc, char *argv[], char *envp[]) if (opts.tfork.snap_roots_n > 0) opts.root = opts.tfork.snap_roots[i]; + opts.keep_pid_hierarchy = 0; + if (tfork_load_ncopy_fabric(i)) { pr_err("tfork-ncopy: copy %d fabric load failed\n", i); diff --git a/criu/criu/pstree.c b/criu/criu/pstree.c index de7f45b3f..d903958c6 100644 --- a/criu/criu/pstree.c +++ b/criu/criu/pstree.c @@ -1280,6 +1280,8 @@ static int new_pid_ns_truncate_pid_hierarchy(pid_t *pid_max) } ns_level_to_truncate = root_item->pid->ns_level - 1; + pr_info("pidns: truncating %u outer pid namespace level(s) for new root pid namespace\n", + ns_level_to_truncate); for (node = rb_first(&pid_root_rb[ALL_PID_NS_ID]); node; ) { next = rb_next(node); @@ -1287,9 +1289,15 @@ static int new_pid_ns_truncate_pid_hierarchy(pid_t *pid_max) pid_node = rb_entry(node, struct pid, root_ns_node); rb_erase(node, &pid_root_rb[ALL_PID_NS_ID]); + pr_info("pidns: truncate before uid=%d real=%d local=%d level=%d\n", + pid_node->uid, pid_node->real, pid_node->local, + pid_node->ns_level); pid_node->ns_level -= ns_level_to_truncate; BUG_ON(pid_node->ns_level <= 0); pid_node->real = pid_node->ns[pid_node->ns_level - 1].ns_pid; + pr_info("pidns: truncate after uid=%d real=%d local=%d level=%d\n", + pid_node->uid, pid_node->real, pid_node->local, + pid_node->ns_level); found = __lookup_pid_root(&new_real_rbtree, pid_node->real, &parent, &link); if (found) { From 7d87562cbc2895c4b99f53a809bfbf29278b26d8 Mon Sep 17 00:00:00 2001 From: yiying-zhang Date: Fri, 17 Jul 2026 17:03:11 -0700 Subject: [PATCH 18/53] Rebase nested tfork restore pid chains --- criu/criu/cr-restore.c | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/criu/criu/cr-restore.c b/criu/criu/cr-restore.c index 788d64187..b93a3404a 100644 --- a/criu/criu/cr-restore.c +++ b/criu/criu/cr-restore.c @@ -1444,10 +1444,24 @@ static inline int fork_with_pid(struct pstree_item *item) } else if (item->pid->ns_level == 1) ret = clone3_with_pid_noasan(restore_task_with_children, &ca, ca.clone_flags & ~strip, SIGCHLD, pid); - else + else { + struct pid tfork_pid = {}; + struct pid *restore_pid = item->pid; + + if (opts.tfork.active && (root_ns_mask & CLONE_NEWPID) && + root_item && root_item->pid->ns_level > 1 && + item->pid->ns_level > 1) { + tfork_pid = *item->pid; + tfork_pid.ns_level--; + restore_pid = &tfork_pid; + pr_info("tfork: restore pid uid=%d local=%d with rebased pid chain level %d -> %d\n", + uid(item), pid, item->pid->ns_level, + restore_pid->ns_level); + } ret = clone3_with_nested_pid_noasan(restore_task_with_children, &ca, ca.clone_flags & ~strip, - SIGCHLD, item->pid); + SIGCHLD, restore_pid); + } } else { BUG_ON(item->pid->ns_level >= 1); close_pid_proc(); From 98c236093b66d1c2980800407ebae742cbd7d121 Mon Sep 17 00:00:00 2001 From: yiying-zhang Date: Fri, 17 Jul 2026 19:29:52 -0700 Subject: [PATCH 19/53] Force pid hierarchy truncation for tfork restores --- criu/criu/pstree.c | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/criu/criu/pstree.c b/criu/criu/pstree.c index d903958c6..b773b6d8c 100644 --- a/criu/criu/pstree.c +++ b/criu/criu/pstree.c @@ -1270,8 +1270,12 @@ static int new_pid_ns_truncate_pid_hierarchy(pid_t *pid_max) unsigned int ns_level_to_truncate; clone_flags = get_clone_mask(root_item->ids, root_ids); - if (!(clone_flags & CLONE_NEWPID)) + if (!(clone_flags & CLONE_NEWPID) && + !(opts.tfork.active && root_item->pid->ns_level > 1)) return 0; + if (!(clone_flags & CLONE_NEWPID)) + pr_info("pidns: forcing tfork pid hierarchy truncation for root level=%d\n", + root_item->pid->ns_level); if (root_item->pid->ns_level <= 1) { pr_err("only 1 level of pid namespace, but CLONE_NEWPID is set, " From d35c69386d602047ccf7ca8da1b7a142828836ef Mon Sep 17 00:00:00 2001 From: yiying-zhang Date: Fri, 17 Jul 2026 19:41:57 -0700 Subject: [PATCH 20/53] Use fresh parent pids for tfork pidns inits --- criu/criu/clone-noasan.c | 16 ++++++++++++++++ criu/criu/cr-restore.c | 23 +++++++++++++++++------ 2 files changed, 33 insertions(+), 6 deletions(-) diff --git a/criu/criu/clone-noasan.c b/criu/criu/clone-noasan.c index 4ba7f6f31..368f6aa1e 100644 --- a/criu/criu/clone-noasan.c +++ b/criu/criu/clone-noasan.c @@ -78,7 +78,10 @@ int clone3_with_pid_noasan(int (*fn)(void *), void *arg, int flags, int exit_sig c_args.flags = flags; c_args.set_tid = ptr_to_u64(&pid); c_args.set_tid_size = 1; + pr_info("clone3 set_tid pid=%d flags=0x%x size=1\n", pid, flags); pid = syscall(__NR_clone3, &c_args, sizeof(c_args)); + if (pid < 0) + pr_perror("clone3 set_tid failed flags=0x%x size=1", flags); if (pid == 0) exit(fn(arg)); return pid; @@ -99,6 +102,12 @@ int clone3_with_nested_pid_noasan(int (*fn)(void *), void *arg, int flags, int e BUG_ON(pid->ns_level > MAX_PID_NS_LEVEL || pid->ns_level <= 1); for (i = 0; i < pid->ns_level; i++) tids[i] = pid->ns[i].ns_pid; + pr_info("clone3 nested set_tid flags=0x%x size=%d tids=%d/%d/%d/%d\n", + flags, pid->ns_level, + tids[0], + pid->ns_level > 1 ? tids[1] : -1, + pid->ns_level > 2 ? tids[2] : -1, + pid->ns_level > 3 ? tids[3] : -1); if (!(flags & CLONE_PARENT)) { if (exit_signal != SIGCHLD) { @@ -112,6 +121,13 @@ int clone3_with_nested_pid_noasan(int (*fn)(void *), void *arg, int flags, int e c_args.set_tid = ptr_to_u64(tids); c_args.set_tid_size = pid->ns_level; pid_ret = syscall(__NR_clone3, &c_args, sizeof(c_args)); + if (pid_ret < 0) + pr_perror("clone3 nested set_tid failed flags=0x%x size=%d tids=%d/%d/%d/%d", + flags, pid->ns_level, + tids[0], + pid->ns_level > 1 ? tids[1] : -1, + pid->ns_level > 2 ? tids[2] : -1, + pid->ns_level > 3 ? tids[3] : -1); if (pid_ret == 0) exit(fn(arg)); return pid_ret; diff --git a/criu/criu/cr-restore.c b/criu/criu/cr-restore.c index b93a3404a..1cfe390e4 100644 --- a/criu/criu/cr-restore.c +++ b/criu/criu/cr-restore.c @@ -1434,11 +1434,9 @@ static inline int fork_with_pid(struct pstree_item *item) strip |= CLONE_NEWUSER; if (kdat.has_clone3_set_tid) { - if (opts.tfork.active && item == root_item && - (ca.clone_flags & CLONE_NEWPID) && - item->pid->ns_level > 1) { - pr_info("tfork: restore root with local pid %d, dropping dumped outer pid chain level=%d\n", - pid, item->pid->ns_level); + if (opts.tfork.active && (ca.clone_flags & CLONE_NEWPID)) { + pr_info("tfork: restore pidns init uid=%d local pid %d with fresh parent pid, dumped chain level=%d\n", + uid(item), pid, item->pid->ns_level); ret = clone3_with_pid_noasan(restore_task_with_children, &ca, ca.clone_flags & ~strip, SIGCHLD, pid); } else if (item->pid->ns_level == 1) @@ -1469,13 +1467,26 @@ static inline int fork_with_pid(struct pstree_item *item) (ca.clone_flags & ~strip) | SIGCHLD, &ca); } if (ret < 0) { + pr_err("fork_with_pid failed item uid=%d local=%d real=%d parent_local=%d flags=0x%lx stripped_flags=0x%lx ns_level=%d root_ns_mask=0x%lx tfork=%d\n", + uid(item), pid, realpid(item), + item->parent ? localpid(item->parent) : -1, + ca.clone_flags, ca.clone_flags & ~strip, + item->pid->ns_level, root_ns_mask, + opts.tfork.active ? 1 : 0); + if (item->pid->ns_level > 0) + pr_err("fork_with_pid pid chain uid=%d ns=%d/%d/%d/%d\n", + uid(item), + item->pid->ns[0].ns_pid, + item->pid->ns_level > 1 ? item->pid->ns[1].ns_pid : -1, + item->pid->ns_level > 2 ? item->pid->ns[2].ns_pid : -1, + item->pid->ns_level > 3 ? item->pid->ns[3].ns_pid : -1); pr_perror("Can't fork for %d", pid); if (errno == EEXIST) set_cr_errno(EEXIST); goto err_unlock; } - if (item == root_item) { + if (opts.tfork.active || item == root_item) { item->pid->real = ret; pr_debug("PID: real %d virt %d\n", item->pid->real, localpid(item)); } From 3d108b0380c39438e96b164928ce08ad0a545df7 Mon Sep 17 00:00:00 2001 From: yiying-zhang Date: Fri, 17 Jul 2026 19:48:46 -0700 Subject: [PATCH 21/53] Repair tfork pidns init clone flags --- criu/criu/cr-restore.c | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/criu/criu/cr-restore.c b/criu/criu/cr-restore.c index 1cfe390e4..36aa08c42 100644 --- a/criu/criu/cr-restore.c +++ b/criu/criu/cr-restore.c @@ -1395,6 +1395,16 @@ static inline int fork_with_pid(struct pstree_item *item) ca.item = item; ca.clone_flags = rsti(item)->clone_flags; + if (opts.tfork.active && item != root_item && + !(ca.clone_flags & CLONE_NEWPID) && + item->pid->ns_level > 1 && + item->pid->ns[0].ns_pid == INIT_PID) { + pr_info("tfork: repairing missing CLONE_NEWPID for pidns init uid=%d local=%d parent_local=%d level=%d\n", + uid(item), pid, + item->parent ? localpid(item->parent) : -1, + item->pid->ns_level); + ca.clone_flags |= CLONE_NEWPID; + } BUG_ON(ca.clone_flags & CLONE_VM); From 13767add655165d6b23d6d6f0640a7fcf74eac22 Mon Sep 17 00:00:00 2001 From: yiying-zhang Date: Fri, 17 Jul 2026 19:59:16 -0700 Subject: [PATCH 22/53] Use fresh parent tids for tfork thread restore --- criu/criu/cr-restore.c | 1 + criu/criu/include/restorer.h | 1 + criu/criu/pie/restorer.c | 17 ++++++++++++++++- 3 files changed, 18 insertions(+), 1 deletion(-) diff --git a/criu/criu/cr-restore.c b/criu/criu/cr-restore.c index 36aa08c42..b2cfbf03a 100644 --- a/criu/criu/cr-restore.c +++ b/criu/criu/cr-restore.c @@ -4087,6 +4087,7 @@ static int sigreturn_restore(struct task_restore_args *task_args, unsigned long task_args->vdso_rt_size = vdso_rt_size; task_args->can_map_vdso = kdat.can_map_vdso; task_args->has_clone3_set_tid = kdat.has_clone3_set_tid; + task_args->tfork_active = opts.tfork.active; new_sp = restorer_stack(task_args->t->mz); diff --git a/criu/criu/include/restorer.h b/criu/criu/include/restorer.h index 73e27caa3..40bb132d1 100644 --- a/criu/criu/include/restorer.h +++ b/criu/criu/include/restorer.h @@ -242,6 +242,7 @@ struct task_restore_args { int child_subreaper; int membarrier_registration_mask; bool has_clone3_set_tid; + bool tfork_active; /* * info about rseq from libc used to diff --git a/criu/criu/pie/restorer.c b/criu/criu/pie/restorer.c index 9aaefe502..a5e89a5c8 100644 --- a/criu/criu/pie/restorer.c +++ b/criu/criu/pie/restorer.c @@ -2465,6 +2465,14 @@ __visible long __export_restore_task(struct task_restore_args *args) c_args.set_tid = ptr_to_u64(thread_args[i].tid_in_ns); c_args.flags = clone_flags; c_args.set_tid_size = thread_args[i].ns_level; + if (args->tfork_active && thread_args[i].ns_level > 1) { + pr_info("tfork: restore thread pid=%d with fresh parent tid, set_tid_size %d -> 1 tids=%d/%d\n", + thread_args[i].pid, + thread_args[i].ns_level, + thread_args[i].tid_in_ns[0], + thread_args[i].tid_in_ns[1]); + c_args.set_tid_size = 1; + } /* The kernel does stack + stack_size. */ c_args.stack = new_sp - RESTORE_STACK_SIZE; c_args.stack_size = RESTORE_STACK_SIZE; @@ -2495,7 +2503,14 @@ __visible long __export_restore_task(struct task_restore_args *args) args->clone_restore_fn); } if (ret != thread_args[i].pid) { - pr_err("Unable to create a thread: %ld\n", ret); + pr_err("Unable to create a thread: %ld expected=%d ns_level=%d tids=%d/%d/%d/%d tfork=%d\n", + ret, thread_args[i].pid, + thread_args[i].ns_level, + thread_args[i].tid_in_ns[0], + thread_args[i].ns_level > 1 ? thread_args[i].tid_in_ns[1] : -1, + thread_args[i].ns_level > 2 ? thread_args[i].tid_in_ns[2] : -1, + thread_args[i].ns_level > 3 ? thread_args[i].tid_in_ns[3] : -1, + args->tfork_active ? 1 : 0); sys_close(fd); mutex_unlock(&task_entries_local->last_pid_mutex); goto core_restore_end; From d2b35f3950fff3fd862dd8f3028b59ef23abd1ff Mon Sep 17 00:00:00 2001 From: yiying-zhang Date: Fri, 17 Jul 2026 20:18:35 -0700 Subject: [PATCH 23/53] Allow fresh tfork thread tids --- criu/criu/pie/restorer.c | 28 ++++++++++++++++++++++------ 1 file changed, 22 insertions(+), 6 deletions(-) diff --git a/criu/criu/pie/restorer.c b/criu/criu/pie/restorer.c index a5e89a5c8..103b1102e 100644 --- a/criu/criu/pie/restorer.c +++ b/criu/criu/pie/restorer.c @@ -761,8 +761,16 @@ __visible long __export_restore_thread(struct thread_restore_args *args) int ret; if (my_pid != args->pid) { - pr_err("Thread pid mismatch %d/%d\n", my_pid, args->pid); - goto core_restore_end; + if (args->ta && args->ta->tfork_active) { + pr_info("tfork: accepting fresh thread tid %d instead of dumped tid %d\n", + my_pid, args->pid); + args->pid = my_pid; + if (args->ns_level > 0) + args->tid_in_ns[args->ns_level - 1] = my_pid; + } else { + pr_err("Thread pid mismatch %d/%d\n", my_pid, args->pid); + goto core_restore_end; + } } /* restore original shadow stack */ @@ -2465,13 +2473,14 @@ __visible long __export_restore_task(struct task_restore_args *args) c_args.set_tid = ptr_to_u64(thread_args[i].tid_in_ns); c_args.flags = clone_flags; c_args.set_tid_size = thread_args[i].ns_level; - if (args->tfork_active && thread_args[i].ns_level > 1) { - pr_info("tfork: restore thread pid=%d with fresh parent tid, set_tid_size %d -> 1 tids=%d/%d\n", + if (args->tfork_active) { + pr_info("tfork: restore thread pid=%d with fresh tid, set_tid_size %d -> 0 tids=%d/%d\n", thread_args[i].pid, thread_args[i].ns_level, thread_args[i].tid_in_ns[0], - thread_args[i].tid_in_ns[1]); - c_args.set_tid_size = 1; + thread_args[i].ns_level > 1 ? thread_args[i].tid_in_ns[1] : -1); + c_args.set_tid = 0; + c_args.set_tid_size = 0; } /* The kernel does stack + stack_size. */ c_args.stack = new_sp - RESTORE_STACK_SIZE; @@ -2502,6 +2511,13 @@ __visible long __export_restore_task(struct task_restore_args *args) RUN_CLONE_RESTORE_FN(ret, clone_flags, new_sp, parent_tid, thread_args, args->clone_restore_fn); } + if (args->tfork_active && ret > 0 && ret != thread_args[i].pid) { + pr_info("tfork: thread tid remapped %d -> %ld\n", + thread_args[i].pid, ret); + thread_args[i].pid = ret; + if (thread_args[i].ns_level > 0) + thread_args[i].tid_in_ns[thread_args[i].ns_level - 1] = ret; + } if (ret != thread_args[i].pid) { pr_err("Unable to create a thread: %ld expected=%d ns_level=%d tids=%d/%d/%d/%d tfork=%d\n", ret, thread_args[i].pid, From 7ee5852753ae8333d97d515ced91df523fe66b3e Mon Sep 17 00:00:00 2001 From: yiying-zhang Date: Fri, 17 Jul 2026 21:07:47 -0700 Subject: [PATCH 24/53] Log tfork restore stage aborts --- criu/criu/cr-restore.c | 6 ++++++ criu/criu/pie/restorer.c | 3 ++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/criu/criu/cr-restore.c b/criu/criu/cr-restore.c index b2cfbf03a..a4f6141f8 100644 --- a/criu/criu/cr-restore.c +++ b/criu/criu/cr-restore.c @@ -189,6 +189,9 @@ static int __restore_wait_inprogress_tasks(int participants) futex_wait_while_gt(np, participants); ret = (int)futex_get(np); if (ret < 0) { + pr_err("restore wait aborted: participants=%d nr_in_progress=%d start_stage=%d cr_err=%d task_cr_err=%d\n", + participants, ret, (int)futex_get(&task_entries->start), + (int)futex_get(&task_entries->cr_err), get_task_cr_err()); set_cr_errno(get_task_cr_err()); return ret; } @@ -227,6 +230,9 @@ static inline void __restore_switch_stage(int next_stage) static int restore_switch_stage(int next_stage) { + pr_info("restore_switch_stage %d participants=%d nr_tasks=%d nr_threads=%d nr_helpers=%d\n", + next_stage, stage_participants(next_stage), task_entries->nr_tasks, + task_entries->nr_threads, task_entries->nr_helpers); __restore_switch_stage(next_stage); return restore_wait_inprogress_tasks(); } diff --git a/criu/criu/pie/restorer.c b/criu/criu/pie/restorer.c index 103b1102e..08392f38a 100644 --- a/criu/criu/pie/restorer.c +++ b/criu/criu/pie/restorer.c @@ -162,7 +162,8 @@ static void sigchld_handler(int signal, siginfo_t *siginfo, void *data) else r = "disappeared with"; - pr_info("Task %d %s %d\n", siginfo->si_pid, r, siginfo->si_status); + pr_err("SIGCHLD during restore: task %d %s %d\n", + siginfo->si_pid, r, siginfo->si_status); futex_abort_and_wake(&task_entries_local->nr_in_progress); /* sa_restorer may be unmaped, so we can't go back to userspace*/ From 2ced506cd4e86e47e9f941a3668fa8324378492b Mon Sep 17 00:00:00 2001 From: yiying-zhang Date: Fri, 17 Jul 2026 21:12:46 -0700 Subject: [PATCH 25/53] Fix restore abort diagnostic build --- criu/criu/cr-restore.c | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/criu/criu/cr-restore.c b/criu/criu/cr-restore.c index a4f6141f8..6b240f66d 100644 --- a/criu/criu/cr-restore.c +++ b/criu/criu/cr-restore.c @@ -189,9 +189,9 @@ static int __restore_wait_inprogress_tasks(int participants) futex_wait_while_gt(np, participants); ret = (int)futex_get(np); if (ret < 0) { - pr_err("restore wait aborted: participants=%d nr_in_progress=%d start_stage=%d cr_err=%d task_cr_err=%d\n", + pr_err("restore wait aborted: participants=%d nr_in_progress=%d start_stage=%d task_cr_err=%d\n", participants, ret, (int)futex_get(&task_entries->start), - (int)futex_get(&task_entries->cr_err), get_task_cr_err()); + get_task_cr_err()); set_cr_errno(get_task_cr_err()); return ret; } From e04c1d7db398e67da075b14661c9f14e77ecabba Mon Sep 17 00:00:00 2001 From: yiying-zhang Date: Fri, 17 Jul 2026 21:21:35 -0700 Subject: [PATCH 26/53] Tolerate clean child exits during tfork restore --- criu/criu/pie/restorer.c | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/criu/criu/pie/restorer.c b/criu/criu/pie/restorer.c index 08392f38a..0af84e431 100644 --- a/criu/criu/pie/restorer.c +++ b/criu/criu/pie/restorer.c @@ -97,6 +97,7 @@ static pid_t *helpers; static int n_helpers; static pid_t *zombies; static int n_zombies; +static bool tfork_active_local; static enum faults fi_strategy; bool fault_injected(enum faults f) { @@ -164,6 +165,13 @@ static void sigchld_handler(int signal, siginfo_t *siginfo, void *data) pr_err("SIGCHLD during restore: task %d %s %d\n", siginfo->si_pid, r, siginfo->si_status); + if (tfork_active_local && + siginfo->si_code == CLD_EXITED && + siginfo->si_status == 0) { + pr_info("tfork: ignoring clean child exit during restore: task %d\n", + siginfo->si_pid); + return; + } futex_abort_and_wake(&task_entries_local->nr_in_progress); /* sa_restorer may be unmaped, so we can't go back to userspace*/ @@ -1761,6 +1769,7 @@ __visible long __export_restore_task(struct task_restore_args *args) fi_strategy = args->fault_strategy; task_entries_local = args->task_entries; + tfork_active_local = args->tfork_active; helpers = args->helpers; n_helpers = args->helpers_n; zombies = args->zombies; From c1d28a77e0d182621022668324701b3ce6cc4c80 Mon Sep 17 00:00:00 2001 From: yiying-zhang Date: Fri, 17 Jul 2026 21:34:41 -0700 Subject: [PATCH 27/53] Make CRIU module loading idempotent --- criu/build.sh | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/criu/build.sh b/criu/build.sh index cb1d9966c..58cb6d15c 100755 --- a/criu/build.sh +++ b/criu/build.sh @@ -93,12 +93,22 @@ for mod in "${ACTIVE_MODULES[@]}"; do fi if lsmod | awk '{print $1}' | grep -qx "${mod}"; then - echo " -- ${mod}: already loaded, rmmod then insmod" + echo " -- ${mod}: already loaded, trying reload" rmmod "${mod}" 2>/dev/null || true - else - echo " -- ${mod}: insmod" + if lsmod | awk '{print $1}' | grep -qx "${mod}"; then + echo " -- ${mod}: still loaded; keeping existing module" + continue + fi + fi + + echo " -- ${mod}: insmod" + if ! insmod "${ko_path}"; then + if lsmod | awk '{print $1}' | grep -qx "${mod}"; then + echo " -- ${mod}: insmod reported already loaded; keeping existing module" + continue + fi + exit 1 fi - insmod "${ko_path}" done echo " -- verify all modules loaded" From c862f96356029fdacf968b305952fce02b885ccd Mon Sep 17 00:00:00 2001 From: yiying-zhang Date: Fri, 17 Jul 2026 21:37:48 -0700 Subject: [PATCH 28/53] Avoid fatal log for clean tfork child exits --- criu/criu/pie/restorer.c | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/criu/criu/pie/restorer.c b/criu/criu/pie/restorer.c index 0af84e431..556927505 100644 --- a/criu/criu/pie/restorer.c +++ b/criu/criu/pie/restorer.c @@ -150,6 +150,14 @@ static void sigchld_handler(int signal, siginfo_t *siginfo, void *data) if (siginfo->si_pid == zombies[i]) return; + if (tfork_active_local && + siginfo->si_code == CLD_EXITED && + siginfo->si_status == 0) { + pr_info("tfork: ignoring clean child exit during restore: task %d\n", + siginfo->si_pid); + return; + } + if (siginfo->si_code == CLD_EXITED) r = "exited, status="; else if (siginfo->si_code == CLD_KILLED) @@ -165,13 +173,6 @@ static void sigchld_handler(int signal, siginfo_t *siginfo, void *data) pr_err("SIGCHLD during restore: task %d %s %d\n", siginfo->si_pid, r, siginfo->si_status); - if (tfork_active_local && - siginfo->si_code == CLD_EXITED && - siginfo->si_status == 0) { - pr_info("tfork: ignoring clean child exit during restore: task %d\n", - siginfo->si_pid); - return; - } futex_abort_and_wake(&task_entries_local->nr_in_progress); /* sa_restorer may be unmaped, so we can't go back to userspace*/ From 235832517965e574140f386ffb9ccc6384425ee9 Mon Sep 17 00:00:00 2001 From: yiying-zhang Date: Fri, 17 Jul 2026 22:08:38 -0700 Subject: [PATCH 29/53] Default tfork single-copy to direct crun --- podman/pkg/domain/infra/abi/container_tfork.go | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/podman/pkg/domain/infra/abi/container_tfork.go b/podman/pkg/domain/infra/abi/container_tfork.go index 331838c7d..dd5130c9e 100644 --- a/podman/pkg/domain/infra/abi/container_tfork.go +++ b/podman/pkg/domain/infra/abi/container_tfork.go @@ -56,11 +56,12 @@ func (ic *ContainerEngine) containerCloneLive(ctx context.Context, opts entities } requestedCopies := copies useSingleCopyConmon := requestedCopies == 1 && os.Getenv("PODMAN_TFORK_SINGLE_COPY_CONMON") == "1" - if requestedCopies == 1 && !useSingleCopyConmon { - // The CRIU/crun single-copy tfork path can abort before producing a - // clone PID (`free(): invalid pointer`). Run a two-copy batch internally - // to use the known-good batch path, then remove the hidden spare clone - // before returning to the caller. + useHiddenSpareCopy := requestedCopies == 1 && os.Getenv("PODMAN_TFORK_HIDDEN_SPARE_COPY") == "1" + if useHiddenSpareCopy && !useSingleCopyConmon { + // Older tfork experiments used a two-copy batch internally to avoid + // single-copy restore bugs, then removed the hidden spare. Keep that + // path available for diagnostics, but make the requested single-copy + // direct-crun path the default. copies = 2 } From 6363d892c2704a12121cdbe802dff2b780272d4c Mon Sep 17 00:00:00 2001 From: yiying-zhang Date: Fri, 17 Jul 2026 22:25:19 -0700 Subject: [PATCH 30/53] Make tfork clone readiness timeout configurable --- .../pkg/domain/infra/abi/container_tfork.go | 23 +++++++++++++++---- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/podman/pkg/domain/infra/abi/container_tfork.go b/podman/pkg/domain/infra/abi/container_tfork.go index dd5130c9e..180843715 100644 --- a/podman/pkg/domain/infra/abi/container_tfork.go +++ b/podman/pkg/domain/infra/abi/container_tfork.go @@ -9,6 +9,7 @@ import ( "os" "os/exec" "path/filepath" + "strconv" "strings" "syscall" "time" @@ -16,7 +17,6 @@ import ( "github.com/containers/podman/v5/libpod" "github.com/containers/podman/v5/libpod/define" - "strconv" "github.com/containers/podman/v5/pkg/domain/entities" "github.com/containers/podman/v5/utils" @@ -31,11 +31,23 @@ const ( tforkSourceFreezeTimeout = 10 * time.Second tforkSourceThawTimeout = 10 * time.Second tforkCloneReadyTimeout = 60 * time.Second - tforkCrunFinishTimeout = tforkCloneReadyTimeout tforkCgroupPollInterval = 50 * time.Millisecond tforkClonePollInterval = 200 * time.Millisecond ) +func tforkCloneReadyTimeoutFromEnv() time.Duration { + value := strings.TrimSpace(os.Getenv("PODMAN_TFORK_CLONE_READY_TIMEOUT_SECS")) + if value == "" { + return tforkCloneReadyTimeout + } + seconds, err := strconv.Atoi(value) + if err != nil || seconds <= 0 { + logrus.Warnf("tfork: ignoring invalid PODMAN_TFORK_CLONE_READY_TIMEOUT_SECS=%q", value) + return tforkCloneReadyTimeout + } + return time.Duration(seconds) * time.Second +} + func (ic *ContainerEngine) containerCloneLive(ctx context.Context, opts entities.ContainerCloneOptions) (rep *entities.ContainerCreateReport, retErr error) { src, err := ic.Libpod.LookupContainer(opts.ID) if err != nil { @@ -596,7 +608,8 @@ func (ic *ContainerEngine) containerCloneLive(ctx context.Context, opts entities } statePath := fmt.Sprintf("/run/crun/%s/status", cloneIDs[0]) needState := copies == 1 - deadline := time.Now().Add(tforkCloneReadyTimeout) + cloneReadyTimeout := tforkCloneReadyTimeoutFromEnv() + deadline := time.Now().Add(cloneReadyTimeout) readyCopies := 0 stateReady := !needState crunExited := false @@ -641,11 +654,11 @@ func (ic *ContainerEngine) containerCloneLive(ctx context.Context, opts entities select { case crunErr = <-crunDone: crunExited = true - case <-time.After(tforkCrunFinishTimeout): + case <-time.After(cloneReadyTimeout): tforkAbortCrunCmd(crunCmd, src, bundleDir, copies) crunAborted = true return nil, fmt.Errorf("timeout waiting %s for crun tfork to finish after %d clones came up; see %s", - tforkCrunFinishTimeout, copies, logPath) + cloneReadyTimeout, copies, logPath) } } if crunErr != nil { From 8b0cbd9fa50a34f47e3d646e5e6670d42e1c4763 Mon Sep 17 00:00:00 2001 From: yiying-zhang Date: Fri, 17 Jul 2026 22:57:56 -0700 Subject: [PATCH 31/53] Use n-copy restore for single-copy tfork clones --- criu/criu/cr-tfork.c | 4 +- criu/criu/crtools.c | 6 +-- crun/src/libcrun/criu.c | 8 +-- crun/src/tfork.c | 4 +- .../pkg/domain/infra/abi/container_tfork.go | 51 +++++-------------- 5 files changed, 23 insertions(+), 50 deletions(-) diff --git a/criu/criu/cr-tfork.c b/criu/criu/cr-tfork.c index ca5a5c61c..aa33b0a04 100644 --- a/criu/criu/cr-tfork.c +++ b/criu/criu/cr-tfork.c @@ -271,7 +271,7 @@ int tfork_read_cropt(void) return 0; snap_path = opts.tfork.snap_root; - if (!snap_path && opts.tfork.copies > 1 && opts.tfork.snap_roots && + if (!snap_path && opts.tfork.copies >= 1 && opts.tfork.snap_roots && opts.tfork.copy_idx < opts.tfork.snap_roots_n) snap_path = opts.tfork.snap_roots[opts.tfork.copy_idx]; @@ -1173,7 +1173,7 @@ int cr_tfork_tasks(pid_t pid) rpc_argv[rpc_n++] = "--tfork-snap-mounts"; rpc_argv[rpc_n++] = snap_mounts_csv; } - if (opts.tfork.copies > 1) { + if (opts.tfork.copies >= 1) { snprintf(copies_arg, sizeof(copies_arg), "%d", opts.tfork.copies); rpc_argv[rpc_n++] = "--tfork-copies"; diff --git a/criu/criu/crtools.c b/criu/criu/crtools.c index 8f9067364..1243c8087 100644 --- a/criu/criu/crtools.c +++ b/criu/criu/crtools.c @@ -332,7 +332,7 @@ int main(int argc, char *argv[], char *envp[]) if (opts.tree_id) pr_warn("Using -t with criu restore is obsoleted\n"); - if (opts.tfork.copies > 1) { + if (opts.tfork.active && opts.tfork.copies >= 1) { int n = opts.tfork.copies, i; pid_t *children; int (*ready_pipes)[2]; @@ -348,14 +348,14 @@ int main(int argc, char *argv[], char *envp[]) const int ns_flags = CLONE_NEWNS; if (!opts.tfork.active) { - pr_err("--tfork-copies>1 requires --tfork-restore " + pr_err("--tfork-copies requires --tfork-restore " "(use 'criu tfork --tfork-copies=N', not " "'criu restore --tfork-copies=N')\n"); return 1; } if (!opts.restore_detach) { - pr_err("--tfork-copies>1 requires --restore-detached\n"); + pr_err("--tfork-copies requires --restore-detached\n"); return 1; } diff --git a/crun/src/libcrun/criu.c b/crun/src/libcrun/criu.c index 112f21f6b..174288d20 100644 --- a/crun/src/libcrun/criu.c +++ b/crun/src/libcrun/criu.c @@ -1513,7 +1513,7 @@ libcrun_container_tfork_linux_criu (libcrun_container_t *container, libcrun_chec return crun_make_error (err, 0, "--tfork-snap-root, --tfork-snap-roots, or --tfork-copy=::--tfork-snap-root=PATH is required"); - if (cr_options->tfork_copies > 1 && cr_options->tfork_snap_roots_n > 0 + if (cr_options->tfork_copies >= 1 && cr_options->tfork_snap_roots_n > 0 && (size_t) cr_options->tfork_copies != cr_options->tfork_snap_roots_n) return crun_make_error (err, 0, "--tfork-copies=%d but --tfork-snap-roots has %zu entries", @@ -1631,7 +1631,7 @@ libcrun_container_tfork_linux_criu (libcrun_container_t *container, libcrun_chec } } - if (cr_options->tfork_copies > 1) + if (cr_options->tfork_copies >= 1) libcriu_wrapper->criu_set_tfork_copies (cr_options->tfork_copies); if (cr_options->tfork_memdump_async) @@ -1860,7 +1860,7 @@ libcrun_container_tfork_linux_criu (libcrun_container_t *container, libcrun_chec pid_t clone_pid; const char *pidfile_name = "tfork.pid"; - if (cr_options->tfork_copies > 1) + if (cr_options->tfork_copies >= 1) pidfile_name = "tfork.pid.copy0"; ret = append_paths (&pidfile_path, err, cr_options->image_path, pidfile_name, NULL); @@ -1875,7 +1875,7 @@ libcrun_container_tfork_linux_criu (libcrun_container_t *container, libcrun_chec if (UNLIKELY (clone_pid <= 0)) return crun_make_error (err, 0, "invalid clone PID %d in `%s`", (int) clone_pid, pidfile_path); - if (cr_options->tfork_copies > 1) + if (cr_options->tfork_copies >= 1) { char children_path[64]; cleanup_free char *children_buf = NULL; diff --git a/crun/src/tfork.c b/crun/src/tfork.c index 31db969ef..a5a25af89 100644 --- a/crun/src/tfork.c +++ b/crun/src/tfork.c @@ -83,7 +83,7 @@ static struct argp_option options[] { "parent-path", OPTION_PARENT_PATH, "DIR", 0, "previous criu images dir, for incremental memdump chains", 0 }, { "tfork-memdump", OPTION_TFORK_MEMDUMP, 0, 0, "dump pages-*.img to image-path during tfork", 0 }, { "tfork-memdump-async", OPTION_TFORK_MEMDUMP_ASYNC, 0, 0, "async page dump (implies --tfork-memdump)", 0 }, - { "tfork-copies", OPTION_TFORK_COPIES, "N", 0, "produce N parallel clones (default 1)", 0 }, + { "tfork-copies", OPTION_TFORK_COPIES, "N", 0, "produce N clones through the n-copy helper (omitted: legacy direct single-copy)", 0 }, { "track-mem", OPTION_TRACK_MEM, 0, 0, "arm soft-dirty for chained incremental dumps", 0 }, { "manage-cgroups-mode", OPTION_MANAGE_CGROUPS_MODE, "MODE", 0, "cgroups mode: 'soft' (default), 'ignore', 'full' and 'strict'", 0 }, @@ -359,7 +359,7 @@ int crun_command_tfork (struct crun_global_arguments *global_args, int argc, char **argv, libcrun_error_t *err) { cr_options.manage_cgroups_mode = -1; - cr_options.tfork_copies = 1; + cr_options.tfork_copies = 0; cr_options.leave_running = true; return crun_run_create_internal (global_args, argc, argv, container_tfork, get_options, &crun_context, &run_argp, diff --git a/podman/pkg/domain/infra/abi/container_tfork.go b/podman/pkg/domain/infra/abi/container_tfork.go index 180843715..2a14b9d0c 100644 --- a/podman/pkg/domain/infra/abi/container_tfork.go +++ b/podman/pkg/domain/infra/abi/container_tfork.go @@ -68,14 +68,8 @@ func (ic *ContainerEngine) containerCloneLive(ctx context.Context, opts entities } requestedCopies := copies useSingleCopyConmon := requestedCopies == 1 && os.Getenv("PODMAN_TFORK_SINGLE_COPY_CONMON") == "1" - useHiddenSpareCopy := requestedCopies == 1 && os.Getenv("PODMAN_TFORK_HIDDEN_SPARE_COPY") == "1" - if useHiddenSpareCopy && !useSingleCopyConmon { - // Older tfork experiments used a two-copy batch internally to avoid - // single-copy restore bugs, then removed the hidden spare. Keep that - // path available for diagnostics, but make the requested single-copy - // direct-crun path the default. - copies = 2 - } + useSingleCopyDirect := requestedCopies == 1 && os.Getenv("PODMAN_TFORK_SINGLE_COPY_DIRECT") == "1" + useNcopyRestore := copies > 1 || (requestedCopies == 1 && !useSingleCopyConmon && !useSingleCopyDirect) var srcRootfs string if cfg := src.Config(); cfg != nil && cfg.ExternalSetup && cfg.Rootfs != "" { @@ -241,11 +235,7 @@ func (ic *ContainerEngine) containerCloneLive(ctx context.Context, opts entities baseName = src.Name() + "-clone" } cloneName := baseName - if requestedCopies == 1 && copies > 1 { - if i > 0 { - cloneName = fmt.Sprintf("%s-tfork-spare-%d", baseName, i) - } - } else if copies > 1 { + if copies > 1 { cloneName = fmt.Sprintf("%s-%d", baseName, i) } cloneNames[i] = cloneName @@ -329,7 +319,7 @@ func (ic *ContainerEngine) containerCloneLive(ctx context.Context, opts entities "--source-state", srcStatePath, "--image-path", imgDir, } - if copies == 1 { + if !useNcopyRestore { crunArgs = append(crunArgs, "--tfork-snap-root", cloneRootfsList[0]) crunArgs = append(crunArgs, "--tfork-snap-mount", "/") if cloneCgroupPaths[0] != "" { @@ -494,8 +484,8 @@ func (ic *ContainerEngine) containerCloneLive(ctx context.Context, opts entities var perCopyExtraFiles []*os.File var perCopyReadEnds []*os.File var perCopyArgs []string - skipTtySrcFds := copies > 1 && hasTTY - if copies > 1 { + skipTtySrcFds := useNcopyRestore && hasTTY + if useNcopyRestore { ifdsForPerCopy, stdioKeys := splitStdioInheritFds(inheritFds) inheritFds = ifdsForPerCopy extraFDBase := 3 @@ -601,13 +591,13 @@ func (ic *ContainerEngine) containerCloneLive(ctx context.Context, opts entities } pidFileFor := func(i int) string { - if copies == 1 { + if !useNcopyRestore { return filepath.Join(imgDir, "tfork.pid") } return filepath.Join(imgDir, fmt.Sprintf("tfork.pid.copy%d", i)) } statePath := fmt.Sprintf("/run/crun/%s/status", cloneIDs[0]) - needState := copies == 1 + needState := !useNcopyRestore && copies == 1 cloneReadyTimeout := tforkCloneReadyTimeoutFromEnv() deadline := time.Now().Add(cloneReadyTimeout) readyCopies := 0 @@ -682,7 +672,7 @@ func (ic *ContainerEngine) containerCloneLive(ctx context.Context, opts entities } visibleCloneIDs := make([]string, 0, requestedCopies) for i, cloneID := range cloneIDs { - clonePID, err := readTforkClonePID(cloneID, imgDir, i, copies) + clonePID, err := readTforkClonePID(cloneID, imgDir, i, copies, useNcopyRestore) if err != nil { return nil, fmt.Errorf("read clone %d PID: %w", i, err) } @@ -747,29 +737,12 @@ func (ic *ContainerEngine) containerCloneLive(ctx context.Context, opts entities } } logrus.Infof("tfork: clone %s (%s) registered in libpod state, pid=%d", cloneID, cloneNames[i], clonePID) - if i >= requestedCopies { - if err := removeHiddenTforkClone(ctx, ic, ctr); err != nil { - logrus.Warnf("tfork: hidden spare clone %s cleanup failed: %v", cloneID, err) - } else { - logrus.Infof("tfork: hidden spare clone %s removed", cloneID) - } - continue - } visibleCloneIDs = append(visibleCloneIDs, cloneID) } return &entities.ContainerCreateReport{Id: strings.Join(visibleCloneIDs, "\n")}, nil } -func removeHiddenTforkClone(ctx context.Context, ic *ContainerEngine, ctr *libpod.Container) error { - oldNoReap, hadNoReap := os.LookupEnv("PODMAN_TFORK_NO_REAP") - if hadNoReap { - _ = os.Unsetenv("PODMAN_TFORK_NO_REAP") - defer os.Setenv("PODMAN_TFORK_NO_REAP", oldNoReap) - } - return ic.Libpod.RemoveContainer(ctx, ctr, true, true, nil) -} - type tforkCgroupFreezer struct { root string statePath string @@ -1324,7 +1297,7 @@ func spawnTforkExitWatcher(ctx context.Context, rt *libpod.Runtime, ctr *libpod. return nil } -func readTforkClonePID(cloneID string, imgDir string, copyIdx, copies int) (int, error) { +func readTforkClonePID(cloneID string, imgDir string, copyIdx, copies int, useNcopyRestore bool) (int, error) { statePath := fmt.Sprintf("/run/crun/%s/status", cloneID) if data, err := os.ReadFile(statePath); err == nil { var st struct { @@ -1335,7 +1308,7 @@ func readTforkClonePID(cloneID string, imgDir string, copyIdx, copies int) (int, } } var pidFile string - if copies == 1 { + if !useNcopyRestore { pidFile = filepath.Join(imgDir, "tfork.pid") } else { pidFile = filepath.Join(imgDir, fmt.Sprintf("tfork.pid.copy%d", copyIdx)) @@ -1348,7 +1321,7 @@ func readTforkClonePID(cloneID string, imgDir string, copyIdx, copies int) (int, if err != nil { return 0, err } - if copies == 1 { + if !useNcopyRestore { return rcPID, nil } initPID, err := readFirstChildPID(rcPID) From 5b778142e16fec2138a054934a4d3bc605e5a848 Mon Sep 17 00:00:00 2001 From: yiying-zhang Date: Fri, 17 Jul 2026 23:26:54 -0700 Subject: [PATCH 32/53] Allow external unix sockets during tfork --- crun/src/tfork.c | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/crun/src/tfork.c b/crun/src/tfork.c index a5a25af89..2c1d3438e 100644 --- a/crun/src/tfork.c +++ b/crun/src/tfork.c @@ -360,6 +360,12 @@ crun_command_tfork (struct crun_global_arguments *global_args, int argc, char ** { cr_options.manage_cgroups_mode = -1; cr_options.tfork_copies = 0; + /* + * Agent runtimes commonly keep Unix sockets connected to helpers outside the + * dumped process subtree (tmux/Codex hooks, host-control bridges, etc.). + * Without this CRIU rejects the dump before restore begins. + */ + cr_options.ext_unix_sk = true; cr_options.leave_running = true; return crun_run_create_internal (global_args, argc, argv, container_tfork, get_options, &crun_context, &run_argp, From ba9488780b92d48246a8bfca848d76ecf6e1bd07 Mon Sep 17 00:00:00 2001 From: yiying-zhang Date: Fri, 17 Jul 2026 23:39:22 -0700 Subject: [PATCH 33/53] Propagate ext unix socket option to tfork --- crun/src/libcrun/criu.c | 1 + 1 file changed, 1 insertion(+) diff --git a/crun/src/libcrun/criu.c b/crun/src/libcrun/criu.c index 174288d20..1e1dbbed2 100644 --- a/crun/src/libcrun/criu.c +++ b/crun/src/libcrun/criu.c @@ -1566,6 +1566,7 @@ libcrun_container_tfork_linux_criu (libcrun_container_t *container, libcrun_chec libcriu_wrapper->criu_set_pid (source_pid); libcriu_wrapper->criu_set_leave_running (true); + libcriu_wrapper->criu_set_ext_unix_sk (cr_options->ext_unix_sk); libcriu_wrapper->criu_set_file_locks (true); cgroup_mode = libcrun_get_cgroup_mode (err); From 7693c326bd119adb3c3b3f21f644f7ea21cec808 Mon Sep 17 00:00:00 2001 From: yiying-zhang Date: Sat, 18 Jul 2026 08:39:26 -0700 Subject: [PATCH 34/53] Skip inotify mark replay for tfork restores --- criu/criu/fsnotify.c | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/criu/criu/fsnotify.c b/criu/criu/fsnotify.c index ab8a29dd6..2aac52d0b 100644 --- a/criu/criu/fsnotify.c +++ b/criu/criu/fsnotify.c @@ -1037,6 +1037,23 @@ static int open_inotify_fd(struct file_desc *d, int *new_fd) return -1; } + if (opts.tfork.active) { + unsigned int skipped = 0; + + list_for_each_entry(wd_info, &info->marks, list) + skipped++; + + if (skipped) + pr_warn("tfork: restored inotify fd %#08x without %u watch mark(s)\n", + info->ife->id, skipped); + + if (restore_fown(tmp, info->ife->fown)) + close_safe(&tmp); + + *new_fd = tmp; + return tmp < 0 ? -1 : 0; + } + list_for_each_entry(wd_info, &info->marks, list) { pr_info("\tRestore 0x%x wd for %#08x\n", wd_info->iwe->wd, wd_info->iwe->id); if (restore_one_inotify(tmp, wd_info)) { From 6a14400201bb7bdb6d0c5473178f2292102b3e96 Mon Sep 17 00:00:00 2001 From: yiying-zhang Date: Sat, 18 Jul 2026 08:46:46 -0700 Subject: [PATCH 35/53] Add tfork restore stage timeout diagnostics --- criu/criu/cr-restore.c | 36 ++++++++++++++++++++++++++++++++---- 1 file changed, 32 insertions(+), 4 deletions(-) diff --git a/criu/criu/cr-restore.c b/criu/criu/cr-restore.c index 6b240f66d..fc1f089c7 100644 --- a/criu/criu/cr-restore.c +++ b/criu/criu/cr-restore.c @@ -186,7 +186,27 @@ static int __restore_wait_inprogress_tasks(int participants) int ret; futex_t *np = &task_entries->nr_in_progress; - futex_wait_while_gt(np, participants); + if (opts.tfork.active) { + int waited; + + for (waited = 0; waited < 100; waited++) { + if ((int)futex_get(np) <= participants) + break; + usleep(100000); + } + + if ((int)futex_get(np) > participants) { + pr_err("tfork restore wait timed out: participants=%d nr_in_progress=%d start_stage=%d nr_tasks=%d nr_threads=%d nr_helpers=%d\n", + participants, (int)futex_get(np), + (int)futex_get(&task_entries->start), + task_entries->nr_tasks, task_entries->nr_threads, + task_entries->nr_helpers); + return -ETIMEDOUT; + } + } else { + futex_wait_while_gt(np, participants); + } + ret = (int)futex_get(np); if (ret < 0) { pr_err("restore wait aborted: participants=%d nr_in_progress=%d start_stage=%d task_cr_err=%d\n", @@ -230,9 +250,17 @@ static inline void __restore_switch_stage(int next_stage) static int restore_switch_stage(int next_stage) { - pr_info("restore_switch_stage %d participants=%d nr_tasks=%d nr_threads=%d nr_helpers=%d\n", - next_stage, stage_participants(next_stage), task_entries->nr_tasks, - task_entries->nr_threads, task_entries->nr_helpers); + if (opts.tfork.active) + pr_warn("tfork: restore_switch_stage %d participants=%d nr_tasks=%d nr_threads=%d nr_helpers=%d\n", + next_stage, stage_participants(next_stage), + task_entries->nr_tasks, task_entries->nr_threads, + task_entries->nr_helpers); + else + pr_info("restore_switch_stage %d participants=%d nr_tasks=%d nr_threads=%d nr_helpers=%d\n", + next_stage, stage_participants(next_stage), + task_entries->nr_tasks, task_entries->nr_threads, + task_entries->nr_helpers); + __restore_switch_stage(next_stage); return restore_wait_inprogress_tasks(); } From 176d7f2055553b2f5c770725705c4ee3fa2e4c5c Mon Sep 17 00:00:00 2001 From: yiying-zhang Date: Sat, 18 Jul 2026 10:08:13 -0700 Subject: [PATCH 36/53] Log tfork restore stage participants --- criu/criu/pie/restorer.c | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/criu/criu/pie/restorer.c b/criu/criu/pie/restorer.c index 556927505..1357ccfbc 100644 --- a/criu/criu/pie/restorer.c +++ b/criu/criu/pie/restorer.c @@ -825,11 +825,17 @@ __visible long __export_restore_thread(struct thread_restore_args *args) } pr_info("%ld: Restored\n", sys_gettid()); + if (args->ta->tfork_active) + pr_warn("tfork: thread restore stage complete pid=%d tid=%ld comm=%s ns_level=%d\n", + args->pid, sys_gettid(), args->comm, args->ns_level); restore_finish_stage(task_entries_local, CR_STATE_RESTORE); if (restore_signals(args->siginfo, args->siginfo_n, false)){ goto core_restore_end; } + if (args->ta->tfork_active) + pr_warn("tfork: thread sigchld stage complete pid=%d tid=%ld comm=%s ns_level=%d\n", + args->pid, sys_gettid(), args->comm, args->ns_level); restore_finish_stage(task_entries_local, CR_STATE_RESTORE_SIGCHLD); /* @@ -2566,6 +2572,10 @@ __visible long __export_restore_task(struct task_restore_args *args) if (restore_membarrier_registrations(args->membarrier_registration_mask) < 0) goto core_restore_end; pr_info("%ld: Restored\n", sys_getpid()); + if (args->tfork_active) + pr_warn("tfork: leader restore stage complete pid=%d tid=%ld comm=%s threads=%d ns_level=%d\n", + args->t->pid, sys_getpid(), args->comm, args->nr_threads, + args->t->ns_level); restore_finish_stage(task_entries_local, CR_STATE_RESTORE); @@ -2609,6 +2619,10 @@ __visible long __export_restore_task(struct task_restore_args *args) if (ret) goto core_restore_end; + if (args->tfork_active) + pr_warn("tfork: leader sigchld stage complete pid=%d tid=%ld comm=%s threads=%d ns_level=%d\n", + args->t->pid, sys_getpid(), args->comm, args->nr_threads, + args->t->ns_level); restore_finish_stage(task_entries_local, CR_STATE_RESTORE_SIGCHLD); rst_tcp_socks_all(args); From e8426fd9c543d7fd465fae2a724af5a1e9a60150 Mon Sep 17 00:00:00 2001 From: yiying-zhang Date: Sat, 18 Jul 2026 10:31:05 -0700 Subject: [PATCH 37/53] Trace tfork leader restore stalls --- criu/criu/pie/restorer.c | 57 +++++++++++++++++++++++++++++++++++++--- 1 file changed, 53 insertions(+), 4 deletions(-) diff --git a/criu/criu/pie/restorer.c b/criu/criu/pie/restorer.c index 1357ccfbc..c32075572 100644 --- a/criu/criu/pie/restorer.c +++ b/criu/criu/pie/restorer.c @@ -828,7 +828,10 @@ __visible long __export_restore_thread(struct thread_restore_args *args) if (args->ta->tfork_active) pr_warn("tfork: thread restore stage complete pid=%d tid=%ld comm=%s ns_level=%d\n", args->pid, sys_gettid(), args->comm, args->ns_level); - restore_finish_stage(task_entries_local, CR_STATE_RESTORE); + ret = restore_finish_stage(task_entries_local, CR_STATE_RESTORE); + if (args->ta->tfork_active) + pr_warn("tfork: thread restore barrier returned pid=%d tid=%ld comm=%s stage=%d\n", + args->pid, sys_gettid(), args->comm, ret); if (restore_signals(args->siginfo, args->siginfo_n, false)){ goto core_restore_end; @@ -836,7 +839,10 @@ __visible long __export_restore_thread(struct thread_restore_args *args) if (args->ta->tfork_active) pr_warn("tfork: thread sigchld stage complete pid=%d tid=%ld comm=%s ns_level=%d\n", args->pid, sys_gettid(), args->comm, args->ns_level); - restore_finish_stage(task_entries_local, CR_STATE_RESTORE_SIGCHLD); + ret = restore_finish_stage(task_entries_local, CR_STATE_RESTORE_SIGCHLD); + if (args->ta->tfork_active) + pr_warn("tfork: thread sigchld barrier returned pid=%d tid=%ld comm=%s stage=%d\n", + args->pid, sys_gettid(), args->comm, ret); /* * Make sure it's before creds, since it's privileged @@ -2577,12 +2583,28 @@ __visible long __export_restore_task(struct task_restore_args *args) args->t->pid, sys_getpid(), args->comm, args->nr_threads, args->t->ns_level); - restore_finish_stage(task_entries_local, CR_STATE_RESTORE); + ret = restore_finish_stage(task_entries_local, CR_STATE_RESTORE); + if (args->tfork_active) + pr_warn("tfork: leader restore barrier returned pid=%d tid=%ld comm=%s stage=%ld helpers=%u zombies=%u inotify=%u\n", + args->t->pid, sys_getpid(), args->comm, ret, + args->helpers_n, args->zombies_n, args->inotify_fds_n); + if (args->tfork_active) + pr_warn("tfork: leader wait_helpers start pid=%d tid=%ld comm=%s helpers=%u\n", + args->t->pid, sys_getpid(), args->comm, args->helpers_n); if (wait_helpers(args) < 0) goto core_restore_end; + if (args->tfork_active) + pr_warn("tfork: leader wait_helpers done pid=%d tid=%ld comm=%s\n", + args->t->pid, sys_getpid(), args->comm); + if (args->tfork_active) + pr_warn("tfork: leader wait_zombies start pid=%d tid=%ld comm=%s zombies=%u\n", + args->t->pid, sys_getpid(), args->comm, args->zombies_n); if (wait_zombies(args) < 0) goto core_restore_end; + if (args->tfork_active) + pr_warn("tfork: leader wait_zombies done pid=%d tid=%ld comm=%s\n", + args->t->pid, sys_getpid(), args->comm); ksigfillset(&to_block); ret = sys_sigprocmask(SIG_SETMASK, &to_block, NULL, sizeof(k_rtsigset_t)); @@ -2591,9 +2613,18 @@ __visible long __export_restore_task(struct task_restore_args *args) goto core_restore_end; } + if (args->tfork_active) + pr_warn("tfork: leader cleanup_inotify start pid=%d tid=%ld comm=%s inotify=%u\n", + args->t->pid, sys_getpid(), args->comm, args->inotify_fds_n); if (cleanup_current_inotify_events(args)) goto core_restore_end; + if (args->tfork_active) + pr_warn("tfork: leader cleanup_inotify done pid=%d tid=%ld comm=%s\n", + args->t->pid, sys_getpid(), args->comm); + if (args->tfork_active) + pr_warn("tfork: leader restore sigaction start pid=%d tid=%ld comm=%s\n", + args->t->pid, sys_getpid(), args->comm); if (!args->compatible_mode) { ret = sys_sigaction(SIGCHLD, &args->sigchld_act, NULL, sizeof(k_rtsigset_t)); } else { @@ -2610,20 +2641,38 @@ __visible long __export_restore_task(struct task_restore_args *args) pr_err("Failed to restore SIGCHLD: %ld\n", ret); goto core_restore_end; } + if (args->tfork_active) + pr_warn("tfork: leader restore sigaction done pid=%d tid=%ld comm=%s\n", + args->t->pid, sys_getpid(), args->comm); + if (args->tfork_active) + pr_warn("tfork: leader restore shared signals start pid=%d tid=%ld comm=%s\n", + args->t->pid, sys_getpid(), args->comm); ret = restore_signals(args->siginfo, args->siginfo_n, true); if (ret) goto core_restore_end; + if (args->tfork_active) + pr_warn("tfork: leader restore shared signals done pid=%d tid=%ld comm=%s\n", + args->t->pid, sys_getpid(), args->comm); + if (args->tfork_active) + pr_warn("tfork: leader restore private signals start pid=%d tid=%ld comm=%s\n", + args->t->pid, sys_getpid(), args->comm); ret = restore_signals(args->t->siginfo, args->t->siginfo_n, false); if (ret) goto core_restore_end; + if (args->tfork_active) + pr_warn("tfork: leader restore private signals done pid=%d tid=%ld comm=%s\n", + args->t->pid, sys_getpid(), args->comm); if (args->tfork_active) pr_warn("tfork: leader sigchld stage complete pid=%d tid=%ld comm=%s threads=%d ns_level=%d\n", args->t->pid, sys_getpid(), args->comm, args->nr_threads, args->t->ns_level); - restore_finish_stage(task_entries_local, CR_STATE_RESTORE_SIGCHLD); + ret = restore_finish_stage(task_entries_local, CR_STATE_RESTORE_SIGCHLD); + if (args->tfork_active) + pr_warn("tfork: leader sigchld barrier returned pid=%d tid=%ld comm=%s stage=%ld\n", + args->t->pid, sys_getpid(), args->comm, ret); rst_tcp_socks_all(args); From eb724fdb28413e22119da94887bc6b97d82a2757 Mon Sep 17 00:00:00 2001 From: yiying-zhang Date: Sat, 18 Jul 2026 18:30:30 -0700 Subject: [PATCH 38/53] Avoid tfork zombie wait deadlock --- criu/criu/pie/restorer.c | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/criu/criu/pie/restorer.c b/criu/criu/pie/restorer.c index c32075572..a58ae5ed5 100644 --- a/criu/criu/pie/restorer.c +++ b/criu/criu/pie/restorer.c @@ -1506,6 +1506,11 @@ static int wait_zombies(struct task_restore_args *task_args) ret = sys_waitid(P_PID, task_args->zombies[i], NULL, WNOWAIT | WEXITED, NULL); if (ret == -ECHILD) { + if (task_args->tfork_active) { + pr_warn("tfork: zombie pid %d is not reparented to task %ld; skipping wait to avoid restore barrier deadlock\n", + task_args->zombies[i], sys_getpid()); + continue; + } /* A process isn't reparented to this task yet. * Let's wait when someone complete this stage * and try again. From 23eebc26adb1b98f8b7c677ced78b04bd34657b7 Mon Sep 17 00:00:00 2001 From: yiying-zhang Date: Mon, 20 Jul 2026 00:03:13 -0700 Subject: [PATCH 39/53] Address tfork review robustness --- criu/build.sh | 14 ++--- criu/criu/cr-restore.c | 29 ++++++++-- criu/criu/cr-tfork.c | 4 ++ criu/criu/crtools.c | 19 ++++--- criu/criu/pie/restorer.c | 55 +++++++++---------- criu/criu/pstree.c | 9 ++- criu/criu/seize.c | 2 + crun/src/libcrun/criu.c | 39 +++++++++---- crun/src/tfork.c | 9 +-- .../pkg/domain/infra/abi/container_tfork.go | 6 +- 10 files changed, 116 insertions(+), 70 deletions(-) diff --git a/criu/build.sh b/criu/build.sh index 58cb6d15c..ff3a95339 100755 --- a/criu/build.sh +++ b/criu/build.sh @@ -94,19 +94,19 @@ for mod in "${ACTIVE_MODULES[@]}"; do if lsmod | awk '{print $1}' | grep -qx "${mod}"; then echo " -- ${mod}: already loaded, trying reload" - rmmod "${mod}" 2>/dev/null || true + if ! rmmod "${mod}"; then + echo "build.sh: failed to unload loaded module ${mod}; aborting to avoid stale module" >&2 + exit 1 + fi if lsmod | awk '{print $1}' | grep -qx "${mod}"; then - echo " -- ${mod}: still loaded; keeping existing module" - continue + echo "build.sh: module ${mod} is still loaded after rmmod; aborting to avoid stale module" >&2 + exit 1 fi fi echo " -- ${mod}: insmod" if ! insmod "${ko_path}"; then - if lsmod | awk '{print $1}' | grep -qx "${mod}"; then - echo " -- ${mod}: insmod reported already loaded; keeping existing module" - continue - fi + echo "build.sh: failed to insert rebuilt module ${mod} from ${ko_path}" >&2 exit 1 fi done diff --git a/criu/criu/cr-restore.c b/criu/criu/cr-restore.c index fc1f089c7..bd4fa8b0a 100644 --- a/criu/criu/cr-restore.c +++ b/criu/criu/cr-restore.c @@ -185,22 +185,28 @@ static int __restore_wait_inprogress_tasks(int participants) { int ret; futex_t *np = &task_entries->nr_in_progress; + const int tfork_restore_wait_timeout_ms = 10000; + const int tfork_restore_wait_poll_us = 100000; if (opts.tfork.active) { int waited; - for (waited = 0; waited < 100; waited++) { + for (waited = 0; waited < tfork_restore_wait_timeout_ms; + waited += tfork_restore_wait_poll_us / 1000) { if ((int)futex_get(np) <= participants) break; - usleep(100000); + usleep(tfork_restore_wait_poll_us); } if ((int)futex_get(np) > participants) { - pr_err("tfork restore wait timed out: participants=%d nr_in_progress=%d start_stage=%d nr_tasks=%d nr_threads=%d nr_helpers=%d\n", + pr_err("tfork restore wait timed out after %dms: participants=%d nr_in_progress=%d start_stage=%d task_cr_err=%d nr_tasks=%d nr_threads=%d nr_helpers=%d\n", + tfork_restore_wait_timeout_ms, participants, (int)futex_get(np), (int)futex_get(&task_entries->start), + get_task_cr_err(), task_entries->nr_tasks, task_entries->nr_threads, task_entries->nr_helpers); + set_cr_errno(ETIMEDOUT); return -ETIMEDOUT; } } else { @@ -1493,8 +1499,21 @@ static inline int fork_with_pid(struct pstree_item *item) if (opts.tfork.active && (root_ns_mask & CLONE_NEWPID) && root_item && root_item->pid->ns_level > 1 && item->pid->ns_level > 1) { - tfork_pid = *item->pid; - tfork_pid.ns_level--; + /* + * Copy only scalar pid identity. struct pid also + * embeds rb_node links owned by the dumped pid trees; + * copying those nodes into a temporary stack object + * corrupts the tree metadata if it ever gets reused. + */ + tfork_pid.item = item->pid->item; + tfork_pid.real = item->pid->real; + tfork_pid.local = item->pid->local; + tfork_pid.uid = item->pid->uid; + tfork_pid.state = item->pid->state; + tfork_pid.stop_signo = item->pid->stop_signo; + tfork_pid.ns_level = item->pid->ns_level - 1; + tfork_pid.leaf_ns_id = item->pid->leaf_ns_id; + memcpy(tfork_pid.ns, item->pid->ns, sizeof(tfork_pid.ns)); restore_pid = &tfork_pid; pr_info("tfork: restore pid uid=%d local=%d with rebased pid chain level %d -> %d\n", uid(item), pid, item->pid->ns_level, diff --git a/criu/criu/cr-tfork.c b/criu/criu/cr-tfork.c index aa33b0a04..ccc87dece 100644 --- a/criu/criu/cr-tfork.c +++ b/criu/criu/cr-tfork.c @@ -1315,6 +1315,10 @@ int cr_tfork_tasks(pid_t pid) argv_new[argc_new++] = pidfile_arg; } argv_new[argc_new++] = "--keep-pid-hierarchy"; + if (argc_new >= argc_max) { + pr_err("tfork restore argv overflow: used=%d max=%d\n", argc_new, argc_max); + exit(1); + } argv_new[argc_new] = NULL; execv("/proc/self/exe", argv_new); diff --git a/criu/criu/crtools.c b/criu/criu/crtools.c index 1243c8087..639ad06f5 100644 --- a/criu/criu/crtools.c +++ b/criu/criu/crtools.c @@ -332,6 +332,13 @@ int main(int argc, char *argv[], char *envp[]) if (opts.tree_id) pr_warn("Using -t with criu restore is obsoleted\n"); + if (!opts.tfork.active && opts.tfork.copies >= 1) { + pr_err("--tfork-copies requires --tfork-restore " + "(use 'criu tfork --tfork-copies=N', not " + "'criu restore --tfork-copies=N')\n"); + return 1; + } + if (opts.tfork.active && opts.tfork.copies >= 1) { int n = opts.tfork.copies, i; pid_t *children; @@ -347,13 +354,6 @@ int main(int argc, char *argv[], char *envp[]) */ const int ns_flags = CLONE_NEWNS; - if (!opts.tfork.active) { - pr_err("--tfork-copies requires --tfork-restore " - "(use 'criu tfork --tfork-copies=N', not " - "'criu restore --tfork-copies=N')\n"); - return 1; - } - if (!opts.restore_detach) { pr_err("--tfork-copies requires --restore-detached\n"); return 1; @@ -450,6 +450,11 @@ int main(int argc, char *argv[], char *envp[]) if (opts.tfork.snap_roots_n > 0) opts.root = opts.tfork.snap_roots[i]; + /* + * The n-copy child only creates the per-copy mount namespace. PID + * namespaces must be recreated by CRIU from the image, otherwise the + * helper would occupy PID 1 before the restored root task. + */ opts.keep_pid_hierarchy = 0; if (tfork_load_ncopy_fabric(i)) { diff --git a/criu/criu/pie/restorer.c b/criu/criu/pie/restorer.c index a58ae5ed5..2efde9cb0 100644 --- a/criu/criu/pie/restorer.c +++ b/criu/criu/pie/restorer.c @@ -150,14 +150,6 @@ static void sigchld_handler(int signal, siginfo_t *siginfo, void *data) if (siginfo->si_pid == zombies[i]) return; - if (tfork_active_local && - siginfo->si_code == CLD_EXITED && - siginfo->si_status == 0) { - pr_info("tfork: ignoring clean child exit during restore: task %d\n", - siginfo->si_pid); - return; - } - if (siginfo->si_code == CLD_EXITED) r = "exited, status="; else if (siginfo->si_code == CLD_KILLED) @@ -826,22 +818,22 @@ __visible long __export_restore_thread(struct thread_restore_args *args) pr_info("%ld: Restored\n", sys_gettid()); if (args->ta->tfork_active) - pr_warn("tfork: thread restore stage complete pid=%d tid=%ld comm=%s ns_level=%d\n", + pr_debug("tfork: thread restore stage complete pid=%d tid=%ld comm=%s ns_level=%d\n", args->pid, sys_gettid(), args->comm, args->ns_level); ret = restore_finish_stage(task_entries_local, CR_STATE_RESTORE); if (args->ta->tfork_active) - pr_warn("tfork: thread restore barrier returned pid=%d tid=%ld comm=%s stage=%d\n", + pr_debug("tfork: thread restore barrier returned pid=%d tid=%ld comm=%s stage=%d\n", args->pid, sys_gettid(), args->comm, ret); if (restore_signals(args->siginfo, args->siginfo_n, false)){ goto core_restore_end; } if (args->ta->tfork_active) - pr_warn("tfork: thread sigchld stage complete pid=%d tid=%ld comm=%s ns_level=%d\n", + pr_debug("tfork: thread sigchld stage complete pid=%d tid=%ld comm=%s ns_level=%d\n", args->pid, sys_gettid(), args->comm, args->ns_level); ret = restore_finish_stage(task_entries_local, CR_STATE_RESTORE_SIGCHLD); if (args->ta->tfork_active) - pr_warn("tfork: thread sigchld barrier returned pid=%d tid=%ld comm=%s stage=%d\n", + pr_debug("tfork: thread sigchld barrier returned pid=%d tid=%ld comm=%s stage=%d\n", args->pid, sys_gettid(), args->comm, ret); /* @@ -2502,7 +2494,12 @@ __visible long __export_restore_task(struct task_restore_args *args) c_args.flags = clone_flags; c_args.set_tid_size = thread_args[i].ns_level; if (args->tfork_active) { - pr_info("tfork: restore thread pid=%d with fresh tid, set_tid_size %d -> 0 tids=%d/%d\n", + /* + * tfork currently restores non-leader threads with fresh TIDs. + * Reusing dumped TIDs collides with the synthetic per-copy restore + * helper in nested PID namespaces; process IDs remain restored exactly. + */ + pr_debug("tfork: restore thread pid=%d with fresh tid, set_tid_size %d -> 0 tids=%d/%d\n", thread_args[i].pid, thread_args[i].ns_level, thread_args[i].tid_in_ns[0], @@ -2584,31 +2581,31 @@ __visible long __export_restore_task(struct task_restore_args *args) goto core_restore_end; pr_info("%ld: Restored\n", sys_getpid()); if (args->tfork_active) - pr_warn("tfork: leader restore stage complete pid=%d tid=%ld comm=%s threads=%d ns_level=%d\n", + pr_debug("tfork: leader restore stage complete pid=%d tid=%ld comm=%s threads=%d ns_level=%d\n", args->t->pid, sys_getpid(), args->comm, args->nr_threads, args->t->ns_level); ret = restore_finish_stage(task_entries_local, CR_STATE_RESTORE); if (args->tfork_active) - pr_warn("tfork: leader restore barrier returned pid=%d tid=%ld comm=%s stage=%ld helpers=%u zombies=%u inotify=%u\n", + pr_debug("tfork: leader restore barrier returned pid=%d tid=%ld comm=%s stage=%ld helpers=%u zombies=%u inotify=%u\n", args->t->pid, sys_getpid(), args->comm, ret, args->helpers_n, args->zombies_n, args->inotify_fds_n); if (args->tfork_active) - pr_warn("tfork: leader wait_helpers start pid=%d tid=%ld comm=%s helpers=%u\n", + pr_debug("tfork: leader wait_helpers start pid=%d tid=%ld comm=%s helpers=%u\n", args->t->pid, sys_getpid(), args->comm, args->helpers_n); if (wait_helpers(args) < 0) goto core_restore_end; if (args->tfork_active) - pr_warn("tfork: leader wait_helpers done pid=%d tid=%ld comm=%s\n", + pr_debug("tfork: leader wait_helpers done pid=%d tid=%ld comm=%s\n", args->t->pid, sys_getpid(), args->comm); if (args->tfork_active) - pr_warn("tfork: leader wait_zombies start pid=%d tid=%ld comm=%s zombies=%u\n", + pr_debug("tfork: leader wait_zombies start pid=%d tid=%ld comm=%s zombies=%u\n", args->t->pid, sys_getpid(), args->comm, args->zombies_n); if (wait_zombies(args) < 0) goto core_restore_end; if (args->tfork_active) - pr_warn("tfork: leader wait_zombies done pid=%d tid=%ld comm=%s\n", + pr_debug("tfork: leader wait_zombies done pid=%d tid=%ld comm=%s\n", args->t->pid, sys_getpid(), args->comm); ksigfillset(&to_block); @@ -2619,16 +2616,16 @@ __visible long __export_restore_task(struct task_restore_args *args) } if (args->tfork_active) - pr_warn("tfork: leader cleanup_inotify start pid=%d tid=%ld comm=%s inotify=%u\n", + pr_debug("tfork: leader cleanup_inotify start pid=%d tid=%ld comm=%s inotify=%u\n", args->t->pid, sys_getpid(), args->comm, args->inotify_fds_n); if (cleanup_current_inotify_events(args)) goto core_restore_end; if (args->tfork_active) - pr_warn("tfork: leader cleanup_inotify done pid=%d tid=%ld comm=%s\n", + pr_debug("tfork: leader cleanup_inotify done pid=%d tid=%ld comm=%s\n", args->t->pid, sys_getpid(), args->comm); if (args->tfork_active) - pr_warn("tfork: leader restore sigaction start pid=%d tid=%ld comm=%s\n", + pr_debug("tfork: leader restore sigaction start pid=%d tid=%ld comm=%s\n", args->t->pid, sys_getpid(), args->comm); if (!args->compatible_mode) { ret = sys_sigaction(SIGCHLD, &args->sigchld_act, NULL, sizeof(k_rtsigset_t)); @@ -2647,36 +2644,36 @@ __visible long __export_restore_task(struct task_restore_args *args) goto core_restore_end; } if (args->tfork_active) - pr_warn("tfork: leader restore sigaction done pid=%d tid=%ld comm=%s\n", + pr_debug("tfork: leader restore sigaction done pid=%d tid=%ld comm=%s\n", args->t->pid, sys_getpid(), args->comm); if (args->tfork_active) - pr_warn("tfork: leader restore shared signals start pid=%d tid=%ld comm=%s\n", + pr_debug("tfork: leader restore shared signals start pid=%d tid=%ld comm=%s\n", args->t->pid, sys_getpid(), args->comm); ret = restore_signals(args->siginfo, args->siginfo_n, true); if (ret) goto core_restore_end; if (args->tfork_active) - pr_warn("tfork: leader restore shared signals done pid=%d tid=%ld comm=%s\n", + pr_debug("tfork: leader restore shared signals done pid=%d tid=%ld comm=%s\n", args->t->pid, sys_getpid(), args->comm); if (args->tfork_active) - pr_warn("tfork: leader restore private signals start pid=%d tid=%ld comm=%s\n", + pr_debug("tfork: leader restore private signals start pid=%d tid=%ld comm=%s\n", args->t->pid, sys_getpid(), args->comm); ret = restore_signals(args->t->siginfo, args->t->siginfo_n, false); if (ret) goto core_restore_end; if (args->tfork_active) - pr_warn("tfork: leader restore private signals done pid=%d tid=%ld comm=%s\n", + pr_debug("tfork: leader restore private signals done pid=%d tid=%ld comm=%s\n", args->t->pid, sys_getpid(), args->comm); if (args->tfork_active) - pr_warn("tfork: leader sigchld stage complete pid=%d tid=%ld comm=%s threads=%d ns_level=%d\n", + pr_debug("tfork: leader sigchld stage complete pid=%d tid=%ld comm=%s threads=%d ns_level=%d\n", args->t->pid, sys_getpid(), args->comm, args->nr_threads, args->t->ns_level); ret = restore_finish_stage(task_entries_local, CR_STATE_RESTORE_SIGCHLD); if (args->tfork_active) - pr_warn("tfork: leader sigchld barrier returned pid=%d tid=%ld comm=%s stage=%ld\n", + pr_debug("tfork: leader sigchld barrier returned pid=%d tid=%ld comm=%s stage=%ld\n", args->t->pid, sys_getpid(), args->comm, ret); rst_tcp_socks_all(args); diff --git a/criu/criu/pstree.c b/criu/criu/pstree.c index b773b6d8c..a991c1898 100644 --- a/criu/criu/pstree.c +++ b/criu/criu/pstree.c @@ -912,6 +912,10 @@ static int read_one_pstree_item(PstreeEntry *e) } pi->pid->state = TASK_ALIVE; pi->pid->uid = e->uid; + pi->nr_threads = e->n_threads; + pi->threads = xmalloc(e->n_threads * sizeof(struct pid)); + if (!pi->threads) + goto err; /* note: we don't fail if we have empty ids */ if (read_pstree_ids(pi) < 0) @@ -952,11 +956,6 @@ static int read_one_pstree_item(PstreeEntry *e) list_add(&pi->sibling, &parent->children); } - pi->nr_threads = e->n_threads; - pi->threads = xmalloc(e->n_threads * sizeof(struct pid)); - if (!pi->threads) - goto err; - for (i = 0; i < e->n_threads; i++) { int insert_status; pi->threads[i].uid = e->threads[i]->uid; diff --git a/criu/criu/seize.c b/criu/criu/seize.c index 5fc7c850e..aa5c05638 100644 --- a/criu/criu/seize.c +++ b/criu/criu/seize.c @@ -399,6 +399,8 @@ static int freezer_wait_processes(void) pid = waitpid(-1, &status, opts.tfork.active ? WNOHANG : 0); if (pid > 0) break; + if (pid < 0 && errno == EINTR) + continue; if (!opts.tfork.active || (pid < 0 && errno != ECHILD && errno != EINTR)) { pr_perror("Unable to wait processes"); xfree(processes_to_wait_pids); diff --git a/crun/src/libcrun/criu.c b/crun/src/libcrun/criu.c index 1e1dbbed2..20c7bdd80 100644 --- a/crun/src/libcrun/criu.c +++ b/crun/src/libcrun/criu.c @@ -46,6 +46,8 @@ # define DESCRIPTORS_FILENAME "descriptors.json" # define CRIU_RUNC_CONFIG_FILE "/etc/criu/runc.conf" # define CRIU_CRUN_CONFIG_FILE "/etc/criu/crun.conf" +# define CRIU_LOG_TAIL_LINES 80 +# define CRIU_LOG_LINE_SIZE 1024 # define CRIU_EXT_NETNS "extRootNetNS" # define CRIU_EXT_PIDNS "extRootPidNS" @@ -543,9 +545,9 @@ static void show_criu_log (const char *work_path, const char *log) { cleanup_free char *log_path = NULL; + cleanup_free char *tail = NULL; libcrun_error_t *tmp_err = NULL; - char line[1024]; - char tail[200][1024]; + char line[CRIU_LOG_LINE_SIZE]; size_t tail_index = 0; size_t tail_count = 0; FILE *f; @@ -566,15 +568,23 @@ show_criu_log (const char *work_path, const char *log) /* Log with error verbosity as this is the default. */ libcrun_error (0, "--- excerpt from CRIU log `%s`", log_path); + tail = calloc (CRIU_LOG_TAIL_LINES, CRIU_LOG_LINE_SIZE); + if (tail == NULL) + { + fclose (f); + return; + } + while (fgets (line, sizeof (line), f) != NULL) { - strncpy (tail[tail_index], line, sizeof (tail[tail_index]) - 1); - tail[tail_index][sizeof (tail[tail_index]) - 1] = '\0'; - tail_index = (tail_index + 1) % 200; - if (tail_count < 200) + char *slot = tail + tail_index * CRIU_LOG_LINE_SIZE; + strncpy (slot, line, CRIU_LOG_LINE_SIZE - 1); + slot[CRIU_LOG_LINE_SIZE - 1] = '\0'; + tail_index = (tail_index + 1) % CRIU_LOG_TAIL_LINES; + if (tail_count < CRIU_LOG_TAIL_LINES) tail_count++; - if (strstr (line, "Error ") != NULL || strstr (line, "Warn ") != NULL + if (strstr (line, "Error ") != NULL || strstr (line, "failed") != NULL || strstr (line, "FAILED") != NULL || strstr (line, "Unable") != NULL || strstr (line, "Can't") != NULL || strstr (line, "No such") != NULL) @@ -586,11 +596,11 @@ show_criu_log (const char *work_path, const char *log) if (tail_count > 0) { - size_t start = (tail_count == 200) ? tail_index : 0; + size_t start = (tail_count == CRIU_LOG_TAIL_LINES) ? tail_index : 0; libcrun_error (0, "--- last %zu CRIU log lines", tail_count); for (size_t i = 0; i < tail_count; i++) { - char *entry = tail[(start + i) % 200]; + char *entry = tail + ((start + i) % CRIU_LOG_TAIL_LINES) * CRIU_LOG_LINE_SIZE; entry[strcspn (entry, "\n")] = '\0'; libcrun_error (0, "%s", entry); } @@ -1381,9 +1391,14 @@ libcrun_container_restore_linux_criu (libcrun_container_status_t *status, libcru # define CRIU_TFORK_MAX_COPY_LOGS 16 static void -show_criu_tfork_restore_copy_logs (const char *image_path) +show_criu_tfork_restore_copy_logs (const char *image_path, int copy_count) { - for (int i = 0; i < CRIU_TFORK_MAX_COPY_LOGS; i++) + if (copy_count < 0) + copy_count = 0; + if (copy_count > CRIU_TFORK_MAX_COPY_LOGS) + copy_count = CRIU_TFORK_MAX_COPY_LOGS; + + for (int i = 0; i < copy_count; i++) { char log[64]; snprintf (log, sizeof (log), "%s.copy%d", CRIU_TFORK_RESTORE_LOG_FILE, i); @@ -1850,7 +1865,7 @@ libcrun_container_tfork_linux_criu (libcrun_container_t *container, libcrun_chec { show_criu_log (cr_options->work_path, CRIU_TFORK_LOG_FILE); show_criu_log (cr_options->image_path, CRIU_TFORK_RESTORE_LOG_FILE); - show_criu_tfork_restore_copy_logs (cr_options->image_path); + show_criu_tfork_restore_copy_logs (cr_options->image_path, cr_options->tfork_copies); return crun_make_error (err, 0, "criu_tfork failed: %d", ret); } diff --git a/crun/src/tfork.c b/crun/src/tfork.c index 2c1d3438e..3a64b2bb9 100644 --- a/crun/src/tfork.c +++ b/crun/src/tfork.c @@ -361,11 +361,12 @@ crun_command_tfork (struct crun_global_arguments *global_args, int argc, char ** cr_options.manage_cgroups_mode = -1; cr_options.tfork_copies = 0; /* - * Agent runtimes commonly keep Unix sockets connected to helpers outside the - * dumped process subtree (tmux/Codex hooks, host-control bridges, etc.). - * Without this CRIU rejects the dump before restore begins. + * External Unix stream sockets can make Codex/tmux stacks dumpable, but they + * may hide unsupported socket topology. Keep the default fail-loud and expose + * this as an explicit escape hatch for agent integrations. */ - cr_options.ext_unix_sk = true; + if (getenv ("CRUN_TFORK_EXT_UNIX_SK") != NULL) + cr_options.ext_unix_sk = true; cr_options.leave_running = true; return crun_run_create_internal (global_args, argc, argv, container_tfork, get_options, &crun_context, &run_argp, diff --git a/podman/pkg/domain/infra/abi/container_tfork.go b/podman/pkg/domain/infra/abi/container_tfork.go index 2a14b9d0c..00543ddeb 100644 --- a/podman/pkg/domain/infra/abi/container_tfork.go +++ b/podman/pkg/domain/infra/abi/container_tfork.go @@ -68,6 +68,10 @@ func (ic *ContainerEngine) containerCloneLive(ctx context.Context, opts entities } requestedCopies := copies useSingleCopyConmon := requestedCopies == 1 && os.Getenv("PODMAN_TFORK_SINGLE_COPY_CONMON") == "1" + // PODMAN_TFORK_SINGLE_COPY_DIRECT is a debugging escape hatch that skips + // the n-copy restore helper for single-copy experiments. Production paths + // keep the n-copy helper even for copies=1 so attach/status handling is + // consistent with multi-copy forks. useSingleCopyDirect := requestedCopies == 1 && os.Getenv("PODMAN_TFORK_SINGLE_COPY_DIRECT") == "1" useNcopyRestore := copies > 1 || (requestedCopies == 1 && !useSingleCopyConmon && !useSingleCopyDirect) @@ -597,7 +601,7 @@ func (ic *ContainerEngine) containerCloneLive(ctx context.Context, opts entities return filepath.Join(imgDir, fmt.Sprintf("tfork.pid.copy%d", i)) } statePath := fmt.Sprintf("/run/crun/%s/status", cloneIDs[0]) - needState := !useNcopyRestore && copies == 1 + needState := !useNcopyRestore cloneReadyTimeout := tforkCloneReadyTimeoutFromEnv() deadline := time.Now().Add(cloneReadyTimeout) readyCopies := 0 From c36af324a6347e045280ec4bfbcd3e189a69994f Mon Sep 17 00:00:00 2001 From: yiying-zhang Date: Mon, 20 Jul 2026 03:55:40 -0700 Subject: [PATCH 40/53] Address tfork single-copy review follow-ups --- criu/build.sh | 3 +++ criu/criu/cr-tfork.c | 6 ++--- criu/criu/pie/restorer.c | 5 ++++ criu/criu/pstree.c | 54 ++++++++++++++++++++++++++++++++++++---- criu/criu/seize.c | 2 +- 5 files changed, 61 insertions(+), 9 deletions(-) diff --git a/criu/build.sh b/criu/build.sh index ff3a95339..cdb1cb472 100755 --- a/criu/build.sh +++ b/criu/build.sh @@ -96,10 +96,12 @@ for mod in "${ACTIVE_MODULES[@]}"; do echo " -- ${mod}: already loaded, trying reload" if ! rmmod "${mod}"; then echo "build.sh: failed to unload loaded module ${mod}; aborting to avoid stale module" >&2 + echo "build.sh: stop running tfork/podman containers that may still hold ${mod}, then retry" >&2 exit 1 fi if lsmod | awk '{print $1}' | grep -qx "${mod}"; then echo "build.sh: module ${mod} is still loaded after rmmod; aborting to avoid stale module" >&2 + echo "build.sh: stop running tfork/podman containers that may still hold ${mod}, then retry" >&2 exit 1 fi fi @@ -107,6 +109,7 @@ for mod in "${ACTIVE_MODULES[@]}"; do echo " -- ${mod}: insmod" if ! insmod "${ko_path}"; then echo "build.sh: failed to insert rebuilt module ${mod} from ${ko_path}" >&2 + echo "build.sh: if the old module is still active, stop running tfork/podman containers and rerun this script" >&2 exit 1 fi done diff --git a/criu/criu/cr-tfork.c b/criu/criu/cr-tfork.c index ccc87dece..512ea5817 100644 --- a/criu/criu/cr-tfork.c +++ b/criu/criu/cr-tfork.c @@ -1314,11 +1314,11 @@ int cr_tfork_tasks(pid_t pid) argv_new[argc_new++] = "--pidfile"; argv_new[argc_new++] = pidfile_arg; } - argv_new[argc_new++] = "--keep-pid-hierarchy"; - if (argc_new >= argc_max) { - pr_err("tfork restore argv overflow: used=%d max=%d\n", argc_new, argc_max); + if (argc_new + 1 >= argv_max) { + pr_err("tfork restore argv overflow: used=%d max=%zu\n", argc_new, argv_max); exit(1); } + argv_new[argc_new++] = "--keep-pid-hierarchy"; argv_new[argc_new] = NULL; execv("/proc/self/exe", argv_new); diff --git a/criu/criu/pie/restorer.c b/criu/criu/pie/restorer.c index 2efde9cb0..94355aa4d 100644 --- a/criu/criu/pie/restorer.c +++ b/criu/criu/pie/restorer.c @@ -2498,6 +2498,11 @@ __visible long __export_restore_task(struct task_restore_args *args) * tfork currently restores non-leader threads with fresh TIDs. * Reusing dumped TIDs collides with the synthetic per-copy restore * helper in nested PID namespaces; process IDs remain restored exactly. + * + * This can leave userspace thread-ID caches stale in the clone: + * robust/errorcheck mutex owner futex words, cached gettid values + * in TLS/TCB, and pthread_join targets derived from old pthread_t + * values may not describe the clone's fresh worker TIDs. */ pr_debug("tfork: restore thread pid=%d with fresh tid, set_tid_size %d -> 0 tids=%d/%d\n", thread_args[i].pid, diff --git a/criu/criu/pstree.c b/criu/criu/pstree.c index a991c1898..a6a0d4eb7 100644 --- a/criu/criu/pstree.c +++ b/criu/criu/pstree.c @@ -732,11 +732,35 @@ static int __pstree_insert_pid(struct pid *pid_node, struct rb_node *root_parent rb_link_and_balance(&uid_root_rb, &pid_node->uid_node, parent, link); } - return 0; + return 0; err: - rb_erase(&pid_node->root_ns_node, &pid_root_rb[ALL_PID_NS_ID]); - return -1; + rb_erase(&pid_node->root_ns_node, &pid_root_rb[ALL_PID_NS_ID]); + return -1; +} + +static void pstree_remove_pid_if_linked(struct pid *pid_node) +{ + struct pid *found; + + if (pid_node->uid > 0) { + found = __lookup_pid_uid(&uid_root_rb, pid_node->uid, NULL, NULL); + if (found == pid_node) + rb_erase(&pid_node->uid_node, &uid_root_rb); + } + + if (pid_node->leaf_ns_id != ALL_PID_NS_ID) { + found = __lookup_pid_leaf(&pid_root_rb[pid_node->leaf_ns_id], + pid_node->local, NULL, NULL); + if (found == pid_node) + rb_erase(&pid_node->leaf_ns_node, + &pid_root_rb[pid_node->leaf_ns_id]); + } + + found = __lookup_pid_root(&pid_root_rb[ALL_PID_NS_ID], + pid_node->real, NULL, NULL); + if (found == pid_node) + rb_erase(&pid_node->root_ns_node, &pid_root_rb[ALL_PID_NS_ID]); } int pstree_insert_pid(struct pid *pid_node) @@ -873,8 +897,9 @@ static int read_pstree_ids(struct pstree_item *pi) */ static int read_one_pstree_item(PstreeEntry *e) { - struct pstree_item *pi; - int ret = -1, i, j; + struct pstree_item *pi = NULL; + int ret = -1, i, j, inserted_threads = 0; + bool linked = false, pid_inserted = false, threads_allocated = false; pi = get_or_create_pstree_item(e->realpid, e->localpid, e->nsid); if (!pi) @@ -916,6 +941,7 @@ static int read_one_pstree_item(PstreeEntry *e) pi->threads = xmalloc(e->n_threads * sizeof(struct pid)); if (!pi->threads) goto err; + threads_allocated = true; /* note: we don't fail if we have empty ids */ if (read_pstree_ids(pi) < 0) @@ -931,6 +957,7 @@ static int read_one_pstree_item(PstreeEntry *e) if (__pstree_insert_pid(pi->pid, NULL, NULL) < 0) goto err; + pid_inserted = true; if (e->ppid == 0) { if (root_item) { @@ -954,6 +981,7 @@ static int read_one_pstree_item(PstreeEntry *e) parent = pid->item; pi->parent = parent; list_add(&pi->sibling, &parent->children); + linked = true; } for (i = 0; i < e->n_threads; i++) { @@ -983,6 +1011,7 @@ static int read_one_pstree_item(PstreeEntry *e) pr_err("Unexpected task %d in a tree %d\n", e->threads[i]->ns[0]->nspid, i); goto err; } + inserted_threads++; } task_entries->nr_threads += e->n_threads; @@ -990,6 +1019,21 @@ static int read_one_pstree_item(PstreeEntry *e) ret = 1; err: + if (ret < 0) { + for (i = 1; i <= inserted_threads; i++) + pstree_remove_pid_if_linked(&pi->threads[i]); + if (root_item == pi) + root_item = NULL; + if (linked) + list_del_init(&pi->sibling); + if (pid_inserted) + pstree_remove_pid_if_linked(pi->pid); + if (threads_allocated) { + xfree(pi->threads); + pi->threads = NULL; + pi->nr_threads = 0; + } + } return ret; } diff --git a/criu/criu/seize.c b/criu/criu/seize.c index aa5c05638..3c00b83ec 100644 --- a/criu/criu/seize.c +++ b/criu/criu/seize.c @@ -399,7 +399,7 @@ static int freezer_wait_processes(void) pid = waitpid(-1, &status, opts.tfork.active ? WNOHANG : 0); if (pid > 0) break; - if (pid < 0 && errno == EINTR) + if (opts.tfork.active && pid < 0 && errno == EINTR) continue; if (!opts.tfork.active || (pid < 0 && errno != ECHILD && errno != EINTR)) { pr_perror("Unable to wait processes"); From 98e5219e88b5b9019243c214bf039fa4a48581c7 Mon Sep 17 00:00:00 2001 From: yiying-zhang Date: Mon, 20 Jul 2026 08:24:32 -0700 Subject: [PATCH 41/53] Fix tfork pstree unwind bounds --- criu/criu/cr-tfork.c | 2 +- criu/criu/pstree.c | 36 +++++++++++++++++++++++++++++------- criu/criu/seize.c | 5 ++++- 3 files changed, 34 insertions(+), 9 deletions(-) diff --git a/criu/criu/cr-tfork.c b/criu/criu/cr-tfork.c index 512ea5817..f99a6d2da 100644 --- a/criu/criu/cr-tfork.c +++ b/criu/criu/cr-tfork.c @@ -1314,7 +1314,7 @@ int cr_tfork_tasks(pid_t pid) argv_new[argc_new++] = "--pidfile"; argv_new[argc_new++] = pidfile_arg; } - if (argc_new + 1 >= argv_max) { + if ((size_t)argc_new + 1 >= argv_max) { pr_err("tfork restore argv overflow: used=%d max=%zu\n", argc_new, argv_max); exit(1); } diff --git a/criu/criu/pstree.c b/criu/criu/pstree.c index a6a0d4eb7..e227ade77 100644 --- a/criu/criu/pstree.c +++ b/criu/criu/pstree.c @@ -742,6 +742,7 @@ static int __pstree_insert_pid(struct pid *pid_node, struct rb_node *root_parent static void pstree_remove_pid_if_linked(struct pid *pid_node) { struct pid *found; + bool valid_leaf_ns = pid_node->leaf_ns_id >= 0 && pid_node->leaf_ns_id <= max_ns_id; if (pid_node->uid > 0) { found = __lookup_pid_uid(&uid_root_rb, pid_node->uid, NULL, NULL); @@ -749,7 +750,7 @@ static void pstree_remove_pid_if_linked(struct pid *pid_node) rb_erase(&pid_node->uid_node, &uid_root_rb); } - if (pid_node->leaf_ns_id != ALL_PID_NS_ID) { + if (pid_node->leaf_ns_id != ALL_PID_NS_ID && valid_leaf_ns) { found = __lookup_pid_leaf(&pid_root_rb[pid_node->leaf_ns_id], pid_node->local, NULL, NULL); if (found == pid_node) @@ -768,11 +769,13 @@ int pstree_insert_pid(struct pid *pid_node) return __pstree_insert_pid(pid_node, NULL, NULL); } -static struct pstree_item *get_or_create_pstree_item(pid_t real, pid_t local, int pidns_id) +static struct pstree_item *get_or_create_pstree_item(pid_t real, pid_t local, int pidns_id, bool *created) { struct pid *found; struct pstree_item *item; + *created = false; + found = __lookup_pid_root(&pid_root_rb[ALL_PID_NS_ID], real, NULL, NULL); if (found) { if (pidns_id != ALL_PID_NS_ID) { @@ -788,6 +791,7 @@ static struct pstree_item *get_or_create_pstree_item(pid_t real, pid_t local, in item->pid->real = real; item->pid->local = local; item->pid->leaf_ns_id = pidns_id; + *created = true; return item; } @@ -898,13 +902,23 @@ static int read_pstree_ids(struct pstree_item *pi) static int read_one_pstree_item(PstreeEntry *e) { struct pstree_item *pi = NULL; - int ret = -1, i, j, inserted_threads = 0; + int ret = -1, i, j, next_inserted_thread = 1; bool linked = false, pid_inserted = false, threads_allocated = false; + bool created_item = false; - pi = get_or_create_pstree_item(e->realpid, e->localpid, e->nsid); + pi = get_or_create_pstree_item(e->realpid, e->localpid, e->nsid, &created_item); if (!pi) goto err; + /* + * get_or_create_pstree_item() can only reuse an item that is still + * TASK_UNDEF. Completed items are rejected here, so the unwind below + * cannot tear down a previously parsed pstree item. + */ BUG_ON(pi->pid->state != TASK_UNDEF); + if (!created_item && (pi->threads || pi->nr_threads)) { + pr_err("Refusing to reuse partially populated pstree item for %d\n", e->realpid); + goto err; + } /* * Populate the ns-chain on pi from the thread-leader entry before @@ -1011,7 +1025,7 @@ static int read_one_pstree_item(PstreeEntry *e) pr_err("Unexpected task %d in a tree %d\n", e->threads[i]->ns[0]->nspid, i); goto err; } - inserted_threads++; + next_inserted_thread = i + 1; } task_entries->nr_threads += e->n_threads; @@ -1019,8 +1033,14 @@ static int read_one_pstree_item(PstreeEntry *e) ret = 1; err: - if (ret < 0) { - for (i = 1; i <= inserted_threads; i++) + if (ret < 0 && pi) { + /* + * threads[0] is the leader mirrored by pi->pid. Only + * non-leader threads are inserted independently, and + * next_inserted_thread always points one past the last + * successfully inserted non-leader slot. + */ + for (i = 1; i < next_inserted_thread; i++) pstree_remove_pid_if_linked(&pi->threads[i]); if (root_item == pi) root_item = NULL; @@ -1033,6 +1053,8 @@ static int read_one_pstree_item(PstreeEntry *e) pi->threads = NULL; pi->nr_threads = 0; } + if (created_item) + xfree(pi); } return ret; } diff --git a/criu/criu/seize.c b/criu/criu/seize.c index 3c00b83ec..db8d95b92 100644 --- a/criu/criu/seize.c +++ b/criu/criu/seize.c @@ -399,8 +399,11 @@ static int freezer_wait_processes(void) pid = waitpid(-1, &status, opts.tfork.active ? WNOHANG : 0); if (pid > 0) break; - if (opts.tfork.active && pid < 0 && errno == EINTR) + if (opts.tfork.active && pid < 0 && errno == EINTR && waited_ms < 500) { + usleep(10 * 1000); + waited_ms += 10; continue; + } if (!opts.tfork.active || (pid < 0 && errno != ECHILD && errno != EINTR)) { pr_perror("Unable to wait processes"); xfree(processes_to_wait_pids); From b1416e6e056a3c245e10a486a755880cc3cf6f2a Mon Sep 17 00:00:00 2001 From: yiying-zhang Date: Mon, 20 Jul 2026 08:48:09 -0700 Subject: [PATCH 42/53] Fix tfork pstree cleanup ownership --- criu/criu/pstree.c | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/criu/criu/pstree.c b/criu/criu/pstree.c index e227ade77..0750792f0 100644 --- a/criu/criu/pstree.c +++ b/criu/criu/pstree.c @@ -742,7 +742,8 @@ static int __pstree_insert_pid(struct pid *pid_node, struct rb_node *root_parent static void pstree_remove_pid_if_linked(struct pid *pid_node) { struct pid *found; - bool valid_leaf_ns = pid_node->leaf_ns_id >= 0 && pid_node->leaf_ns_id <= max_ns_id; + bool valid_leaf_ns = pid_node->leaf_ns_id >= 0 && + (unsigned int)pid_node->leaf_ns_id < pid_namespace_count; if (pid_node->uid > 0) { found = __lookup_pid_uid(&uid_root_rb, pid_node->uid, NULL, NULL); @@ -1013,7 +1014,7 @@ static int read_one_pstree_item(PstreeEntry *e) pi->threads[i].state = TASK_THREAD; pi->threads[i].item = NULL; if (i == 0) { - + /* The leader is indexed through pi->pid, not this mirror. */ pi->pid->ns_level = pi->threads[0].ns_level; pi->pid->local = pi->threads[0].ns[0].ns_pid; memcpy(pi->pid->ns, pi->threads[0].ns, e->threads[0]->n_ns * sizeof(struct pid_ns)); @@ -1053,8 +1054,12 @@ static int read_one_pstree_item(PstreeEntry *e) pi->threads = NULL; pi->nr_threads = 0; } - if (created_item) - xfree(pi); + /* + * Restore pstree items come from the shared linear arena. The + * item may no longer be the last allocation after read_pstree_ids(), + * so it cannot be released individually. Restore teardown reclaims + * the arena after this parse failure. + */ } return ret; } From 4292aecf1675e440ae15ec9cdd551c0ee392f99a Mon Sep 17 00:00:00 2001 From: yiying-zhang Date: Mon, 20 Jul 2026 11:11:29 -0700 Subject: [PATCH 43/53] Fix tfork clone behavioral gaps --- criu/criu/fsnotify.c | 17 ---- criu/criu/pie/restorer.c | 38 ++------ criu/criu/pstree.c | 1 + criu/lib/pycriu/images/images.py | 92 ++++++++++++++++++- criu/test/others/pycriu/Makefile | 7 +- criu/test/others/pycriu/test_pstree_compat.py | 65 +++++++++++++ 6 files changed, 170 insertions(+), 50 deletions(-) create mode 100644 criu/test/others/pycriu/test_pstree_compat.py diff --git a/criu/criu/fsnotify.c b/criu/criu/fsnotify.c index 2aac52d0b..ab8a29dd6 100644 --- a/criu/criu/fsnotify.c +++ b/criu/criu/fsnotify.c @@ -1037,23 +1037,6 @@ static int open_inotify_fd(struct file_desc *d, int *new_fd) return -1; } - if (opts.tfork.active) { - unsigned int skipped = 0; - - list_for_each_entry(wd_info, &info->marks, list) - skipped++; - - if (skipped) - pr_warn("tfork: restored inotify fd %#08x without %u watch mark(s)\n", - info->ife->id, skipped); - - if (restore_fown(tmp, info->ife->fown)) - close_safe(&tmp); - - *new_fd = tmp; - return tmp < 0 ? -1 : 0; - } - list_for_each_entry(wd_info, &info->marks, list) { pr_info("\tRestore 0x%x wd for %#08x\n", wd_info->iwe->wd, wd_info->iwe->id); if (restore_one_inotify(tmp, wd_info)) { diff --git a/criu/criu/pie/restorer.c b/criu/criu/pie/restorer.c index 94355aa4d..8776bd2b0 100644 --- a/criu/criu/pie/restorer.c +++ b/criu/criu/pie/restorer.c @@ -763,16 +763,8 @@ __visible long __export_restore_thread(struct thread_restore_args *args) int ret; if (my_pid != args->pid) { - if (args->ta && args->ta->tfork_active) { - pr_info("tfork: accepting fresh thread tid %d instead of dumped tid %d\n", - my_pid, args->pid); - args->pid = my_pid; - if (args->ns_level > 0) - args->tid_in_ns[args->ns_level - 1] = my_pid; - } else { - pr_err("Thread pid mismatch %d/%d\n", my_pid, args->pid); - goto core_restore_end; - } + pr_err("Thread pid mismatch %d/%d\n", my_pid, args->pid); + goto core_restore_end; } /* restore original shadow stack */ @@ -2495,22 +2487,15 @@ __visible long __export_restore_task(struct task_restore_args *args) c_args.set_tid_size = thread_args[i].ns_level; if (args->tfork_active) { /* - * tfork currently restores non-leader threads with fresh TIDs. - * Reusing dumped TIDs collides with the synthetic per-copy restore - * helper in nested PID namespaces; process IDs remain restored exactly. - * - * This can leave userspace thread-ID caches stale in the clone: - * robust/errorcheck mutex owner futex words, cached gettid values - * in TLS/TCB, and pthread_join targets derived from old pthread_t - * values may not describe the clone's fresh worker TIDs. + * Preserve the TID visible in the clone's innermost PID namespace. + * Outer namespace TIDs are allocated by the kernel so concurrent + * copy helpers cannot collide with each other on the host. */ - pr_debug("tfork: restore thread pid=%d with fresh tid, set_tid_size %d -> 0 tids=%d/%d\n", + pr_debug("tfork: restore thread pid=%d with innermost tid=%d, set_tid_size %d -> 1\n", thread_args[i].pid, - thread_args[i].ns_level, thread_args[i].tid_in_ns[0], - thread_args[i].ns_level > 1 ? thread_args[i].tid_in_ns[1] : -1); - c_args.set_tid = 0; - c_args.set_tid_size = 0; + thread_args[i].ns_level); + c_args.set_tid_size = 1; } /* The kernel does stack + stack_size. */ c_args.stack = new_sp - RESTORE_STACK_SIZE; @@ -2541,13 +2526,6 @@ __visible long __export_restore_task(struct task_restore_args *args) RUN_CLONE_RESTORE_FN(ret, clone_flags, new_sp, parent_tid, thread_args, args->clone_restore_fn); } - if (args->tfork_active && ret > 0 && ret != thread_args[i].pid) { - pr_info("tfork: thread tid remapped %d -> %ld\n", - thread_args[i].pid, ret); - thread_args[i].pid = ret; - if (thread_args[i].ns_level > 0) - thread_args[i].tid_in_ns[thread_args[i].ns_level - 1] = ret; - } if (ret != thread_args[i].pid) { pr_err("Unable to create a thread: %ld expected=%d ns_level=%d tids=%d/%d/%d/%d tfork=%d\n", ret, thread_args[i].pid, diff --git a/criu/criu/pstree.c b/criu/criu/pstree.c index 0750792f0..1b626bc6b 100644 --- a/criu/criu/pstree.c +++ b/criu/criu/pstree.c @@ -1049,6 +1049,7 @@ static int read_one_pstree_item(PstreeEntry *e) list_del_init(&pi->sibling); if (pid_inserted) pstree_remove_pid_if_linked(pi->pid); + pi->pid->state = TASK_UNDEF; if (threads_allocated) { xfree(pi->threads); pi->threads = NULL; diff --git a/criu/lib/pycriu/images/images.py b/criu/lib/pycriu/images/images.py index 927eee972..37961bb53 100644 --- a/criu/lib/pycriu/images/images.py +++ b/criu/lib/pycriu/images/images.py @@ -43,6 +43,8 @@ import os import array +from google.protobuf.message import DecodeError + from . import magic from . import pb from . import pb2dict @@ -190,6 +192,94 @@ def count(self, f): return entries +class pstree_handler: + """Read both legacy per-task and current file-level PSTREE images.""" + + @staticmethod + def _read_payload(f): + header = f.read(4) + if not header: + return None + if len(header) != 4: + raise ValueError("truncated PSTREE entry header") + + size, = struct.unpack('i', header) + if size < 0: + raise ValueError("negative PSTREE entry size") + + payload = f.read(size) + if len(payload) != size: + raise ValueError("truncated PSTREE entry payload") + return payload + + @staticmethod + def _parse(payload, message_type): + message = message_type() + try: + message.ParseFromString(payload) + except DecodeError: + return None + return message if message.IsInitialized() else None + + def load(self, f, pretty=False, no_payload=False): + payload = self._read_payload(f) + if payload is None: + return [] + + legacy = self._parse(payload, pb.pstree_entry) + if legacy is not None: + entries = [legacy] + while True: + payload = self._read_payload(f) + if payload is None: + break + entry = self._parse(payload, pb.pstree_entry) + if entry is None: + raise ValueError("invalid legacy PSTREE entry") + entries.append(entry) + return [pb2dict.pb2dict(entry, pretty) for entry in entries] + + entry = self._parse(payload, pb.pstree_file_entry) + if entry is None: + raise ValueError("invalid PSTREE entry") + if self._read_payload(f) is not None: + raise ValueError("file-level PSTREE image has multiple entries") + + return [pb2dict.pb2dict(entry, pretty)] + + def loads(self, data, pretty=False): + return self.load(io.BytesIO(data), pretty) + + def dump(self, entries, f): + if not entries: + return + + file_level = ('tree' in entries[0] or + 'ns_max_pids' in entries[0]) + if file_level and len(entries) != 1: + raise ValueError("file-level PSTREE image requires one entry") + + message_type = (pb.pstree_file_entry if file_level + else pb.pstree_entry) + for entry in entries: + message = message_type() + pb2dict.dict2pb(entry, message) + payload = message.SerializeToString() + f.write(struct.pack('i', len(payload))) + f.write(payload) + + def dumps(self, entries): + f = io.BytesIO() + self.dump(entries, f) + return f.getvalue() + + def count(self, f): + entries = 0 + while self._read_payload(f) is not None: + entries += 1 + return entries + + # Special handler for pagemap.img class pagemap_handler: """ @@ -502,7 +592,7 @@ def skip(self, f, pbuff): tcp_stream_extra_handler()), 'STATS': entry_handler(pb.stats_entry), 'PAGEMAP': pagemap_handler(), # Special one - 'PSTREE': entry_handler(pb.pstree_file_entry), + 'PSTREE': pstree_handler(), 'REG_FILES': entry_handler(pb.reg_file_entry), 'NS_FILES': entry_handler(pb.ns_file_entry), 'EVENTFD_FILE': entry_handler(pb.eventfd_file_entry), diff --git a/criu/test/others/pycriu/Makefile b/criu/test/others/pycriu/Makefile index b6e3b4814..d6bae9022 100644 --- a/criu/test/others/pycriu/Makefile +++ b/criu/test/others/pycriu/Makefile @@ -13,7 +13,7 @@ CRIU_SOCKET := $(BUILD_DIR)/$(SOCKET_NAME) STATUS_FIFO := $(BUILD_DIR)/startup.status STATUS_FD := 200 -run: start +run: pstree-compat start cleanup() { $(MAKE) --no-print-directory stop || true; } trap cleanup EXIT INT TERM "$(PYTHON)" test_check.py @@ -21,6 +21,9 @@ run: start "$(PYTHON)" test_check_images_dir.py "$(PYTHON)" test_check_work_dir_fd.py +pstree-compat: + "$(PYTHON)" test_pstree_compat.py + start: mkdir -p "$(BUILD_DIR)" if [ -s "$(PIDFILE)" ] && kill -0 "$$(cat "$(PIDFILE)")" 2>/dev/null; then @@ -60,4 +63,4 @@ clean: fi rm -rf "$(BUILD_DIR)" -.PHONY: start stop clean run \ No newline at end of file +.PHONY: start stop clean run pstree-compat diff --git a/criu/test/others/pycriu/test_pstree_compat.py b/criu/test/others/pycriu/test_pstree_compat.py new file mode 100644 index 000000000..5cda76e13 --- /dev/null +++ b/criu/test/others/pycriu/test_pstree_compat.py @@ -0,0 +1,65 @@ +#!/usr/bin/env python3 +import io +import os +import struct +import sys + +SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) +LIB_DIR = os.path.normpath(os.path.join(SCRIPT_DIR, "../../../lib")) +if LIB_DIR not in sys.path: + sys.path.insert(0, LIB_DIR) + +from pycriu.images import images, pb # noqa: E402 + + +def encode(*messages): + output = io.BytesIO() + for message in messages: + payload = message.SerializeToString() + output.write(struct.pack('i', len(payload))) + output.write(payload) + return output.getvalue() + + +def task(message, realpid, localpid, uid): + message.realpid = realpid + message.ppid = 0 + message.pgid = localpid + message.sid = localpid + message.nsid = 7 + message.localpid = localpid + message.uid = uid + + +def main(): + handler = images.handlers['PSTREE'] + + old_first = pb.pstree_entry() + task(old_first, 1001, 1, 11) + old_second = pb.pstree_entry() + task(old_second, 1002, 2, 12) + old_blob = encode(old_first, old_second) + old_entries = handler.loads(old_blob) + assert [entry['realpid'] for entry in old_entries] == [1001, 1002] + assert handler.loads(handler.dumps(old_entries)) == old_entries + assert handler.count(io.BytesIO(old_blob)) == 2 + + current = pb.pstree_file_entry() + ns_max = current.ns_max_pids.add() + ns_max.ns_id = 7 + ns_max.pid_max = 2 + task(current.tree.add(), 1001, 1, 11) + task(current.tree.add(), 1002, 2, 12) + current_blob = encode(current) + current_entries = handler.loads(current_blob) + assert len(current_entries) == 1 + assert [entry['realpid'] for entry in current_entries[0]['tree']] == [1001, 1002] + assert handler.loads(handler.dumps(current_entries)) == current_entries + assert handler.count(io.BytesIO(current_blob)) == 1 + + print("PASS") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) From fec9257af5ee87e15506276d73e1735fd3880f13 Mon Sep 17 00:00:00 2001 From: yiying-zhang Date: Mon, 20 Jul 2026 11:24:32 -0700 Subject: [PATCH 44/53] Harden tfork review follow-ups --- criu/criu/pie/restorer.c | 2 +- criu/lib/pycriu/images/images.py | 4 ++++ criu/test/others/pycriu/Makefile | 3 ++- 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/criu/criu/pie/restorer.c b/criu/criu/pie/restorer.c index 8776bd2b0..a63dbdd62 100644 --- a/criu/criu/pie/restorer.c +++ b/criu/criu/pie/restorer.c @@ -2485,7 +2485,7 @@ __visible long __export_restore_task(struct task_restore_args *args) c_args.set_tid = ptr_to_u64(thread_args[i].tid_in_ns); c_args.flags = clone_flags; c_args.set_tid_size = thread_args[i].ns_level; - if (args->tfork_active) { + if (args->tfork_active && thread_args[i].ns_level > 0) { /* * Preserve the TID visible in the clone's innermost PID namespace. * Outer namespace TIDs are allocated by the kernel so concurrent diff --git a/criu/lib/pycriu/images/images.py b/criu/lib/pycriu/images/images.py index 37961bb53..3d2e02777 100644 --- a/criu/lib/pycriu/images/images.py +++ b/criu/lib/pycriu/images/images.py @@ -222,10 +222,14 @@ def _parse(payload, message_type): return message if message.IsInitialized() else None def load(self, f, pretty=False, no_payload=False): + # PSTREE has no out-of-band EXTRA data, so no_payload has no effect. payload = self._read_payload(f) if payload is None: return [] + # Format detection intentionally relies on pstree_entry being proto2 + # with required fields. A file-level payload may parse as that message, + # but it cannot be initialized because its wire fields have other types. legacy = self._parse(payload, pb.pstree_entry) if legacy is not None: entries = [legacy] diff --git a/criu/test/others/pycriu/Makefile b/criu/test/others/pycriu/Makefile index d6bae9022..369ac5667 100644 --- a/criu/test/others/pycriu/Makefile +++ b/criu/test/others/pycriu/Makefile @@ -13,7 +13,8 @@ CRIU_SOCKET := $(BUILD_DIR)/$(SOCKET_NAME) STATUS_FIFO := $(BUILD_DIR)/startup.status STATUS_FD := 200 -run: pstree-compat start +run: pstree-compat + $(MAKE) --no-print-directory start cleanup() { $(MAKE) --no-print-directory stop || true; } trap cleanup EXIT INT TERM "$(PYTHON)" test_check.py From 64e913f53930dfb1766dc0036c092dbda1140b7b Mon Sep 17 00:00:00 2001 From: yiying-zhang Date: Tue, 21 Jul 2026 18:27:58 -0700 Subject: [PATCH 45/53] Rewrite tclone setup for Gensee Crate --- README.md | 470 +++++++++++++++++++++++++++++++++++++++++------------- 1 file changed, 357 insertions(+), 113 deletions(-) diff --git a/README.md b/README.md index 0b4845d72..438379f8b 100644 --- a/README.md +++ b/README.md @@ -1,35 +1,56 @@ -# Tclone: Low-Latency Full-Workspace Forking for AI Agents +# Tclone Runtime for Gensee Crate -Tclone is a workspace-versioning substrate built for computer-use agents. Tclone provides a versioned personal workspace that can be quickly forked, snapshotted, and rolledback. -It forks a live, running container in milliseconds: clones share -memory and file-cache pages copy-on-write, so a branch is runnable -instantly while its durable checkpoint streams to disk in the background — -letting computer-use agents explore many action paths in parallel. +This repository is Gensee's fork of tclone. It provides the patched Linux +kernel, CRIU, crun, conmon, and Podman components used by +[`gensee-crate`](https://github.com/GenseeAI/gensee-crate) for fast, live +container forks. -Please find more details in our [paper](https://arxiv.org/abs/2605.17320) and -[blog post](https://mlsys.wuklab.io/posts/tclone/). - -image +Gensee owns the container lifecycle. After this host is prepared, use +`gensee run --runtime tclone` to launch an agent. Do not manually start a +webtop source container or run `podman container clone`; Gensee creates, +forks, compares, merges, promotes, and discards the containers on behalf of +the agent after the required user approvals. ## Components | Directory | Role | |---|---| -| [`criu/`](criu/) | `criu tfork` + `vma_cherrypick` / `capbypass` kernel modules + libcriu | -| [`crun/`](crun/) | `crun tfork` OCI runtime verb | -| [`conmon/`](conmon/) | `--tfork` flag | -| [`podman/`](podman/) | `container clone --live` | -| [`linux-pagecache-cow/`](linux-pagecache-cow/) | Linux kernel with a CoW page-cache (`filecow`) layer | -| [`ubuntu-img/`](ubuntu-img/) | sample webtop image (optional) | -| [`agents/`](agents/) | OSWorld evaluation harness | +| [`linux-pagecache-cow/`](linux-pagecache-cow/) | Linux kernel with the page-cache CoW support used by tclone | +| [`criu/`](criu/) | `criu tfork`, libcriu, and the tclone kernel modules | +| [`crun/`](crun/) | `crun tfork` OCI runtime implementation | +| [`conmon/`](conmon/) | tclone-aware conmon with the `--tfork` flag | +| [`podman/`](podman/) | Podman with `container clone --live` | +| [`podman-tfork.sh`](podman-tfork.sh) | Wrapper that selects the in-tree Podman, conmon, crun, and libcriu | +| [`ubuntu-img/`](ubuntu-img/) | Source for the tmux-capable container image used by Gensee | + +## Requirements + +- Ubuntu on x86_64 with root access. +- A dedicated btrfs filesystem for rootful Podman's graphroot. +- Enough free space for the kernel build, container image, and fork overlays. +- A host installation of the agent CLI you will launch, such as Codex. +- `tmux` on the host and inside the container image for automatic source/fork + pane management. + +Tclone is currently rootful, btrfs-only, and amd64-only. + +## 1. Clone this repository + +The repository's default branch contains the Gensee integration and the merged +tclone stability fixes. + +```bash +git clone --recurse-submodules https://github.com/GenseeAI/os4agent.git +cd os4agent +git submodule update --init --recursive +``` -## Prerequisites +Run all remaining tclone commands from this repository root unless a step says +otherwise. -- Ubuntu x86_64, root access. -- btrfs filesystem at podman's graphroot - (`findmnt -no FSTYPE /var/lib/containers/storage` → `btrfs`). +## 2. Configure the required sysctls -## Required sysctls +Apply the settings immediately: ```bash sudo sysctl -w kernel.io_uring_disabled=2 @@ -38,151 +59,374 @@ sudo sysctl -w fs.inotify.max_user_instances=524288 sudo sysctl -w kernel.apparmor_restrict_unprivileged_unconfined=0 ``` -Persist by appending to `/etc/sysctl.d/90-tfork.conf`. +Persist them across reboots: + +```bash +sudo tee /etc/sysctl.d/90-tfork.conf >/dev/null <<'EOF' +kernel.io_uring_disabled=2 +fs.nr_open=1048576 +fs.inotify.max_user_instances=524288 +kernel.apparmor_restrict_unprivileged_unconfined=0 +EOF + +sudo sysctl --system +``` + +## 3. Configure rootful Podman storage on btrfs -## Fetch submodules +Install Podman and the btrfs tools first: ```bash -git submodule update --init --recursive +sudo apt update +sudo apt install -y btrfs-progs podman +``` + +On a new machine, configure storage before the first rootful Podman command. +Mount a dedicated btrfs filesystem and point rootful Podman at a directory on +it. For example, after mounting btrfs at `/mnt/btrfs`: + +```toml +# /etc/containers/storage.conf +[storage] +driver = "btrfs" +runroot = "/run/containers/storage" +graphroot = "/mnt/btrfs/podman" ``` -## Build (in this order, all as root) +Do not change an existing Podman graphroot without first accounting for its +containers and images. Formatting and mounting the btrfs device is intentionally +left to the host administrator. + +If rootful Podman was already initialized, inspect its current store before +changing anything: ```bash -sudo apt install podman # podman has some other components we won't modify - # this makes installing components much simpler -sudo ./criu/build.sh # libcriu + kernel modules -sudo ./crun/build.sh # links against in-tree libcriu -sudo ./conmon/build.sh # --tfork flag -sudo ./podman/build.sh # container clone --live +sudo podman info --format '{{.Store.GraphRoot}} {{.Store.GraphDriverName}}' +GRAPHROOT="$(sudo podman info --format '{{.Store.GraphRoot}}')" +findmnt -T "$GRAPHROOT" ``` -## Custom kernel (page-cache CoW) +The reported driver and filesystem must both be `btrfs`. Step 6 verifies the +same store through the newly built tclone wrapper. + +## 4. Build and boot the tclone kernel -[`linux-pagecache-cow/`](linux-pagecache-cow/) is a modified Linux that -adds a copy-on-write page-cache (`filecow`) layer shared across -`address_space`s when one btrfs subvol is a snapshot of another. With this -kernel running, the default `btrfs subvolume snapshot` rootfs path of -`--live` clones shares its file pages with the source through the kernel -CoW path. +Install common Ubuntu kernel-build dependencies: -```sh -# install your distro's kernel build dependencies (gcc, make, bison, flex, -# libelf-dev, libssl-dev, bc, etc.) +```bash +sudo apt update +sudo apt install -y \ + build-essential bc bison flex cpio dwarves fakeroot \ + libelf-dev libncurses-dev libssl-dev rsync +``` + +Build and install the page-cache CoW kernel: + +```bash cd linux-pagecache-cow cp config .config ./build_kernel.sh build -sudo ./build_kernel.sh install # then reboot into the pgcachecow kernel +sudo ./build_kernel.sh install +sudo reboot ``` -Verify after reboot: +After reconnecting, return to the repository and verify that the new kernel is +running: -```sh +```bash +cd ~/os4agent uname -r -cat /proc/filecow_stats # ra_unbounded_calls / ra_order_calls grow on fan-out +cat /proc/filecow_stats ``` -## Run podman +`uname -r` should end in `-pgcachecow`, and `/proc/filecow_stats` must exist. +Build the userspace stack only after booting this kernel so the tclone kernel +modules are compiled against the running kernel. -Run podman through [`./podman-tfork.sh`](./podman-tfork.sh) — a wrapper that -points the in-tree podman at the in-tree conmon, crun, and libcriu without -touching any system files. It writes a `CONTAINERS_CONF` (in-tree conmon + -crun, `cgroup_manager = "cgroupfs"`, `log_driver = "k8s-file"`), sets -`LD_LIBRARY_PATH` for libcriu, and exports `OS4AGENT_CRUN` / `OS4AGENT_CONMON`. -All other arguments pass through to podman. +## 5. Build the tclone userspace stack -## Verify +Build the components in this order: -Wiring: +```bash +cd ~/os4agent + +sudo ./criu/build.sh +sudo ./crun/build.sh +sudo ./conmon/build.sh +sudo ./podman/build.sh +``` + +`criu/build.sh` builds libcriu and loads these modules: + +- `vma_cherrypick` +- `criu_capbypass` +- `pkey_state` +- `reparent_task` + +Stop existing tclone containers before rebuilding CRIU. The build fails closed +if an old module is still in use and cannot be unloaded. + +Always invoke Podman through [`podman-tfork.sh`](podman-tfork.sh). The wrapper +selects the matching in-tree binaries, sets `LD_LIBRARY_PATH`, uses +`cgroup_manager = "cgroupfs"`, and preserves the rootful Podman store expected +by Gensee. + +## 6. Verify the tclone stack + +Check the runtime wiring: ```bash -sudo ./podman-tfork.sh info | grep -A2 -E "conmon:|ociRuntime:|cgroupManager:|graphStatus:|graphRoot:|kernel:|logDriver:" +sudo ./podman-tfork.sh info | + grep -A2 -E 'conmon:|ociRuntime:|cgroupManager:|graphStatus:|graphRoot:|kernel:|logDriver:' ``` -`conmon` and `ociRuntime` should point at the in-tree binaries (the -`ociRuntime` version reads `criu_tfork_*`); `cgroupManager` = `cgroupfs`, -`logDriver` = `k8s-file`, `graphRoot` on a btrfs mount, `kernel` the -page-cache-CoW build. +The output should show: -The tfork pieces are live: +- the in-tree `conmon/bin/conmon`; +- the in-tree `crun/crun`; +- `cgroupManager: cgroupfs`; +- `logDriver: k8s-file`; +- a btrfs graphroot; and +- the `-pgcachecow` kernel. + +Verify the individual tfork pieces: ```bash -# all 4 criu kernel modules loaded (criu/build.sh insmods these): lsmod | grep -E 'vma_cherrypick|criu_capbypass|pkey_state|reparent_task' -# tfork verbs/flags present in the in-tree binaries: -LD_LIBRARY_PATH=$(pwd)/criu/lib/c ./crun/crun --help | grep tfork # crun tfork verb -./conmon/bin/conmon --help 2>&1 | grep -- --tfork # conmon --tfork -sudo ./podman-tfork.sh container clone --help | grep -- --live # podman --live +LD_LIBRARY_PATH="$PWD/criu/lib/c" \ + ./crun/crun --help | grep tfork -# page-cache-CoW kernel running: +./conmon/bin/conmon --help 2>&1 | grep -- --tfork +sudo ./podman-tfork.sh container clone --help | grep -- --live cat /proc/filecow_stats ``` -## Start os-world container +Do not continue to Gensee until these checks pass. + +## 7. Prepare the Gensee container image + +Pull the default image through the rootful tclone wrapper. Pulling it with +ordinary rootless Podman puts it in a different image store and Gensee will not +find it. + ```bash -sudo ./podman-tfork.sh run -d \ - --name webtop-src \ - --log-driver=k8s-file \ - --security-opt seccomp=unconfined \ - --security-opt apparmor=unconfined \ - --shm-size=2g \ - --tmpfs /config:size=512m \ - --tmpfs /tmp:size=1g \ - --tmpfs /run:size=256m \ - -e PUID=1000 -e PGID=1000 -e TZ=Etc/UTC \ - -e CUSTOM_USER=admin -e PASSWORD=changeme \ - -p 3101:3001 \ - ghcr.io/wuklab/webtop:ubuntu-kde +sudo ./podman-tfork.sh pull ghcr.io/wuklab/webtop:ubuntu-kde +sudo ./podman-tfork.sh image inspect \ + ghcr.io/wuklab/webtop:ubuntu-kde >/dev/null ``` -## Clone 4 +To build the image locally instead: + ```bash -sudo ./podman-tfork.sh container clone --live --copies=4 \ - --persistent=async \ - --tfork-tcp-close --tfork-ghost-limit=$((64 << 20)) \ - --name webtop-fan webtop-src +sudo ./podman-tfork.sh build \ + -t gensee-tclone-webtop:tmux \ + ./ubuntu-img ``` -## Live-clone flags & environment +If you build locally, set `GENSEE_TCLONE_IMAGE` to +`gensee-tclone-webtop:tmux`. Otherwise, use the fully qualified GHCR name to +avoid Podman's short-name resolution error. -Flags below attach to `podman container clone --live`. Run -`./podman-tfork.sh --tfork-help` for the same reference at the shell. +Gensee creates and live-clones the source container itself. There is no manual +source-container or Podman clone step. -| Flag | Default | Effect | -|---|---|---| -| `--live` | off | engage the tfork path; required to clone live. | -| `--copies N` | 1 | fan out to N parallel clones from one source freeze. | -| `--persistent[=async\|sync]` | off | persist source memory to clone's image-dir (`pages-*.img`). Bare `--persistent` → async; `=sync` flushes before clone returns. | -| `--tfork-ghost-limit BYTES` | 256 MiB | raise CRIU's per-dump ghost-file cap above its 1 MiB default. GUI apps (chromium, firefox, KDE) keep multi-MiB unlinked tmp files mmap'd. `0` falls back to CRIU's default. | -| `--tfork-tcp-close[=BOOL]` | true | dump ESTABLISHED TCP sockets as closed (clones with fresh netns reconnect cleanly). `=false` reverts to CRIU's refuse-on-established. | +## 8. Install Gensee Crate -## Clean up +Install the Linux prerequisites and Rust: -If `podman container clone --live` hangs in Phase A (CRIU's cgroup walk) -and eventually fails with `timeout waiting for N tfork.pid* files`, the -cgroup tree has likely accumulated empty zombie cgroups from prior crashed -clones. CRIU enumerates every cgroup the source process belongs to, and a -few hundred thousand empty entries push past the 60s podman timeout. +```bash +sudo apt update +sudo apt install -y \ + build-essential curl git jq libssl-dev nftables pkg-config tmux + +curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | + sh -s -- -y +source "$HOME/.cargo/env" +``` + +Build and install Gensee: ```bash +cd ~ +git clone https://github.com/GenseeAI/gensee-crate.git +cd gensee-crate +cargo install --path crate/gensee-crate-cli --force +``` + +Configure Gensee's Codex hooks: + +```bash +export GENSEE_HOME="${GENSEE_HOME:-$HOME/.gensee}" +gensee setup codex --yes --gensee-home "$GENSEE_HOME" +``` + +Open `/hooks` in Codex once and trust the installed Gensee hook command. + +## 9. Configure the tclone runtime + +Add these exports to the host shell profile: + +```bash +export GENSEE_HOME="${GENSEE_HOME:-$HOME/.gensee}" +export GENSEE_TCLONE_PODMAN="$HOME/os4agent/podman-tfork.sh" +export GENSEE_TCLONE_IMAGE="ghcr.io/wuklab/webtop:ubuntu-kde" +export GENSEE_TCLONE_READY_TIMEOUT_SECS=120 +``` + +If Node and the agent CLI come from NVM, also export: + +```bash +export GENSEE_TCLONE_NODE_ROOT="$HOME/.nvm" +export GENSEE_TCLONE_NODE_BIN="$(dirname "$(command -v node)")" +``` + +Gensee copies or mounts the detected host agent configuration into the source +container. The image must contain `tmux`; the default image does. + +## 10. Launch Codex through Gensee + +Start a host tmux session so Gensee can automatically open and close source and +fork panes: + +```bash +tmux new -s gensee +``` + +Inside tmux, enter the project you want Codex to edit and launch it: + +```bash +cd /path/to/your/project + +GENSEE_BIN="$(command -v gensee)" + +sudo env \ + "PATH=$PATH" \ + "HOME=$HOME" \ + "TERM=$TERM" \ + "TMUX=$TMUX" \ + "GENSEE_HOME=$GENSEE_HOME" \ + "GENSEE_TCLONE_PODMAN=$GENSEE_TCLONE_PODMAN" \ + "GENSEE_TCLONE_IMAGE=$GENSEE_TCLONE_IMAGE" \ + "GENSEE_TCLONE_READY_TIMEOUT_SECS=$GENSEE_TCLONE_READY_TIMEOUT_SECS" \ + "$GENSEE_BIN" run --runtime tclone -- codex +``` + +If you use the optional NVM variables, include them in the `sudo env` command: + +```bash +"GENSEE_TCLONE_NODE_ROOT=$GENSEE_TCLONE_NODE_ROOT" \ +"GENSEE_TCLONE_NODE_BIN=$GENSEE_TCLONE_NODE_BIN" \ +``` + +The launcher prints the source run ID and starts Codex in a tmux-backed source +container. Normal Gensee/Codex operation is chat-driven: Codex asks before +creating a fork, Gensee opens the fork pane, the work continues in the fork, +and Codex summarizes the result before offering merge, promote, or discard. +Users should not type Gensee lifecycle commands manually. + +## 11. Smoke-test the mediated fork workflow + +In the source Codex chat, submit a deliberately small fork-worthy request: + +```text +Make a tiny test strategy smoke test: create fork-smoke-1.txt containing +"first fork". Run only git diff --check. +``` + +Expected behavior: + +1. Codex asks permission to create a fork. +2. After approval, Gensee creates and opens the fork pane. +3. The cloned Codex session continues the original request in the fork. +4. The fork reports its changed files and test result. +5. Codex asks whether to merge, promote, or discard. +6. After explicit approval, Gensee performs the selected action and returns + focus to the source. + +For parallel-fork testing, ask Codex to try two materially different approaches. +Gensee keeps the source pane on the left, stacks fork panes on the right, and +returns the comparison and group-level lifecycle choice to the source Codex. + +## Troubleshooting + +### Image not found or short-name resolution failed + +Pull through the same rootful wrapper Gensee uses and use the fully qualified +image name: + +```bash +sudo "$GENSEE_TCLONE_PODMAN" pull \ + ghcr.io/wuklab/webtop:ubuntu-kde +export GENSEE_TCLONE_IMAGE=ghcr.io/wuklab/webtop:ubuntu-kde +``` + +### Gensee reports that a container is missing + +Use the same `sudo`, `GENSEE_HOME`, and `GENSEE_TCLONE_PODMAN` values for every +Gensee tclone invocation. Rootless Podman and the rootful wrapper use different +stores. + +### Clone readiness times out + +Increase the host-side timeout before launching Gensee: + +```bash +export GENSEE_TCLONE_READY_TIMEOUT_SECS=120 +export PODMAN_TFORK_CLONE_READY_TIMEOUT_SECS=120 +``` + +If a clone hangs while CRIU walks the source cgroups and reports a timeout +waiting for `tfork.pid*` files, remove stopped tclone containers and then run: + +```bash +cd ~/os4agent sudo ./tfork-cgroup-cleanup.sh ``` -## Usage with Agent-S3 +### No space left on device + +Ask Gensee to delete tracked tclone runs before removing Podman storage: + +```bash +GENSEE_BIN="$(command -v gensee)" + +sudo env \ + "PATH=$PATH" \ + "HOME=$HOME" \ + "GENSEE_HOME=$GENSEE_HOME" \ + "GENSEE_TCLONE_PODMAN=$GENSEE_TCLONE_PODMAN" \ + "GENSEE_TCLONE_IMAGE=$GENSEE_TCLONE_IMAGE" \ + "$GENSEE_BIN" run delete --all + +sudo "$GENSEE_TCLONE_PODMAN" system df +``` + +Do not delete the graphroot manually while containers or tclone processes are +running. + +### Rebuilding after changing CRIU or the kernel modules + +Stop active tclone containers first, then rerun `sudo ./criu/build.sh`. The +script intentionally refuses to continue if a loaded module cannot be removed. -check [agent-s README](agents/agent-s/README.md) +## Security and limitations -## Limitations +- The tclone runtime is not currently a confinement boundary. Gensee source + containers run with unconfined seccomp and AppArmor settings required by the + live-clone implementation. +- Agent configuration and credentials copied into the source are inherited by + its forks. +- Tclone currently requires rootful Podman, btrfs, amd64, and the custom + page-cache CoW kernel. +- The page-cache CoW kernel currently has a known memory leak. -- btrfs only -- rootful only -- amd64 only -- linux page-cache CoW currently has a memory leak that will be fixed +See +[`gensee-crate/docs/tclone.md`](https://github.com/GenseeAI/gensee-crate/blob/main/docs/tclone.md) +for Gensee's fork, comparison, merge, promotion, and discard behavior. ## License -This repository contains multiple components under their respective -licenses (GPL-2.0, LGPL-2.1, Apache-2.0, GPL-3.0). The license of a given -file is the one of the directory it lives in; see the `LICENSE`/`COPYING` -file there. +This repository contains multiple components under their respective licenses +(GPL-2.0, LGPL-2.1, Apache-2.0, and GPL-3.0). The license of a given file is the +one in that component's `LICENSE` or `COPYING` file. From 90409b1413aa85a84b28cbae69b561db385d8e54 Mon Sep 17 00:00:00 2001 From: yiying-zhang Date: Fri, 24 Jul 2026 15:38:30 -0700 Subject: [PATCH 46/53] Clarify Gensee tclone setup --- README.md | 114 ++++++++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 103 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 438379f8b..f465c6ce5 100644 --- a/README.md +++ b/README.md @@ -106,8 +106,32 @@ GRAPHROOT="$(sudo podman info --format '{{.Store.GraphRoot}}')" findmnt -T "$GRAPHROOT" ``` -The reported driver and filesystem must both be `btrfs`. Step 6 verifies the -same store through the newly built tclone wrapper. +The reported driver and filesystem must both be `btrfs`. An `overlay` driver +stored on a btrfs filesystem is still overlay storage, and tclone snapshots fail +against it with errors such as `Not a Btrfs filesystem`. + +If you cannot change the host-wide rootful store, create a dedicated storage +configuration and pass it to every tclone Podman and Gensee command: + +```bash +export GENSEE_HOME="${GENSEE_HOME:-$HOME/.gensee}" +export CONTAINERS_STORAGE_CONF="$GENSEE_HOME/tclone-btrfs-storage.conf" +mkdir -p "$GENSEE_HOME" /mnt/btrfs/tclone-root /mnt/btrfs/tclone-run + +cat >"$CONTAINERS_STORAGE_CONF" <<'EOF' +[storage] +driver = "btrfs" +runroot = "/mnt/btrfs/tclone-run" +graphroot = "/mnt/btrfs/tclone-root" +EOF + +sudo env "CONTAINERS_STORAGE_CONF=$CONTAINERS_STORAGE_CONF" \ + podman info --format '{{.Store.GraphRoot}} {{.Store.GraphDriverName}}' +``` + +Images are scoped to the selected store. If `CONTAINERS_STORAGE_CONF` is set +when Gensee runs, use the same value when pulling or building the image. Step 6 +verifies the same store through the newly built tclone wrapper. ## 4. Build and boot the tclone kernel @@ -166,6 +190,16 @@ sudo ./podman/build.sh Stop existing tclone containers before rebuilding CRIU. The build fails closed if an old module is still in use and cannot be unloaded. +If `insmod` reports `Invalid module format`, the modules were built for a +different kernel than the one currently running. Reboot into the +`-pgcachecow` kernel, verify `uname -r`, then rerun `sudo ./criu/build.sh`. +You can inspect the expected kernel release with: + +```bash +modinfo criu/kernel_module/vma_cherrypick/vma_cherrypick.ko | grep vermagic +uname -r +``` + Always invoke Podman through [`podman-tfork.sh`](podman-tfork.sh). The wrapper selects the matching in-tree binaries, sets `LD_LIBRARY_PATH`, uses `cgroup_manager = "cgroupfs"`, and preserves the rootful Podman store expected @@ -176,7 +210,8 @@ by Gensee. Check the runtime wiring: ```bash -sudo ./podman-tfork.sh info | +sudo env "CONTAINERS_STORAGE_CONF=$CONTAINERS_STORAGE_CONF" \ + ./podman-tfork.sh info | grep -A2 -E 'conmon:|ociRuntime:|cgroupManager:|graphStatus:|graphRoot:|kernel:|logDriver:' ``` @@ -193,12 +228,14 @@ Verify the individual tfork pieces: ```bash lsmod | grep -E 'vma_cherrypick|criu_capbypass|pkey_state|reparent_task' +ls -l /dev/vma_cherrypick /dev/criu_capbypass /dev/reparent /dev/pkey_state LD_LIBRARY_PATH="$PWD/criu/lib/c" \ ./crun/crun --help | grep tfork ./conmon/bin/conmon --help 2>&1 | grep -- --tfork -sudo ./podman-tfork.sh container clone --help | grep -- --live +sudo env "CONTAINERS_STORAGE_CONF=$CONTAINERS_STORAGE_CONF" \ + ./podman-tfork.sh container clone --help | grep -- --live cat /proc/filecow_stats ``` @@ -211,15 +248,18 @@ ordinary rootless Podman puts it in a different image store and Gensee will not find it. ```bash -sudo ./podman-tfork.sh pull ghcr.io/wuklab/webtop:ubuntu-kde -sudo ./podman-tfork.sh image inspect \ +sudo env "CONTAINERS_STORAGE_CONF=$CONTAINERS_STORAGE_CONF" \ + ./podman-tfork.sh pull ghcr.io/wuklab/webtop:ubuntu-kde +sudo env "CONTAINERS_STORAGE_CONF=$CONTAINERS_STORAGE_CONF" \ + ./podman-tfork.sh image inspect \ ghcr.io/wuklab/webtop:ubuntu-kde >/dev/null ``` To build the image locally instead: ```bash -sudo ./podman-tfork.sh build \ +sudo env "CONTAINERS_STORAGE_CONF=$CONTAINERS_STORAGE_CONF" \ + ./podman-tfork.sh build \ -t gensee-tclone-webtop:tmux \ ./ubuntu-img ``` @@ -272,6 +312,10 @@ export GENSEE_HOME="${GENSEE_HOME:-$HOME/.gensee}" export GENSEE_TCLONE_PODMAN="$HOME/os4agent/podman-tfork.sh" export GENSEE_TCLONE_IMAGE="ghcr.io/wuklab/webtop:ubuntu-kde" export GENSEE_TCLONE_READY_TIMEOUT_SECS=120 +export GENSEE_TMP_ROOT="${GENSEE_TMP_ROOT:-/tmp}" +export TMPDIR="$GENSEE_TMP_ROOT" +# Include this only if you created the dedicated storage config in step 3. +# export CONTAINERS_STORAGE_CONF="$GENSEE_HOME/tclone-btrfs-storage.conf" ``` If Node and the agent CLI come from NVM, also export: @@ -281,8 +325,20 @@ export GENSEE_TCLONE_NODE_ROOT="$HOME/.nvm" export GENSEE_TCLONE_NODE_BIN="$(dirname "$(command -v node)")" ``` +Keep `GENSEE_TMP_ROOT` outside the workspace you will run agents in. If Gensee +stages inside the workspace, later launches can recursively copy the +`gensee-agent-guard` staging tree and fail with `File name too long`. + +Use the same sudo-preserving wrapper for every Gensee tclone command: + +```bash +alias gensee-tclone='sudo env "PATH=$PATH" "HOME=$HOME" "TERM=$TERM" "TMUX=$TMUX" "TMPDIR=$TMPDIR" "GENSEE_TMP_ROOT=$GENSEE_TMP_ROOT" "CONTAINERS_STORAGE_CONF=$CONTAINERS_STORAGE_CONF" "GENSEE_HOME=$GENSEE_HOME" "GENSEE_TCLONE_PODMAN=$GENSEE_TCLONE_PODMAN" "GENSEE_TCLONE_IMAGE=$GENSEE_TCLONE_IMAGE" "GENSEE_TCLONE_READY_TIMEOUT_SECS=$GENSEE_TCLONE_READY_TIMEOUT_SECS" gensee' +``` + Gensee copies or mounts the detected host agent configuration into the source -container. The image must contain `tmux`; the default image does. +container. The image must contain `tmux`; the default image does. If you rebuild +or reinstall Gensee, stop the old source and launch a fresh source so the +host-control process uses the new binary. ## 10. Launch Codex through Gensee @@ -305,6 +361,9 @@ sudo env \ "HOME=$HOME" \ "TERM=$TERM" \ "TMUX=$TMUX" \ + "TMPDIR=$TMPDIR" \ + "GENSEE_TMP_ROOT=$GENSEE_TMP_ROOT" \ + "CONTAINERS_STORAGE_CONF=$CONTAINERS_STORAGE_CONF" \ "GENSEE_HOME=$GENSEE_HOME" \ "GENSEE_TCLONE_PODMAN=$GENSEE_TCLONE_PODMAN" \ "GENSEE_TCLONE_IMAGE=$GENSEE_TCLONE_IMAGE" \ @@ -325,6 +384,13 @@ creating a fork, Gensee opens the fork pane, the work continues in the fork, and Codex summarizes the result before offering merge, promote, or discard. Users should not type Gensee lifecycle commands manually. +You can use the wrapper form instead: + +```bash +cd /path/to/your/project +gensee-tclone run --runtime tclone -- codex +``` + ## 11. Smoke-test the mediated fork workflow In the source Codex chat, submit a deliberately small fork-worthy request: @@ -356,7 +422,8 @@ Pull through the same rootful wrapper Gensee uses and use the fully qualified image name: ```bash -sudo "$GENSEE_TCLONE_PODMAN" pull \ +sudo env "CONTAINERS_STORAGE_CONF=$CONTAINERS_STORAGE_CONF" \ + "$GENSEE_TCLONE_PODMAN" pull \ ghcr.io/wuklab/webtop:ubuntu-kde export GENSEE_TCLONE_IMAGE=ghcr.io/wuklab/webtop:ubuntu-kde ``` @@ -364,8 +431,33 @@ export GENSEE_TCLONE_IMAGE=ghcr.io/wuklab/webtop:ubuntu-kde ### Gensee reports that a container is missing Use the same `sudo`, `GENSEE_HOME`, and `GENSEE_TCLONE_PODMAN` values for every -Gensee tclone invocation. Rootless Podman and the rootful wrapper use different -stores. +Gensee tclone invocation. Rootless Podman, rootful Podman without +`CONTAINERS_STORAGE_CONF`, and rootful Podman with `CONTAINERS_STORAGE_CONF` +can all use different stores. + +### Fork appears in `gensee run list` but no tmux pane opens + +The attach pane re-enters `gensee run attach`, so it needs the same +`GENSEE_HOME`, `GENSEE_TMP_ROOT`, `TMPDIR`, `CONTAINERS_STORAGE_CONF`, and +`GENSEE_TCLONE_PODMAN` environment as the original launch. Use the +`gensee-tclone` alias above for `run`, `list`, `fork`, `attach`, `send`, +`exec`, `merge`, `switch`, and cleanup. + +If this happens after rebuilding Gensee, launch a fresh source. Already-running +sources keep their old host-control process in memory. + +### `File name too long` during launch + +Set `GENSEE_TMP_ROOT` and `TMPDIR` to a directory outside the workspace, then +launch again. If a previous failed launch left a staging tree inside the +workspace, remove that generated `gensee-agent-guard` directory before retrying. + +### Kernel modules fail with `Invalid module format` + +The `.ko` files were built for a different kernel release than the booted +kernel. Reboot into the `-pgcachecow` kernel, run `uname -r`, rebuild with +`sudo ./criu/build.sh`, and verify that `/dev/vma_cherrypick`, +`/dev/criu_capbypass`, `/dev/reparent`, and `/dev/pkey_state` exist. ### Clone readiness times out From 23328852ea9a1b70ca5450a003b1de893d220e73 Mon Sep 17 00:00:00 2001 From: yiying-zhang Date: Fri, 24 Jul 2026 20:48:27 -0700 Subject: [PATCH 47/53] Fix tclone kernel and image setup docs --- README.md | 48 +++++++++++++---------------- linux-pagecache-cow/build_kernel.sh | 1 + ubuntu-img/Dockerfile | 1 + ubuntu-img/docker-compose.yml | 2 +- 4 files changed, 25 insertions(+), 27 deletions(-) diff --git a/README.md b/README.md index f465c6ce5..b85f397d8 100644 --- a/README.md +++ b/README.md @@ -243,30 +243,25 @@ Do not continue to Gensee until these checks pass. ## 7. Prepare the Gensee container image -Pull the default image through the rootful tclone wrapper. Pulling it with -ordinary rootless Podman puts it in a different image store and Gensee will not -find it. - -```bash -sudo env "CONTAINERS_STORAGE_CONF=$CONTAINERS_STORAGE_CONF" \ - ./podman-tfork.sh pull ghcr.io/wuklab/webtop:ubuntu-kde -sudo env "CONTAINERS_STORAGE_CONF=$CONTAINERS_STORAGE_CONF" \ - ./podman-tfork.sh image inspect \ - ghcr.io/wuklab/webtop:ubuntu-kde >/dev/null -``` - -To build the image locally instead: +Build the Gensee image locally through the rootful tclone wrapper. The upstream +webtop images do not include all packages Gensee's tclone workflow expects, +including `tmux`, so do not use them directly. ```bash sudo env "CONTAINERS_STORAGE_CONF=$CONTAINERS_STORAGE_CONF" \ ./podman-tfork.sh build \ - -t gensee-tclone-webtop:tmux \ + -t localhost/gensee-tclone-webtop:tmux \ ./ubuntu-img + +sudo env "CONTAINERS_STORAGE_CONF=$CONTAINERS_STORAGE_CONF" \ + ./podman-tfork.sh image inspect \ + localhost/gensee-tclone-webtop:tmux >/dev/null ``` -If you build locally, set `GENSEE_TCLONE_IMAGE` to -`gensee-tclone-webtop:tmux`. Otherwise, use the fully qualified GHCR name to -avoid Podman's short-name resolution error. +Use the fully qualified local image name +`localhost/gensee-tclone-webtop:tmux` for `GENSEE_TCLONE_IMAGE`. Building or +pulling with ordinary rootless Podman puts the image in a different image store +and Gensee will not find it. Gensee creates and live-clones the source container itself. There is no manual source-container or Podman clone step. @@ -310,7 +305,7 @@ Add these exports to the host shell profile: ```bash export GENSEE_HOME="${GENSEE_HOME:-$HOME/.gensee}" export GENSEE_TCLONE_PODMAN="$HOME/os4agent/podman-tfork.sh" -export GENSEE_TCLONE_IMAGE="ghcr.io/wuklab/webtop:ubuntu-kde" +export GENSEE_TCLONE_IMAGE="localhost/gensee-tclone-webtop:tmux" export GENSEE_TCLONE_READY_TIMEOUT_SECS=120 export GENSEE_TMP_ROOT="${GENSEE_TMP_ROOT:-/tmp}" export TMPDIR="$GENSEE_TMP_ROOT" @@ -336,9 +331,9 @@ alias gensee-tclone='sudo env "PATH=$PATH" "HOME=$HOME" "TERM=$TERM" "TMUX=$TMUX ``` Gensee copies or mounts the detected host agent configuration into the source -container. The image must contain `tmux`; the default image does. If you rebuild -or reinstall Gensee, stop the old source and launch a fresh source so the -host-control process uses the new binary. +container. The image must contain `tmux`; the local image built in step 7 does. +If you rebuild or reinstall Gensee, stop the old source and launch a fresh +source so the host-control process uses the new binary. ## 10. Launch Codex through Gensee @@ -418,14 +413,15 @@ returns the comparison and group-level lifecycle choice to the source Codex. ### Image not found or short-name resolution failed -Pull through the same rootful wrapper Gensee uses and use the fully qualified -image name: +Build through the same rootful wrapper Gensee uses and use the fully qualified +local image name: ```bash sudo env "CONTAINERS_STORAGE_CONF=$CONTAINERS_STORAGE_CONF" \ - "$GENSEE_TCLONE_PODMAN" pull \ - ghcr.io/wuklab/webtop:ubuntu-kde -export GENSEE_TCLONE_IMAGE=ghcr.io/wuklab/webtop:ubuntu-kde + "$GENSEE_TCLONE_PODMAN" build \ + -t localhost/gensee-tclone-webtop:tmux \ + "$HOME/os4agent/ubuntu-img" +export GENSEE_TCLONE_IMAGE=localhost/gensee-tclone-webtop:tmux ``` ### Gensee reports that a container is missing diff --git a/linux-pagecache-cow/build_kernel.sh b/linux-pagecache-cow/build_kernel.sh index c36ce0a59..94bbaeefd 100755 --- a/linux-pagecache-cow/build_kernel.sh +++ b/linux-pagecache-cow/build_kernel.sh @@ -9,6 +9,7 @@ version="7.0.1" # Append a suffix LocalVersion="-pgcachecow" num_cores=$(($(nproc --all) - 2)) +num_cores=$(( num_cores > 1 ? num_cores : 1 )) ## Functions delete_old_kernel_contents () { diff --git a/ubuntu-img/Dockerfile b/ubuntu-img/Dockerfile index 235528ac9..782c9193c 100644 --- a/ubuntu-img/Dockerfile +++ b/ubuntu-img/Dockerfile @@ -119,6 +119,7 @@ RUN \ libreoffice-style-breeze \ libreoffice-writer \ thunderbird \ + tmux \ ubuntu-wallpapers \ ubuntu-wallpapers-jammy \ vlc && \ diff --git a/ubuntu-img/docker-compose.yml b/ubuntu-img/docker-compose.yml index cf5768548..3dc4c708a 100644 --- a/ubuntu-img/docker-compose.yml +++ b/ubuntu-img/docker-compose.yml @@ -1,6 +1,6 @@ services: webtop: - image: ${IMAGE:-ghcr.io/wuklab/webtop:ubuntu-kde} + image: ${IMAGE:-localhost/gensee-tclone-webtop:tmux} container_name: webtop security_opt: - seccomp=unconfined From 6dcbf2e1210cdf07a71b0d4c0914992044f122e6 Mon Sep 17 00:00:00 2001 From: Shengqi Zhu Date: Tue, 28 Jul 2026 17:36:25 -0700 Subject: [PATCH 48/53] Initialize temporary CRIU process UIDs --- criu/criu/cr-dump.c | 1 + criu/criu/image.c | 1 + criu/criu/include/pstree.h | 5 +++++ criu/criu/unittest/mock.c | 4 ++++ criu/criu/unittest/unit.c | 10 ++++++++++ 5 files changed, 21 insertions(+) diff --git a/criu/criu/cr-dump.c b/criu/criu/cr-dump.c index 6728b8625..595e4743e 100644 --- a/criu/criu/cr-dump.c +++ b/criu/criu/cr-dump.c @@ -887,6 +887,7 @@ static int collect_pstree_ids_predump(void) crt.i.pid->state = TASK_ALIVE; crt.i.pid->real = getpid(); + pid_assign_uid(crt.i.pid); if (predump_task_ns_ids(&crt.i)) return -1; diff --git a/criu/criu/image.c b/criu/criu/image.c index 2783b5797..1262ec85c 100644 --- a/criu/criu/image.c +++ b/criu/criu/image.c @@ -376,6 +376,7 @@ int prepare_inventory(InventoryEntry *he) crt.i.pid->state = TASK_ALIVE; crt.i.pid->real = getpid(); + pid_assign_uid(crt.i.pid); if (get_task_ids(&crt.i)) return -1; diff --git a/criu/criu/include/pstree.h b/criu/criu/include/pstree.h index f9bdeffd1..170f89930 100644 --- a/criu/criu/include/pstree.h +++ b/criu/criu/include/pstree.h @@ -20,6 +20,11 @@ extern atomic_t pid_uid_generator; #define HELPER_UID_BASE (0x40000000) +static inline void pid_assign_uid(struct pid *pid) +{ + pid->uid = atomic_inc_return(&pid_uid_generator); +} + struct pstree_item { struct pstree_item *parent; struct list_head children; /* list of my children */ diff --git a/criu/criu/unittest/mock.c b/criu/criu/unittest/mock.c index b2d507278..b9601fffa 100644 --- a/criu/criu/unittest/mock.c +++ b/criu/criu/unittest/mock.c @@ -97,6 +97,10 @@ int close_service_fd(int type) return 0; } +void invalidate_proc_self_fd(void) +{ +} + void compel_log_init(int log_fn, unsigned int level) { } diff --git a/criu/criu/unittest/unit.c b/criu/criu/unittest/unit.c index 54769e6f2..910960370 100644 --- a/criu/criu/unittest/unit.c +++ b/criu/criu/unittest/unit.c @@ -3,19 +3,29 @@ #include #include "log.h" +#include "pstree.h" #include "util.h" #include "criu-log.h" int parse_statement(int i, char *line, char **configuration); +atomic_t pid_uid_generator = ATOMIC_INIT(0); + int main(int argc, char *argv[], char *envp[]) { char **configuration; + struct pid first_pid = {}; + struct pid second_pid = {}; int i; configuration = malloc(10 * sizeof(char *)); log_init(NULL); + pid_assign_uid(&first_pid); + pid_assign_uid(&second_pid); + assert(first_pid.uid > 0); + assert(second_pid.uid > first_pid.uid); + i = parse_statement(0, "", configuration); assert(i == 0); From 29fe65c57abf12d6f605814f182a913a9e8f6240 Mon Sep 17 00:00:00 2001 From: Shengqi Zhu Date: Tue, 28 Jul 2026 23:33:30 -0700 Subject: [PATCH 49/53] Initialize complete temporary PID state --- criu/criu/cr-dump.c | 2 +- criu/criu/image.c | 2 +- criu/criu/include/pstree.h | 16 ++++++++++++++-- criu/criu/pstree.c | 26 +++++++++++++------------- criu/criu/unittest/unit.c | 16 ++++++++++++++-- 5 files changed, 43 insertions(+), 19 deletions(-) diff --git a/criu/criu/cr-dump.c b/criu/criu/cr-dump.c index 595e4743e..5179e5e53 100644 --- a/criu/criu/cr-dump.c +++ b/criu/criu/cr-dump.c @@ -885,9 +885,9 @@ static int collect_pstree_ids_predump(void) * write_img_inventory(). */ + pid_init_dump(crt.i.pid, &crt.i); crt.i.pid->state = TASK_ALIVE; crt.i.pid->real = getpid(); - pid_assign_uid(crt.i.pid); if (predump_task_ns_ids(&crt.i)) return -1; diff --git a/criu/criu/image.c b/criu/criu/image.c index 1262ec85c..1a93344d5 100644 --- a/criu/criu/image.c +++ b/criu/criu/image.c @@ -374,9 +374,9 @@ int prepare_inventory(InventoryEntry *he) he->has_lsmtype = true; he->lsmtype = host_lsm_type(); + pid_init_dump(crt.i.pid, &crt.i); crt.i.pid->state = TASK_ALIVE; crt.i.pid->real = getpid(); - pid_assign_uid(crt.i.pid); if (get_task_ids(&crt.i)) return -1; diff --git a/criu/criu/include/pstree.h b/criu/criu/include/pstree.h index 170f89930..51617015a 100644 --- a/criu/criu/include/pstree.h +++ b/criu/criu/include/pstree.h @@ -20,9 +20,21 @@ extern atomic_t pid_uid_generator; #define HELPER_UID_BASE (0x40000000) -static inline void pid_assign_uid(struct pid *pid) +static inline void pid_init_dump(struct pid *pid, struct pstree_item *item) { - pid->uid = atomic_inc_return(&pid_uid_generator); + *pid = (struct pid){ + .item = item, + .real = -1, + .local = -1, + .uid = atomic_inc_return(&pid_uid_generator), + .state = TASK_UNDEF, + .stop_signo = -1, + .ns_level = -1, + .leaf_ns_id = ALL_PID_NS_ID, + }; + rb_init_node(&pid->leaf_ns_node); + rb_init_node(&pid->root_ns_node); + rb_init_node(&pid->uid_node); } struct pstree_item { diff --git a/criu/criu/pstree.c b/criu/criu/pstree.c index 1b626bc6b..29f1e5d61 100644 --- a/criu/criu/pstree.c +++ b/criu/criu/pstree.c @@ -272,26 +272,26 @@ struct pstree_item *__alloc_pstree_item(bool rst) INIT_LIST_HEAD(&item->children); INIT_LIST_HEAD(&item->sibling); - item->pid->ns_level = -1; - item->pid->leaf_ns_id = ALL_PID_NS_ID; - item->pid->real = -1; - item->pid->local = -1; - if (!rst) - item->pid->uid = atomic_inc_return(&pid_uid_generator); - else + pid_init_dump(item->pid, item); + else { + item->pid->ns_level = -1; + item->pid->leaf_ns_id = ALL_PID_NS_ID; + item->pid->real = -1; + item->pid->local = -1; item->pid->uid = -1; - item->pid->state = TASK_UNDEF; - item->pid->stop_signo = -1; + item->pid->state = TASK_UNDEF; + item->pid->stop_signo = -1; + item->pid->item = item; + rb_init_node(&item->pid->leaf_ns_node); + rb_init_node(&item->pid->root_ns_node); + rb_init_node(&item->pid->uid_node); + } item->born_sid = -1; item->tfork_pidfd = -1; item->tfork_memfd = -1; item->tfork_pagemap_fd = -1; - item->pid->item = item; futex_init(&item->task_st); - rb_init_node(&item->pid->leaf_ns_node); - rb_init_node(&item->pid->root_ns_node); - rb_init_node(&item->pid->uid_node); return item; } diff --git a/criu/criu/unittest/unit.c b/criu/criu/unittest/unit.c index 910960370..cf9d79b24 100644 --- a/criu/criu/unittest/unit.c +++ b/criu/criu/unittest/unit.c @@ -16,15 +16,27 @@ int main(int argc, char *argv[], char *envp[]) char **configuration; struct pid first_pid = {}; struct pid second_pid = {}; + struct pstree_item first_item = { .pid = &first_pid }; + struct pstree_item second_item = { .pid = &second_pid }; int i; configuration = malloc(10 * sizeof(char *)); log_init(NULL); - pid_assign_uid(&first_pid); - pid_assign_uid(&second_pid); + pid_init_dump(&first_pid, &first_item); + pid_init_dump(&second_pid, &second_item); assert(first_pid.uid > 0); assert(second_pid.uid > first_pid.uid); + assert(first_pid.item == &first_item); + assert(first_pid.real == -1); + assert(first_pid.local == -1); + assert(first_pid.state == TASK_UNDEF); + assert(first_pid.stop_signo == -1); + assert(first_pid.ns_level == -1); + assert(first_pid.leaf_ns_id == ALL_PID_NS_ID); + assert(RB_EMPTY_NODE(&first_pid.leaf_ns_node)); + assert(RB_EMPTY_NODE(&first_pid.root_ns_node)); + assert(RB_EMPTY_NODE(&first_pid.uid_node)); i = parse_statement(0, "", configuration); assert(i == 0); From ec2017b38c09a84c1b6b63a21a8cc35461413760 Mon Sep 17 00:00:00 2001 From: yiying-zhang Date: Fri, 31 Jul 2026 03:09:24 -0700 Subject: [PATCH 50/53] perf(tfork): wake restore barriers with futex --- criu/criu/cr-restore.c | 52 ++++++++++++++++++++++++++++++++++++------ 1 file changed, 45 insertions(+), 7 deletions(-) diff --git a/criu/criu/cr-restore.c b/criu/criu/cr-restore.c index bd4fa8b0a..d04cb0bbf 100644 --- a/criu/criu/cr-restore.c +++ b/criu/criu/cr-restore.c @@ -186,19 +186,57 @@ static int __restore_wait_inprogress_tasks(int participants) int ret; futex_t *np = &task_entries->nr_in_progress; const int tfork_restore_wait_timeout_ms = 10000; - const int tfork_restore_wait_poll_us = 100000; if (opts.tfork.active) { - int waited; + struct timespec started, now, timeout; + int wait_ret = 0; - for (waited = 0; waited < tfork_restore_wait_timeout_ms; - waited += tfork_restore_wait_poll_us / 1000) { - if ((int)futex_get(np) <= participants) + /* + * All paths that decrement this barrier use + * futex_dec_and_wake(). Waiting on the observed value avoids + * paying up to one 100ms polling interval at every restore stage. + */ + if (clock_gettime(CLOCK_MONOTONIC, &started)) { + pr_perror("tfork restore wait: clock_gettime"); + return -errno; + } + while ((int)futex_get(np) > participants) { + int64_t elapsed_ns, remaining_ns; + uint32_t observed = futex_get(np); + + if (observed & FUTEX_ABORT_FLAG) + break; + if (clock_gettime(CLOCK_MONOTONIC, &now)) { + pr_perror("tfork restore wait: clock_gettime"); + return -errno; + } + elapsed_ns = + (int64_t)(now.tv_sec - started.tv_sec) * NSEC_PER_SEC + + (now.tv_nsec - started.tv_nsec); + remaining_ns = + (int64_t)tfork_restore_wait_timeout_ms * 1000000 - + elapsed_ns; + if (remaining_ns <= 0) { + wait_ret = -ETIMEDOUT; + break; + } + timeout.tv_sec = remaining_ns / NSEC_PER_SEC; + timeout.tv_nsec = remaining_ns % NSEC_PER_SEC; + wait_ret = sys_futex( + (uint32_t *)&np->raw.counter, FUTEX_WAIT, + observed, &timeout, NULL, 0); + if (wait_ret == 0 || wait_ret == -EINTR || + wait_ret == -EWOULDBLOCK) + continue; + if (wait_ret == -ETIMEDOUT) break; - usleep(tfork_restore_wait_poll_us); + pr_err("tfork restore futex wait failed: %d\n", wait_ret); + set_cr_errno(-wait_ret); + return wait_ret; } - if ((int)futex_get(np) > participants) { + if (wait_ret == -ETIMEDOUT && + (int)futex_get(np) > participants) { pr_err("tfork restore wait timed out after %dms: participants=%d nr_in_progress=%d start_stage=%d task_cr_err=%d nr_tasks=%d nr_threads=%d nr_helpers=%d\n", tfork_restore_wait_timeout_ms, participants, (int)futex_get(np), From 929f4c2858e58c914fb85c03f8dbeba12f84e249 Mon Sep 17 00:00:00 2001 From: yiying-zhang Date: Fri, 31 Jul 2026 03:09:24 -0700 Subject: [PATCH 51/53] test(tfork): add Phase B A/B benchmark --- criu/test/others/tfork-phase-b-ab.sh | 110 +++++++++++++++++++++++++++ 1 file changed, 110 insertions(+) create mode 100755 criu/test/others/tfork-phase-b-ab.sh diff --git a/criu/test/others/tfork-phase-b-ab.sh b/criu/test/others/tfork-phase-b-ab.sh new file mode 100755 index 000000000..d24f07dc6 --- /dev/null +++ b/criu/test/others/tfork-phase-b-ab.sh @@ -0,0 +1,110 @@ +#!/usr/bin/env bash +set -euo pipefail + +PODMAN=${PODMAN:-podman} +PODMAN_GLOBAL_ARGS=${PODMAN_GLOBAL_ARGS:-} +OLD_CRIU_ROOT=${OLD_CRIU_ROOT:?set OLD_CRIU_ROOT} +NEW_CRIU_ROOT=${NEW_CRIU_ROOT:?set NEW_CRIU_ROOT} +SAMPLES=${SAMPLES:-20} +IMAGE=${IMAGE:-docker.io/library/alpine:3.19} +PREFIX=${PREFIX:-tfork-phase-b-$RANDOM} +OUTPUT=${OUTPUT:-/tmp/tfork-phase-b-ab.tsv} +OS4AGENT_CRUN=${OS4AGENT_CRUN:-crun} +WORKLOAD_PROCESSES=${WORKLOAD_PROCESSES:-1} +LOG_DIR=${LOG_DIR:-} + +read -r -a podman_global_args <<<"$PODMAN_GLOBAL_ARGS" +source_name=${PREFIX}-source + +podman_cmd() { + "$PODMAN" "${podman_global_args[@]}" "$@" +} + +cleanup() { + podman_cmd ps -a --format '{{.Names}}' | + awk -v prefix="$PREFIX" 'index($0, prefix) == 1' | + while read -r name; do + podman_cmd rm -f -t 0 "$name" >/dev/null 2>&1 || true + done +} +trap cleanup EXIT + +run_clone() { + local variant=$1 + local root=$2 + local index=$3 + local name=${PREFIX}-${variant}-${index} + local started ended elapsed rootfs bundle + + started=$(date +%s%N) + env \ + PATH="$root/criu:$PATH" \ + LD_LIBRARY_PATH="$root/lib/c${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" \ + OS4AGENT_CRUN="$OS4AGENT_CRUN" \ + "$PODMAN" "${podman_global_args[@]}" container clone \ + --live --tfork-overlay-btrfs "$source_name" "$name" >/dev/null + ended=$(date +%s%N) + elapsed=$(( (ended - started) / 1000000 )) + + [[ $(podman_cmd exec "$name" cat /tmp/sentinel) == source-before-fork ]] + if [[ -n $LOG_DIR ]]; then + rootfs=$(podman_cmd inspect --format '{{.Rootfs}}' "$name") + bundle=$(dirname "$rootfs") + test -f "$bundle/img/tfork.log" + cp "$bundle/img/tfork.log" \ + "$LOG_DIR/${variant}-${index}.tfork.log" + if [[ -f $bundle/img/tfork-restore.log.copy0 ]]; then + cp "$bundle/img/tfork-restore.log.copy0" \ + "$LOG_DIR/${variant}-${index}.restore.log" + fi + fi + podman_cmd kill -s KILL "$name" >/dev/null + podman_cmd rm -f -t 0 "$name" >/dev/null + printf '%s\t%d\t%d\n' "$variant" "$index" "$elapsed" | tee -a "$OUTPUT" +} + +cleanup +: >"$OUTPUT" +if [[ -n $LOG_DIR ]]; then + mkdir -p "$LOG_DIR" +fi +podman_cmd run -d --name "$source_name" \ + --log-driver k8s-file \ + --security-opt seccomp=unconfined \ + --security-opt apparmor=unconfined \ + "$IMAGE" sh -c \ + 'count=$1 + i=1 + while [ "$i" -lt "$count" ]; do + sleep 86400 & + i=$((i + 1)) + done + echo source-before-fork >/tmp/sentinel + exec tail -f /dev/null' sh "$WORKLOAD_PROCESSES" >/dev/null + +for ((attempt = 0; attempt < 100; attempt++)); do + actual_processes=$(podman_cmd top "$source_name" pid | + awk 'NR > 1 { count++ } END { print count + 0 }') + if ((actual_processes == WORKLOAD_PROCESSES)); then + break + fi + sleep 0.05 +done +if ((actual_processes != WORKLOAD_PROCESSES)); then + printf 'expected %d source processes, found %d\n' \ + "$WORKLOAD_PROCESSES" "$actual_processes" >&2 + exit 1 +fi + +for ((i = 1; i <= SAMPLES; i++)); do + if ((i % 2)); then + run_clone old "$OLD_CRIU_ROOT" "$i" + run_clone new "$NEW_CRIU_ROOT" "$i" + else + run_clone new "$NEW_CRIU_ROOT" "$i" + run_clone old "$OLD_CRIU_ROOT" "$i" + fi +done + +podman_cmd rm -f -t 0 "$source_name" >/dev/null +trap - EXIT From b3067ece4986cee6c850124f114a2db91918baf7 Mon Sep 17 00:00:00 2001 From: yiying-zhang Date: Fri, 31 Jul 2026 11:34:45 -0700 Subject: [PATCH 52/53] perf(tfork): add opt-in residual latency probes --- criu/criu/cr-dump.c | 132 ++++++++++++++++++++++++++- criu/criu/cr-restore.c | 81 ++++++++++++++++ criu/criu/cr-tfork.c | 53 +++++++++++ criu/test/others/tfork-phase-b-ab.sh | 5 +- 4 files changed, 269 insertions(+), 2 deletions(-) diff --git a/criu/criu/cr-dump.c b/criu/criu/cr-dump.c index 5179e5e53..905b472af 100644 --- a/criu/criu/cr-dump.c +++ b/criu/criu/cr-dump.c @@ -6,6 +6,7 @@ #include #include #include +#include #include @@ -92,6 +93,94 @@ #include "timer.h" #include "sigact.h" +/* + * Opt-in tfork profiling. Keep the normal path to one cached branch per + * timing point and aggregate per-task intervals so profiling does not add a + * log write for every process and operation. + */ +struct tfork_task_profile { + uint64_t task_count; + uint64_t total; + uint64_t stat_identity; + uint64_t mappings; + uint64_t fds; + uint64_t proc_metadata; + uint64_t infect; + uint64_t parasite_metadata; + uint64_t image_ids; + uint64_t files; + uint64_t pages; + uint64_t signal_timers; + uint64_t core_cgroup; + uint64_t stop_threads_cure; + uint64_t mm_fs; + uint64_t cleanup; +}; + +static struct tfork_task_profile tfork_task_profile; + +static bool tfork_profile_enabled(void) +{ + static int enabled = -1; + const char *value; + + if (enabled >= 0) + return opts.tfork.active && enabled; + + value = getenv("CRIU_TFORK_PROFILE"); + enabled = value && value[0] && strcmp(value, "0"); + return opts.tfork.active && enabled; +} + +static uint64_t tfork_profile_now(void) +{ + struct timespec ts; + + if (!tfork_profile_enabled()) + return 0; + if (clock_gettime(CLOCK_MONOTONIC, &ts)) + return 0; + return (uint64_t)ts.tv_sec * 1000000000ULL + ts.tv_nsec; +} + +static void tfork_profile_add(uint64_t *total, uint64_t started) +{ + uint64_t now; + + if (!started) + return; + now = tfork_profile_now(); + if (now >= started) + *total += now - started; +} + +static void tfork_profile_dump_tasks(void) +{ + struct tfork_task_profile *p = &tfork_task_profile; + + if (!tfork_profile_enabled()) + return; + +#define TFORK_PROFILE_US(field) ((unsigned long long)(p->field / 1000ULL)) + pr_info("tfork-profile: phase=A tasks=%llu task_total_us=%llu " + "stat_identity_us=%llu mappings_us=%llu fds_us=%llu " + "proc_metadata_us=%llu\n", + (unsigned long long)p->task_count, TFORK_PROFILE_US(total), + TFORK_PROFILE_US(stat_identity), TFORK_PROFILE_US(mappings), + TFORK_PROFILE_US(fds), TFORK_PROFILE_US(proc_metadata)); + pr_info("tfork-profile: phase=A infect_us=%llu parasite_metadata_us=%llu " + "image_ids_us=%llu files_us=%llu pages_us=%llu " + "signal_timers_us=%llu\n", + TFORK_PROFILE_US(infect), TFORK_PROFILE_US(parasite_metadata), + TFORK_PROFILE_US(image_ids), TFORK_PROFILE_US(files), + TFORK_PROFILE_US(pages), TFORK_PROFILE_US(signal_timers)); + pr_info("tfork-profile: phase=A core_cgroup_us=%llu " + "stop_threads_cure_us=%llu mm_fs_us=%llu cleanup_us=%llu\n", + TFORK_PROFILE_US(core_cgroup), TFORK_PROFILE_US(stop_threads_cure), + TFORK_PROFILE_US(mm_fs), TFORK_PROFILE_US(cleanup)); +#undef TFORK_PROFILE_US +} + /* * Architectures can overwrite this function to restore register sets that * are not covered by ptrace_set/get_regs(). @@ -1572,6 +1661,11 @@ static int dump_one_task(struct pstree_item *item, InventoryEntry *parent_ie) struct proc_posix_timers_stat proc_args; struct mem_dump_ctl mdc; unsigned long cflags; + uint64_t profile_task_started = tfork_profile_now(); + uint64_t profile_started; + + if (profile_task_started) + tfork_task_profile.task_count++; vm_area_list_init(&vmas); @@ -1583,8 +1677,9 @@ static int dump_one_task(struct pstree_item *item, InventoryEntry *parent_ie) /* * zombies are dumped separately in dump_zombies() */ - return 0; + goto profiled_dead; + profile_started = tfork_profile_now(); pr_info("Obtaining task stat ... \n"); ret = parse_pid_stat(pid, &pps_buf); if (ret < 0) @@ -1641,13 +1736,17 @@ static int dump_one_task(struct pstree_item *item, InventoryEntry *parent_ie) pr_err("Dump TIME namespace (pid: %d) failed with %d\n", pid, ret); goto err; } + tfork_profile_add(&tfork_task_profile.stat_identity, profile_started); + profile_started = tfork_profile_now(); ret = collect_mappings(pid, &vmas, dump_filemap); if (ret) { pr_err("Collect mappings (pid: %d) failed with %d\n", pid, ret); goto err; } + tfork_profile_add(&tfork_task_profile.mappings, profile_started); + profile_started = tfork_profile_now(); if (!shared_fdtable(item)) { dfds = xmalloc(sizeof(*dfds)); if (!dfds) @@ -1661,7 +1760,9 @@ static int dump_one_task(struct pstree_item *item, InventoryEntry *parent_ie) parasite_ensure_args_size(drain_fds_size(dfds)); } + tfork_profile_add(&tfork_task_profile.fds, profile_started); + profile_started = tfork_profile_now(); ret = parse_posix_timers(pid, &proc_args); if (ret < 0) { pr_err("Can't read posix timers file (pid: %d)\n", pid); @@ -1681,12 +1782,15 @@ static int dump_one_task(struct pstree_item *item, InventoryEntry *parent_ie) pr_err("Dump %d rseq failed %d\n", pid, ret); goto err; } + tfork_profile_add(&tfork_task_profile.proc_metadata, profile_started); + profile_started = tfork_profile_now(); parasite_ctl = parasite_infect_seized(pid, item, &vmas); if (!parasite_ctl) { pr_err("Can't infect (pid: %d) with parasite\n", pid); goto err; } + tfork_profile_add(&tfork_task_profile.infect, profile_started); ret = fixup_thread_rseq(item, 0); if (ret) { @@ -1712,6 +1816,7 @@ static int dump_one_task(struct pstree_item *item, InventoryEntry *parent_ie) goto err_cure; } + profile_started = tfork_profile_now(); ret = parasite_fixup_vdso(parasite_ctl, pid, &vmas); if (ret) { pr_err("Can't fixup vdso VMAs (pid: %d)\n", pid); @@ -1729,7 +1834,9 @@ static int dump_one_task(struct pstree_item *item, InventoryEntry *parent_ie) pr_err("Can't dump misc (pid: %d)\n", pid); goto err_cure; } + tfork_profile_add(&tfork_task_profile.parasite_metadata, profile_started); + profile_started = tfork_profile_now(); cr_imgset = cr_task_imgset_open(uid(item), O_DUMP); if (!cr_imgset) goto err_cure; @@ -1739,7 +1846,9 @@ static int dump_one_task(struct pstree_item *item, InventoryEntry *parent_ie) pr_err("Dump ids (pid: %d) failed with %d\n", pid, ret); goto err_cure; } + tfork_profile_add(&tfork_task_profile.image_ids, profile_started); + profile_started = tfork_profile_now(); if (dfds) { ret = dump_task_files_seized(parasite_ctl, item, dfds); if (ret) { @@ -1752,12 +1861,14 @@ static int dump_one_task(struct pstree_item *item, InventoryEntry *parent_ie) goto err_cure; } } + tfork_profile_add(&tfork_task_profile.files, profile_started); mdc.pre_dump = false; mdc.lazy = opts.lazy_pages; mdc.stat = &pps_buf; mdc.parent_ie = parent_ie; + profile_started = tfork_profile_now(); if (!opts.tfork.active) { ret = parasite_dump_pages_seized(item, &vmas, &mdc, parasite_ctl); if (ret) @@ -1770,7 +1881,9 @@ static int dump_one_task(struct pstree_item *item, InventoryEntry *parent_ie) if (ret) goto err_cure; } + tfork_profile_add(&tfork_task_profile.pages, profile_started); + profile_started = tfork_profile_now(); ret = parasite_dump_sigacts_seized(parasite_ctl, item); if (ret) { pr_err("Can't dump sigactions (pid: %d) with parasite\n", pid); @@ -1788,7 +1901,9 @@ static int dump_one_task(struct pstree_item *item, InventoryEntry *parent_ie) pr_err("Can't dump posix timers (pid: %d)\n", pid); goto err_cure; } + tfork_profile_add(&tfork_task_profile.signal_timers, profile_started); + profile_started = tfork_profile_now(); ret = dump_task_core_all(parasite_ctl, item, &pps_buf, cr_imgset, &misc); if (ret) { pr_err("Dump core (pid: %d) failed with %d\n", pid, ret); @@ -1800,7 +1915,9 @@ static int dump_one_task(struct pstree_item *item, InventoryEntry *parent_ie) pr_err("Dump cgroup of threads in process (pid: %d) failed with %d\n", pid, ret); goto err_cure; } + tfork_profile_add(&tfork_task_profile.core_cgroup, profile_started); + profile_started = tfork_profile_now(); ret = compel_stop_daemon(parasite_ctl); if (ret) { pr_err("Can't stop daemon in parasite (pid: %d)\n", pid); @@ -1825,7 +1942,9 @@ static int dump_one_task(struct pstree_item *item, InventoryEntry *parent_ie) pr_err("Can't cure (pid: %d) from parasite\n", pid); goto err; } + tfork_profile_add(&tfork_task_profile.stop_threads_cure, profile_started); + profile_started = tfork_profile_now(); ret = dump_task_mm(pid, &pps_buf, &misc, &vmas, cr_imgset); if (ret) { pr_err("Dump mappings (pid: %d) failed with %d\n", pid, ret); @@ -1837,13 +1956,17 @@ static int dump_one_task(struct pstree_item *item, InventoryEntry *parent_ie) pr_err("Dump fs (pid: %d) failed with %d\n", pid, ret); goto err; } + tfork_profile_add(&tfork_task_profile.mm_fs, profile_started); exit_code = 0; err: + profile_started = tfork_profile_now(); close_cr_imgset(&cr_imgset); close_pid_proc(); free_mappings(&vmas); xfree(dfds); + tfork_profile_add(&tfork_task_profile.cleanup, profile_started); + tfork_profile_add(&tfork_task_profile.total, profile_task_started); return exit_code; err_cure: @@ -1851,6 +1974,10 @@ static int dump_one_task(struct pstree_item *item, InventoryEntry *parent_ie) if (ret) pr_err("Can't cure (pid: %d) from parasite\n", pid); goto err; + +profiled_dead: + tfork_profile_add(&tfork_task_profile.total, profile_task_started); + return 0; } static int alarm_attempts = 0; @@ -2324,9 +2451,12 @@ int cr_dump_tasks(pid_t pid) if (collect_and_suspend_lsm() < 0) goto err; + if (tfork_profile_enabled()) + memset(&tfork_task_profile, 0, sizeof(tfork_task_profile)); for_each_pstree_item(item) if (dump_one_task(item, parent_ie)) goto err; + tfork_profile_dump_tasks(); if (!opts.tfork.active) { ret = run_plugins(DUMP_DEVICES_LATE, pid); diff --git a/criu/criu/cr-restore.c b/criu/criu/cr-restore.c index d04cb0bbf..5c927315d 100644 --- a/criu/criu/cr-restore.c +++ b/criu/criu/cr-restore.c @@ -5,6 +5,7 @@ #include #include #include +#include #include @@ -117,6 +118,48 @@ #define arch_export_restore_task __export_restore_task #endif +static bool tfork_restore_profile; +static uint64_t tfork_restore_profile_origin; +static uint64_t tfork_restore_profile_last; +static unsigned int tfork_restore_wait_seq; + +static uint64_t tfork_restore_profile_now(void) +{ + struct timespec ts; + + if (clock_gettime(CLOCK_MONOTONIC, &ts)) + return 0; + return (uint64_t)ts.tv_sec * 1000000000ULL + ts.tv_nsec; +} + +static void tfork_restore_profile_init(void) +{ + const char *value = getenv("CRIU_TFORK_PROFILE"); + + tfork_restore_profile = opts.tfork.active && value && value[0] && + strcmp(value, "0"); + if (!tfork_restore_profile) + return; + tfork_restore_profile_origin = tfork_restore_profile_now(); + tfork_restore_profile_last = tfork_restore_profile_origin; + tfork_restore_wait_seq = 0; + pr_warn("tfork-profile: phase=B-restore mark=start pid=%d\n", getpid()); +} + +static void tfork_restore_profile_mark(const char *mark) +{ + uint64_t now; + + if (!tfork_restore_profile) + return; + now = tfork_restore_profile_now(); + pr_warn("tfork-profile: phase=B-restore mark=%s pid=%d delta_us=%llu elapsed_us=%llu\n", + mark, getpid(), + (unsigned long long)((now - tfork_restore_profile_last) / 1000ULL), + (unsigned long long)((now - tfork_restore_profile_origin) / 1000ULL)); + tfork_restore_profile_last = now; +} + #ifndef arch_export_unmap #define arch_export_unmap __export_unmap #define arch_export_unmap_compat __export_unmap_compat @@ -186,6 +229,15 @@ static int __restore_wait_inprogress_tasks(int participants) int ret; futex_t *np = &task_entries->nr_in_progress; const int tfork_restore_wait_timeout_ms = 10000; + uint64_t profile_started = 0; + unsigned int profile_seq = 0; + int profile_initial = 0; + + if (tfork_restore_profile) { + profile_started = tfork_restore_profile_now(); + profile_seq = ++tfork_restore_wait_seq; + profile_initial = (int)futex_get(np); + } if (opts.tfork.active) { struct timespec started, now, timeout; @@ -251,6 +303,16 @@ static int __restore_wait_inprogress_tasks(int participants) futex_wait_while_gt(np, participants); } + if (profile_started) { + uint64_t now = tfork_restore_profile_now(); + + pr_warn("tfork-profile: phase=B-wait seq=%u pid=%d stage=%d " + "participants=%d initial=%d final=%d duration_us=%llu\n", + profile_seq, getpid(), (int)futex_get(&task_entries->start), + participants, profile_initial, (int)futex_get(np), + (unsigned long long)((now - profile_started) / 1000ULL)); + } + ret = (int)futex_get(np); if (ret < 0) { pr_err("restore wait aborted: participants=%d nr_in_progress=%d start_stage=%d task_cr_err=%d\n", @@ -2564,6 +2626,7 @@ static int restore_root_task(struct pstree_item *init) pr_err("Failed to prepare namespace before tasks\n"); return -1; } + tfork_restore_profile_mark("root-pre-restore-and-namespace-prep"); if (localpid(init) == INIT_PID) { if (!(root_ns_mask & CLONE_NEWPID)) { @@ -2613,6 +2676,7 @@ static int restore_root_task(struct pstree_item *init) pr_err("fork_with_pid failed: %d\n", ret); goto out; } + tfork_restore_profile_mark("fork-root-task"); if (is_simple_userns_tree()) { if (prepare_userns(init)) { @@ -2671,6 +2735,7 @@ static int restore_root_task(struct pstree_item *init) pr_err("restore_wait_inprogress_tasks failed: %d\n", ret); goto out_kill; } + tfork_restore_profile_mark("wait-namespaces-created"); ret = run_scripts(ACT_SETUP_NS); if (ret) { @@ -2687,6 +2752,7 @@ static int restore_root_task(struct pstree_item *init) pr_err("Root task logs above show which step failed (err_step).\n"); goto out_kill; } + tfork_restore_profile_mark("prepare-namespaces-stage"); if (root_ns_mask & CLONE_NEWNS) { mnt_ns_fd = open_proc(init->pid->real, "ns/mnt"); @@ -2731,6 +2797,7 @@ static int restore_root_task(struct pstree_item *init) pr_err("restore_wait_inprogress_tasks (post-fork) failed: %d\n", ret); goto out_kill; } + tfork_restore_profile_mark("post-fork-wait"); ret = apply_memfd_seals(); if (ret < 0) { @@ -2774,6 +2841,7 @@ static int restore_root_task(struct pstree_item *init) pr_err("restore_switch_stage RESTORE_SIGCHLD failed: %d\n", ret); goto out_kill; } + tfork_restore_profile_mark("restore-sigchld-stage"); ret = stop_usernsd(); if (ret < 0) { @@ -2829,8 +2897,10 @@ static int restore_root_task(struct pstree_item *init) pr_err("write_restored_pid failed\n"); goto out_kill; } + tfork_restore_profile_mark("post-restore-housekeeping"); network_unlock(); + tfork_restore_profile_mark("network-unlock"); /* * Stop getting sigchld, after we resume the tasks they @@ -2847,11 +2917,13 @@ static int restore_root_task(struct pstree_item *init) pr_err("attach_to_tasks failed\n"); goto out_kill_network_unlocked; } + tfork_restore_profile_mark("attach-restored-tasks"); if (restore_switch_stage(CR_STATE_RESTORE_CREDS)) { pr_err("restore_switch_stage RESTORE_CREDS failed\n"); goto out_kill_network_unlocked; } + tfork_restore_profile_mark("restore-creds-stage"); timing_stop(TIME_RESTORE); @@ -2866,6 +2938,7 @@ static int restore_root_task(struct pstree_item *init) } __restore_switch_stage(CR_STATE_COMPLETE); + tfork_restore_profile_mark("catch-lazy-and-complete"); ret = compel_stop_on_syscall(task_entries->nr_threads, __NR(rt_sigreturn, 0), __NR(rt_sigreturn, 1)); if (ret) { @@ -2878,6 +2951,7 @@ static int restore_root_task(struct pstree_item *init) /* just before releasing threads we have to restore rseq_cs */ if (restore_rseq_cs()) pr_err("Unable to restore rseq_cs state\n"); + tfork_restore_profile_mark("stop-finalize-and-rseq"); /* * Some external devices such as GPUs might need a very late @@ -2917,6 +2991,7 @@ static int restore_root_task(struct pstree_item *init) pr_err("finalize_restore_detach failed\n"); goto out_kill_network_unlocked; } + tfork_restore_profile_mark("hooks-freezer-and-detach"); pr_info("Restore finished successfully. Tasks resumed.\n"); write_stats(RESTORE_STATS); @@ -3035,6 +3110,7 @@ int cr_restore_tasks(void) if (init_service_fd()) return 1; + tfork_restore_profile_init(); if (check_async_memdump_inflight() < 0) return -1; @@ -3046,6 +3122,7 @@ int cr_restore_tasks(void) if (tfork_read_cropt()) return -1; } + tfork_restore_profile_mark("inventory-and-cropt"); if (init_stats(RESTORE_STATS)) return -1; @@ -3091,6 +3168,7 @@ int cr_restore_tasks(void) } } } + tfork_restore_profile_mark("task-entries-and-pstree"); if (fdstore_init()) return -1; @@ -3109,6 +3187,7 @@ int cr_restore_tasks(void) if (crtools_prepare_shared() < 0) goto err; + tfork_restore_profile_mark("fdstore-plugins-and-shared"); if (prepare_cgroup()) goto clean_cgroup; @@ -3118,8 +3197,10 @@ int cr_restore_tasks(void) if (prepare_lazy_pages_socket() < 0) goto clean_cgroup; + tfork_restore_profile_mark("cgroup-signals-and-lazy-pages"); ret = restore_root_task(root_item); + tfork_restore_profile_mark("restore-root-returned"); clean_cgroup: fini_cgroup(); err: diff --git a/criu/criu/cr-tfork.c b/criu/criu/cr-tfork.c index f99a6d2da..43393ccbd 100644 --- a/criu/criu/cr-tfork.c +++ b/criu/criu/cr-tfork.c @@ -3,6 +3,7 @@ #include #include #include +#include #include #include @@ -44,6 +45,44 @@ #define VMA_CHERRYPICK_FD_ENV "CRIU_VMA_CHERRYPICK_FD" +static bool tfork_parent_profile; +static uint64_t tfork_parent_profile_origin; +static uint64_t tfork_parent_profile_last; + +static uint64_t tfork_parent_profile_now(void) +{ + struct timespec ts; + + if (clock_gettime(CLOCK_MONOTONIC, &ts)) + return 0; + return (uint64_t)ts.tv_sec * 1000000000ULL + ts.tv_nsec; +} + +static void tfork_parent_profile_init(void) +{ + const char *value = getenv("CRIU_TFORK_PROFILE"); + + tfork_parent_profile = value && value[0] && strcmp(value, "0"); + if (!tfork_parent_profile) + return; + tfork_parent_profile_origin = tfork_parent_profile_now(); + tfork_parent_profile_last = tfork_parent_profile_origin; +} + +static void tfork_parent_profile_mark(const char *mark) +{ + uint64_t now; + + if (!tfork_parent_profile) + return; + now = tfork_parent_profile_now(); + pr_info("tfork-profile: phase=B-parent mark=%s delta_us=%llu elapsed_us=%llu\n", + mark, + (unsigned long long)((now - tfork_parent_profile_last) / 1000ULL), + (unsigned long long)((now - tfork_parent_profile_origin) / 1000ULL)); + tfork_parent_profile_last = now; +} + static int tfork_dup_inherited_vma_cherrypick(int env_fd) { struct stat fst, dst; @@ -573,6 +612,7 @@ static int cr_tfork_finish(int ret) { int j; + tfork_parent_profile_mark("finish-enter"); for (j = 0; j < opts.tfork.pidfd_map_nr; j++) { if (opts.tfork.pidfd_map[j].pidfd >= 0) close(opts.tfork.pidfd_map[j].pidfd); @@ -600,6 +640,7 @@ static int cr_tfork_finish(int ret) if (bfd_flush_images()) ret = -1; + tfork_parent_profile_mark("finish-close-fds-and-flush"); cgp_fini(); @@ -607,6 +648,7 @@ static int cr_tfork_finish(int ret) network_unlock(); delete_link_remaps(); clean_cr_time_mounts(); + tfork_parent_profile_mark("finish-unlock-and-clean-mounts"); cr_plugin_fini(CR_PLUGIN_STAGE__DUMP, ret); @@ -615,6 +657,7 @@ static int cr_tfork_finish(int ret) pstree_switch_state(root_item, TASK_ALIVE); timing_stop(TIME_FROZEN); + tfork_parent_profile_mark("finish-unseize-source"); seccomp_free_entries(); free_file_locks(); @@ -624,6 +667,7 @@ static int cr_tfork_finish(int ret) close_service_fd(CR_PROC_FD_OFF); close_image_dir(); + tfork_parent_profile_mark("finish-free-and-close"); if (ret) { pr_err("tfork FAILED.\n"); @@ -939,10 +983,12 @@ int cr_tfork_tasks(pid_t pid) } pr_info("tfork: Phase B — setting up clone restore\n"); + tfork_parent_profile_init(); opts.tfork.vma_cherrypick_fd = tfork_open_vma_cherrypick(); if (opts.tfork.vma_cherrypick_fd < 0) goto err; + tfork_parent_profile_mark("open-vma-cherrypick"); nr = 0; for_each_pstree_item(item) @@ -970,12 +1016,14 @@ int cr_tfork_tasks(pid_t pid) pidfd, item->pid->real, localpid(item), uid(item), item->pid->leaf_ns_id, item->pid->ns_level); } + tfork_parent_profile_mark("pidfd-map"); ret = run_scripts(ACT_PRE_TFORK_RESTORE); if (ret) { pr_err("Pre-tfork-restore script failed: %d\n", ret); goto err; } + tfork_parent_profile_mark("pre-restore-hook"); img_dir_fd = get_service_fd(IMG_FD_OFF); cropt_fd = openat(img_dir_fd, "tfork.cropt", @@ -996,6 +1044,7 @@ int cr_tfork_tasks(pid_t pid) opts.tfork.pidfd_map[j].vpid, (int)opts.tfork.pidfd_map[j].real_pid); fclose(f); + tfork_parent_profile_mark("write-cropt"); if (opts.output) { char phasea_path[PATH_MAX]; @@ -1017,6 +1066,7 @@ int cr_tfork_tasks(pid_t pid) if (dst >= 0) close(dst); } + tfork_parent_profile_mark("copy-phase-a-log"); child = fork(); if (child < 0) { @@ -1024,6 +1074,8 @@ int cr_tfork_tasks(pid_t pid) ret = -1; goto err; } + if (child > 0) + tfork_parent_profile_mark("fork-restore-child"); if (child == 0) { char img_dir_arg[PATH_MAX]; @@ -1340,6 +1392,7 @@ int cr_tfork_tasks(pid_t pid) WTERMSIG(status)); ret = -1; } + tfork_parent_profile_mark("wait-restore-child"); err: return cr_tfork_finish(ret); diff --git a/criu/test/others/tfork-phase-b-ab.sh b/criu/test/others/tfork-phase-b-ab.sh index d24f07dc6..07d75a464 100755 --- a/criu/test/others/tfork-phase-b-ab.sh +++ b/criu/test/others/tfork-phase-b-ab.sh @@ -12,8 +12,10 @@ OUTPUT=${OUTPUT:-/tmp/tfork-phase-b-ab.tsv} OS4AGENT_CRUN=${OS4AGENT_CRUN:-crun} WORKLOAD_PROCESSES=${WORKLOAD_PROCESSES:-1} LOG_DIR=${LOG_DIR:-} +TFORK_CLONE_ARGS=${TFORK_CLONE_ARGS:-} read -r -a podman_global_args <<<"$PODMAN_GLOBAL_ARGS" +read -r -a tfork_clone_args <<<"$TFORK_CLONE_ARGS" source_name=${PREFIX}-source podman_cmd() { @@ -42,7 +44,8 @@ run_clone() { LD_LIBRARY_PATH="$root/lib/c${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" \ OS4AGENT_CRUN="$OS4AGENT_CRUN" \ "$PODMAN" "${podman_global_args[@]}" container clone \ - --live --tfork-overlay-btrfs "$source_name" "$name" >/dev/null + --live --tfork-overlay-btrfs "${tfork_clone_args[@]}" \ + "$source_name" "$name" >/dev/null ended=$(date +%s%N) elapsed=$(( (ended - started) / 1000000 )) From a4f3c061ef9515a115a25fe5e0179d137d75958e Mon Sep 17 00:00:00 2001 From: yiying-zhang Date: Sat, 1 Aug 2026 20:21:06 -0700 Subject: [PATCH 53/53] fix(tfork): harden residual profile timestamps --- criu/criu/cr-dump.c | 2 +- criu/criu/cr-restore.c | 24 +++++++++++++++++------- criu/criu/cr-tfork.c | 13 ++++++++++--- 3 files changed, 28 insertions(+), 11 deletions(-) diff --git a/criu/criu/cr-dump.c b/criu/criu/cr-dump.c index 905b472af..0e9cf708e 100644 --- a/criu/criu/cr-dump.c +++ b/criu/criu/cr-dump.c @@ -1662,7 +1662,7 @@ static int dump_one_task(struct pstree_item *item, InventoryEntry *parent_ie) struct mem_dump_ctl mdc; unsigned long cflags; uint64_t profile_task_started = tfork_profile_now(); - uint64_t profile_started; + uint64_t profile_started = 0; if (profile_task_started) tfork_task_profile.task_count++; diff --git a/criu/criu/cr-restore.c b/criu/criu/cr-restore.c index 5c927315d..188ab4993 100644 --- a/criu/criu/cr-restore.c +++ b/criu/criu/cr-restore.c @@ -143,21 +143,28 @@ static void tfork_restore_profile_init(void) tfork_restore_profile_origin = tfork_restore_profile_now(); tfork_restore_profile_last = tfork_restore_profile_origin; tfork_restore_wait_seq = 0; - pr_warn("tfork-profile: phase=B-restore mark=start pid=%d\n", getpid()); + pr_info("tfork-profile: phase=B-restore mark=start pid=%d\n", getpid()); } static void tfork_restore_profile_mark(const char *mark) { uint64_t now; + uint64_t delta = 0; + uint64_t elapsed = 0; if (!tfork_restore_profile) return; now = tfork_restore_profile_now(); - pr_warn("tfork-profile: phase=B-restore mark=%s pid=%d delta_us=%llu elapsed_us=%llu\n", + if (now >= tfork_restore_profile_last) { + delta = now - tfork_restore_profile_last; + tfork_restore_profile_last = now; + } + if (now >= tfork_restore_profile_origin) + elapsed = now - tfork_restore_profile_origin; + pr_info("tfork-profile: phase=B-restore mark=%s pid=%d delta_us=%llu elapsed_us=%llu\n", mark, getpid(), - (unsigned long long)((now - tfork_restore_profile_last) / 1000ULL), - (unsigned long long)((now - tfork_restore_profile_origin) / 1000ULL)); - tfork_restore_profile_last = now; + (unsigned long long)(delta / 1000ULL), + (unsigned long long)(elapsed / 1000ULL)); } #ifndef arch_export_unmap @@ -305,12 +312,15 @@ static int __restore_wait_inprogress_tasks(int participants) if (profile_started) { uint64_t now = tfork_restore_profile_now(); + uint64_t elapsed = 0; - pr_warn("tfork-profile: phase=B-wait seq=%u pid=%d stage=%d " + if (now >= profile_started) + elapsed = now - profile_started; + pr_info("tfork-profile: phase=B-wait seq=%u pid=%d stage=%d " "participants=%d initial=%d final=%d duration_us=%llu\n", profile_seq, getpid(), (int)futex_get(&task_entries->start), participants, profile_initial, (int)futex_get(np), - (unsigned long long)((now - profile_started) / 1000ULL)); + (unsigned long long)(elapsed / 1000ULL)); } ret = (int)futex_get(np); diff --git a/criu/criu/cr-tfork.c b/criu/criu/cr-tfork.c index 43393ccbd..82534eaa4 100644 --- a/criu/criu/cr-tfork.c +++ b/criu/criu/cr-tfork.c @@ -72,15 +72,22 @@ static void tfork_parent_profile_init(void) static void tfork_parent_profile_mark(const char *mark) { uint64_t now; + uint64_t delta = 0; + uint64_t elapsed = 0; if (!tfork_parent_profile) return; now = tfork_parent_profile_now(); + if (now >= tfork_parent_profile_last) { + delta = now - tfork_parent_profile_last; + tfork_parent_profile_last = now; + } + if (now >= tfork_parent_profile_origin) + elapsed = now - tfork_parent_profile_origin; pr_info("tfork-profile: phase=B-parent mark=%s delta_us=%llu elapsed_us=%llu\n", mark, - (unsigned long long)((now - tfork_parent_profile_last) / 1000ULL), - (unsigned long long)((now - tfork_parent_profile_origin) / 1000ULL)); - tfork_parent_profile_last = now; + (unsigned long long)(delta / 1000ULL), + (unsigned long long)(elapsed / 1000ULL)); } static int tfork_dup_inherited_vma_cherrypick(int env_fd)