Skip to content

feat(memfd): async pause/copy (wait-on-read)#2626

Draft
ValentaTomas wants to merge 2 commits into
zero-copy-pausefrom
memfd-bg-copy
Draft

feat(memfd): async pause/copy (wait-on-read)#2626
ValentaTomas wants to merge 2 commits into
zero-copy-pausefrom
memfd-bg-copy

Conversation

@ValentaTomas
Copy link
Copy Markdown
Member

@ValentaTomas ValentaTomas commented May 11, 2026

Background goroutine copies memfd → diff cache after Pause returns. The
snapshot file + diff metadata are written synchronously; the byte copy
runs detached so Pause returns sooner. Memfd is closed (hugetlb pages
released) at the end of the copy.

Reads (ReadAt/Slice/CachePath) Wait for the copy before serving.

Gated by MemfdBackgroundCopyFlag; requires UseMemFdFlag (#2522).

@cla-bot cla-bot Bot added the cla-signed label May 11, 2026
@cursor
Copy link
Copy Markdown

cursor Bot commented May 11, 2026

PR Summary

Medium Risk
Adds concurrency and lifecycle coupling between snapshot diff reads and an async memfd→cache copy, which can cause hangs or resource retention if the background copy stalls or cancellation is mishandled.

Overview
This introduces an async memfd→cache copy path (MemfdCache + memfd-background-copy flag) and widens the diff interface to block.DiffSource, but ReadAt/Slice/CachePath wait using context.Background(), so callers cannot cancel and may hang indefinitely if the copy blocks; the new background goroutine also extends memfd lifetime (hugepage retention) until copy completion or Close, increasing the impact of stalls/leaks.

Reviewed by Cursor Bugbot for commit 3964c31. Bugbot is set up for automated code reviews on this repo. Configure here.

@codecov
Copy link
Copy Markdown

codecov Bot commented May 11, 2026

❌ 3 Tests Failed:

Tests completed Failed Passed Skipped
2624 3 2621 5
View the full list of 7 ❄️ flaky test(s)
github.com/e2b-dev/infra/tests/integration/internal/tests/api/metrics::TestTeamMetrics

Flake rate in main: 71.70% (Passed 221 times, Failed 560 times)

Stack Traces | 1.44s run time
=== RUN   TestTeamMetrics
=== PAUSE TestTeamMetrics
=== CONT  TestTeamMetrics
    team_metrics_test.go:61: 
        	Error Trace:	.../api/metrics/team_metrics_test.go:61
        	Error:      	Should be true
        	Test:       	TestTeamMetrics
        	Messages:   	MaxConcurrentSandboxes should be >= 0
--- FAIL: TestTeamMetrics (1.44s)
github.com/e2b-dev/infra/tests/integration/internal/tests/api/sandboxes::TestUpdateNetworkConfig

Flake rate in main: 76.85% (Passed 231 times, Failed 767 times)

Stack Traces | 223s run time
=== RUN   TestUpdateNetworkConfig
=== PAUSE TestUpdateNetworkConfig
=== CONT  TestUpdateNetworkConfig
    sandbox_network_out_test.go:30: Building custom template for network egress tests...
    template.go:44: network-egress-test: [info] Building template dn6rbxdajjkk7umst5dm/24842bca-e4d2-43b7-ac70-abbde844a046
    template.go:44: network-egress-test: [info] [base] FROM ubuntu:22.04 [ffd709f131f42dfab282de47a91dd2c139e900c1c11fc574b49b517a05ef0a32]
    template.go:44: network-egress-test: [info] Base Docker image size: 30 MB
    template.go:44: network-egress-test: [info] Creating file system and pulling Docker image
    template.go:44: network-egress-test: [info] Uncompressing layer sha256:40d16f30db405106ef8074779bdf41f012465c2a785bbeaa2eab9f2081099b47 30 MB
    template.go:44: network-egress-test: [info] Uncompressing layer sha256:a117ce130b830c2c8b89fab9e22b8d2edf10f029d4e2e908abc645ba6d1d2cff 12 MB
    template.go:44: network-egress-test: [info] Uncompressing layer sha256:8c4b1b28875140ed3abacaf16ad0d696f6bef912f52d2148f261a23e3349465b 168 B
    template.go:44: network-egress-test: [info] Layers extracted
    template.go:44: network-egress-test: [info] Root filesystem structure: bin, boot, dev, etc, home, lib, lib32, lib64, libx32, media, mnt, opt, proc, root, run, sbin, srv, sys, tmp, usr, var
    template.go:44: network-egress-test: [info] Provisioning sandbox template
    template.go:44: network-egress-test: [info] Provisioning was successful, cleaning up
    template.go:44: network-egress-test: [info] Sandbox template provisioned
    template.go:44: network-egress-test: [info] [base] DEFAULT USER user [90bdd4afa342293c931373351bf578872dec9179214ba3e8bf9edba311466213]
    template.go:44: network-egress-test: [info] [builder 1/1] RUN sudo apt-get update && sudo apt-get install -y curl iputils-ping dnsutils openssh-client gnupg && sudo rm -rf .../lib/apt/lists/* [2463a9871c5acb096dc00a6b03e4965da11a9e03c5f0618cbf556435df9fae17]
    template.go:44: network-egress-test: [info] [builder 1/1] [stdout]: Hit:1 http://security.ubuntu.com/ubuntu jammy-security InRelease
    template.go:44: network-egress-test: [info] [builder 1/1] [stdout]: Hit:2 http://archive.ubuntu.com/ubuntu jammy InRelease
    template.go:44: network-egress-test: [info] [builder 1/1] [stdout]: Hit:3 http://archive.ubuntu.com/ubuntu jammy-updates InRelease
    template.go:44: network-egress-test: [info] [builder 1/1] [stdout]: Hit:4 http://archive.ubuntu.com/ubuntu jammy-backports InRelease
    template.go:44: network-egress-test: [info] [builder 1/1] [stdout]: Reading package lists...
    template.go:44: network-egress-test: [info] [builder 1/1] [stdout]: Reading package lists...
    template.go:44: network-egress-test: [info] [builder 1/1] [stdout]: Building dependency tree...
    template.go:44: network-egress-test: [info] [builder 1/1] [stdout]: Reading state information...
    template.go:44: network-egress-test: [info] [builder 1/1] [stdout]: curl is already the newest version (7.81.0-1ubuntu1.24).
    template.go:44: network-egress-test: [info] [builder 1/1] [stdout]: openssh-client is already the newest version (1:8.9p1-3ubuntu0.15).
    template.go:44: network-egress-test: [info] [builder 1/1] [stdout]: openssh-client set to manually installed.
    template.go:44: network-egress-test: [info] [builder 1/1] [stdout]: The following additional packages will be installed:
    template.go:44: network-egress-test: [info] [builder 1/1] [stdout]: bind9-dnsutils bind9-host bind9-libs dirmngr gnupg-l10n gnupg-utils gpg
    template.go:44: network-egress-test: [info] [builder 1/1] [stdout]: gpg-agent gpg-wks-client gpg-wks-server gpgconf gpgsm libassuan0 libicu70
    template.go:44: network-egress-test: [info] [builder 1/1] [stdout]: libksba8 liblmdb0 libmaxminddb0 libnpth0 libuv1 libxml2 pinentry-curses
    template.go:44: network-egress-test: [info] [builder 1/1] [stdout]: Suggested packages:
    template.go:44: network-egress-test: [info] [builder 1/1] [stdout]: dbus-user-session libpam-systemd pinentry-gnome3 tor parcimonie xloadimage
    template.go:44: network-egress-test: [info] [builder 1/1] [stdout]: scdaemon mmdb-bin pinentry-doc
    template.go:44: network-egress-test: [info] [builder 1/1] [stdout]: The following NEW packages will be installed:
    template.go:44: network-egress-test: [info] [builder 1/1] [stdout]: bind9-dnsutils bind9-host bind9-libs dirmngr dnsutils gnupg gnupg-l10n
    template.go:44: network-egress-test: [info] [builder 1/1] [stdout]: gnupg-utils gpg gpg-agent gpg-wks-client gpg-wks-server gpgconf gpgsm
    template.go:44: network-egress-test: [info] [builder 1/1] [stdout]: iputils-ping libassuan0 libicu70 libksba8 liblmdb0 libmaxminddb0 libnpth0
    template.go:44: network-egress-test: [info] [builder 1/1] [stdout]: libuv1 libxml2 pinentry-curses
    template.go:44: network-egress-test: [info] [builder 1/1] [stdout]: 0 upgraded, 24 newly installed, 0 to remove and 0 not upgraded.
    template.go:44: network-egress-test: [info] [builder 1/1] [stdout]: Need to get 15.3 MB of archives.
    template.go:44: network-egress-test: [info] [builder 1/1] [stdout]: After this operation, 48.2 MB of additional disk space will be used.
    template.go:44: network-egress-test: [info] [builder 1/1] [stdout]: Get:1 http://archive.ubuntu.com/ubuntu jammy-updates/main amd64 iputils-ping amd64 3:20211215-1ubuntu0.1 [43.0 kB]
    template.go:44: network-egress-test: [info] [builder 1/1] [stdout]: Get:2 http://archive.ubuntu.com/ubuntu jammy/main amd64 libicu70 amd64 70.1-2 [10.6 MB]
    template.go:44: network-egress-test: [info] [builder 1/1] [stdout]: Get:3 http://archive.ubuntu.com/ubuntu jammy-updates/main amd64 libxml2 amd64 2.9.13+dfsg-1ubuntu0.11 [765 kB]
    template.go:44: network-egress-test: [info] [builder 1/1] [stdout]: Get:4 http://archive.ubuntu.com/ubuntu jammy/main amd64 liblmdb0 amd64 0.9.24-1build2 [47.6 kB]
    template.go:44: network-egress-test: [info] [builder 1/1] [stdout]: Get:5 http://archive.ubuntu.com/ubuntu jammy/main amd64 libmaxminddb0 amd64 1.5.2-1build2 [24.7 kB]
    template.go:44: network-egress-test: [info] [builder 1/1] [stdout]: Get:6 http://archive.ubuntu.com/ubuntu jammy-updates/main amd64 libuv1 amd64 1.43.0-1ubuntu0.1 [92.7 kB]
    template.go:44: network-egress-test: [info] [builder 1/1] [stdout]: Get:7 http://archive.ubuntu.com/ubuntu jammy-updates/main amd64 bind9-libs amd64 1:9.18.39-0ubuntu0.22.04.3 [1262 kB]
    template.go:44: network-egress-test: [info] [builder 1/1] [stdout]: Get:8 http://archive.ubuntu.com/ubuntu jammy-updates/main amd64 bind9-host amd64 1:9.18.39-0ubuntu0.22.04.3 [52.5 kB]
    template.go:44: network-egress-test: [info] [builder 1/1] [stdout]: Get:9 http://archive.ubuntu.com/ubuntu jammy-updates/main amd64 bind9-dnsutils amd64 1:9.18.39-0ubuntu0.22.04.3 [158 kB]
    template.go:44: network-egress-test: [info] [builder 1/1] [stdout]: Get:10 http://archive.ubuntu.com/ubuntu jammy/main amd64 libassuan0 amd64 2.5.5-1build1 [38.2 kB]
    template.go:44: network-egress-test: [info] [builder 1/1] [stdout]: Get:11 http://archive.ubuntu.com/ubuntu jammy-updates/main amd64 gpgconf amd64 2.2.27-3ubuntu2.5 [94.3 kB]
    template.go:44: network-egress-test: [info] [builder 1/1] [stdout]: Get:12 http://archive.ubuntu.com/ubuntu jammy-updates/main amd64 libksba8 amd64 1.6.0-2ubuntu0.2 [119 kB]
    template.go:44: network-egress-test: [info] [builder 1/1] [stdout]: Get:13 http://archive.ubuntu.com/ubuntu jammy/main amd64 libnpth0 amd64 1.6-3build2 [8664 B]
    template.go:44: network-egress-test: [info] [builder 1/1] [stdout]: Get:14 http://archive.ubuntu.com/ubuntu jammy-updates/main amd64 dirmngr amd64 2.2.27-3ubuntu2.5 [293 kB]
    template.go:44: network-egress-test: [info] [builder 1/1] [stdout]: Get:15 http://archive.ubuntu.com/ubuntu jammy-updates/universe amd64 dnsutils all 1:9.18.39-0ubuntu0.22.04.3 [3924 B]
    template.go:44: network-egress-test: [info] [builder 1/1] [stdout]: Get:16 http://archive.ubuntu.com/ubuntu jammy-updates/main amd64 gnupg-l10n all 2.2.27-3ubuntu2.5 [54.5 kB]
    template.go:44: network-egress-test: [info] [builder 1/1] [stdout]: Get:17 http://archive.ubuntu.com/ubuntu jammy-updates/main amd64 gnupg-utils amd64 2.2.27-3ubuntu2.5 [309 kB]
    template.go:44: network-egress-test: [info] [builder 1/1] [stdout]: Get:18 http://archive.ubuntu.com/ubuntu jammy-updates/main amd64 gpg amd64 2.2.27-3ubuntu2.5 [519 kB]
    template.go:44: network-egress-test: [info] [builder 1/1] [stdout]: Get:19 http://archive.ubuntu.com/ubuntu jammy/main amd64 pinentry-curses amd64 1.1.1-1build2 [34.4 kB]
    template.go:44: network-egress-test: [info] [builder 1/1] [stdout]: Get:20 http://archive.ubuntu.com/ubuntu jammy-updates/main amd64 gpg-agent amd64 2.2.27-3ubuntu2.5 [209 kB]
    template.go:44: network-egress-test: [info] [builder 1/1] [stdout]: Get:21 http://archive.ubuntu.com/ubuntu jammy-updates/main amd64 gpg-wks-client amd64 2.2.27-3ubuntu2.5 [62.7 kB]
    template.go:44: network-egress-test: [info] [builder 1/1] [stdout]: Get:22 http://archive.ubuntu.com/ubuntu jammy-updates/main amd64 gpg-wks-server amd64 2.2.27-3ubuntu2.5 [57.6 kB]
    template.go:44: network-egress-test: [info] [builder 1/1] [stdout]: Get:23 http://archive.ubuntu.com/ubuntu jammy-updates/main amd64 gpgsm amd64 2.2.27-3ubuntu2.5 [197 kB]
    template.go:44: network-egress-test: [info] [builder 1/1] [stdout]: Get:24 http://archive.ubuntu.com/ubuntu jammy-updates/main amd64 gnupg all 2.2.27-3ubuntu2.5 [315 kB]
    template.go:44: network-egress-test: [info] [builder 1/1] [stderr]: debconf: delaying package configuration, since apt-utils is not installed
    template.go:44: network-egress-test: [info] [builder 1/1] [stdout]: Fetched 15.3 MB in 1s (16.2 MB/s)
    template.go:44: network-egress-test: [info] [builder 1/1] [stdout]: Selecting previously unselected package iputils-ping.
    template.go:44: network-egress-test: [info] [builder 1/1] [stdout]: (Reading database ...
    template.go:44: network-egress-test: [info] [builder 1/1] [stdout]: (Reading database ... 5%(Reading database ... 10%(Reading database ... 15%(Reading database ... 20%(Reading database ... 25%(Reading database ... 30%(Reading database ... 35%(Reading database ... 40%(Reading database ... 45%(Reading database ... 50%(Reading database ... 55%(Reading database ... 60%(Reading database ... 65%(Reading database ... 70%
    template.go:44: network-egress-test: [info] [builder 1/1] [stdout]: (Reading database ... 75%
    template.go:44: network-egress-test: [info] [builder 1/1] [stdout]: (Reading database ... 80%
    template.go:44: network-egress-test: [info] [builder 1/1] [stdout]: (Reading database ... 85%
    template.go:44: network-egress-test: [info] [builder 1/1] [stdout]: (Reading database ... 90%
    template.go:44: network-egress-test: [info] [builder 1/1] [stdout]: (Reading database ... 95%
    template.go:44: network-egress-test: [info] [builder 1/1] [stdout]: (Reading database ... 100%(Reading database ... 12395 files and directories currently installed.)
    template.go:44: network-egress-test: [info] [builder 1/1] [stdout]: Preparing to unpack .../00-iputils-ping_3%3a20211215-1ubuntu0.1_amd64.deb ...
    template.go:44: network-egress-test: [info] [builder 1/1] [stdout]: Unpacking iputils-ping (3:20211215-1ubuntu0.1) ...
    template.go:44: network-egress-test: [info] [builder 1/1] [stdout]: Selecting previously unselected package libicu70:amd64.
    template.go:44: network-egress-test: [info] [builder 1/1] [stdout]: Preparing to unpack .../01-libicu70_70.1-2_amd64.deb ...
    template.go:44: network-egress-test: [info] [builder 1/1] [stdout]: Unpacking libicu70:amd64 (70.1-2) ...
    template.go:44: network-egress-test: [info] [builder 1/1] [stdout]: Selecting previously unselected package libxml2:amd64.
    template.go:44: network-egress-test: [info] [builder 1/1] [stdout]: Preparing to unpack .../02-libxml2_2.9.13+dfsg-1ubuntu0.11_amd64.deb ...
    template.go:44: network-egress-test: [info] [builder 1/1] [stdout]: Unpacking libxml2:amd64 (2.9.13+dfsg-1ubuntu0.11) ...
    template.go:44: network-egress-test: [info] [builder 1/1] [stdout]: Selecting previously unselected package liblmdb0:amd64.
    template.go:44: network-egress-test: [info] [builder 1/1] [stdout]: Preparing to unpack .../03-liblmdb0_0.9.24-1build2_amd64.deb ...
    template.go:44: network-egress-test: [info] [builder 1/1] [stdout]: Unpacking liblmdb0:amd64 (0.9.24-1build2) ...
    template.go:44: network-egress-test: [info] [builder 1/1] [stdout]: Selecting previously unselected package libmaxminddb0:amd64.
    template.go:44: network-egress-test: [info] [builder 1/1] [stdout]: Preparing to unpack .../04-libmaxminddb0_1.5.2-1build2_amd64.deb ...
    template.go:44: network-egress-test: [info] [builder 1/1] [stdout]: Unpacking libmaxminddb0:amd64 (1.5.2-1build2) ...
    template.go:44: network-egress-test: [info] [builder 1/1] [stdout]: Selecting previously unselected package libuv1:amd64.
    template.go:44: network-egress-test: [info] [builder 1/1] [stdout]: Preparing to unpack .../05-libuv1_1.43.0-1ubuntu0.1_amd64.deb ...
    template.go:44: network-egress-test: [info] [builder 1/1] [stdout]: Unpacking libuv1:amd64 (1.43.0-1ubuntu0.1) ...
    template.go:44: network-egress-test: [info] [builder 1/1] [stdout]: Selecting previously unselected package bind9-libs:amd64.
    template.go:44: network-egress-test: [info] [builder 1/1] [stdout]: Preparing to unpack .../06-bind9-libs_1%3a9.18.39-0ubuntu0.22.04.3_amd64.deb ...
    template.go:44: network-egress-test: [info] [builder 1/1] [stdout]: Unpacking bind9-libs:amd64 (1:9.18.39-0ubuntu0.22.04.3) ...
    template.go:44: network-egress-test: [info] [builder 1/1] [stdout]: Selecting previously unselected package bind9-host.
    template.go:44: network-egress-test: [info] [builder 1/1] [stdout]: Preparing to unpack .../07-bind9-host_1%3a9.18.39-0ubuntu0.22.04.3_amd64.deb ...
    template.go:44: network-egress-test: [info] [builder 1/1] [stdout]: Unpacking bind9-host (1:9.18.39-0ubuntu0.22.04.3) ...
    template.go:44: network-egress-test: [info] [builder 1/1] [stdout]: Selecting previously unselected package bind9-dnsutils.
    template.go:44: network-egress-test: [info] [builder 1/1] [stdout]: Preparing to unpack .../08-bind9-dnsutils_1%3a9.18.39-0ubuntu0.22.04.3_amd64.deb ...
    template.go:44: network-egress-test: [info] [builder 1/1] [stdout]: Unpacking bind9-dnsutils (1:9.18.39-0ubuntu0.22.04.3) ...
    template.go:44: network-egress-test: [info] [builder 1/1] [stdout]: Selecting previously unselected package libassuan0:amd64.
    template.go:44: network-egress-test: [info] [builder 1/1] [stdout]: Preparing to unpack .../09-libassuan0_2.5.5-1build1_amd64.deb ...
    template.go:44: network-egress-test: [info] [builder 1/1] [stdout]: Unpacking libassuan0:amd64 (2.5.5-1build1) ...
    template.go:44: network-egress-test: [info] [builder 1/1] [stdout]: Selecting previously unselected package gpgconf.
    template.go:44: network-egress-test: [info] [builder 1/1] [stdout]: Preparing to unpack .../10-gpgconf_2.2.27-3ubuntu2.5_amd64.deb ...
    template.go:44: network-egress-test: [info] [builder 1/1] [stdout]: Unpacking gpgconf (2.2.27-3ubuntu2.5) ...
    template.go:44: network-egress-test: [info] [builder 1/1] [stdout]: Selecting previously unselected package libksba8:amd64.
    template.go:44: network-egress-test: [info] [builder 1/1] [stdout]: Preparing to unpack .../11-libksba8_1.6.0-2ubuntu0.2_amd64.deb ...
    template.go:44: network-egress-test: [info] [builder 1/1] [stdout]: Unpacking libksba8:amd64 (1.6.0-2ubuntu0.2) ...
    template.go:44: network-egress-test: [info] [builder 1/1] [stdout]: Selecting previously unselected package libnpth0:amd64.
    template.go:44: network-egress-test: [info] [builder 1/1] [stdout]: Preparing to unpack .../12-libnpth0_1.6-3build2_amd64.deb ...
    template.go:44: network-egress-test: [info] [builder 1/1] [stdout]: Unpacking libnpth0:amd64 (1.6-3build2) ...
    template.go:44: network-egress-test: [info] [builder 1/1] [stdout]: Selecting previously unselected package dirmngr.
    template.go:44: network-egress-test: [info] [builder 1/1] [stdout]: Preparing to unpack .../13-dirmngr_2.2.27-3ubuntu2.5_amd64.deb ...
    template.go:44: network-egress-test: [info] [builder 1/1] [stdout]: Unpacking dirmngr (2.2.27-3ubuntu2.5) ...
    template.go:44: network-egress-test: [info] [builder 1/1] [stdout]: Selecting previously unselected package dnsutils.
    template.go:44: network-egress-test: [info] [builder 1/1] [stdout]: Preparing to unpack .../14-dnsutils_1%3a9.18.39-0ubuntu0.22.04.3_all.deb ...
    template.go:44: network-egress-test: [info] [builder 1/1] [stdout]: Unpacking dnsutils (1:9.18.39-0ubuntu0.22.04.3) ...
    template.go:44: network-egress-test: [info] [builder 1/1] [stdout]: Selecting previously unselected package gnupg-l10n.
    template.go:44: network-egress-test: [info] [builder 1/1] [stdout]: Preparing to unpack .../15-gnupg-l10n_2.2.27-3ubuntu2.5_all.deb ...
    template.go:44: network-egress-test: [info] [builder 1/1] [stdout]: Unpacking gnupg-l10n (2.2.27-3ubuntu2.5) ...
    template.go:44: network-egress-test: [info] [builder 1/1] [stdout]: Selecting previously unselected package gnupg-utils.
    template.go:44: network-egress-test: [info] [builder 1/1] [stdout]: Preparing to unpack .../16-gnupg-utils_2.2.27-3ubuntu2.5_amd64.deb ...
    template.go:44: network-egress-test: [info] [builder 1/1] [stdout]: Unpacking gnupg-utils (2.2.27-3ubuntu2.5) ...
    template.go:44: network-egress-test: [info] [builder 1/1] [stdout]: Selecting previously unselected package gpg.
    template.go:44: network-egress-test: [info] [builder 1/1] [stdout]: Preparing to unpack .../17-gpg_2.2.27-3ubuntu2.5_amd64.deb ...
    template.go:44: network-egress-test: [info] [builder 1/1] [stdout]: Unpacking gpg (2.2.27-3ubuntu2.5) ...
    template.go:44: network-egress-test: [info] [builder 1/1] [stdout]: Selecting previously unselected package pinentry-curses.
    template.go:44: network-egress-test: [info] [builder 1/1] [stdout]: Preparing to unpack .../18-pinentry-curses_1.1.1-1build2_amd64.deb ...
    template.go:44: network-egress-test: [info] [builder 1/1] [stdout]: Unpacking pinentry-curses (1.1.1-1build2) ...
    template.go:44: network-egress-test: [info] [builder 1/1] [stdout]: Selecting previously unselected package gpg-agent.
    template.go:44: network-egress-test: [info] [builder 1/1] [stdout]: Preparing to unpack .../19-gpg-agent_2.2.27-3ubuntu2.5_amd64.deb ...
    template.go:44: network-egress-test: [info] [builder 1/1] [stdout]: Unpacking gpg-agent (2.2.27-3ubuntu2.5) ...
    template.go:44: network-egress-test: [info] [builder 1/1] [stdout]: Selecting previously unselected package gpg-wks-client.
    template.go:44: network-egress-test: [info] [builder 1/1] [stdout]: Preparing to unpack .../20-gpg-wks-client_2.2.27-3ubuntu2.5_amd64.deb ...
    template.go:44: network-egress-test: [info] [builder 1/1] [stdout]: Unpacking gpg-wks-client (2.2.27-3ubuntu2.5) ...
    template.go:44: network-egress-test: [info] [builder 1/1] [stdout]: Selecting previously unselected package gpg-wks-server.
    template.go:44: network-egress-test: [info] [builder 1/1] [stdout]: Preparing to unpack .../21-gpg-wks-server_2.2.27-3ubuntu2.5_amd64.deb ...
    template.go:44: network-egress-test: [info] [builder 1/1] [stdout]: Unpacking gpg-wks-server (2.2.27-3ubuntu2.5) ...
    template.go:44: network-egress-test: [info] [builder 1/1] [stdout]: Selecting previously unselected package gpgsm.
    template.go:44: network-egress-test: [info] [builder 1/1] [stdout]: Preparing to unpack .../22-gpgsm_2.2.27-3ubuntu2.5_amd64.deb ...
    template.go:44: network-egress-test: [info] [builder 1/1] [stdout]: Unpacking gpgsm (2.2.27-3ubuntu2.5) ...
    template.go:44: network-egress-test: [info] [builder 1/1] [stdout]: Selecting previously unselected package gnupg.
    template.go:44: network-egress-test: [info] [builder 1/1] [stdout]: Preparing to unpack .../23-gnupg_2.2.27-3ubuntu2.5_all.deb ...
    template.go:44: network-egress-test: [info] [builder 1/1] [stdout]: Unpacking gnupg (2.2.27-3ubuntu2.5) ...
    template.go:44: network-egress-test: [info] [builder 1/1] [stdout]: Setting up libksba8:amd64 (1.6.0-2ubuntu0.2) ...
    template.go:44: network-egress-test: [info] [builder 1/1] [stdout]: Setting up liblmdb0:amd64 (0.9.24-1build2) ...
    template.go:44: network-egress-test: [info] [builder 1/1] [stdout]: Setting up libmaxminddb0:amd64 (1.5.2-1build2) ...
    template.go:44: network-egress-test: [info] [builder 1/1] [stdout]: Setting up libnpth0:amd64 (1.6-3build2) ...
    template.go:44: network-egress-test: [info] [builder 1/1] [stdout]: Setting up libassuan0:amd64 (2.5.5-1build1) ...
    template.go:44: network-egress-test: [info] [builder 1/1] [stdout]: Setting up libuv1:amd64 (1.43.0-1ubuntu0.1) ...
    template.go:44: network-egress-test: [info] [builder 1/1] [stdout]: Setting up gnupg-l10n (2.2.27-3ubuntu2.5) ...
    template.go:44: network-egress-test: [info] [builder 1/1] [stdout]: Setting up gpgconf (2.2.27-3ubuntu2.5) ...
    template.go:44: network-egress-test: [info] [builder 1/1] [stdout]: Setting up iputils-ping (3:20211215-1ubuntu0.1) ...
    template.go:44: network-egress-test: [info] [builder 1/1] [stdout]: Setting up gpg (2.2.27-3ubuntu2.5) ...
    template.go:44: network-egress-test: [info] [builder 1/1] [stdout]: Setting up libicu70:amd64 (70.1-2) ...
    template.go:44: network-egress-test: [info] [builder 1/1] [stdout]: Setting up gnupg-utils (2.2.27-3ubuntu2.5) ...
    template.go:44: network-egress-test: [info] [builder 1/1] [stdout]: Setting up pinentry-curses (1.1.1-1build2) ...
    template.go:44: network-egress-test: [info] [builder 1/1] [stdout]: Setting up gpg-agent (2.2.27-3ubuntu2.5) ...
    template.go:44: network-egress-test: [info] [builder 1/1] [stdout]: Created symlink .../systemd/user/sockets.target.wants/gpg-agent-browser.socket → .../systemd/user/gpg-agent-browser.socket.
    template.go:44: network-egress-test: [info] [builder 1/1] [stdout]: Created symlink .../systemd/user/sockets.target.wants/gpg-agent-extra.socket → .../systemd/user/gpg-agent-extra.socket.
    template.go:44: network-egress-test: [info] [builder 1/1] [stdout]: Created symlink .../systemd/user/sockets.target.wants/gpg-agent-ssh.socket → .../systemd/user/gpg-agent-ssh.socket.
    template.go:44: network-egress-test: [info] [builder 1/1] [stdout]: Created symlink .../systemd/user/sockets.target.wants/gpg-agent.socket → .../systemd/user/gpg-agent.socket.
    template.go:44: network-egress-test: [info] [builder 1/1] [stdout]: Setting up gpgsm (2.2.27-3ubuntu2.5) ...
    template.go:44: network-egress-test: [info] [builder 1/1] [stdout]: Setting up dirmngr (2.2.27-3ubuntu2.5) ...
    template.go:44: network-egress-test: [info] [builder 1/1] [stdout]: Created symlink .../systemd/user/sockets.target.wants/dirmngr.socket → .../systemd/user/dirmngr.socket.
    template.go:44: network-egress-test: [info] [builder 1/1] [stdout]: Setting up gpg-wks-server (2.2.27-3ubuntu2.5) ...
    template.go:44: network-egress-test: [info] [builder 1/1] [stdout]: Setting up libxml2:amd64 (2.9.13+dfsg-1ubuntu0.11) ...
    template.go:44: network-egress-test: [info] [builder 1/1] [stdout]: Setting up gpg-wks-client (2.2.27-3ubuntu2.5) ...
    template.go:44: network-egress-test: [info] [builder 1/1] [stdout]: Setting up bind9-libs:amd64 (1:9.18.39-0ubuntu0.22.04.3) ...
    template.go:44: network-egress-test: [info] [builder 1/1] [stdout]: Setting up gnupg (2.2.27-3ubuntu2.5) ...
    template.go:44: network-egress-test: [info] [builder 1/1] [stdout]: Setting up bind9-host (1:9.18.39-0ubuntu0.22.04.3) ...
    template.go:44: network-egress-test: [info] [builder 1/1] [stdout]: Setting up bind9-dnsutils (1:9.18.39-0ubuntu0.22.04.3) ...
    template.go:44: network-egress-test: [info] [builder 1/1] [stdout]: Setting up dnsutils (1:9.18.39-0ubuntu0.22.04.3) ...
    template.go:44: network-egress-test: [info] [builder 1/1] [stdout]: Processing triggers for libc-bin (2.35-0ubuntu3.13) ...
    template.go:44: network-egress-test: [info] [finalize] Finalizing template build [678d8c6396745e76c1e6fcb6ec74a06eb1cc3bffc1101dbaec5c89848fb75045]
    template.go:44: network-egress-test: [info] [optimize] Optimizing template [027a14840d213f9902f523344074c7f19992129998b9867f8c379d0012f44fe9]
    template.go:44: network-egress-test: [info] Build finished, took 2m45s
    sandbox_network_out_test.go:32: Build completed successfully
    sandbox_network_out_test.go:49: Network test template built: dn6rbxdajjkk7umst5dm
--- FAIL: TestUpdateNetworkConfig (223.34s)
github.com/e2b-dev/infra/tests/integration/internal/tests/api/sandboxes::TestUpdateNetworkConfig/pause_resume_preserves_allow_internet_access_false

Flake rate in main: 77.39% (Passed 222 times, Failed 760 times)

Stack Traces | 3.4s run time
=== RUN   TestUpdateNetworkConfig/pause_resume_preserves_allow_internet_access_false
Executing command curl in sandbox ij7edfczsd0j1ucynprsd
    sandbox_network_update_test.go:372: Command [curl] output: event:{start:{pid:1342}}
    sandbox_network_update_test.go:372: Command [curl] output: event:{end:{exit_code:35  exited:true  status:"exit status 35"  error:"exit status 35"}}
Executing command curl in sandbox ij7edfczsd0j1ucynprsd
    sandbox_network_update_test.go:372: Command [curl] output: event:{start:{pid:1343}}
    sandbox_network_update_test.go:372: Command [curl] output: event:{end:{exit_code:35  exited:true  status:"exit status 35"  error:"exit status 35"}}
    sandbox_network_update_test.go:391: Command [curl] output: event:{start:{pid:1344}}
    sandbox_network_update_test.go:391: Command [curl] output: event:{data:{stdout:"HTTP/2 302 \r\nx-content-type-options: nosniff\r\nlocation: https://dns.google/\r\ndate: Sun, 17 May 2026 09:58:14 GMT\r\ncontent-type: text/html; charset=UTF-8\r\nserver: HTTP server (unknown)\r\ncontent-length: 216\r\nx-xss-protection: 0\r\nx-frame-options: SAMEORIGIN\r\nalt-svc: h3=\":443\"; ma=2592000,h3-29=\":443\"; ma=2592000\r\n\r\n"}}
    sandbox_network_update_test.go:391: Command [curl] output: event:{end:{exited:true  status:"exit status 0"}}
    sandbox_network_update_test.go:391: Command [curl] completed successfully in sandbox ij7edfczsd0j1ucynprsd
    sandbox_network_update_test.go:391: 
        	Error Trace:	.../api/sandboxes/sandbox_network_out_test.go:74
        	            				.../api/sandboxes/sandbox_network_update_test.go:60
        	            				.../api/sandboxes/sandbox_network_update_test.go:391
        	Error:      	An error is expected but got nil.
        	Test:       	TestUpdateNetworkConfig/pause_resume_preserves_allow_internet_access_false
        	Messages:   	https://8.8.8.8 should be blocked
--- FAIL: TestUpdateNetworkConfig/pause_resume_preserves_allow_internet_access_false (3.40s)
github.com/e2b-dev/infra/tests/integration/internal/tests/envd::TestBindLocalhost

Flake rate in main: 56.36% (Passed 391 times, Failed 505 times)

Stack Traces | 0s run time
=== RUN   TestBindLocalhost
=== PAUSE TestBindLocalhost
=== CONT  TestBindLocalhost
--- FAIL: TestBindLocalhost (0.00s)
github.com/e2b-dev/infra/tests/integration/internal/tests/envd::TestBindLocalhost/bind_localhost

Flake rate in main: 64.33% (Passed 219 times, Failed 395 times)

Stack Traces | 9.87s run time
=== RUN   TestBindLocalhost/bind_localhost
=== PAUSE TestBindLocalhost/bind_localhost
=== CONT  TestBindLocalhost/bind_localhost
    localhost_bind_test.go:69: Command [python] output: event:{start:{pid:1274}}
Executing command python in sandbox ipawa030x417jfupf87u0
    localhost_bind_test.go:90: 
        	Error Trace:	.../tests/envd/localhost_bind_test.go:90
        	Error:      	Not equal: 
        	            	expected: 200
        	            	actual  : 502
        	Test:       	TestBindLocalhost/bind_localhost
        	Messages:   	Unexpected status code 502 for bind address localhost
--- FAIL: TestBindLocalhost/bind_localhost (9.87s)
github.com/e2b-dev/infra/tests/integration/internal/tests/orchestrator::TestSandboxMemoryIntegrity

Flake rate in main: 66.17% (Passed 229 times, Failed 448 times)

Stack Traces | 70.4s run time
=== RUN   TestSandboxMemoryIntegrity
=== PAUSE TestSandboxMemoryIntegrity
=== CONT  TestSandboxMemoryIntegrity
    sandbox_memory_integrity_test.go:26: Build completed successfully
--- FAIL: TestSandboxMemoryIntegrity (70.39s)
github.com/e2b-dev/infra/tests/integration/internal/tests/orchestrator::TestSandboxMemoryIntegrity/tmpfs_hash

Flake rate in main: 66.87% (Passed 219 times, Failed 442 times)

Stack Traces | 23.6s run time
=== RUN   TestSandboxMemoryIntegrity/tmpfs_hash
=== PAUSE TestSandboxMemoryIntegrity/tmpfs_hash
=== CONT  TestSandboxMemoryIntegrity/tmpfs_hash
    sandbox_memory_integrity_test.go:70: Command [bash] output: event:{start:{pid:1257}}
    sandbox_memory_integrity_test.go:70: Command [bash] output: event:{data:{stdout:"Total memory: 985 MB\nUsed memory before tmpfs mount: 188 MB\nFree memory before tmpfs mount: 796 MB\nMemory to use in integrity test (80% of free, min 64MB): 636 MB\n"}}
Executing command bash in sandbox i27edk17e6rj4rlt53hoc (user: root)
    sandbox_memory_integrity_test.go:70: Command [bash] output: event:{data:{stderr:"636+0 records in\n636+0 records out\n666894336 bytes (667 MB, 636 MiB) copied, 2.91743 s, 229 MB/s\n"}}
    sandbox_memory_integrity_test.go:70: Command [bash] output: event:{data:{stderr:"\tCommand being timed: \"dd if=/dev/urandom of=/mnt/testfile bs=1M count=636\"\n\tUser time (seconds): 0.00\n\tSystem time (seconds): 2.89\n\tPercent of CPU this job got: 99%\n\tElapsed (wall clock) time (h:mm:ss or m:ss): 0:02.92\n\tAverage shared text size (kbytes): 0\n\tAverage unshared data size (kbytes): 0\n\tAverage stack size (kbytes): 0\n\tAverage total size (kbytes): 0\n\tMaximum resident set size (kbytes): 2704\n\tAverage resident set size (kbytes): 0\n\tMajor (requiring I/O) page faults: 2\n\tMinor (reclaiming a frame) page faults: 345\n\tVoluntary context switches: 3\n\tInvoluntary context switches: 42\n\tSwaps: 0\n\tFile system inputs: 176\n\tFile system outputs: 0\n\tSocket messages sent: 0\n\tSocket messages received: 0\n\tSignals delivered: 0\n\tPage size (bytes): 4096\n\tExit status: 0\n"}}
    sandbox_memory_integrity_test.go:70: Command [bash] output: event:{data:{stdout:"Used memory after tmpfs mount and file fill: 828 MB\n"}}
    sandbox_memory_integrity_test.go:70: Command [bash] output: event:{end:{exited:true status:"exit status 0"}}
    sandbox_memory_integrity_test.go:70: Command [bash] completed successfully in sandbox ieobcooc58b4v0lrxym9w
Executing command bash in sandbox ieobcooc58b4v0lrxym9w (user: root)
    sandbox_memory_integrity_test.go:74: Command [bash] output: event:{start:{pid:1273}}
    sandbox_memory_integrity_test.go:74: Command [bash] output: event:{data:{stdout:"6db7ad815eb9ea560e88f6dcbcf791bb055ce85618b6266a4fc48c285e008d9b\n"}}
    sandbox_memory_integrity_test.go:74: Command [bash] output: event:{end:{exited:true status:"exit status 0"}}
    sandbox_memory_integrity_test.go:74: Command [bash] completed successfully in sandbox ieobcooc58b4v0lrxym9w
Executing command bash in sandbox ieobcooc58b4v0lrxym9w (user: root)
    sandbox_memory_integrity_test.go:99: Command [bash] output: event:{start:{pid:1276}}
    sandbox_memory_integrity_test.go:100: 
        	Error Trace:	.../tests/orchestrator/sandbox_memory_integrity_test.go:100
        	Error:      	Received unexpected error:
        	            	failed to execute command bash in sandbox ieobcooc58b4v0lrxym9w: invalid_argument: protocol error: incomplete envelope: unexpected EOF
        	Test:       	TestSandboxMemoryIntegrity/tmpfs_hash
--- FAIL: TestSandboxMemoryIntegrity/tmpfs_hash (23.63s)

To view more test analytics, go to the Test Analytics Dashboard
📋 Got 3 mins? Take this short survey to help us improve Test Analytics.

Copy link
Copy Markdown
Contributor

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

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

Code Review

The Slice method returns a direct reference to the memfd's memory mapping which is unmapped by the background runCopy goroutine upon completion, creating a risk of SIGSEGV if the slice is accessed afterward. Furthermore, the background copy process lacks an explicit synchronization step, such as msync, before closing the memfd, which can result in stale data when the cache file is read from disk.

Comment on lines +275 to +285
}

func (m *MemfdCache) Slice(off, length int64) ([]byte, error) {
m.mu.RLock()
if m.src != nil {
defer m.mu.RUnlock()

return m.src.slice(off, length)
}
m.mu.RUnlock()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

critical

The Slice method returns a direct reference to the memfd's memory mapping. This is dangerous because the background goroutine runCopy closes the memfd and unmaps its memory as soon as the copy finishes. If a caller attempts to access the returned slice after the background copy completes, it will trigger a SIGSEGV. To ensure safety, the memfd should remain mapped until the MemfdCache itself is closed, or Slice must be implemented in a way that guarantees the lifetime of the returned buffer.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Valid catch — fixed in 9a6a97c. MemfdCache.Slice now returns BytesNotAvailableError while the background copy is in flight, forcing callers through ReadAt (which copies into the caller's buffer, no UAF). After the copy completes m.src is nil and Slice delegates to the underlying *Cache as before.

Comment on lines +208 to +216
}

m.mu.Lock()
src := m.src
m.src = nil
m.mu.Unlock()

if closeErr := src.memfd.Close(); closeErr != nil {
joined := errors.Join(err, fmt.Errorf("close memfd: %w", closeErr))
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

high

The background copy goroutine captures m.src and sets it to nil before closing the memfd. While this correctly routes subsequent ReadAt calls to the underlying cache, it doesn't ensure that the data has been flushed to disk. If a caller uses CachePath() (which calls Wait()) and immediately reads the file from disk, they might encounter stale data because the mmap writes haven't been synchronized. Consider calling msync or fsync before closing the memfd to guarantee data persistence.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Disagree — no msync/fsync needed. The cache file is mmap'd MAP_SHARED, so writes through *m.cache.mmap immediately enter the kernel page cache and are visible to any subsequent file read on the same host. msync/fsync would only matter for crash durability, which isn't a constraint here (a host crash kills the sandbox anyway and the upload runs after the copy finishes). The CachePath caller after Wait() reads through the same page cache and sees the same bytes.

Copy link
Copy Markdown

@cursor cursor Bot left a comment

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Autofix Details

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Slice returns reference to asynchronously munmapped memory
    • Fixed by copying memfd-backed data into a new buffer before returning, preventing use-after-free when background goroutine unmaps the memory.

Create PR

Or push these changes by commenting:

@cursor push 4e38d8cdcc
Preview (4e38d8cdcc)
diff --git a/packages/orchestrator/pkg/sandbox/block/memfd.go b/packages/orchestrator/pkg/sandbox/block/memfd.go
--- a/packages/orchestrator/pkg/sandbox/block/memfd.go
+++ b/packages/orchestrator/pkg/sandbox/block/memfd.go
@@ -279,7 +279,13 @@
 	if m.src != nil {
 		defer m.mu.RUnlock()
 
-		return m.src.slice(off, length)
+		srcSlice, err := m.src.slice(off, length)
+		if err != nil {
+			return nil, err
+		}
+		buf := make([]byte, len(srcSlice))
+		copy(buf, srcSlice)
+		return buf, nil
 	}
 	m.mu.RUnlock()

You can send follow-ups to the cloud agent here.

Comment thread packages/orchestrator/pkg/sandbox/block/memfd.go Outdated
@bchalios bchalios force-pushed the zero-copy-pause branch 7 times, most recently from e5aedb7 to 6d430ef Compare May 15, 2026 11:11
Copy link
Copy Markdown

@cursor cursor Bot left a comment

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Autofix Details

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: CachePath wait is uncancellable via context.Background()
    • Updated Diff interface and all implementations to accept context parameter, allowing Wait calls to respect caller's cancellation and timeouts instead of blocking indefinitely.

Create PR

Or push these changes by commenting:

@cursor push 5e202ea1ae
Preview (5e202ea1ae)
diff --git a/packages/orchestrator/pkg/sandbox/block/memfd.go b/packages/orchestrator/pkg/sandbox/block/memfd.go
--- a/packages/orchestrator/pkg/sandbox/block/memfd.go
+++ b/packages/orchestrator/pkg/sandbox/block/memfd.go
@@ -7,11 +7,8 @@
 	"errors"
 	"fmt"
 	"sync"
+	"sync/atomic"
 	"syscall"
-
-	"go.uber.org/zap"
-
-	"github.com/e2b-dev/infra/packages/shared/pkg/logger"
 )
 
 // Memfd wraps a memfd received from Firecracker
@@ -84,12 +81,91 @@
 	return err
 }
 
-// MemfdCache wraps a *Cache that is being populated from a memfd.
+// memfdSource indexes the memfd-backed regions by cache offset so that reads
+// against a MemfdCache can be served directly from the memfd while the
+// background copy is still in flight.
+type memfdSource struct {
+	memfd   *Memfd
+	entries []memfdRange
+}
+
+type memfdRange struct {
+	cacheStart int64
+	srcStart   int64
+	size       int64
+}
+
+func newMemfdSource(memfd *Memfd, ranges []Range) *memfdSource {
+	entries := make([]memfdRange, len(ranges))
+	var cacheOff int64
+	for i, r := range ranges {
+		entries[i] = memfdRange{cacheStart: cacheOff, srcStart: r.Start, size: r.Size}
+		cacheOff += r.Size
+	}
+
+	return &memfdSource{memfd: memfd, entries: entries}
+}
+
+func (s *memfdSource) findEntry(cacheOff int64) int {
+	lo, hi := 0, len(s.entries)
+	for lo < hi {
+		mid := (lo + hi) / 2
+		if s.entries[mid].cacheStart > cacheOff {
+			hi = mid
+		} else {
+			lo = mid + 1
+		}
+	}
+	i := lo - 1
+	if i < 0 {
+		return -1
+	}
+	e := s.entries[i]
+	if cacheOff >= e.cacheStart+e.size {
+		return -1
+	}
+
+	return i
+}
+
+func (s *memfdSource) readAt(b []byte, cacheOff int64) (int, error) {
+	n := 0
+	for n < len(b) {
+		i := s.findEntry(cacheOff + int64(n))
+		if i < 0 {
+			return n, nil
+		}
+		e := s.entries[i]
+		offsetInEntry := cacheOff + int64(n) - e.cacheStart
+		toCopy := min(int64(len(b)-n), e.size-offsetInEntry)
+		src, err := s.memfd.Slice(e.srcStart+offsetInEntry, toCopy)
+		if err != nil {
+			return n, fmt.Errorf("memfd slice: %w", err)
+		}
+		copy(b[n:n+int(toCopy)], src)
+		n += int(toCopy)
+	}
+
+	return n, nil
+}
+
+// MemfdCache wraps a *Cache that is being populated from a memfd in the
+// background. Reads are served directly from the memfd until the copy
+// completes; afterwards they delegate to the underlying Cache and the memfd
+// is closed.
 type MemfdCache struct {
 	cache *Cache
-	memfd *Memfd
+
+	mu     sync.RWMutex // guards src
+	src    *memfdSource // nil once the background copy has completed
+	cancel context.CancelFunc
+	done   chan struct{}
+	err    atomic.Pointer[error]
 }
 
+// NewCacheFromMemfd creates a Cache backed by an in-flight copy from the given
+// memfd. The returned wrapper takes ownership of memfd: callers must Close the
+// wrapper (which also closes the memfd).
 func NewCacheFromMemfd(
 	ctx context.Context,
 	blockSize int64,
@@ -109,7 +185,6 @@
 	}
 
 	if size == 0 {
-		// We can close Memfd. We won't be reading anything out of it.
 		if closeErr := memfd.Close(); closeErr != nil {
 			return nil, errors.Join(fmt.Errorf("close memfd: %w", closeErr), cache.Close())
 		}
@@ -117,34 +192,44 @@
 		return &MemfdCache{cache: cache}, nil
 	}
 
-	memfdCache := &MemfdCache{
-		cache: cache,
-		memfd: memfd,
+	copyCtx, cancel := context.WithCancel(context.WithoutCancel(ctx))
+	m := &MemfdCache{
+		cache:  cache,
+		src:    newMemfdSource(memfd, ranges),
+		cancel: cancel,
+		done:   make(chan struct{}),
 	}
 
-	err = memfdCache.writeToDisk(ctx, ranges)
+	go m.runCopy(copyCtx, ranges)
+
+	return m, nil
+}
+
+func (m *MemfdCache) runCopy(ctx context.Context, ranges []Range) {
+	defer close(m.done)
+
+	err := m.copyFromMemfd(ctx, ranges)
 	if err != nil {
-		return nil, errors.Join(fmt.Errorf("could not write memfd to disk: %w", err), memfdCache.Close())
+		m.err.Store(&err)
 	}
 
-	// Close memfd to release the memory
-	// At the moment, we always close it. In the future, we will implement
-	// copying at the background, so the file descriptor will be kept valid
-	if err := memfdCache.memfd.Close(); err != nil {
-		logger.L().Warn(ctx, "Could not close memfd", zap.Error(err))
+	m.mu.Lock()
+	src := m.src
+	m.src = nil
+	m.mu.Unlock()
+
+	if closeErr := src.memfd.Close(); closeErr != nil {
+		joined := errors.Join(err, fmt.Errorf("close memfd: %w", closeErr))
+		m.err.Store(&joined)
 	}
-	memfdCache.memfd = nil
-
-	return memfdCache, nil
 }
 
-func (m *MemfdCache) writeToDisk(ctx context.Context, ranges []Range) error {
+func (m *MemfdCache) copyFromMemfd(ctx context.Context, ranges []Range) error {
 	var cacheOff int64
-
 	for _, r := range ranges {
 		rangeStart := cacheOff
 
-		src, err := m.memfd.Slice(r.Start, r.Size)
+		src, err := m.src.memfd.Slice(r.Start, r.Size)
 		if err != nil {
 			return fmt.Errorf("bad memfd slice [%d,%d): %w", r.Start, r.Start+r.Size, err)
 		}
@@ -167,11 +252,48 @@
 	return nil
 }
 
+// Wait blocks until the background copy completes (or ctx is cancelled), and
+// returns any error that occurred.
+func (m *MemfdCache) Wait(ctx context.Context) error {
+	if m.done == nil {
+		return nil
+	}
+	select {
+	case <-ctx.Done():
+		return ctx.Err()
+	case <-m.done:
+	}
+	if errPtr := m.err.Load(); errPtr != nil {
+		return *errPtr
+	}
+
+	return nil
+}
+
 func (m *MemfdCache) ReadAt(b []byte, off int64) (int, error) {
+	m.mu.RLock()
+	if m.src != nil {
+		defer m.mu.RUnlock()
+
+		return m.src.readAt(b, off)
+	}
+	m.mu.RUnlock()
+
 	return m.cache.ReadAt(b, off)
 }
 
+// Slice returns BytesNotAvailableError while the background copy is in
+// flight: the memfd-backed slice would outlive the RLock and could be
+// Munmap'd asynchronously by runCopy. Callers should fall back to ReadAt
+// (which copies into the caller's buffer) or Wait first.
 func (m *MemfdCache) Slice(off, length int64) ([]byte, error) {
+	m.mu.RLock()
+	defer m.mu.RUnlock()
+
+	if m.src != nil {
+		return nil, BytesNotAvailableError{}
+	}
+
 	return m.cache.Slice(off, length)
 }
 
@@ -192,17 +314,10 @@
 }
 
 func (m *MemfdCache) Close() error {
-	var err error
-
-	if m.memfd != nil {
-		if e := m.memfd.Close(); e != nil {
-			err = fmt.Errorf("error closing memfd: %w", e)
-		}
+	if m.cancel != nil {
+		m.cancel()
+		<-m.done
 	}
 
-	if e := m.cache.Close(); e != nil {
-		err = errors.Join(err, fmt.Errorf("error closing cache: %w", e))
-	}
-
-	return err
+	return m.cache.Close()
 }

diff --git a/packages/orchestrator/pkg/sandbox/block/memfd_test.go b/packages/orchestrator/pkg/sandbox/block/memfd_test.go
--- a/packages/orchestrator/pkg/sandbox/block/memfd_test.go
+++ b/packages/orchestrator/pkg/sandbox/block/memfd_test.go
@@ -304,30 +304,82 @@
 	require.EqualValues(t, 0, sz)
 }
 
-func TestMemfdCache_ContextCancellation(t *testing.T) {
+// Cancelling the parent ctx after construction must not kill the in-flight
+// copy: NewCacheFromMemfd detaches via context.WithoutCancel so the copy
+// can outlive the Pause RPC. Cancellation only happens via Close.
+func TestMemfdCache_ParentContextCancellationDoesNotAbortCopy(t *testing.T) {
 	t.Parallel()
 
 	pageSize := int64(header.PageSize)
 	size := pageSize * 16
-	fd, _ := newTestMemfd(t, size)
+	fd, expected := newTestMemfd(t, size)
 
 	ranges := []Range{{Start: 0, Size: size}}
 
 	ctx, cancel := context.WithCancel(t.Context())
+
+	cache, err := NewCacheFromMemfd(ctx, pageSize, t.TempDir()+"/cache", NewFromFd(fd, int(size)), ranges)
+	require.NoError(t, err)
+	t.Cleanup(func() { _ = cache.Close() })
+
 	cancel()
 
-	_, err := NewCacheFromMemfd(ctx, pageSize, t.TempDir()+"/cache", NewFromFd(fd, int(size)), ranges)
-	require.ErrorIs(t, err, context.Canceled)
+	require.NoError(t, cache.Wait(t.Context()))
+
+	got := make([]byte, size)
+	n, err := cache.ReadAt(got, 0)
+	require.NoError(t, err)
+	require.Equal(t, int(size), n)
+	require.Equal(t, expected, got)
 }
 
-// On the happy path, NewCacheFromMemfd closes the memfd internally and nils
-// the field, so subsequent MemfdCache.Close must still cleanly close the
-// underlying *Cache without trying to re-close the memfd. The cache file is
-// then removed by Cache.Close.
-func TestMemfdCache_CloseAfterSuccessfulPopulationRemovesCacheFile(t *testing.T) {
+// Wait blocks until the background copy completes; after it returns the
+// cache file on disk must contain the full payload.
+func TestMemfdCache_WaitWritesFile(t *testing.T) {
 	t.Parallel()
 
 	pageSize := int64(header.PageSize)
+	size := pageSize * 12
+	fd, expected := newTestMemfd(t, size)
+
+	cachePath := t.TempDir() + "/cache"
+	cache, err := NewCacheFromMemfd(t.Context(), pageSize, cachePath, NewFromFd(fd, int(size)), []Range{{Start: 0, Size: size}})
+	require.NoError(t, err)
+	t.Cleanup(func() { _ = cache.Close() })
+
+	require.NoError(t, cache.Wait(t.Context()))
+
+	fromFile, err := os.ReadFile(cachePath)
+	require.NoError(t, err)
+	require.Equal(t, expected, fromFile)
+}
+
+// Slice must refuse to hand out memfd-backed views while the copy is still
+// in flight, since runCopy would Munmap them asynchronously.
+func TestMemfdCache_SliceUnavailableUntilCopyDone(t *testing.T) {
+	t.Parallel()
+
+	pageSize := int64(header.PageSize)
+	size := pageSize * 8
+	fd, _ := newTestMemfd(t, size)
+
+	cache, err := NewCacheFromMemfd(t.Context(), pageSize, t.TempDir()+"/cache", NewFromFd(fd, int(size)), []Range{{Start: 0, Size: size}})
+	require.NoError(t, err)
+	t.Cleanup(func() { _ = cache.Close() })
+
+	require.NoError(t, cache.Wait(t.Context()))
+
+	_, err = cache.Slice(0, pageSize)
+	require.NoError(t, err)
+}
+
+// MemfdCache.Close must wait for the background copy goroutine before
+// removing the cache file, otherwise the goroutine would race with file
+// teardown.
+func TestMemfdCache_CloseRemovesCacheFile(t *testing.T) {
+	t.Parallel()
+
+	pageSize := int64(header.PageSize)
 	size := pageSize * 4
 	fd, _ := newTestMemfd(t, size)
 

diff --git a/packages/orchestrator/pkg/sandbox/build/diff.go b/packages/orchestrator/pkg/sandbox/build/diff.go
--- a/packages/orchestrator/pkg/sandbox/build/diff.go
+++ b/packages/orchestrator/pkg/sandbox/build/diff.go
@@ -31,7 +31,7 @@
 	storage.SeekableReader
 	block.FramedSlicer
 	CacheKey() DiffStoreKey
-	CachePath() (string, error)
+	CachePath(ctx context.Context) (string, error)
 	FileSize() (int64, error)
 	BlockSize() int64
 	Init(ctx context.Context) error
@@ -41,7 +41,7 @@
 
 var _ Diff = (*NoDiff)(nil)
 
-func (n *NoDiff) CachePath() (string, error) {
+func (n *NoDiff) CachePath(_ context.Context) (string, error) {
 	return "", nil
 }
 

diff --git a/packages/orchestrator/pkg/sandbox/build/local_diff.go b/packages/orchestrator/pkg/sandbox/build/local_diff.go
--- a/packages/orchestrator/pkg/sandbox/build/local_diff.go
+++ b/packages/orchestrator/pkg/sandbox/build/local_diff.go
@@ -109,7 +109,15 @@
 	return NewLocalDiffFromCache(cacheKey, cache)
 }
 
-func (b *localDiff) CachePath() (string, error) {
+func (b *localDiff) CachePath(ctx context.Context) (string, error) {
+	if w, ok := b.cache.(interface {
+		Wait(ctx context.Context) error
+	}); ok {
+		if err := w.Wait(ctx); err != nil {
+			return "", fmt.Errorf("memfd copy: %w", err)
+		}
+	}
+
 	return b.cache.Path(), nil
 }
 

diff --git a/packages/orchestrator/pkg/sandbox/build/mocks/mockdiff.go b/packages/orchestrator/pkg/sandbox/build/mocks/mockdiff.go
--- a/packages/orchestrator/pkg/sandbox/build/mocks/mockdiff.go
+++ b/packages/orchestrator/pkg/sandbox/build/mocks/mockdiff.go
@@ -130,8 +130,8 @@
 }
 
 // CachePath provides a mock function for the type MockDiff
-func (_mock *MockDiff) CachePath() (string, error) {
-	ret := _mock.Called()
+func (_mock *MockDiff) CachePath(ctx context.Context) (string, error) {
+	ret := _mock.Called(ctx)
 
 	if len(ret) == 0 {
 		panic("no return value specified for CachePath")
@@ -139,16 +139,16 @@
 
 	var r0 string
 	var r1 error
-	if returnFunc, ok := ret.Get(0).(func() (string, error)); ok {
-		return returnFunc()
+	if returnFunc, ok := ret.Get(0).(func(context.Context) (string, error)); ok {
+		return returnFunc(ctx)
 	}
-	if returnFunc, ok := ret.Get(0).(func() string); ok {
-		r0 = returnFunc()
+	if returnFunc, ok := ret.Get(0).(func(context.Context) string); ok {
+		r0 = returnFunc(ctx)
 	} else {
 		r0 = ret.Get(0).(string)
 	}
-	if returnFunc, ok := ret.Get(1).(func() error); ok {
-		r1 = returnFunc()
+	if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok {
+		r1 = returnFunc(ctx)
 	} else {
 		r1 = ret.Error(1)
 	}
@@ -161,13 +161,14 @@
 }
 
 // CachePath is a helper method to define mock.On call
-func (_e *MockDiff_Expecter) CachePath() *MockDiff_CachePath_Call {
-	return &MockDiff_CachePath_Call{Call: _e.mock.On("CachePath")}
+//   - ctx context.Context
+func (_e *MockDiff_Expecter) CachePath(ctx interface{}) *MockDiff_CachePath_Call {
+	return &MockDiff_CachePath_Call{Call: _e.mock.On("CachePath", ctx)}
 }
 
-func (_c *MockDiff_CachePath_Call) Run(run func()) *MockDiff_CachePath_Call {
+func (_c *MockDiff_CachePath_Call) Run(run func(ctx context.Context)) *MockDiff_CachePath_Call {
 	_c.Call.Run(func(args mock.Arguments) {
-		run()
+		run(args[0].(context.Context))
 	})
 	return _c
 }
@@ -177,7 +178,7 @@
 	return _c
 }
 
-func (_c *MockDiff_CachePath_Call) RunAndReturn(run func() (string, error)) *MockDiff_CachePath_Call {
+func (_c *MockDiff_CachePath_Call) RunAndReturn(run func(context.Context) (string, error)) *MockDiff_CachePath_Call {
 	_c.Call.Return(run)
 	return _c
 }

diff --git a/packages/orchestrator/pkg/sandbox/build/storage_diff.go b/packages/orchestrator/pkg/sandbox/build/storage_diff.go
--- a/packages/orchestrator/pkg/sandbox/build/storage_diff.go
+++ b/packages/orchestrator/pkg/sandbox/build/storage_diff.go
@@ -123,7 +123,7 @@
 }
 
 // The local file might not be synced.
-func (b *StorageDiff) CachePath() (string, error) {
+func (b *StorageDiff) CachePath(_ context.Context) (string, error) {
 	return b.cachePath, nil
 }
 

diff --git a/packages/orchestrator/pkg/sandbox/build_upload_v3.go b/packages/orchestrator/pkg/sandbox/build_upload_v3.go
--- a/packages/orchestrator/pkg/sandbox/build_upload_v3.go
+++ b/packages/orchestrator/pkg/sandbox/build_upload_v3.go
@@ -14,12 +14,12 @@
 )
 
 func (u *Upload) runV3(ctx context.Context) error {
-	memfilePath, err := u.snap.MemfileDiff.CachePath()
+	memfilePath, err := u.snap.MemfileDiff.CachePath(ctx)
 	if err != nil {
 		return fmt.Errorf("error getting memfile diff path: %w", err)
 	}
 
-	rootfsPath, err := u.snap.RootfsDiff.CachePath()
+	rootfsPath, err := u.snap.RootfsDiff.CachePath(ctx)
 	if err != nil {
 		return fmt.Errorf("error getting rootfs diff path: %w", err)
 	}

diff --git a/packages/orchestrator/pkg/sandbox/build_upload_v4.go b/packages/orchestrator/pkg/sandbox/build_upload_v4.go
--- a/packages/orchestrator/pkg/sandbox/build_upload_v4.go
+++ b/packages/orchestrator/pkg/sandbox/build_upload_v4.go
@@ -15,12 +15,12 @@
 )
 
 func (u *Upload) runV4(ctx context.Context) error {
-	memSrc, err := u.snap.MemfileDiff.CachePath()
+	memSrc, err := u.snap.MemfileDiff.CachePath(ctx)
 	if err != nil {
 		return fmt.Errorf("memfile diff path: %w", err)
 	}
 
-	rootfsSrc, err := u.snap.RootfsDiff.CachePath()
+	rootfsSrc, err := u.snap.RootfsDiff.CachePath(ctx)
 	if err != nil {
 		return fmt.Errorf("rootfs diff path: %w", err)
 	}

diff --git a/packages/shared/pkg/featureflags/flags.go b/packages/shared/pkg/featureflags/flags.go
--- a/packages/shared/pkg/featureflags/flags.go
+++ b/packages/shared/pkg/featureflags/flags.go
@@ -122,9 +122,9 @@
 
 	// UseMemFdFlag enables memfd-backed guest memory. When enabled, Firecracker
 	// allocates guest memory via memfd_create and passes the fd to the UFFD
-	// handler over the UFFD socket on snapshot restore. This allows the
-	// orchestrator to read dirty pages via pread without having to call
-	// process_vm_readv() to copy memory.
+	// handler over the UFFD socket on snapshot restore. This lets the
+	// orchestrator mmap the memfd directly to copy dirty pages, instead of
+	// calling process_vm_readv() across processes.
 	UseMemFdFlag = NewBoolFlag("use-memfd", false)
 
 	// PeerToPeerChunkTransferFlag enables peer-to-peer chunk routing.

You can send follow-ups to the cloud agent here.

Comment thread packages/orchestrator/pkg/sandbox/build/local_diff.go
@ValentaTomas ValentaTomas force-pushed the memfd-bg-copy branch 3 times, most recently from 119b176 to c34e39c Compare May 17, 2026 05:29
Copy link
Copy Markdown

@cursor cursor Bot left a comment

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Autofix Details

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Data race on Memfd lazy mmap initialization
    • Added sync.Once to protect lazy mmap initialization in Memfd.Slice, preventing concurrent goroutines from performing double-mmap operations.

Create PR

Or push these changes by commenting:

@cursor push 1e0ba6164e
Preview (1e0ba6164e)
diff --git a/packages/orchestrator/pkg/sandbox/block/memfd.go b/packages/orchestrator/pkg/sandbox/block/memfd.go
--- a/packages/orchestrator/pkg/sandbox/block/memfd.go
+++ b/packages/orchestrator/pkg/sandbox/block/memfd.go
@@ -6,14 +6,18 @@
 	"context"
 	"errors"
 	"fmt"
+	"sync"
+	"sync/atomic"
 	"syscall"
 )
 
 // Memfd wraps a memfd received from Firecracker.
 type Memfd struct {
-	fd   int
-	size int
-	mmap []byte
+	fd       int
+	size     int
+	mmap     []byte
+	mmapOnce sync.Once
+	mmapErr  error
 }
 
 func NewFromFd(fd, size int) *Memfd {
@@ -23,12 +27,16 @@
 // Slice returns a zero-copy view of [offset, offset+size). Valid until Close.
 // The underlying mmap is created lazily on first call.
 func (m *Memfd) Slice(offset, size int64) ([]byte, error) {
-	if m.mmap == nil {
+	m.mmapOnce.Do(func() {
 		b, err := syscall.Mmap(m.fd, 0, m.size, syscall.PROT_READ, syscall.MAP_SHARED)
 		if err != nil {
-			return nil, fmt.Errorf("mmap memfd: %w", err)
+			m.mmapErr = fmt.Errorf("mmap memfd: %w", err)
+			return
 		}
 		m.mmap = b
+	})
+	if m.mmapErr != nil {
+		return nil, m.mmapErr
 	}
 	if offset < 0 || offset+size > int64(m.size) {
 		return nil, fmt.Errorf("range [%d, %d) out of bounds (size %d)", offset, offset+size, m.size)
@@ -56,10 +64,21 @@
 }
 
 // MemfdCache is a Cache populated from a memfd. The memfd is consumed (and
-// closed) during construction; the wrapper exists so the upcoming async-copy
-// and dedup PRs can attach extra state without churning callers.
+// closed) during construction; the wrapper exists so the upcoming dedup PR
+// can attach extra state without churning callers.
+//
+// When constructed via NewCacheFromMemfdAsync the copy runs in a background
+// goroutine and the memfd lifetime extends until the goroutine finishes.
+// In-flight reads route through the memfd via memfdSource; afterwards they
+// delegate to the embedded Cache and the memfd is closed.
 type MemfdCache struct {
 	*Cache
+
+	mu     sync.RWMutex // guards src
+	src    *memfdSource // non-nil while the background copy is in flight
+	cancel context.CancelFunc
+	done   chan struct{}
+	err    atomic.Pointer[error]
 }
 
 func NewCacheFromMemfd(
@@ -83,7 +102,123 @@
 	return &MemfdCache{Cache: cache}, nil
 }
 
-// copyFromMemfd is the seam the upcoming async-copy and dedup PRs replace.
+// NewCacheFromMemfdAsync returns a MemfdCache that streams the memfd into
+// the cache on a background goroutine. The caller's gRPC handler can return
+// as soon as the snapshot file and diff metadata are written; the FC stop
+// and the memfd copy then run in parallel after the response. The returned
+// wrapper takes ownership of memfd; callers must Close it (which also
+// cancels and joins the copy goroutine).
+//
+// Reads against the wrapper are served from the memfd via memfdSource while
+// the copy is in flight; afterwards they delegate to the embedded Cache and
+// the memfd is closed.
+func NewCacheFromMemfdAsync(
+	ctx context.Context,
+	blockSize int64,
+	filePath string,
+	memfd *Memfd,
+	ranges []Range,
+) (*MemfdCache, error) {
+	cache, err := NewCache(GetSize(ranges), blockSize, filePath, false)
+	if err != nil {
+		return nil, errors.Join(err, memfd.Close())
+	}
+	if len(ranges) == 0 {
+		if closeErr := memfd.Close(); closeErr != nil {
+			return nil, errors.Join(fmt.Errorf("close memfd: %w", closeErr), cache.Close())
+		}
+
+		return &MemfdCache{Cache: cache}, nil
+	}
+
+	// Detach from the request context so the copy can outlive Pause; Close
+	// drives cancellation instead.
+	copyCtx, cancel := context.WithCancel(context.WithoutCancel(ctx))
+	m := &MemfdCache{
+		Cache:  cache,
+		src:    newMemfdSource(memfd, ranges),
+		cancel: cancel,
+		done:   make(chan struct{}),
+	}
+
+	go m.runCopy(copyCtx, ranges)
+
+	return m, nil
+}
+
+func (m *MemfdCache) runCopy(ctx context.Context, ranges []Range) {
+	defer close(m.done)
+
+	err := copyFromMemfd(ctx, m.Cache, m.src.memfd, ranges)
+	if err != nil {
+		m.err.Store(&err)
+	}
+
+	m.mu.Lock()
+	src := m.src
+	m.src = nil
+	m.mu.Unlock()
+
+	if closeErr := src.memfd.Close(); closeErr != nil {
+		joined := errors.Join(err, fmt.Errorf("close memfd: %w", closeErr))
+		m.err.Store(&joined)
+	}
+}
+
+// Wait blocks until the background copy completes (or ctx is cancelled).
+func (m *MemfdCache) Wait(ctx context.Context) error {
+	if m.done == nil {
+		return nil
+	}
+	select {
+	case <-ctx.Done():
+		return ctx.Err()
+	case <-m.done:
+	}
+	if errPtr := m.err.Load(); errPtr != nil {
+		return *errPtr
+	}
+
+	return nil
+}
+
+func (m *MemfdCache) ReadAt(b []byte, off int64) (int, error) {
+	m.mu.RLock()
+	if m.src != nil {
+		defer m.mu.RUnlock()
+
+		return m.src.readAt(b, off)
+	}
+	m.mu.RUnlock()
+
+	return m.Cache.ReadAt(b, off)
+}
+
+// Slice returns BytesNotAvailableError while the copy is in flight: the
+// memfd-backed slice would outlive the RLock and could be Munmap'd
+// asynchronously. Callers fall back to ReadAt or Wait first.
+func (m *MemfdCache) Slice(off, length int64) ([]byte, error) {
+	m.mu.RLock()
+	defer m.mu.RUnlock()
+
+	if m.src != nil {
+		return nil, BytesNotAvailableError{}
+	}
+
+	return m.Cache.Slice(off, length)
+}
+
+func (m *MemfdCache) Close() error {
+	if m.cancel != nil {
+		m.cancel()
+		<-m.done
+	}
+
+	return m.Cache.Close()
+}
+
+// copyFromMemfd is the seam the dedup PR replaces with a compare-conditional
+// memcpy. Kept sequential so the dedup drop-in stays trivial.
 func copyFromMemfd(ctx context.Context, cache *Cache, memfd *Memfd, ranges []Range) error {
 	var cacheOff int64
 	for _, r := range ranges {
@@ -105,3 +240,66 @@
 
 	return nil
 }
+
+// memfdSource indexes the memfd-backed ranges by cache offset so reads can
+// be served from the memfd while the background copy is still in flight.
+type memfdSource struct {
+	memfd   *Memfd
+	entries []memfdRange
+}
+
+type memfdRange struct {
+	cacheStart int64
+	srcStart   int64
+	size       int64
+}
+
+func newMemfdSource(memfd *Memfd, ranges []Range) *memfdSource {
+	entries := make([]memfdRange, len(ranges))
+	var cacheOff int64
+	for i, r := range ranges {
+		entries[i] = memfdRange{cacheStart: cacheOff, srcStart: r.Start, size: r.Size}
+		cacheOff += r.Size
+	}
+
+	return &memfdSource{memfd: memfd, entries: entries}
+}
+
+func (s *memfdSource) findEntry(cacheOff int64) int {
+	lo, hi := 0, len(s.entries)
+	for lo < hi {
+		mid := (lo + hi) / 2
+		if s.entries[mid].cacheStart > cacheOff {
+			hi = mid
+		} else {
+			lo = mid + 1
+		}
+	}
+	i := lo - 1
+	if i < 0 || cacheOff >= s.entries[i].cacheStart+s.entries[i].size {
+		return -1
+	}
+
+	return i
+}
+
+func (s *memfdSource) readAt(b []byte, cacheOff int64) (int, error) {
+	n := 0
+	for n < len(b) {
+		i := s.findEntry(cacheOff + int64(n))
+		if i < 0 {
+			return n, nil
+		}
+		e := s.entries[i]
+		offsetInEntry := cacheOff + int64(n) - e.cacheStart
+		toCopy := min(int64(len(b)-n), e.size-offsetInEntry)
+		src, err := s.memfd.Slice(e.srcStart+offsetInEntry, toCopy)
+		if err != nil {
+			return n, fmt.Errorf("memfd slice: %w", err)
+		}
+		copy(b[n:n+int(toCopy)], src)
+		n += int(toCopy)
+	}
+
+	return n, nil
+}

diff --git a/packages/orchestrator/pkg/sandbox/block/memfd_test.go b/packages/orchestrator/pkg/sandbox/block/memfd_test.go
--- a/packages/orchestrator/pkg/sandbox/block/memfd_test.go
+++ b/packages/orchestrator/pkg/sandbox/block/memfd_test.go
@@ -5,6 +5,7 @@
 import (
 	"context"
 	"crypto/rand"
+	"os"
 	"syscall"
 	"testing"
 
@@ -126,3 +127,54 @@
 	_, err := NewCacheFromMemfd(ctx, pageSize, t.TempDir()+"/cache", NewFromFd(fd, int(size)), []Range{{Start: 0, Size: size}})
 	require.ErrorIs(t, err, context.Canceled)
 }
+
+// Cancelling the parent ctx after construction must not abort the in-flight
+// copy: NewCacheFromMemfdAsync detaches via context.WithoutCancel so the
+// copy outlives the Pause RPC. Cancellation happens via Close.
+func TestMemfdCacheAsync_ParentContextCancellationDoesNotAbortCopy(t *testing.T) {
+	t.Parallel()
+
+	pageSize := int64(header.PageSize)
+	size := pageSize * 16
+
+	fd, expected := newTestMemfd(t, size)
+	ctx, cancel := context.WithCancel(t.Context())
+
+	cache, err := NewCacheFromMemfdAsync(ctx, pageSize, t.TempDir()+"/cache", NewFromFd(fd, int(size)), []Range{{Start: 0, Size: size}})
+	require.NoError(t, err)
+	t.Cleanup(func() { _ = cache.Close() })
+
+	cancel()
+	require.NoError(t, cache.Wait(t.Context()))
+
+	got := make([]byte, size)
+	n, err := cache.ReadAt(got, 0)
+	require.NoError(t, err)
+	require.Equal(t, int(size), n)
+	require.Equal(t, expected, got)
+}
+
+// Wait flushes the background copy; afterwards Slice succeeds (the
+// BytesNotAvailableError gate has lifted) and the cache file on disk has
+// the full payload.
+func TestMemfdCacheAsync_WaitFlushesToFile(t *testing.T) {
+	t.Parallel()
+
+	pageSize := int64(header.PageSize)
+	size := pageSize * 12
+
+	fd, expected := newTestMemfd(t, size)
+	cachePath := t.TempDir() + "/cache"
+	cache, err := NewCacheFromMemfdAsync(t.Context(), pageSize, cachePath, NewFromFd(fd, int(size)), []Range{{Start: 0, Size: size}})
+	require.NoError(t, err)
+	t.Cleanup(func() { _ = cache.Close() })
+
+	require.NoError(t, cache.Wait(t.Context()))
+
+	_, err = cache.Slice(0, pageSize)
+	require.NoError(t, err)
+
+	fromFile, err := os.ReadFile(cachePath)
+	require.NoError(t, err)
+	require.Equal(t, expected, fromFile)
+}

diff --git a/packages/orchestrator/pkg/sandbox/build/local_diff.go b/packages/orchestrator/pkg/sandbox/build/local_diff.go
--- a/packages/orchestrator/pkg/sandbox/build/local_diff.go
+++ b/packages/orchestrator/pkg/sandbox/build/local_diff.go
@@ -110,6 +110,14 @@
 }
 
 func (b *localDiff) CachePath() (string, error) {
+	if w, ok := b.cache.(interface {
+		Wait(ctx context.Context) error
+	}); ok {
+		if err := w.Wait(context.Background()); err != nil {
+			return "", fmt.Errorf("memfd copy: %w", err)
+		}
+	}
+
 	return b.cache.Path(), nil
 }
 

diff --git a/packages/orchestrator/pkg/sandbox/fc/memory.go b/packages/orchestrator/pkg/sandbox/fc/memory.go
--- a/packages/orchestrator/pkg/sandbox/fc/memory.go
+++ b/packages/orchestrator/pkg/sandbox/fc/memory.go
@@ -29,6 +29,7 @@
 	cachePath string,
 	blockSize int64,
 	memfd *block.Memfd,
+	bgCopy bool,
 ) (block.Cacher, error) {
 	var guestRanges []block.Range
 
@@ -36,7 +37,11 @@
 		guestRanges = append(guestRanges, r)
 	}
 
-	cache, err := block.NewCacheFromMemfd(ctx, blockSize, cachePath, memfd, guestRanges)
+	ctor := block.NewCacheFromMemfd
+	if bgCopy {
+		ctor = block.NewCacheFromMemfdAsync
+	}
+	cache, err := ctor(ctx, blockSize, cachePath, memfd, guestRanges)
 	if err != nil {
 		return nil, fmt.Errorf("create MemfdCache: %w", err)
 	}
@@ -85,10 +90,11 @@
 	cachePath string,
 	blockSize int64,
 	memfd *block.Memfd,
+	bgCopy bool,
 ) (block.Cacher, error) {
 	if memfd == nil {
 		return p.exportMemoryFromFc(ctx, include, cachePath, blockSize)
 	}
 
-	return p.exportMemoryFromMemfd(ctx, include, cachePath, blockSize, memfd)
+	return p.exportMemoryFromMemfd(ctx, include, cachePath, blockSize, memfd, bgCopy)
 }

diff --git a/packages/orchestrator/pkg/sandbox/sandbox.go b/packages/orchestrator/pkg/sandbox/sandbox.go
--- a/packages/orchestrator/pkg/sandbox/sandbox.go
+++ b/packages/orchestrator/pkg/sandbox/sandbox.go
@@ -1136,6 +1136,7 @@
 		s.config.DefaultCacheDir,
 		s.process,
 		s.memory.Memfd(ctx),
+		s.featureFlags.BoolFlag(ctx, featureflags.MemfdBackgroundCopyFlag),
 	)
 	if err != nil {
 		return nil, fmt.Errorf("error while post processing: %w", err)
@@ -1204,6 +1205,7 @@
 	cacheDir string,
 	fc *fc.Process,
 	memfd *block.Memfd,
+	bgCopy bool,
 ) (d build.Diff, h *header.Header, e error) {
 	ctx, span := tracer.Start(ctx, "process-memory")
 	defer span.End()
@@ -1226,6 +1228,7 @@
 		memfileDiffPath,
 		diffMetadata.BlockSize,
 		memfd,
+		bgCopy,
 	)
 	if err != nil {
 		return nil, nil, fmt.Errorf("failed to export memory: %w", err)

diff --git a/packages/shared/pkg/featureflags/flags.go b/packages/shared/pkg/featureflags/flags.go
--- a/packages/shared/pkg/featureflags/flags.go
+++ b/packages/shared/pkg/featureflags/flags.go
@@ -126,6 +126,11 @@
 	// dirty pages instead of calling process_vm_readv() across processes.
 	UseMemFdFlag = NewBoolFlag("use-memfd", false)
 
+	// MemfdBackgroundCopyFlag streams the memfd into the snapshot cache on a
+	// background goroutine, so gRPC Pause can return as soon as the diff
+	// metadata is written. Only takes effect when UseMemFdFlag is also on.
+	MemfdBackgroundCopyFlag = NewBoolFlag("memfd-background-copy", false)
+
 	// PeerToPeerChunkTransferFlag enables peer-to-peer chunk routing.
 	PeerToPeerChunkTransferFlag = NewBoolFlag("peer-to-peer-chunk-transfer", false)
 	// PeerToPeerAsyncCheckpointFlag makes Checkpoint upload fire-and-forget instead

You can send follow-ups to the cloud agent here.

Comment thread packages/orchestrator/pkg/sandbox/block/memfd.go Outdated
@ValentaTomas ValentaTomas force-pushed the memfd-bg-copy branch 6 times, most recently from 83b5f20 to 402c402 Compare May 17, 2026 06:34
Copy link
Copy Markdown

@cursor cursor Bot left a comment

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Autofix Details

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Memfd close failure overwrites successful copy result
    • Modified runCopy to only store memfd close errors when the copy itself failed, preventing successful data copies from being invalidated by cleanup failures.

Create PR

Or push these changes by commenting:

@cursor push 2a0008f770
Preview (2a0008f770)
diff --git a/packages/orchestrator/pkg/sandbox/block/memfd.go b/packages/orchestrator/pkg/sandbox/block/memfd.go
--- a/packages/orchestrator/pkg/sandbox/block/memfd.go
+++ b/packages/orchestrator/pkg/sandbox/block/memfd.go
@@ -172,8 +172,10 @@
 	m.mu.Unlock()
 
 	if closeErr := src.memfd.Close(); closeErr != nil {
-		joined := errors.Join(err, fmt.Errorf("close memfd: %w", closeErr))
-		m.err.Store(&joined)
+		if err != nil {
+			joined := errors.Join(err, fmt.Errorf("close memfd: %w", closeErr))
+			m.err.Store(&joined)
+		}
 	}
 }

You can send follow-ups to the cloud agent here.

Comment thread packages/orchestrator/pkg/sandbox/block/memfd.go
@ValentaTomas ValentaTomas force-pushed the memfd-bg-copy branch 2 times, most recently from 5b861f4 to 4c3d3b4 Compare May 17, 2026 07:04
Copy link
Copy Markdown

@cursor cursor Bot left a comment

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Memfd and hugepages not released after copy completes
    • Added m.src = nil in runCopy after successful copy to release memfd and hugepages, allowing reads to delegate to the underlying Cache.

Create PR

Or push these changes by commenting:

@cursor push 87c8ee0df1
Preview (87c8ee0df1)
diff --git a/packages/orchestrator/pkg/sandbox/block/memfd.go b/packages/orchestrator/pkg/sandbox/block/memfd.go
--- a/packages/orchestrator/pkg/sandbox/block/memfd.go
+++ b/packages/orchestrator/pkg/sandbox/block/memfd.go
@@ -165,6 +165,9 @@
 
 func (m *MemfdCache) runCopy(ctx context.Context, dirty *roaring.Bitmap, blockSize int64) {
 	m.err = copyFromMemfd(ctx, m.Cache, m.memfd, dirty, blockSize)
+	if m.err == nil {
+		m.src = nil
+	}
 	close(m.done)
 }
 

diff --git a/packages/orchestrator/pkg/sandbox/block/memfd_test.go b/packages/orchestrator/pkg/sandbox/block/memfd_test.go
--- a/packages/orchestrator/pkg/sandbox/block/memfd_test.go
+++ b/packages/orchestrator/pkg/sandbox/block/memfd_test.go
@@ -134,3 +134,30 @@
 		require.Equal(t, expected[srcBlock*pageSize:(srcBlock+1)*pageSize], slice, "Slice block %d", i)
 	}
 }
+
+// After the background copy completes, reads must delegate to the underlying
+// Cache (m.src becomes nil), releasing the memfd and its hugepage-backed memory.
+func TestNewCacheFromMemfdAsync_ReleasesSrcAfterCopy(t *testing.T) {
+	t.Parallel()
+
+	pageSize := int64(header.PageSize)
+	memfd, expected := newTestMemfd(t, pageSize*4)
+
+	dirty := roaring.New()
+	dirty.AddRange(0, 4)
+
+	cache, err := NewCacheFromMemfdAsync(t.Context(), pageSize, t.TempDir()+"/cache", memfd, dirty)
+	require.NoError(t, err)
+	t.Cleanup(func() { _ = cache.Close() })
+
+	require.NoError(t, cache.Wait(t.Context()))
+
+	// After Wait, m.src must be nil so reads delegate to Cache.
+	require.Nil(t, cache.src, "m.src should be nil after copy completes")
+
+	// Reads must still work via the Cache.
+	got := make([]byte, pageSize)
+	_, err = cache.ReadAt(got, 0)
+	require.NoError(t, err)
+	require.Equal(t, expected[:pageSize], got)
+}

You can send follow-ups to the cloud agent here.

Reviewed by Cursor Bugbot for commit 4c3d3b4. Configure here.

Comment thread packages/orchestrator/pkg/sandbox/block/memfd.go
Introduce the MemfdCache wrapper (embedding *Cache) plus the
NewCacheFromMemfdAsync constructor: copy runs on a goroutine so gRPC
Pause can return as soon as the snapshot file and diff metadata are
written. The MemfdBackgroundCopyFlag gates the dispatch in
fc.ExportMemory; flag-off keeps the existing sync NewCacheFromMemfd
path untouched.

In-flight reads route through a memfdSource indexed by cache offset;
afterwards they delegate to the embedded Cache and the memfd is
closed. Slice returns BytesNotAvailableError while the copy is in
flight to prevent UAF on the asynchronous Munmap; callers fall back
to ReadAt or Wait first. localDiff takes block.DiffSource and uses a
Wait type-assertion in CachePath so existing FS-reading upload paths
see complete data.
In-flight reads belong in the experimental v2 design. Here MemfdCache is
just a wait-on-read wrapper: ReadAt/Slice block on Wait, then delegate
to the embedded Cache. Drops memfdSource, sync.RWMutex, and the
read-during-copy test.
@ValentaTomas ValentaTomas changed the title feat(memfd): async background copy via MemfdCache wrapper feat(memfd): async pause/copy (wait-on-read) May 17, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants