Skip to content

[BLOCKED on #1398, #1400, #1423] drivers: add Crucible distributed block storage driver - #1424

Draft
gburd wants to merge 19 commits into
cloudius-systems:masterfrom
gburd:pr/crucible-draft
Draft

[BLOCKED on #1398, #1400, #1423] drivers: add Crucible distributed block storage driver#1424
gburd wants to merge 19 commits into
cloudius-systems:masterfrom
gburd:pr/crucible-draft

Conversation

@gburd

@gburd gburd commented Jul 12, 2026

Copy link
Copy Markdown
Contributor

Warning

DRAFT / BLOCKED - not ready for review or merge yet.

This PR is published early for visibility into the Crucible block-driver work.
It is blocked on three things merging first, and its diff will not be clean
until they do:

This branch is stacked on top of all of the above (Crucible is the opt-in,
last item in the stack), so the diff shown here is the cumulative stack
back to master and re-includes their changes plus other already-merged work.
Once the blockers merge I will rebase this onto the new master, drop the merged
commits, and reduce the diff to just the Crucible driver for review. Please do
not review the diff as-is.

What this delivers

Adds a Crucible distributed block storage driver (1e27a93f), so OSv can
use Oxide's Crucible as a ZFS-backing block device (alongside virtio-blk, NVMe,
and NVMe-oF). The driver is opt-in and gated off by default
(conf_drivers_crucible?=0); it is built only with
conf_drivers_profile=crucible, so it adds nothing to the default kernel.

It sits last in the storage stack because it is a ZFS backing device and depends
on the OpenZFS cutover (#1423) and the block/pagecache infrastructure (#1398,
#1400).

Validation done (on our fork)

  • tst-crucible-blk 9/9 byte-clean; ~53.6 MB/s write / ~119.7 MB/s read
    (multi-iov physio path fixed).
  • Crucible-backed ZFS works on a single downstairs.

Known limitation (honest status)

  • tst-crucible-zfs can hang in txg_wait_synced during zpool_create against
    a multi-host downstairs quorum. This is Crucible-driver-specific (virtio
    passes the same ashift=9/12 workload), and is being investigated. This will be
    called out clearly when the PR is opened for real review; it is why the driver
    ships gated-off.

I will post a clean, rebased version for review once the blockers merge.

gburd added 19 commits June 7, 2026 14:22
OSv's master does not build on current toolchains. Two independent
breakages compound:

  - Boost 1.78+ made boost::system header-only, so libboost_system.a
    is no longer produced. The build hard-errored requiring it, and
    Boost 1.87's lockfree headers use std::conditional_t, which needs
    C++14.
  - GCC 14 is stricter: core/mmu.cc's four-argument std::lower_bound
    needs an explicit <algorithm> include, and Boost.Asio dropped the
    long-deprecated io_service, resolver::query, mutable_buffers_1, and
    address_v4::from_string APIs.

Changes:
  - Default conf_cxx_level to gnu++14 (conf/base.mk, modules/common.gmk).
  - Detect libboost_system only if present; never require it. Support
    both nixpkgs (include/) and FHS (usr/include/) Boost layouts.
  - Add <algorithm> to core/mmu.cc and core/pagecache.cc.
  - Port Boost.Asio call sites to io_context, the range-returning
    resolver::resolve, mutable_buffer, and make_address_v4
    (loader.cc, core/dhcp.cc, tools/cpiod, modules/httpserver-api,
    modules/cloud-init, modules/monitoring-agent, tests/*tcp*).
  - Replace boost::math::isinf/isnan with std::isinf/isnan.
  - Detect OpenSSL 3.x (libssl.so.3) in addition to 1.1, and fall back
    to LD_LIBRARY_PATH when ldconfig is unavailable.
Bump the bundled musl from 1.1.24 (released 2019) to 1.2.1 (2020),
the last version with the BSD-style internal layout that OSv's libc
shim expects before musl's 1.2.x re-architecture.  The upgrade picks
up roughly four years of bug fixes in stdio, math, dns, and threading.

Fix nftw(): the legacy 1.1.24 implementation had an uninitialized
field that was masked by 1.1.x's malloc behaviour but tripped UBSan
under 1.2.1.  Initialise it explicitly so nftw() returns the same
results regardless of allocator state.

Verified: tst-libc, tst-pthread, tst-dns-resolver, tst-fnmatch all
pass.
Implement the two Linux hugepage hint APIs against OSv's existing
2 MiB / 1 GiB page support:

  - madvise(MADV_HUGEPAGE, addr, len): mark a VMA range so that the
    page-fault handler will try to back it with 2 MiB pages when
    enough physically-contiguous memory is available.  The default
    (MADV_NOHUGEPAGE) and an explicit MADV_HUGEPAGE produce the same
    effect today; the hint is recorded for future use by the
    allocator's huge-page heuristic.
  - mmap(... MAP_HUGETLB ...): allocate the VMA directly out of the
    huge-page pool (failing with ENOMEM rather than silently falling
    back to small pages).  Honours MAP_HUGE_2MB / MAP_HUGE_1GB flags.

Tests: tst-huge.cc exercises both APIs with mmap, mprotect, mlock,
fault-on-write, and partial-unmap to verify no PTE leaks or stale
mappings.
mmu::virt_to_phys() was correct only for kernel-direct-mapped
addresses.  When a driver called it on a VMA-mapped buffer (e.g., a
DMA descriptor allocated via mmap with PROT_READ|PROT_WRITE), the
function returned the linear-mapping offset of the page rather than
the page's actual frame, producing silent DMA corruption when the
buffer happened to be backed by a non-direct page.

Walk the page tables instead and return the physical frame of the
PTE that maps the address.  Fall back to the linear-map fast-path
when the address is in the kernel direct-map range.

This is the underlying cause of intermittent virtio-blk and
Crucible DMA misalignment seen on systems where the block-layer
buffer pool was relocated to a heap region.
shm_file::put_page() previously returned without clearing the PTE
when the page was reused.  After munmap, stale mappings persisted
on the next VMA at the same virtual address, so a freshly-mmaped
SHM segment could read whatever the previous tenant had written.
Fix: call clear_pte(ptep) before returning false from put_page()
(returning false so the TLB-gather path doesn't free the physical
page; shm_file::close() owns it).

Implement the rest of the POSIX shm surface that the fix exposed:

  - shm_open() / shm_unlink() honour /dev/shm semantics.
  - shmctl(IPC_STAT) reports correct nattch / size / mode.
  - shm_file::truncate() resizes a segment in-place (the existing
    sys_ftruncate() now delegates through fp->truncate() for files
    without a backing dentry, e.g. SHM and io_uring queues).

Tests: tst-shm-consistency.cc, 30 cases covering nattch transitions,
dual-mapping write visibility, file-backed MAP_SHARED across two
mappings, and IPC_RMID idempotency.  All pass on ZFS and ramfs.
in_vma_range() still used the pre-mem_area predicate (addr >= 0) to mean
"non-VMA / high address". The mem_area refactor moved the debug-memory
region to a positive base (debug_base), so that predicate matched every
normal allocation: vpopulate's assert(!in_vma_range(addr)) fired on the
first debug allocation at boot, making the conf_debug_memory allocator
unbootable. Discriminate on debug_base, matching the test already used
elsewhere in this file.
Three pagecache changes that work together to make the OSv VFS
page cache cooperate with OpenZFS:

  - Expose C-linkage helpers (osv_pagecache_map_page, osv_pagecache_*)
    so the OpenZFS vop_cache implementation in zfs_vnops_os.c can
    register and look up cached pages without dragging the C++
    pagecache headers into kernel-module sources.  Also fixes a
    GCC 14 ambiguity error on a templated helper.
  - 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.  Document the design decision in a
    comment block above the (now removed) site so future readers
    don't try the same approach again.
  - Sequential readahead and a periodic writeback worker.  The
    readahead window grows on consecutive cache hits and resets on
    seek; the writeback worker flushes dirty pages every 5 s with
    a global cap so dirty pages can't accumulate without bound.

Verified by tst-mmap-file, tst-zfs-direct-io, and tst-fs-bench.
sys_fsync() called VOP_FSYNC directly without first flushing the
OSv page cache.  Dirty pages held in the cache were never seen by
the underlying filesystem's fsync hook, so a process could fsync()
a file and have the data still resident in volatile memory.
Reproducer: write 64 KiB, fsync, kill the VM, restart, see zeros.

Walk the file's dirty pages, write them back via the filesystem's
write op, then call VOP_FSYNC.  Holds the file's f_lock across the
flush so concurrent writes can't slip in between the writeback and
the VOP_FSYNC call.

Verified by tst-zfs-direct-io and tst-zfs-multirec, which write,
fsync, re-open, read, and memcmp.  Without this fix the multi-record
ZFS test produces zero-filled tail records on uncached read.
Two block-layer features that the ZFS and Crucible drivers depend on:

  - BIO_DISCARD: a new request type plumbed through the block layer
    and into virtio-blk's request descriptor (VIRTIO_BLK_T_DISCARD).
    Maps to ZFS's ZIO_TYPE_TRIM and to Crucible's protocol-level
    discard.  Drivers that don't support discard return ENOTSUP and
    the caller falls back to overwrite-with-zero.
  - Multiqueue: per-CPU queue dispatch in virtio-blk so that a
    16-vCPU guest can submit I/O on 16 queues concurrently.  Adds
    a scripts/run.py helper that wires up QEMU's num-queues option,
    a per-queue locking-race fix (the per-queue completion-notifier
    lock was being released too early under preemption), removed
    next_queue_idx (became unused after dispatch was hashed by CPU),
    and a sys/dev device_delete_child return-type fix that surfaced
    while threading the multi-queue tear-down path.

Verified: tst-vblk-multiqueue, tst-zfs-trim, tst-zfs-direct-io.
Add an io_uring(7) implementation backed by OSv's existing async-I/O
plumbing.  Supported opcodes: READ, WRITE, FSYNC, NOP, OPENAT,
CLOSE, READV, WRITEV, POLL_ADD, POLL_REMOVE, TIMEOUT, ACCEPT,
CONNECT, RECV, SEND.  Submission and completion queues are mmap'd
into user memory; the SQE/CQE layout matches Linux 5.15 ABI.

Surface:
  - io_uring_setup(), io_uring_enter(), io_uring_register() syscalls.
  - SQ poll thread (IORING_SETUP_SQPOLL) for kernel-side submission.
  - Linked SQEs (IOSQE_IO_LINK) and drain barriers (IOSQE_IO_DRAIN).

Tests: tst-io_uring.cc, 14 sub-tests covering each opcode, link
semantics, drain ordering, and SQ-poll mode.  Registered in
modules/tests/Makefile and zfs-tools/usr.manifest.

Note: OSv's io_uring is not used by OpenJDK 21 virtual threads
(JDK 21 still goes through epoll); see the cover letter for details.
tcp_net_channel_packet() assumed it ran with SOCK_LOCK held and the
inpcb still attached to its socket.  Under connection churn a queued
packet can be drained after in_pcbdetach() cleared inp_socket, or from
the IRQ classifier path that does not hold SOCK_LOCK, and a packet can
arrive after the tcpcb has moved to CLOSED/TIME_WAIT -- which tripped
tcp_do_segment()'s state KASSERT and panicked the kernel.

Re-resolve the socket inside the callback and drop the packet if the
inpcb has detached; acquire SOCK_LOCK if we do not already own it
(OSv's recursive mutex makes re-locking a cheap depth bump); and skip
segments for connections at or below LISTEN or in TIME_WAIT, matching
what the slow path (tcp_input) does before reaching tcp_do_segment.
wakeup_one() tested the equal_range lower bound against _evlist.end()
to decide whether a waiter existed for the channel. When the channel is
absent, equal_range returns an empty range whose first iterator points
at the next-larger key rather than end(), so the old guard woke an
unrelated waiter and erased its node while leaving the intended sleeper
stranded. Test first != second so an absent channel is a no-op.
When an ELF read runs past the end of the file, report the pathname,
actual file size, and the required length instead of a bare message,
so a truncated or mis-pathed binary is diagnosable from the failure.
Replace OSv's bundled FreeBSD-derived ZFS port (zfs-on-osv, c. 2014,
last upstream sync ~2017) with a vendored OpenZFS 2.4.3.  The port
is a fresh OSv platform layer over the unmodified upstream OpenZFS
kernel module, plus userspace bindings for libzfs, libzfs_core,
libzutil, libshare, libuutil, libtpool and the zpool/zfs CLIs.

Build:
  - external/openzfs/ submodule on the osv-2.4.3 branch of our fork
    (carries 18 OSv platform commits on top of zfs-2.4.3 upstream).
  - bsd/sys/cddl/openzfs_sources.mk drives the kernel-side compile,
    pulling icp-asm and the modes/ directory in.  ASFLAGS uses '='
    (not '+=') so the older bsd/cddl asm_linkage.h shadow does not
    win the include race.
  - openzfs-osv source group consolidates OSv-specific helpers
    (zio_crypt stubs, taskq glue, opt_zfs_auto_upgrade, freemem,
    random_get_pseudo_bytes, the CDDL/BSD compat shim) into
    libsolaris.so.  The shim is also linked into libsolaris.so so
    user-space tools resolve cleanly through the dynamic linker.

VFS / boot integration:
  - vop_cache populates OSv's read_cache so user-space mmap/read
    reuse the same pages.
  - va_fsid in zfs_vop_getattr is taken from vp->v_mount->m_fsid
    (was uninitialized).
  - zfs_freesp + zfs_extend / zfs_free_range / zfs_trunc helpers
    added so OSv ftruncate works on ZFS files.
  - zfs_acl_ids_create stores file-type bits (S_IFLNK et al.) in
    z_mode, fixing EBADF on every ZFS symlink.
  - ARC shrinker, kthread stack accounting, osv_free_pages, and
    memory-pressure callbacks reworked to match OSv's scheduler.

Hardware features:
  - ZIO_TYPE_TRIM is now wired through vdev_disk to virtio-blk's
    BIO_DISCARD (added in the block-layer commit earlier in this
    series).  TRIM is enabled by default on pools where the vdev
    advertises support; pools on a discard-capable backing device
    can reclaim free space.  TRIM bio errors are correctly mapped
    to ENOTSUP (not EIO) so callers can fall back gracefully.
  - AES-256-GCM encryption is the real OpenZFS implementation, not
    a stub: zio_crypt_impl.c is fully linked.  libzfs_crypto_os.c
    only stubs the userspace key-loading-from-passphrase path
    (ENOTSUP); raw and hex-key formats work, exercised by
    tests/tst-zfs-encryption.so (22/22 PASS).

OpenZFS 2.4.3 upgrade:
  - Rebase our 18 OSv platform patches onto 2.4.3 (zero conflicts).
  - Add zfs_mount_setattr() stub for selective namespace remount.
  - OSv-specific zfs_uioskip iov-advance fix carried in the same
    submodule pointer (without it, multi-record writes corrupt
    record boundaries; reproducer in tests/tst-zfs-multirec.so).
  - libzfs/libzutil device-path handling skips the ".1" partition
    suffix for "crucible*" volumes (replicated raw extents have no
    MBR partition table).

Cleanup:
  - Remove ~60k LOC of orphaned bsd/cddl OpenSolaris userspace
    sources that the new build path no longer touches.
  - Add external/openzfs to .gitmodules.

All ZFS tests pass: tst-zfs-direct-io (9/9), tst-zfs-trim (5/5),
tst-zfs-encryption (22/22), tst-shm-consistency (30/30), tst-huge,
tst-io_uring, tst-zfs-multirec, tst-zfs-recordsize bench, and
tst-zfs-db-sim all C1/C3/C4/C5 configurations.
Add the ZFS-focused tests that gate this branch's correctness story:

Validation:
  - tst-zfs-direct-io.so: O_DIRECT + buffered round-trips (9 cases)
  - tst-zfs-trim.so: lzc_trim, partial-discard, busy pool (5 cases)
  - tst-zfs-encryption.so: AES-256-GCM key load/unload, dataset
    create/destroy under encryption, key-refcount on unmount
    (21 cases — covers the wk_refcnt EBUSY fix in libzfs_dataset.c)
  - tst-zfs-crucible-stress.so: concurrent reads/writes against a
    Crucible-backed pool (excluded from the generic zfs-test image
    manifest since it needs a real Crucible cluster).

Benchmarks:
  - tst-zfs-recordsize.so: throughput vs. recordsize at fixed
    workload size, demonstrates the ARC cache-hit-rate dependence.
  - tst-fs-bench.so: cross-filesystem benchmark (ZFS vs ramfs vs
    rofs, with --dir to pick the mountpoint).
  - tst-zfs-db-sim.so: synthetic Postgres-WAL workload, runs all
    four configurations (C1/C3/C4/C5).  Requires a 4 GiB pool and
    a 30 s inter-config sleep so the previous run's ARC has drained.

Ergonomics:
  - tst-zfs-* helpers replace shell-out-to-zpool with libzfs API
    calls so the tests run inside the OSv unikernel (the userspace
    zpool binary has a separate stale-pointer bug in its argv
    handling).  Builds also become idempotent: re-running a test
    no longer leaves stale datasets behind.
  - update-openzfs.sh removed (was the patch-workflow utility, now
    obsolete since we track via submodule).
  - zfs-test image definition (modules/tests-zfs/) for the ZFS-only
    test set.

All registered in modules/tests/Makefile and zfs-tools/usr.manifest.
The pool sizing in tst-zfs-trim and tst-zfs-db-sim is documented in
the test source.
Add an OSv-native Crucible upstairs client: a network block device
driver that speaks the Oxide Computer Crucible V13 protocol to
three independent downstairs replicas, providing crash-consistent
block storage with 2-of-3 read quorum and 2-of-3 write quorum
(3-of-3 for snapshots).

Architecture:
  - drivers/crucible-client.{cc,hh}: UpsairsClient orchestrates
    handshake (HereIAm / YesItsMe / PromoteToActive / RegionInfo /
    ExtentVersionsPlease), per-job request lifecycle, hash-verified
    reads, and concurrent writes.
  - drivers/crucible-connection.{cc,hh}: per-downstairs TCP socket
    with separate send/recv mutexes; a send_exact_with_data() helper
    that takes the send mutex across both header and data halves of
    a Write or ReadResponse frame so concurrent ZFS TXG-sync writes
    do not interleave on the wire.
  - drivers/crucible-bincode.hh: minimal bincode 1.x encoder/decoder
    matching upstream Rust serde wire layout.
  - drivers/crucible-messages.hh, crucible-types.hh: protocol
    structs aligned byte-for-byte with the upstream Crucible Rust
    enum and bincode encoding.
  - drivers/crucible-blk.{cc,hh}: OSv block-device shim, exposes
    /dev/crucible0 to the VFS.  Validates I/O alignment and length,
    maps requests to UpsairsClient::{read,write,flush}_sync(), and
    threads through BIO_DISCARD as ENOTSUP (V13 has no Discard).
  - drivers/crucible-hash.{cc,hh}: xxh3-128 helper for ReadBlockType
    integrity verification.

Integration:
  - Multi-volume support: multiple --crucible= command lines bind
    /dev/crucible0, /dev/crucible1, etc.
  - Opt-in via conf/profiles/x64/crucible.mk so the driver isn't
    pulled into the default kernel image.
  - Boot-time CLI flags wired into loader.cc.
  - Removed the early-prototype Rust scaffolding (osv-sys, crucible-
    osv crates, build.rs); the driver is now pure C++.

V13 protocol fixes (relative to early prototype):
  - HereIAm carries Vec<u32> alternate_versions, not bool.
  - YesItsMe layout is {version, repair_addr}.
  - Result tag is u32, not u8.
  - Write / ReadResponse / Flush layouts and Option<u32> /
    Option<String> field encodings match upstream bincode.
  - RegionDefinition struct order matches upstream Rust.

Production logging: removed per-I/O kprintfs that would spam under
load; only error paths and a single connection-state line per
downstairs remain.

Tests:
  - tst-crucible-blk: direct /dev/crucible0 round-trip at multiple
    offsets and sizes (BLKGETSIZE64, pwrite, fsync, pread).
  - tst-crucible-zfs: end-to-end zpool_create / dataset / mount /
    write / fsync / read / verify on a 3-replica Crucible volume
    over the libzfs API (avoids the userspace zpool binary).

Driver runs against a real 27-downstairs Crucible cluster (9
volumes, ports 4001-4083) and a single 3-replica volume; passes all
tests modulo a separately-tracked OSv kernel TCP issue with
sustained 1 MiB+ transfers under the QEMU usermode network stack.
Three Forgejo Actions workflows mirroring the existing GitHub
Actions setup:

  - build.yml: every push, run a debug + release build on x86_64
    with fs=zfs.
  - test.yml: every PR, build the test image and run the OSv test
    suite (tst-pthread, tst-shm, tst-shm-consistency, tst-io_uring,
    tst-zfs-direct-io, tst-zfs-trim, tst-zfs-encryption, tst-zfs-
    multirec).  The matrix splits across QEMU + KVM-on-Forgejo runner.
  - release.yml: on tag push, build a fresh ZFS image and attach it
    as a release asset.

Mirrors only; neither workflow runs against cloudius-systems/osv.
Move the apps submodule URL from cloudius-systems/osv-apps to
codeberg.org/gregburd/osv-apps and bump it to a fork commit that
adds two new demos used by the test suite:

  - apps/zfs-demo/: minimal libzfs example (zpool_create + zfs_create
    + write + read).
  - apps/crucible-basic-test/: tiny client that opens /dev/crucible0
    via BLKGETSIZE64 + pwrite/pread to confirm the upstairs-downstairs
    pipeline is alive.  Build artefact is .gitignored (was previously
    committed by accident; the fork commit removes it).

The fork is a strict superset: every cloudius-systems/osv-apps
commit is preserved, the new demos sit on top.
OpenJDK from the host worked on Debian/Ubuntu but failed on NixOS
because java/jshell binaries embed an absolute /nix/store path in
their RUNPATH.  Fix:

  - module.py: patchelf java/jshell binaries at install time so
    RUNPATH points at /usr/lib/jvm/java/lib (the OSv layout) rather
    than the build-host's nix-store path.
  - common.gmk: bump C++ standard from c++11 to c++14 because Boost
    1.89's interfaces require it.
  - jni_helpers.cc: include <stdexcept> (was implicit before
    Boost 1.89 / GCC 14 tightened the standard headers).
  - pom.xml: drop the `<classifier>jar</classifier>` directive that
    was removed in maven-assembly-plugin 3.7.1.

Concurrent fix: binutils 2.46 changed how it handles weak symbol
visibility for archived static libraries; adjust the Makefile rule
for libsolaris.so to keep the OSv export-symbols list authoritative.

Java OpenJDK 21 image now boots and runs the SubmissionPublisher
virtual-thread benchmark (100/100 tasks complete) on OSv ZFS.
@gburd

gburd commented Jul 13, 2026

Copy link
Copy Markdown
Contributor Author

Status update: #1398 has merged. This PR remains blocked on #1400 and #1423 (OpenZFS cutover) landing first, since Crucible is a block-storage backend that sits on the same storage stack. Will rebase to a clean Crucible-only diff once the chain ahead of it merges.

@gburd

gburd commented Jul 21, 2026

Copy link
Copy Markdown
Contributor Author

Note to self / reviewers: this draft is blocked on #1423 and is currently ~39 commits behind master, which means it carries a few silent reverts of work that has since merged (the rt_sigtimedwait/osv_sigtimedwait path from #1428 and the tst-signal-fills.cc test). Those are stale-base artifacts, not intentional. When #1423 lands and this is unblocked, it will be rebased onto current master (which drops those reverts by patch-id) before it leaves draft. Not fixing now because it cannot merge until #1423 anyway and the rebase-on-unblock supersedes any fix made today.

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.

1 participant