Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,11 @@ $(BUILD_DIR)/probe-volume-naming: $(BUILD_DIR)/probe-volume-naming.o \
@echo " LD $@"
$(Q)$(CC) $(CFLAGS) -o $@ $^

## Build the disk-write probe test-slab-exit-writes measures with (native macOS)
$(BUILD_DIR)/probe-disk-writes: $(BUILD_DIR)/probe-disk-writes.o | $(BUILD_DIR)
@echo " LD $@"
$(Q)$(CC) $(CFLAGS) -o $@ $^

## Build the filename codec host test (native macOS binary)
# casefold.o is a leaf translation unit with no syscall-layer dependencies, so
# the test links exactly the code under test and nothing else.
Expand Down
30 changes: 24 additions & 6 deletions docs/internals.md
Original file line number Diff line number Diff line change
Expand Up @@ -711,8 +711,9 @@ the backing file and sends the snapshot fd over `SCM_RIGHTS`:
- Parent stays on `MAP_SHARED` and does NOT remap -- HVF caches the host
VA->PA mapping from `hv_vm_map`, and a `MAP_FIXED` remap does not
update Stage-2, so a remapping parent would observe stale pages.
- Child maps the snapshot fd `MAP_PRIVATE`, producing an instant CoW
clone with zero data copy.
- Child maps the snapshot fd `MAP_SHARED` and keeps it as its own backing
file, so its writes land in the clone and it can clone that in turn for
its own forks. No data is copied.
- The IPC header sets `has_shm = 1` and `num_regions = 0`, skipping memory
serialization entirely.
- Child calls `guest_init_from_shm()` instead of `guest_init()`, and must
Expand All @@ -726,10 +727,27 @@ the backing file and sends the snapshot fd over `SCM_RIGHTS`:
This path is roughly 50x faster than the legacy IPC copy path on large
guest memories.

The CoW path is disabled when hosting Rosetta because HVF caches the host
VA->PA mapping from `hv_vm_map`, and Rosetta's translated code touches
the parent's slab in ways the snapshot model cannot intercept. Rosetta
forks fall back to the legacy IPC copy path.
If `fclonefileat` fails, a native guest sends its live backing fd instead.
The child maps the parent's file `MAP_PRIVATE`, so any page it has not
written still reads the parent's current bytes, and the parent sets
`g->shm_exported`.

The host diagnostic switch `ELFUSE_DISABLE_FORK_CLONEFILE=1` bypasses the
clone attempt so `test-slab-exit-fork` can exercise the live-fd fallback on
APFS. Other values leave the clone attempt enabled.

On normal teardown, `guest_destroy` truncates the backing file before its
last close: the file is unlinked, but that close would still write every
dirty page back to disk. It skips the truncate when `shm_exported` is set, because
truncating a file another process maps privately stalls the host while the
kernel exhausts memory.

If a worker vCPU remains live past the join cap, `guest_destroy` returns
before the truncate and leaves resource cleanup to process exit.

Rosetta also attempts the APFS clone, but falls back to region copy when
cloning fails or is disabled. The live-fd fallback could expose partially
updated translator state from the parent's slab to the child.

macOS rejects `MAP_PRIVATE` on `shm_open` fds (`EINVAL`), so the backing
file is created via `mkstemp` + `unlink`.
Expand Down
1 change: 1 addition & 0 deletions mk/config.mk
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ NATIVE_TESTS := tests/test-multi-vcpu.c tests/test-rwx.c \
tests/test-casefold-walk-host.c \
tests/test-absock-names-host.c \
tests/probe-volume-naming.c \
tests/probe-disk-writes.c \
tests/test-dynamic-array-host.c \
tests/test-string-builder-host.c \
tests/test-wakeup-pipe-host.c \
Expand Down
91 changes: 91 additions & 0 deletions mk/tests.mk
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ ELFUSE_HOST_NOFILE_MIN ?= $(shell bash "$(CURDIR)/tests/test-config.sh" --host-n
test-sysroot-inotify-names test-sysroot-exec-names \
test-sysroot-interp-fallback test-sysroot-interp-cased \
test-sysroot-absock-names test-absock-cleanup test-registry-stale-pid \
test-slab-exit-writes test-slab-exit-fork \
test-linkat-symlink-fallback test-casefold-host \
test-casefold-walk-host test-absock-names-host \
test-wakeup-pipe-host test-guest-env-host \
Expand Down Expand Up @@ -350,6 +351,8 @@ check: $(ELFUSE_BIN) $(TEST_DEPS) check-syscall-coverage check-eintr-contract ch
$(call run-lane,test-fuse-alpine,Alpine sysroot FUSE validation)
$(call run-lane,test-timeout-disable,timeout=0 validation)
$(call run-lane,test-launch-flags,launch flags)
$(call run-lane,test-slab-exit-writes,exit discards dirty guest memory)
$(call run-lane,test-slab-exit-fork,fork children and the exit truncate)
$(call run-lane,test-rosetta-cli,rosetta CLI gating)
$(call run-lane,test-bench-guardrail,hot-syscall guardrail)
$(call run-lane,test-sharun,sharun launcher and probe)
Expand Down Expand Up @@ -728,6 +731,94 @@ test-absock-cleanup: $(ELFUSE_BIN) $(BUILD_DIR)/test-absock-cleanup
fi; \
$(ASSERT_NO_ABSOCK_LEAK)

# Run one lane step with its output in $$tmpdir/NAME.log, failing the lane
# with that log when the step fails.
define SLAB_STEP
step() { name=$$1; shift; "$$@" > "$$tmpdir/$$name.log" 2>&1 || { printf "FAIL: %s\n" "$$name"; cat "$$tmpdir/$$name.log"; exit 1; }; }
endef

SLAB_GUEST_DEPS :=
ifndef GUEST_TEST_BINARIES
SLAB_GUEST_DEPS := $(TEST_DIR)/test-slab-exit
endif

define SLAB_GUEST
guest="$(TEST_DIR)/test-slab-exit"; \
if [ -n "$(GUEST_TEST_BINARIES)" ] && [ ! -x "$$guest" ]; then \
printf "$(YELLOW)SKIP$(RESET) test-slab-exit unavailable in prebuilt bundle\n"; \
exit 0; \
fi
endef

# guest_destroy truncates the slab file so that exit writes none of the
# guest's dirty memory back. The probe records the kernel's block-write count
# from the zombie. A control that writes and fsyncs 32 MiB must register at
# least 24 MiB, or the counter measures nothing here and the lane skips; a run
# that dirties 256 MiB must then stay under 32 MiB.
## exit discards dirty guest memory
test-slab-exit-writes: $(ELFUSE_BIN) $(SLAB_GUEST_DEPS) \
$(BUILD_DIR)/probe-disk-writes
@$(SYSROOT_SCRATCH); \
$(SLAB_STEP); \
$(SLAB_GUEST); \
probe=$(BUILD_DIR)/probe-disk-writes; \
step control $$probe "$$tmpdir/n" $(ELFUSE_BIN) "$$guest" \
write 32 "$$tmpdir/file"; \
n=$$(cat "$$tmpdir/n"); \
if [ "$$n" -lt $$((24 << 20)) ]; then \
printf "$(YELLOW)SKIP$(RESET) disk counter saw %s bytes of a 32 MiB fsync\n" \
"$$n"; \
exit 0; \
fi; \
printf " %-30s " "256 MiB dirty at exit"; \
step dirty $$probe "$$tmpdir/n" $(ELFUSE_BIN) "$$guest" dirty 256; \
n=$$(cat "$$tmpdir/n"); \
if [ "$$n" -ge $$((32 << 20)) ]; then \
printf "FAIL: %s bytes written\n" "$$n"; exit 1; \
fi; \
printf "OK (%s bytes written)\n" "$$n"

# The truncate is safe only while no other process maps the slab file.
# orphan has a child read its memory after its parent exited. The parent
# must skip truncation for the live-fd fallback, but not for an APFS clone.
# Force the fallback too, since an APFS clone cannot exercise that guard.
## fork children and the exit truncate
test-slab-exit-fork: $(ELFUSE_BIN) $(SLAB_GUEST_DEPS)
Comment thread
henrybear327 marked this conversation as resolved.
@$(SYSROOT_SCRATCH); \
$(SLAB_STEP); \
$(SLAB_GUEST); \
for mode in normal fallback; do \
disable_clone=0; \
if [ "$$mode" = fallback ]; then disable_clone=1; fi; \
dir="$$tmpdir/$$mode"; \
mkdir "$$dir"; \
printf " %-30s " "child outlives parent ($$mode)"; \
step "$$mode" env ELFUSE_DISABLE_FORK_CLONEFILE=$$disable_clone \
$(ELFUSE_BIN) --verbose "$$guest" orphan "$$dir"; \
touch "$$dir/go"; \
i=0; \
while [ ! -s "$$dir/result" ] && [ $$i -lt 300 ]; do \
sleep 0.1; i=$$((i + 1)); \
done; \
result=$$(cat "$$dir/result" 2>/dev/null || true); \
if [ -z "$$result" ]; then \
printf "FAIL: no result\n"; exit 1; \
fi; \
if [ "$$result" != ok ]; then \
printf "FAIL: %s\n" "$$result"; exit 1; \
fi; \
if grep -q 'live shm fd as fallback' "$$tmpdir/$$mode.log"; then \
if ! grep -q 'maps the slab, not truncated' "$$tmpdir/$$mode.log"; then \
printf "FAIL: truncate not skipped for live slab\n"; exit 1; \
fi; \
elif [ "$$mode" = fallback ]; then \
printf "FAIL: forced fallback not taken\n"; exit 1; \
elif grep -q 'maps the slab, not truncated' "$$tmpdir/$$mode.log"; then \
printf "FAIL: truncate skipped after a clone\n"; exit 1; \
fi; \
printf "OK\n"; \
done

# An exited member's registry record outlives it, and macOS can hand its host
# pid to another elfuse process. The recipe plants such a record, host pid of
# a live unrelated elfuse run with a start time it does not have, and the
Expand Down
13 changes: 12 additions & 1 deletion src/core/guest.c
Original file line number Diff line number Diff line change
Expand Up @@ -871,8 +871,19 @@ void guest_destroy(guest_t *g)
}
g->nregions = 0;
g->npreannounced = 0;
/* Close the shm fd if guest memory owns one (parent with shm backing) */

/* The backing file is unlinked, yet this last close still writes its dirty
* pages back to disk; truncating first discards them. Not when a fork child
* was handed the file itself: it reads every page it has not written from
* here, and truncating under that private mapping stalls the host while the
* kernel exhausts memory.
*/
if (g->shm_fd >= 0) {
if (g->shm_exported)
log_debug(
"guest_destroy: a fork child maps the slab, not truncated");
else if (ftruncate(g->shm_fd, 0) != 0)
log_debug("guest_destroy: slab truncate: %s", strerror(errno));
close(g->shm_fd);
g->shm_fd = -1;
}
Expand Down
1 change: 1 addition & 0 deletions src/core/guest.h
Original file line number Diff line number Diff line change
Expand Up @@ -451,6 +451,7 @@ typedef struct {
typedef struct {
void *host_base; /* Host pointer to allocated guest memory */
int shm_fd; /* File fd backing host_base for CoW fork (-1 if MAP_ANON) */
bool shm_exported; /* A fork child was handed the shm_fd file itself */

uint64_t guest_size; /* Total size (determined by IPA capacity) */
uint64_t ipa_base; /* IPA base for hv_vm_map (GUEST_IPA_BASE) */
Expand Down
29 changes: 21 additions & 8 deletions src/runtime/forkipc.c
Original file line number Diff line number Diff line change
Expand Up @@ -203,9 +203,10 @@ int fork_child_main(int ipc_fd,
guest_t g;

if (hdr.has_shm) {
/* CoW fork: receive shm fd via SCM_RIGHTS, then map MAP_PRIVATE. This
* gives the child an instant copy-on-write snapshot of the parent's
* entire guest memory, with no region enumeration or byte copying.
/* CoW fork: receive shm fd via SCM_RIGHTS for guest_init_from_shm to
* map. This gives the child an instant copy-on-write snapshot of the
* parent's entire guest memory, with no region enumeration or byte
* copying.
*/
int shm_fd = -1, shm_count = 0;
if (fork_ipc_recv_fds(ipc_fd, &shm_fd, 1, &shm_count) < 0 ||
Expand Down Expand Up @@ -1769,8 +1770,8 @@ int64_t sys_clone(hv_vcpu_t vcpu,
goto fail_snapshot;

/* CoW fast path: if shm_fd >= 0, send a snapshot of guest memory to the
* child instead of the per-region copy. The child maps that snapshot
* MAP_PRIVATE; subsequent writes on either side are private.
* child instead of the per-region copy; guest_init_from_shm maps it so the
* child's writes stay out of the parent's memory.
*
* The parent's own mapping cannot be flipped to MAP_PRIVATE here: hv_vm_map
* caches the host VA->PA mapping, and a MAP_FIXED remap invalidates it (the
Expand Down Expand Up @@ -1833,8 +1834,15 @@ int64_t sys_clone(hv_vcpu_t vcpu,
* fork (Redis BGSAVE, checkpointing runtimes). On failure the fallback
* differs per design above: Rosetta drops use_shm so the region-copy
* path runs; native guests keep use_shm and send the live g->shm_fd.
* Exercise the fallback on APFS hosts.
*/
snapshot_shm_fd = fork_snapshot_shm_via_clonefile(g->shm_fd);
const char *disable_clone = getenv("ELFUSE_DISABLE_FORK_CLONEFILE");
if (disable_clone && strcmp(disable_clone, "1") == 0) {
snapshot_shm_fd = -1;
errno = ENOTSUP;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: This new branch routes a deliberately disabled clone through the error path, so on Rosetta the pre-existing handler logs "clone: rosetta CoW snapshot via fclonefileat failed (Operation not supported)" even though fclonefileat was never attempted, and native guests log a fabricated ENOTSUP. Emit the diagnostic before faking the failure, or use a distinct message for the disabled case, so the logs report the real cause.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At src/runtime/forkipc.c, line 1842:

<comment>This new branch routes a deliberately disabled clone through the error path, so on Rosetta the pre-existing handler logs "clone: rosetta CoW snapshot via fclonefileat failed (Operation not supported)" even though fclonefileat was never attempted, and native guests log a fabricated ENOTSUP. Emit the diagnostic before faking the failure, or use a distinct message for the disabled case, so the logs report the real cause.</comment>

<file context>
@@ -1834,8 +1834,15 @@ int64_t sys_clone(hv_vcpu_t vcpu,
+        const char *disable_clone = getenv("ELFUSE_DISABLE_FORK_CLONEFILE");
+        if (disable_clone && strcmp(disable_clone, "1") == 0) {
+            snapshot_shm_fd = -1;
+            errno = ENOTSUP;
+        } else {
+            snapshot_shm_fd = fork_snapshot_shm_via_clonefile(g->shm_fd);
</file context>

} else {
snapshot_shm_fd = fork_snapshot_shm_via_clonefile(g->shm_fd);
}
if (snapshot_shm_fd < 0) {
if (g->is_rosetta) {
log_warn(
Expand Down Expand Up @@ -2010,6 +2018,12 @@ int64_t sys_clone(hv_vcpu_t vcpu,
goto fail_snapshot;
}

/* Only an admitted child keeps the live slab mapped. The flag stays set
* after it exits, which costs only the write-back.
*/
if (use_shm && snapshot_shm_fd < 0)
g->shm_exported = true;

/* The child's inherited slave copies are live from the moment fork returns,
* so they go on the shared count here rather than in the child's own init,
* which the parent's close of its copy can beat.
Expand All @@ -2033,8 +2047,7 @@ int64_t sys_clone(hv_vcpu_t vcpu,
close(ipc_sock);

/* After CoW fork, parent stays on MAP_SHARED because no remap was done. The
* shm fd is kept open so subsequent forks can also use CoW. The child has
* its own MAP_PRIVATE view of the same file.
* shm fd is kept open so subsequent forks can also use CoW.
*/

/* CLONE_VFORK suspends the parent until the child exits or execs. The
Expand Down
65 changes: 65 additions & 0 deletions tests/probe-disk-writes.c
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
/*
* Record the bytes a command wrote to disk, as the kernel charged them
*
* Copyright 2026 elfuse contributors
* SPDX-License-Identifier: Apache-2.0
*
* Usage: probe-disk-writes OUT CMD [ARG...]
*
* Runs CMD, waits for it to exit without reaping it, and writes the
* ri_diskio_byteswritten count of the zombie to OUT. That count includes the
* write-back a process forces by closing the last descriptor of a file with
* dirty mapped pages, which is what test-slab-exit-writes measures; once
* reaped, the count is gone. OUT keeps the number clear of whatever CMD prints.
* Exits with CMD's status.
*/

#include <libproc.h>
#include <spawn.h>
#include <stdio.h>
#include <string.h>
#include <sys/resource.h>
#include <sys/wait.h>

extern char **environ;

int main(int argc, char **argv)
{
if (argc < 3) {
fprintf(stderr, "usage: %s OUT CMD [ARG...]\n", argv[0]);
return 2;
}

pid_t pid;
int rc = posix_spawnp(&pid, argv[2], NULL, NULL, argv + 2, environ);
if (rc != 0) {
fprintf(stderr, "probe-disk-writes: %s: %s\n", argv[2], strerror(rc));
return 2;
}

siginfo_t info;
if (waitid(P_PID, (id_t) pid, &info, WEXITED | WNOWAIT) < 0) {
perror("probe-disk-writes: waitid");
return 2;
}
struct rusage_info_v4 usage;
if (proc_pid_rusage(pid, RUSAGE_INFO_V4, (rusage_info_t *) &usage) != 0) {
perror("probe-disk-writes: proc_pid_rusage");
return 2;
}

int status;
if (waitpid(pid, &status, 0) < 0) {
perror("probe-disk-writes: waitpid");
return 2;
}
FILE *out = fopen(argv[1], "w");
if (!out ||
fprintf(out, "%llu\n",
(unsigned long long) usage.ri_diskio_byteswritten) < 0 ||
fclose(out) != 0) {
perror(argv[1]);
return 2;
}
return WIFEXITED(status) ? WEXITSTATUS(status) : 128 + WTERMSIG(status);
}
Loading
Loading