Skip to content

pagecache/vfs: ZFS page-cache bridge, readahead, writeback, fsync flush - #1398

Merged
wkozaczuk merged 1 commit into
cloudius-systems:masterfrom
gburd:pr/pagecache
Jul 12, 2026
Merged

pagecache/vfs: ZFS page-cache bridge, readahead, writeback, fsync flush#1398
wkozaczuk merged 1 commit into
cloudius-systems:masterfrom
gburd:pr/pagecache

Conversation

@gburd

@gburd gburd commented Jun 24, 2026

Copy link
Copy Markdown
Contributor

Make the OSv VFS page cache cooperate with the OpenZFS port landing later in this series, and fix a data-durability bug in sys_fsync.

This branch bases directly on current master (3df7df7, the just-merged mmu-shm work). Two commits.

Changes

  • pagecache: ZFS bridging, sequential readahead, periodic writeback (54430d8).

    • Expose C-linkage helpers (osv_pagecache_map_page, etc.) so the OpenZFS vop_cache in zfs_vnops_os.c can register/look up cached pages without pulling C++ pagecache headers into module sources. Also fixes a GCC 14 ambiguity on a templated helper.
    • Remove the original ARC-bridge code path (shared ARC<->read_cache pages). It was never reachable: IS_ZFS() always returned false on OSv and the bridge structures were only initialised on the dead branch. A comment documents the decision so it isn't re-attempted.
    • Sequential readahead (window grows on consecutive hits, resets on seek) plus a 5 s periodic writeback worker with a global dirty-page cap.
  • vfs: flush page cache before VOP_FSYNC in sys_fsync (2ef4642).
    sys_fsync() called VOP_FSYNC without first flushing the OSv page cache, so dirty cached pages were never seen by the filesystem's fsync hook -- a process could fsync() and still lose data on crash (reproducer: write 64 KiB, fsync, kill VM, restart, read zeros). Walk the file's dirty pages, write them back, then VOP_FSYNC, holding f_lock across the flush so concurrent writes can't slip in.

Verification

Kernel compiles and links clean on GCC 14.3 / Boost 1.87 (./scripts/build image=empty, fresh loader.elf, RC=0). The page-cache helpers are consumed by the later OpenZFS PR; this PR adds only the kernel-side surface and the fsync fix.

@gburd

gburd commented Jul 6, 2026

Copy link
Copy Markdown
Contributor Author

Rebased onto current master (past the merged #1410). The two block-I/O hunks (fs/vfs/vfs_bdev.cc, fs/vfs/vfs_fops.cc) that previously showed up here were part of #1410 and are now dropped, so this PR is down to the three pagecache files: core/pagecache.cc, fs/vfs/vfs_syscalls.cc, include/osv/pagecache.hh. Build-verified green on current master.

@gburd

gburd commented Jul 6, 2026

Copy link
Copy Markdown
Contributor Author

Friendly ping on this one. This PR is the base of the ZFS-enablement stack: the OpenZFS 2.4.3 cutover (replacing the c.2014 BSD-ZFS port) sits directly on top of it, along with #1400 and #1402. I'd like to open the OpenZFS PR as a clean, ZFS-only diff, which is only possible once these three prerequisites land on master.

The branch is rebased on current master and mergeable. Happy to address anything outstanding, otherwise a merge here unblocks the next step. Thanks.

@nyh

nyh commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

@wkozaczuk if you have time I'd like you to look at this PR please, as you have much more recent ZFS experience than I do. Thanks.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

This PR updates OSv’s VFS page cache to better interoperate with the upcoming OpenZFS port by adding C-linkage entry points, introducing sequential readahead and periodic writeback, and addressing an fsync durability gap by flushing page-cache data before invoking filesystem fsync hooks.

Changes:

  • Add new pagecache APIs for inode-based writeback, whole-cache writeback, and a periodic writeback daemon with a configurable interval.
  • Implement a simple sequential readahead heuristic for read faults.
  • Update sys_fsync() to flush dirty page-cache data prior to VOP_FSYNC().

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 7 comments.

File Description
include/osv/pagecache.hh Adds declarations for new pagecache writeback APIs and a C-linkage wrapper for inode writeback.
fs/vfs/vfs_syscalls.cc Flushes page-cache dirty data before calling VOP_FSYNC() to improve durability for mmap-backed writes.
core/pagecache.cc Removes dead ARC-bridge code, adds sequential readahead, periodic writeback, inode/whole-cache writeback helpers, and new C-linkage helper functions.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread core/pagecache.cc Outdated
Comment thread core/pagecache.cc
Comment thread core/pagecache.cc
Comment thread core/pagecache.cc Outdated
Comment thread fs/vfs/vfs_syscalls.cc
Comment thread include/osv/pagecache.hh Outdated
Comment thread core/pagecache.cc

@nyh nyh left a comment

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.

I felt out of my league trying to review this patch. I left a few comments (one written by AI), I'll need to review this PR again, because unlike your other PRs, this one does change pre-existing code in ways that I don't really understand.

Comment thread core/pagecache.cc Outdated
// directly with the mmap layer. ZFS files now use the regular read_cache path,
// fed by the zfs_vop_cache() vnode hook which calls zfs_read() per page.
//
// No double-caching occurs: ARC caches compressed ZFS blocks; read_cache caches

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.

Isn't this still "double caching" to have the same pages both compressed an uncompressed in memory?

I'm not sure if this comment is trying to describe the current situation, or the future situation if we move to a newer version of ZFS.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

It describes the current situation, not a hypothetical future one. After the OpenZFS 2.4.3 cutover a page mapped through the mmap fault path is held twice in DRAM: once in the ARC as the decompressed block, and once in read_cache as the 4KB page the fault handed out. That is real double-caching and the comment says so ("a real cost we accept for now").

The old BSD-ZFS port avoided it via arc_share_buf(), which let mmap alias the ARC buffer in place. OpenZFS 2.x made that function static-internal, so the in-place sharing hook is gone and we fall back to copying into read_cache. Eliminating the duplication again needs a supported in-place ARC-sharing API upstream in OpenZFS; until then the copy is the honest, working behavior. I reworded the block to make the "current, not future" framing explicit.

Comment thread core/pagecache.cc Outdated
// unmap_arc_buf(), and map_arc_buf() are kept as no-ops below because
// fs/zfs/zfs_initialize.c (linked into libsolaris.so) still references them.
// A future cleanup can remove both sides once the old BSD-ZFS compat layer is
// fully retired.

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.

I'm curious what is the overall story behind replacing the ancient ZFS with a new ZFS.

When we originally chose to implement ZFS in OSv instead of some "simpler" filesystem, our theory was that users will love all the sophisticated modern features of ZFS. In the years that have passed, most "toy" users of OSv didn't care too much about the filesystem, and ZFS - even the ancient one - has been an overkill. So I am curious what you are planning to do with ZFS, and why a more modern version of ZFS is important.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fair question, and you are right that for a "toy" user any filesystem is fine. The motivation for this fork is not the classic ZFS feature list (snapshots/checksums/compression), though those come along for free. Three concrete things drove it:

  1. Maintenance. The 2014-era BSD-ZFS port is frozen and diverged from any upstream. It cannot pick up a decade of bug fixes, and every OSv-side change (aarch64, O_DIRECT, page cache) has to be hand-carried against dead code. Tracking OpenZFS 2.x means fixes flow from a project that is still actively maintained instead of being reinvented here.

  2. O_DIRECT / real I/O paths. The old port returns EINVAL on O_DIRECT (zfs_vnops.c); OpenZFS 2.4 implements it. That matters for the workloads I am actually running on OSv - Postgres and other DB engines that want direct I/O and predictable cache behavior rather than double-buffering through the page cache.

  3. Distributed block backends. The fork pairs ZFS with NVMe-oF/TCP and a Crucible (Oxide) distributed-block driver, running RAID-Z across several network targets. That has been verified byte-clean against live nvmet-tcp and TrueNAS. This is the use case where ZFS earns its keep on OSv: end-to-end integrity over unreliable remote storage, which a "simpler" filesystem does not give you.

So the plan is not "ZFS for its own sake" - it is a maintained, modern storage stack under DB-style workloads on top of distributed block devices. Happy to split that rationale into the eventual ZFS-cutover PR description where it belongs; this page-cache PR just needs to not regress the existing path.

Comment thread core/pagecache.cc Outdated
#include <osv/sched.hh>
#include <osv/clock.hh>

// NOTE: The ARC bridge (IS_ZFS() == true path) is intentionally unreachable

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.

In the commit message you said: "Remove the original ARC-bridge code path that tried to share pages between the ZFS ARC and the OSv read_cache. It was never reachable: IS_ZFS() always returned false on OSv (m_fsid distinct), and the bridge data structures were only initialised on the unreachable branch."
I had no idea if that is actually true, so I asked my AI friend. Its response was:

The developer's claim is not supported by the code. Here's what the code actually shows:

IS_ZFS() does return true for ZFS files. The encoding chain is consistent:

  1. zfs_vfsops.c (which includes vfs_id.h) sets: vfsp->vfs_fsid.__val[1] = (ZFS_ID | fsid_guid) >> 32; // = 0x06000000 | (fsid_guid >> 32)
  2. zfs_vnops.c reconstructs: vap->va_fsid = ((uint32_t)fsid->__val[0]) | ((dev_t)((uint32_t)fsid->__val[1]) << 32);. This places 0x06 in bits 56–63 of va_fsid (= st_dev).
  3. IS_ZFS check (st_dev & 0xff00000000000000ULL) == ZFS_ID = (6ULL<<56) — which matches exactly. The check fires correctly for ZFS.

The ARC-bridge code IS reachable. The ZFS vnops table at zfs_vnops.c:5095 has zfs_arc in the vop_cache slot (position 19 in struct vnops). The call chain is:

pagecache::get() [IS_ZFS → true]
→ create_read_cached_page()
→ fp->read_page_from_cache()
→ VOP_CACHE() → zfs_arc()
→ dmu_map_uio()
→ mmu_map() [bsd/porting/mmu.cc]
→ pagecache::map_arc_buf()
→ (*arc_share_buf_fun)(ab)

The bridge data structures are unconditionally initialized — arc_read_cache and cached_page_arc::arc_cache_map are static globals, and the function pointers are wired up via register_pagecache_arc_funs() called from libsolaris.so at load time.

There is a puzzling comment in tst-shm-consistency.cc:368 that says (zfs_vop_cache=NULL), but that contradicts the actual zfs_vnops table — that comment is incorrect.

The "m_fsid distinct" argument doesn't hold either: ROFS uses ROFS_ID = (7ULL<<56) and ZFS uses ZFS_ID = (6ULL<<56), they're distinct by design, and IS_ZFS correctly discriminates between them.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Your AI is right and my commit message was wrong. I traced it again against the old BSD-ZFS code and the bridge was reachable and live:

  • zfs_vfsops.c set vfs_fsid.__val[1] = (ZFS_ID | fsid_guid) >> 32
  • zfs_vnops.c reconstructed va_fsid with 0x06 in the top byte
  • so IS_ZFS(st_dev) did return true, and the chain pagecache::get() -> create_read_cached_page() -> read_page_from_cache() -> VOP_CACHE() -> zfs_arc() -> ... -> map_arc_buf() -> arc_share_buf() was wired up, with register_pagecache_arc_funs() called unconditionally from libsolaris at load. My 'IS_ZFS always false / m_fsid distinct' justification was simply incorrect, and the tst-shm-consistency.cc:368 comment I leaned on is also wrong.

The correct reason removal is safe is on the OpenZFS side, not the old code: the OpenZFS 2.4.3 port intentionally does NOT tag the fsid with ZFS_ID (module/os/osv/zfs/zfs_vfsops.c), because arc_share_buf() became a static-internal function in OpenZFS 2.x and is no longer callable from the bridge. With ZFS_ID cleared, IS_ZFS() returns false and ZFS files take the same read_cache path as ROFS, fed by the zfs_vop_cache() hook. So the bridge is dead after the cutover because the port clears ZFS_ID, not because IS_ZFS was always false on the old code -- the causality is inverted from what I wrote.

I've reworded the in-code comment (near register_pagecache_arc_funs in cb4757c) to state this accurately, including the honest note that a page served this way is now held twice in DRAM (once in the ARC as the decompressed block, once in read_cache). I'll fix the commit message to match before this merges. Thanks for catching it -- and thank your AI friend.

Comment thread core/pagecache.cc
extern "C" void osv_free_page(void *p)
{
memory::free_page(p);
}

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.

We have a bunch of these extern "C" osv_*() functions already in core/osv_c_wrappers.cc. Maybe it's a good idea to put those there too? I think they aren't too related to core/pagecache.cc.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I looked at moving them to core/osv_c_wrappers.cc. The difference is coupling: the wrappers already in osv_c_wrappers.cc are thin adapters over public APIs (sched, app, run, version) -- they need no file-private state from another TU. These pagecache wrappers are the opposite: osv_pagecache_map_page/read_page/map_page_if_absent all operate directly on pagecache.cc's file-static internals -- read_cache, read_lock, find_in_cache(), and the cached_page type. Moving them to osv_c_wrappers.cc would force exposing all of that internal state through a header just so another TU can reach it, which is worse encapsulation than keeping the C entry points next to the data they manipulate. So I'd prefer to keep them in pagecache.cc. If you feel strongly the other way I'll move them, but it would mean promoting several currently-private symbols to a shared header.

@wkozaczuk

Copy link
Copy Markdown
Collaborator

I have applied this PR to the latest master and was able to build a simple hello world example with a ZFS image:

./scripts/build image=native-example

However, OSv hangs just before trying to run an app (possibly trying to finish initializing ZFS filesystem):

OSv v0.57.0-359-g9baa80d5
eth0: 192.168.122.15
Booted up in 271.72 ms
Cmdline: /hello

I am speculating that your changes rely on the new OpenZFS version of libsolaris.sobuilt from your tree and do not work with the old FreeBSD code that is part of the OSv tree. Is it true?

I have tried to review the code, and I think in general it all makes sense, but I am not able to validate it completely. So here is an idea: would it be possible to tweak your changes to pagecache.cc so that it can work with both old BSD ZFS and OpenZFS?

Specifically, maybe we can keep the existing ARC-related functions that old BSD ZFS uses, add new ones we need for OpenZFS, and tweak bool get(vfs_file* fp,...), bool release(vfs_file* fp, ...) and void sync(vfs_file* fp, ...) (the hardest part) to behave differently - old way OR new way - based on some global boolean flag (for example is_open_ZFS) set by some new callback function called from zfs_initialize() in fs/zfs/zfs_initialize.c. This way we can preserve compatibility with the old ZFS implementation and the new one. This would also limit the risk of these changes and unblock you from moving forward with the OpenZFS upstreaming, etc. Eventually, once we feel comfortable, we can remove the old ZFS code altogether.

@gburd

gburd commented Jul 9, 2026

Copy link
Copy Markdown
Contributor Author

Yes, exactly right - and thank you for taking the time to build and boot it. Your diagnosis is correct: this PR as written assumes the OpenZFS libsolaris.so, and it breaks the old BSD-ZFS tree.

The mechanism: on current master get() has an IS_ZFS(st.st_dev) branch that routes ZFS reads through arc_read_cache and shares ARC buffers in place via arc_share_buf(), using the register_pagecache_arc_funs() bridge that the old fs/zfs/zfs_initialize.c registers at init. This PR deleted that branch and stubbed the bridge to no-ops, on the assumption that ZFS files reach the page cache with IS_ZFS()==false. That assumption is only true for the OpenZFS port: its zfs_vfsops.c deliberately leaves the ZFS_ID byte clear in the fsid (arc_share_buf() became static-internal in OpenZFS 2.x, so the in-place-share hook is gone). The old BSD-ZFS port still tags the fsid with ZFS_ID, so with the old libsolaris.so its files hit the now-deleted branch and never get a read page - hence the hang loading the app off ZFS.

I agree completely with keeping both paths working. I'd like to propose a slightly lighter wiring than the global is_open_ZFS flag, because the discriminator you want already exists per-file:

  • Keep the old ARC read path exactly as it is on master, still guarded by the existing IS_ZFS(st.st_dev) check in get()/release()/sync(), and keep register_pagecache_arc_funs() / map_arc_buf() / unmap_arc_buf() / the access scanner live (not stubbed).
  • OpenZFS files arrive with IS_ZFS()==false (the port clears the ZFS_ID byte on purpose - see module/os/osv/zfs/zfs_vfsops.c), so they fall through to the same read_cache path as ROFS with no new flag.
  • The additive changes in this PR - sequential readahead, the periodic writeback daemon, and the fsync page-cache flush - never touch the ARC branch; they operate only on read_cache/write_cache, so they help both stacks and stay as-is.

Net effect: old BSD-ZFS keeps its ARC-sharing fast path unchanged, OpenZFS uses the read_cache path, and we need no global boolean or new init callback because IS_ZFS(st.st_dev) already tells the two apart per file. When we later retire the old BSD-ZFS code, the IS_ZFS()==true branch and the bridge come out in that same cleanup.

If you'd rather have an explicit is_open_ZFS global set from zfs_initialize() regardless, I'm happy to do it that way instead - just let me know. Otherwise I'll rework this PR to restore the IS_ZFS()-guarded ARC path, rebuild an old-BSD-ZFS native-example image, and confirm it boots and runs before re-pushing.

@wkozaczuk

Copy link
Copy Markdown
Collaborator

I like your proposal, and I agree we probably do not need this extra global flag to differentiate between old and new OpenZFS. I assume you will have an equivalent of the fs/zfs/zfs_initialize.c for OpenZFS that will activate the writeback_worker only if OpenZFS. In the worst case, we can have a boolean BSD_ZFS of whatever (if we really need it) that could be set by start_pagecache_access_scanner() only called from fs/zfs/zfs_initialize.c.

I do think you will have to differentiate between BSD and OpenZFS in some places of get()/release()/sync(). For example, in get() you call readahead_if_sequential() which is OpenZFS specific, no? Similarly, the sync() seems to be quite different. The release() is the only one that would be left unchanged given that IS_ZFS(st.st_dev) does its trick. I also assume your change to sys_fsync() in vfs_syscalls.cc applies to both OpenZFS and BSD, right? Similarly, the change to cached_page_write::writeback() fixes a bug that applies to both as well?

Finally, I imagine at some point you will send another PR to add a new OpenZFS libsolaris.so module (meaning a new dir under modules/), right?

@wkozaczuk

wkozaczuk commented Jul 9, 2026

Copy link
Copy Markdown
Collaborator

BTW, at some point it would be nice to tackle #1201 so the old BSD becomes a module under modules/ and it does not pollute the main makefile. Maybe I can make Claude do it for me.

@gburd

gburd commented Jul 10, 2026

Copy link
Copy Markdown
Contributor Author

Pushed 11d4f46 restoring the IS_ZFS()-guarded ARC bridge, per the plan above. This is a fast-forward on top of the existing series (no rewrite of the earlier commits).

Verified both stacks boot and run on current tree:

  • Old BSD-ZFS: ./scripts/build image=native-example then boot -> Cmdline: /hello / Hello from C code. This is exactly where your build hung before; the ARC read path is live again.
  • ROFS: fs=rofs image=native-example boots and runs the same app, exercising the read_cache path (readahead + the reworked get()).

Answering your specific questions:

  • readahead_if_sequential() is OpenZFS/ROFS-specific - correct. It is gated if (ret == 0 && !IS_ZFS(st.st_dev)), so old BSD-ZFS (ARC pages shared in place) never takes it. Only read_cache files get readahead.
  • sys_fsync() change applies to both - yes. It flushes the page cache before VOP_FSYNC regardless of filesystem.
  • cached_page_write::writeback() dirty-flag fix applies to both - yes. release() checks write_cache before any IS_ZFS branch, so writeable mmap pages land in write_cache for both stacks; the fix (clear _dirty only after VOP_WRITE succeeds) protects both.
  • Periodic writeback_worker - it only iterates write_cache, which is populated identically for both stacks, so it is safe to run unconditionally. No OpenZFS-only gating needed, and no global BSD_ZFS flag: IS_ZFS(st.st_dev) already discriminates per file in get()/release()/sync().
  • A future OpenZFS libsolaris.so under modules/ - yes, that is the plan; it will ship its own zfs_initialize.c equivalent. This PR keeps the old bridge live so nothing regresses until then.

Re #1201 (old BSD-ZFS -> modules/): agreed that is the clean end state. When it lands, the IS_ZFS()==true branches, cached_page_arc, the access scanner and the four bridge function pointers all come out together (noted in the new commit message and the top-of-file comment). Happy to take that on.

@wkozaczuk wkozaczuk left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Hi,

I like this PR. I have reviewed it and left mostly questions to make sure I understand some of your changes that may affect BSD ZFS and ROFS more deeply. I have made some minor suggestions for improvement.

I think we are close to get it approved.

PS. I have also run your PR with both ZFS and ROFS image and everything passes.

Waldek

Comment thread core/pagecache.cc
Comment thread core/pagecache.cc Outdated
Comment thread core/pagecache.cc
Comment thread core/pagecache.cc
Comment thread core/pagecache.cc
Comment thread core/pagecache.cc
Comment thread core/pagecache.cc
Comment thread core/pagecache.cc
Comment thread fs/vfs/vfs_syscalls.cc Outdated
{
struct stat st;
if (sys_fstat(fp, &st) == 0) {
error = osv_pagecache_writeback_inode(st.st_dev, st.st_ino,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Nitpick: why do not we call pagecache::writeback_inode() directly?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Agreed -- sys_fsync() is C++ so there is no reason to bounce through the extern "C" osv_pagecache_writeback_inode() wrapper. The wrapper exists for C callers; the direct call is cleaner here. Will switch it to pagecache::writeback_inode().

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done in a1b277e -- sys_fsync() now calls pagecache::writeback_inode() directly.

Comment thread core/pagecache.cc
@gburd

gburd commented Jul 10, 2026

Copy link
Copy Markdown
Contributor Author

Pushed two follow-up commits addressing this round of review:

  • 584d6ae pagecache: keep mapping intact in fsync/writeback phase-1 dirty scan -- fixes the real TLB bug you flagged at :863/:917/:967. Phase 1 now uses clear_dirty() (bit-only compare_exchange, mapping preserved) instead of flush_check_dirty() (which unmapped via clear_pte()). An empty to_flush now provably means no PTE changed, so skipping flush_tlb_all() is correct. Previously phase 1 silently unmapped scanned pages, and the skipped flush left stale writable TLB entries that could drop an mmap store across fsync.
  • a1b277e vfs: call pagecache::writeback_inode() directly from sys_fsync -- the :403 nitpick.

Replied inline to the rest: the _dirty/PTE-bit sync question (:843), the mark_dirty retry being phase-independent (:873), the free/double-free contracts (:509/:567), ROFS readahead being an effective no-op (:759), and why I'm keeping the phase-1/phase-2 sweeps separate rather than merging (:856/:910/:960) plus the factor-out follow-up (:969). Both commits compile clean on NixOS.

@wkozaczuk

wkozaczuk commented Jul 11, 2026

Copy link
Copy Markdown
Collaborator

@nyh do you see anything else that should prevent us from merging this PR?

@gburd thanks for your responses. That has clarified a lot (and helped find and fix one issue). Shall we squash this PR eventually before we merge it?

As I was writing it, I ran tests with ROFS, and I got this crash:

./scripts/build image=tests -j$(nproc) fs=rofs
./scripts/test.py
  TEST java-perms                         OSv v0.57.0-360-g97701dcb
eth0: 192.168.122.15
Booted up in 154.99 ms
Cmdline: /usr/lib/jvm/java/bin/java -cp /tests/java/tests.jar io.osv.TestDomainPermissions !
Assertion failed: 0 (core/pagecache.cc: operator(): 157)

[backtrace]
0x0000000040238ed8 <__assert_fail+24>
0x0000000040358030 <pagecache::cached_page::ptep_remove::result_type boost::variant<decltype(nullptr), mmu::hw_ptep<0>, std::unique_ptr<std::unordered_set<mmu::hw_ptep<0>, std::hash<mmu::hw_ptep<0> >, std::equal_to<mmu::hw_ptep<0> >, std::allocator<mmu::hw_ptep<0> > >, std::default_delete<std::unordered_set<mmu::hw_ptep<0>, std::hash<mmu::hw_ptep<0> >, std::equal_to<mmu::hw_ptep<0> >, std::allocator<mmu::hw_ptep<0> > > > > >::apply_visitor<pagecache::cached_page::ptep_remove>(pagecache::cached_page::ptep_remove&) &+144>
0x0000000040353428 <???+1077228584>
0x000000004035348f <pagecache::remove_read_mapping(pagecache::hashkey&, mmu::hw_ptep<0>)+47>
0x000000004035394b <pagecache::get(vfs_file*, long, mmu::hw_ptep<0>, mmu::pt_element<0>, bool, bool)+1179>
0x00000000403b29af <vfs_file::map_page(unsigned long, mmu::hw_ptep<0>, mmu::pt_element<0>, bool, bool)+31>
0x00000000402b8930 <mmu::map_file_page_mmap::map(unsigned long, mmu::hw_ptep<0>, mmu::pt_element<0>, bool)+48>
0x00000000402bfbea <mmu::map_level<mmu::populate_small<(mmu::account_opt)1>, 1>::operator()(mmu::hw_ptep<1>, unsigned long)+410>
0x00000000402bfd9d <mmu::map_level<mmu::populate_small<(mmu::account_opt)1>, 2>::operator()(mmu::hw_ptep<2>, unsigned long)+285>
0x00000000402c000d <mmu::map_level<mmu::populate_small<(mmu::account_opt)1>, 4>::operator()(mmu::hw_ptep<4>, unsigned long)+541>
0x00000000402b8595 <???+1076594069>
0x00000000402b3e20 <mmu::vm_fault(unsigned long, exception_frame*)+192>
0x000000004030454b <page_fault+139>
0x0000000040303282 <???+1076900482>
0x00001000008766b9 <???+8873657>
0x000020000f9466f5 <???+261383925>
0x000020000f937cec <???+261324012>
0x000010000087d4d1 <???+8901841>
0x000010000085957f <???+8754559>
0x000010000085bcee <???+8764654>
0x000010000085bebf <???+8765119>
0x000010000085bebf <???+8765119>
0x000010000085bebf <???+8765119>
0x00001000008373e2 <???+8614882>
0x0000100000837dd1 <???+8617425>
0x0000100000959645 <???+9803333>
0x000020000f944b20 <???+261376800>
0x000020000f94023d <???+261358141>
0x000020000f937cec <???+261324012>
0x000010000087d4d1 <???+8901841>
0x000010000085957f <???+8754559>
0x000010000085bcee <???+8764654>
0x0000100000afef79 <???+11530105>
0x0000100000aff9b2 <???+11532722>
0x000010000087807b <???+8880251>
0x00001000008785b6 <???+8881590>
0x000020000f951b14 <???+261430036>
0x000020000f940396 <???+261358486>
0x000020000f937cec <???+261324012>
0x000010000087d4d1 <???+8901841>
0x000010000087d99a <???+8903066>
0x0000100000f02258 <???+15737432>
0x0000100000925e11 <???+9592337>
0x000010000000e76f <???+59247>
0x0000100000012b0c <???+76556>
0x00000000403cfd95 <???+1077738901>
0x000000004037d0c7 <thread_main_c+39>
0x0000000040304451 <???+1076905041>
Test java-perms FAILED

It does not happen on ZFS image. As a matter of fact, all unit tests pass on the old BSD ZFS image. Maybe the readahead_if_sequential() called here is a culprit:

 753             DROP_LOCK(write_lock) {
 754                 // page is not in cache yet, create and try again
 755                 // function may sleep so drop write lock while executing it
 756                 ret = create_read_cached_page(fp, key);
 757                 // Sequential readahead only applies to the read_cache path;
 758                 // ZFS pages are shared in place from the ARC.
 759                 if (ret == 0 && !IS_ZFS(st.st_dev))
 760                     readahead_if_sequential(fp, key);
 761             }

@gburd

gburd commented Jul 11, 2026

Copy link
Copy Markdown
Contributor Author

Thank you for catching this, and for running the ROFS suite - that was a real bug.

Fixed in b4e3482. Root cause: readahead_if_sequential() inserts pages into the read cache speculatively, with no PTE mapping recorded until the page is first faulted. Before readahead, every read_cache page always had at least one mapping, so ptep_remove's std::nullptr_t case asserted as "impossible". A MAP_PRIVATE COW write fault on a prefetched-but-never-read page calls remove_read_mapping(key, ptep), which hits that case with nothing to remove and trips the assert. It only shows on ROFS because ZFS shares ARC pages in place and never takes the readahead path (which is exactly why all the ZFS unit tests passed).

The fix makes ptep_remove on an unmapped page return 0 (zero remaining mappings) instead of asserting, so remove_read_mapping() drops the page and the COW copy proceeds. Both callers of unmap() already handle a 0 return correctly.

I added tests/tst-mmap-file-cow.cc as a regression test: it mmaps a ROFS-resident file MAP_PRIVATE, reads sequentially to trigger readahead, then writes to the prefetched pages to force the COW path. Verified it reproduces the exact assert(0) at pagecache.cc:128 against the pre-fix code, and passes with the fix.

Yes, let's squash before merge - happy to do that whenever you and @nyh are ready to take it. I don't see anything else outstanding on my side.

@gburd

gburd commented Jul 12, 2026

Copy link
Copy Markdown
Contributor Author

Rebased onto current master and squashed the review-fix commits into a single commit (per the request above), head now 23a602f5. The net change is byte-for-byte identical to the previous branch across all five touched files (core/pagecache.cc, fs/vfs/vfs_syscalls.cc, include/osv/pagecache.hh, modules/tests/Makefile, tests/tst-mmap-file-cow.cc); master had not touched any of them since the old merge-base, so the rebase was conflict-free.

Re-tested on current master, both filesystems:

  • ROFS (fs=rofs image=tests): tst-mmap-file-cow PASSED (the readahead-COW crash you found), tst-mmap-file 30/30, tst-mmap OK, tst-readdir-rofs 63/63.
  • ZFS (fs=zfs image=tests, old BSD-ZFS dual path): tst-mmap-file 30/30, tst-mmap OK, tst-fs-link 16/16, tst-concurrent-read "Identical count 1000".

The java-perms crash is resolved; the JVM now loads (any remaining java-perms issue on my end is an unrelated broken-JVM packaging problem on my build host, not the page-cache path).

Nothing outstanding on my side - ready to merge whenever you and @nyh are.

@wkozaczuk

Copy link
Copy Markdown
Collaborator

Hi,

I have used one of the apps to test - apps/graalvm-httpserver (I am not sure you can build it, and I have a prebuilt Capstan image), and it crashes very consistently.

OSv v0.57.0-360-g97701dcb
eth0: 172.18.0.2
Booted up in 127.17 ms
Cmdline: runscript /run/default;
runscript expand fn='/run/default' line=0 '/httpserverapp'
Listening on port 8000 ...
[ [ SubstrateSegfaultHandler caught signal 11 ] ]
[registers]
RIP: 0x000010000005526d <???+348781>
RFL: 0x0000000000010206  CS:  0x0000000000000008  SS:  0x0000000000000010
RAX: 0x0025538e00000000  RBX: 0x0000200000a01bb0  RCX: 0x0000200000300600  RDX: 0x00001000004d4cd0
RSI: 0x00001000004d4cd0  RDI: 0x00001000003360c4  RBP: 0x0000200000a01a38  R8:  0x0000100000529120
R9:  0x0000100000336000  R10: 0x0000200000300520  R11: 0x000000000000d097  R12: 0x000010000006e444
R13: 0x0000100000336000  R14: 0x0000200000300520  R15: 0x000000000000d097  RSP: 0x0000200000300600
general protection fault

[backtrace]
0x0000000040305fd9 <general_protection+105>
0x00000000403031cf <???+1076900303>
0x00001000006cba4f <???+7125583>

It runs on a ROFS image, and I have discovered that when I revert this function:

505 void map_read_cached_page(hashkey *key, void *page)
 506 {
 507     SCOPE_LOCK(read_lock);
 508     cached_page* pc = new cached_page(*key, page);
 509     read_cache.emplace(*key, pc);
 510     /*auto res = read_cache.emplace(*key, pc);
 511     if (!res.second) {
 512         // Key already present (a concurrent VOP_CACHE inserted it first).
 513         // emplace() did not take ownership, so free both the wrapper and the
 514         // page the caller handed us to avoid leaking them.
 515         delete pc;
 516         memory::free_page(page);
 517     }*/
 518 }

it goes away. I wonder if truly map_read_cached_page() can free the page. This also works

505 void map_read_cached_page(hashkey *key, void *page)
 506 {
 507     SCOPE_LOCK(read_lock);
 508     cached_page* pc = new cached_page(*key, page);
 509     //read_cache.emplace(*key, pc);
 510     auto res = read_cache.emplace(*key, pc);
 511     if (!res.second) {
 512         // Key already present (a concurrent VOP_CACHE inserted it first).
 513         // emplace() did not take ownership, so free both the wrapper and the
 514         // page the caller handed us to avoid leaking them.
 515         delete pc;
 516         //memory::free_page(page);
 517     }
 518 }

@wkozaczuk

Copy link
Copy Markdown
Collaborator

The map_read_cached_page() is called by rofs_map_cached_page() which actually does not allocate any page on demand; it simply passes the address of an existing page from the ROFS read-around cache. Now I am not sure I fully understand why rofs_map_cached_page() gets called many times for the same key, but possibly because of the concurrent calls to VOP_CACHE which rofs_map_cached_page() maps to.

…, fsync flush

Extend the page cache so that memory mappings of files backed by OpenZFS share
pages with the ARC (rather than double-buffering), add sequential readahead for
the read-cache path, run periodic write-back of dirty mmap pages, and flush the
page cache before VOP_FSYNC so fsync/fdatasync see mmap writes.

Page-cache changes (core/pagecache.cc, include/osv/pagecache.hh):

- ZFS ARC bridge: for ZFS-backed files, map pages in place from the ARC via the
  IS_ZFS()-guarded arc_read_cache path, sharing the ARC buffer instead of
  copying it into a private read-cache page.  The old BSD-ZFS ARC dual path is
  kept live so nothing regresses until the OpenZFS libsolaris module lands (see
  issue cloudius-systems#1201).
- Sequential readahead: readahead_if_sequential() speculatively populates the
  next few pages when it sees a sequential access pattern.  It is gated to the
  read-cache path (!IS_ZFS), so ROFS and OpenZFS benefit while old BSD-ZFS,
  which shares ARC pages in place, does not take it.
- Write-back: cached_page_write::writeback() clears the software dirty flag only
  after VOP_WRITE succeeds (a failed write stays dirty and is retried), and a
  periodic writeback worker flushes dirty mmap pages so they reach the
  filesystem without an explicit msync/fsync.
- fsync/sync/writeback_inode use a two-phase scan: phase 1 promotes the hardware
  PTE dirty bit into the software dirty flag with clear_dirty() (mapping-
  preserving), so an empty to_flush set provably means no PTE changed and the
  TLB flush can be skipped; phase 2 collects and writes back.  The phases stay
  distinct so a multiply-mapped page has every alias promoted before any is
  collected.

VFS (fs/vfs/vfs_syscalls.cc): flush the page cache before VOP_FSYNC in
sys_fsync so data written through a shared mmap is persisted by fsync(2)/
fdatasync(2).  sys_fsync calls pagecache::writeback_inode() directly (it is
C++), while the extern "C" wrapper stays for C callers.

A COW write fault on a page that readahead loaded into the read cache but that
was never faulted for read previously hit assert(0) in ptep_remove (the page has
no PTE mappings recorded); it now reports zero remaining mappings so the page is
dropped and the COW copy proceeds.  Regression test tests/tst-mmap-file-cow.cc
covers the readahead + MAP_PRIVATE COW path on ROFS.
@gburd

gburd commented Jul 12, 2026

Copy link
Copy Markdown
Contributor Author

Excellent diagnosis - thank you, that was exactly right, and it was a real bug I introduced.

Root cause: map_read_cached_page() has two callers with opposite page-ownership contracts, and I wrongly assumed one of them:

  • ROFS (rofs_map_cached_page) passes a borrowed page - rofs::cache_get_page_address() returns the address of an existing page in the ROFS read-around cache. It is not ours to free.
  • ZFS (zfs_vop_cache in zfs_vnops_os.c) passes an owned page it just allocated with osv_alloc_page(), which must be freed on a collision to avoid a leak.

My collision handler called memory::free_page(page) unconditionally, which freed a page still live in the ROFS read-around cache - hence the corruption and the GP fault (and why reverting the free_page made it disappear). The reason the same key hits the collision path repeatedly is, as you found, concurrent VOP_CACHE calls racing to populate the same (dev, ino, offset).

Fix (rebased into the PR, head now 3204f71f): map_read_cached_page() no longer frees the page - it returns a bool (inserted or not) and leaves the page to the caller. The ROFS caller ignores it (borrowed page, nothing to free); the ZFS wrapper osv_pagecache_map_page() frees its own allocated page only when the insert lost the race:

extern "C" void osv_pagecache_map_page(void *key, void *page)
{
    if (!map_read_cached_page(static_cast<hashkey*>(key), page)) {
        memory::free_page(page);   // ZFS allocated it; free on collision
    }
}

I also rebased the PR onto current master (it had gone CONFLICTING after the prctl PR merged - just an adjacent line in modules/tests/Makefile) and it is MERGEABLE again, still a single commit.

I strengthened tst-mmap-file-cow to also hammer the same file with 8 concurrent mapping/read threads to exercise the collision path. It passes on ROFS across repeated runs on 4 vCPUs; tst-mmap-file (30/30) and the ZFS suite are unaffected. I don't have your GraalVM Capstan image locally - if you're able to re-run it against 3204f71f that would be a great confirmation.

@wkozaczuk
wkozaczuk merged commit 244554b into cloudius-systems:master Jul 12, 2026
@gburd

gburd commented Jul 13, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for catching this and doing the bisection - you are exactly right about the cause, and the good news is the fix is already in the merged master.

The version you tested (OSv v0.57.0-360-g97701dcb) is the commit right before the pagecache merge (244554b4), so it still had the older revision of map_read_cached_page() that called memory::free_page(page) on a dedup collision. As you found, that is wrong for ROFS: rofs_map_cached_page() passes a borrowed page from the ROFS read-around cache, so freeing it corrupts that cache and produces the GP fault.

The final merged code fixes this by not freeing the page inside map_read_cached_page() at all - it just returns a bool and lets each caller decide, because the two callers have opposite ownership contracts:

// core/pagecache.cc (as merged)
bool map_read_cached_page(hashkey *key, void *page)
{
    SCOPE_LOCK(read_lock);
    cached_page* pc = new cached_page(*key, page);
    auto res = read_cache.emplace(*key, pc);
    if (!res.second) {
        // Key already present; emplace() did not take ownership of the wrapper,
        // so free the wrapper (always OSv-owned).  Leave @page to the caller.
        delete pc;
        return false;
    }
    return true;
}
  • ROFS (rofs_map_cached_page) passes a borrowed read-around page and ignores the return value -> never freed. (matches your working workaround)
  • ZFS (osv_pagecache_map_page) allocated the page with osv_alloc_page(), so it frees on a false return to avoid a leak.

As for why the same key maps repeatedly: yes, concurrent VOP_CACHE calls for the same offset race to insert, and only the first wins the emplace().

Could you retest apps/graalvm-httpserver against current master (past 244554b4)? I believe the crash is resolved there. If you still see it on merged master, I will dig in further.

@wkozaczuk

wkozaczuk commented Jul 13, 2026 via email

Copy link
Copy Markdown
Collaborator

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants