Skip to content

Imfs contiguous for mmap#166

Open
rennergade wants to merge 61 commits into
mainfrom
imfs-contiguous-for-mmap
Open

Imfs contiguous for mmap#166
rennergade wants to merge 61 commits into
mainfrom
imfs-contiguous-for-mmap

Conversation

@rennergade

Copy link
Copy Markdown
Contributor

No description provided.

rennergade added 30 commits May 10, 2026 14:55
…-mmap'd files

Precondition for the mmap(MAP_SHARED) plumbing: imfs needs a regular-file
storage shape whose backing is a single contiguous host page range
(SYS_MMAP'd, ANON|SHARED), rather than the existing chunk-chain.  That's
the only shape RawPOSIX can alias into a cage's vmmap when the cage
mmaps the file, and it's what makes multi-cage MAP_SHARED 'work for free'
— every cage that maps the file ends up aliased onto the same host
pages.

Adding it as a sibling variant rather than morphing Reg keeps the
existing chunk-based code path untouched, so this change is zero-impact
for current callers.  Follow-ups will (1) wire the mmap handler to
recognize RegMapped fds and route the cage's mmap call back through
SYS_MMAP with MAP_FIXED at the stored host_addr, (2) implement
read/write/ftruncate against the host mapping, (3) decide where the
RegMapped vs Reg choice gets made at create time (path-based config in
the routing layer is the leading candidate).
…Mapped nodes

Two private helpers on Filesystem so RegMapped nodes can grow / release
their host page range without callers needing to know how to invoke
threei.

ensure_mapped_backing(node_idx, min_capacity):
  - No-op if current capacity already covers the request.
  - Refuses with EBUSY if mmap_refs > 0 and a grow is needed (moving
    the region would silently invalidate cages that still have it
    mapped — better to fail than to corrupt).
  - Otherwise rounds min_capacity up to a page, allocates a fresh
    MAP_ANONYMOUS | MAP_SHARED region via make_threei_call(SYS_MMAP),
    copies the live bytes from the old region (anonymous mappings
    start zeroed so no tail-fill is needed), and releases the old
    region via SYS_MUNMAP.

free_mapped_backing(node_idx):
  - Releases the host mapping via SYS_MUNMAP when the RegMapped node
    is being reclaimed.  Debug-asserts mmap_refs == 0 to surface
    refcount bugs early rather than silently leaking host pages.

Both go through make_threei_call so RawPOSIX is the one allocating /
freeing — gives us real host kernel pages, which is what later
RegMapped mmap-routing will alias into the calling cage's vmmap with
MAP_FIXED.  Nothing calls these yet; wiring read/write/ftruncate and
the mmap handler are the next commits.
…directly

read_from_node, write_to_node, and truncate_node now have a RegMapped
fast path that runs before the chunk-walking code.

- read: single copy_nonoverlapping out of `host_addr + offset` for
  `min(buf.len(), total_size - offset)` bytes.  No chunk walk.
- write: ensure_mapped_backing(end_offset), then a single
  copy_nonoverlapping into the mapping.  Anonymous backing means any
  hole between the old total_size and the write offset is already
  zeroed.  If the grow fails (EBUSY because cages still have the
  region mapped, or ENOMEM), return 0 — caller surfaces as short
  write / ENOSPC.
- truncate: grow → ensure_mapped_backing then update total_size.
  Shrink → just update total_size; we deliberately do not munmap the
  tail so any live cage mapping past the new EOF stays valid.

The chunk-based paths for plain `Reg` are untouched.  RegMapped nodes
still aren't created anywhere (that's the next decision — path-based
config in the routing layer, or an explicit choice in the imfs grate's
config), so this commit is a no-op at runtime; it just makes the
storage code ready for a RegMapped node to actually exist.
…o cage vmmap

This is the wire-up that makes `mmap(RegMapped_fd, MAP_SHARED, …)`
actually map the file's host-backed pages into the calling cage.

ImfsState::mmap(cage_id, addr, len, prot, flags, fd, off):
  - Refuses anonymous mappings (MAP_ANONYMOUS / fd == -1) — handler
    forwards those to RawPOSIX directly.
  - Looks up fd → node.  Only RegMapped is handled; plain Reg gets
    -ENODEV (its chunk-chain backing isn't contiguous and isn't
    suitable for being aliased into a cage vmmap).
  - Ensures the host region covers off+len via ensure_mapped_backing.
  - Issues make_threei_call(SYS_MMAP) targeted at the calling cage
    with MAP_FIXED | MAP_ANONYMOUS | MAP_SHARED at `host_addr + off`.
    MAP_FIXED tells RawPOSIX to alias those exact host pages into
    the cage's vmmap — meaning every cage that maps the same file
    shares physical pages, which is what gives MAP_SHARED its
    cross-cage write-visibility semantics (postgres' dyn-shm path
    needs this).
  - Bumps mmap_refs and records (cage, mapped_vaddr) → (node_idx,
    len) so munmap can find it.

ImfsState::munmap(cage_id, addr, len):
  - Always forwards to RawPOSIX via SYS_MUNMAP so the cage's vmmap
    entry is torn down, regardless of whether we know about it.
  - If we tracked the address, decrements the node's mmap_refs so
    later grows / frees aren't pinned by a stale refcount.

handlers.rs gains mmap_handler / munmap_handler, both wired into
GrateBuilder in main.rs.  The mmap handler also handles the
forward-to-RawPOSIX case (when imfs returns -ENOSYS to mean 'not
mine').

Also broadens NodeType::Reg checks in dirent type, open/O_TRUNC,
truncate, and ftruncate to accept NodeType::RegMapped — same
semantics, different storage variant.

Still no RegMapped nodes get created anywhere; that's the next
commit (path-based config / startup-flag decision).
No upfront decision needed about which files will be mapped; every
regular file starts cheap (chunk-chain) and is promoted in-place on
the first mmap call that targets it.

Adds promote_to_mapped(node_idx): allocates a host MAP_ANON|MAP_SHARED
region sized to the current total_size, drains the chunk chain into
it in file order, reclaims the chunks, and flips both the NodeInfo
variant and NodeType to the mapped form.  After promotion the
existing host-mapped fast paths in read/write/ftruncate take over —
no caller-visible change other than 'this file is now mappable'.

Wired into ImfsState::mmap before the existing 'must be RegMapped'
check, so the first mmap() on a file is the trigger.  Files that
never get mapped never pay the promotion cost.

This obviates needing a path-based config or an 'all files mapped'
startup flag — the right decision is made at the moment we actually
know it's needed.  Cost: one extra full-file memcpy on the first
mmap of each file (the drain), trivial vs the typical workload that
follows.

The Node::new arm for NodeType::RegMapped is now technically dead
(nothing creates RegMapped at construction), but keeping it lets the
variant exist for any future paths that want to allocate a node
already-mapped.
Three follow-ups to the RegMapped lazy-promotion work, plus tests:

(1) Cage exit cleanup.  Adds ImfsState::cage_exit(cage_id) which
    walks mmap_tracking and decrements the corresponding mmap_refs
    counters for every live RegMapped mapping owned by that cage,
    then removes the per-cage cwd and fd-offset entries.  Hooked via
    new exit_handler / exit_group_handler in handlers.rs (forwards
    SYS_EXIT / SYS_EXIT_GROUP through threei after the cleanup),
    registered in main.rs.  Without this, a cage that exited without
    explicit munmap'ing would leak mmap_refs and permanently pin
    that file's host region against grows.

(2) reclaim_node frees the host mapping.  When a RegMapped node is
    being reclaimed (final close after unlink), call
    free_mapped_backing first.  Without this the SYS_MMAP'd region
    leaks every time a mapped file is deleted.

(3) truncate_node shrink path.  On RegMapped shrink, zero the bytes
    between the new EOF and the old EOF in the host mapping so a
    cage with a live mapping past the new EOF doesn't observe stale
    data.  We can't reach into the cage's vmmap to actually shorten
    its mapping (real Linux would SIGBUS post-shrink), so degrade
    to zeros — strictly safer than leaving stale contents visible.

Tests added in test/imfs_test.c:
  - test_mmap_basic: open → ftruncate → mmap → write through
    mapping → read via fd round-trips correctly (also exercises
    the lazy Reg→RegMapped promotion path).
  - test_mmap_truncate_shrink_zeros: bytes past the new EOF in a
    live mapping read as zero, head bytes untouched.
  - test_mmap_shared_fork: parent maps with MAP_SHARED, forks, child
    sees parent's writes and overwrites, parent sees child's writes
    — covers the cross-cage MAP_SHARED-via-aliased-host-pages path
    that motivated the whole design.
  - test_mmap_refcount_release: mmap → munmap → ftruncate grow
    succeeds, indirectly checking that munmap properly decrements
    mmap_refs (a leak would surface as EBUSY here).
write_to_node panicked at `data[used..local_offset].fill(0)` when the
write offset was past a freshly-allocated chunk's end.  Triggered by
truncate_node's grow path on a fresh file: ftruncate(fd, 4096) on an
empty Reg file calls write_to_node(.., 4095, &[0]), which allocates
one chunk then tries to zero-fill bytes 0..4095 in a 1024-byte chunk.

Fix: after allocating (or stepping to) a chunk, if local_offset still
exceeds CHUNK_SIZE, mark the whole chunk as zero-used and advance to
the next slot.  Pre-existing bug, exposed by the new test_mmap_basic
which is the first test to grow a fresh file past one chunk.
mmap_handler and munmap_handler were reading the calling cage from
arg1cage, but arg1 is the addr pointer — its cage tag can be a
transient grate's cage (see open_handler's note), not the caller's.
Switch to arg5cage (fd, integer) for mmap and arg2cage (len, integer)
for munmap.  Also stop discarding the integer args' cage tags in the
ENOSYS-forwarding path so the forward replays the real per-arg cages
RawPOSIX expects.
The cage-side mmap that aliases imfs's host pages into the calling
cage's vmmap was forwarding the caller's flags with just MAP_FIXED
added.  The caller's flags describe a file-backed mapping (no
MAP_ANON, fd = imfs fd), but we're calling RawPOSIX with fd=-1 since
the file-backing role is now ours.  RawPOSIX/libc rejects fd=-1
without MAP_ANON with EINVAL — which is what the test saw as errno=12
(ENOMEM, our default-to-ENOMEM error path swallowing the real EINVAL).

Hardcode MAP_ANON | MAP_SHARED | MAP_FIXED for the cage-side call;
the caller's flags are intentionally not propagated to RawPOSIX.
The cage-side SYS_MMAP needs to MAP_FIXED at the *host* page our
first SYS_MMAP gave us, not at that offset reinterpreted against the
calling cage's base.  Mark arg1cage with GRATE_MEMORY_FLAG so glibc's
make_threei_call wrapper translates target_addr from the grate's
wasm offset to a real host pointer (`__lind_base + target_addr`)
before RawPOSIX sees it.
Distinguishes which mmap step is failing in the test:
  -74 EBADMSG   = promote_to_mapped failed (first SYS_MMAP, target=grate)
  -75 ENOTUNIQ  = ensure_mapped_backing failed
  -76 EBADFD    = host_addr is still 0 after promote
  -77 EREMCHG   = cage-side SYS_MMAP returned non-syscall threei error

Will revert once we know which path is hit.
mmap returns a wasm32 user-space address.  find_map_space picks the
END of the gap (so on an empty cage it returns ~0xFFFFF000 — the top
of the 4GB vmmap).  Those addresses look negative as i32, and
grate-rs's make_threei_call collapses every negative i32 into
Err(MakeSyscallError(n)).  We were treating any such Err as ENOMEM,
turning a successful high-address mmap into a -ENOMEM failure on the
caller side.

glibc handles this by only treating values in (-256, 0) — the actual
errno range — as errors.  Apply the same convention in
promote_to_mapped, ensure_mapped_backing, and the cage-side mmap
return handling: large-negative i32s are valid user_addrs.
Only the alias / promote-to-mapped flow makes sense for imfs-owned
files.  An mmap call landing here with a non-imfs fd (kernel fd
inherited through a layered grate, IPC fd, etc.) was being treated
as an imfs fd: `entry.underfd` would get used as a node_idx and we
would silently mangle the call.

Gate the imfs path on `fdkind == IMFS_FDKIND`.  Anything else (no
fdtable entry, or a different fdkind) bounces back as -ENOSYS so the
handler forwards the original SYS_MMAP unchanged to the next layer.
stderr-flushed eprintlns at entry / exit / decision points for:
  - read_handler, write_handler, lseek_handler
  - mmap_handler, munmap_handler
  - exit_handler, exit_group_handler, fork_handler
  - imfs::mmap, imfs::promote_to_mapped, imfs::ensure_mapped_backing

So we can see which call lands when the runtime starts truncating
output.  Drop before merge.
…fd I/O

Path A from PR 1185's design discussion: stop trying to alias the
grate's host pages into the calling cage's vmmap via MAP_FIXED +
GRATE_MEMORY_FLAG.  That approach collided with whatever the cage
already had at the derived uaddr (stack, libc data, etc.), silently
corrupting state.

New model:
  - mmap forwards to RawPOSIX as plain MAP_ANON | MAP_SHARED (no
    MAP_FIXED, no GRATE_MEMORY_FLAG).  Runtime picks a free uaddr in
    the calling cage's vmmap.
  - Seed the new region by copying the file's current chunk bytes
    through copy_data_between_cages.
  - read_from_node / write_to_node route through the owner cage's
    mapping via copy_data_between_cages when any cage has the node
    mapped.
  - munmap (or cage_exit on an unclosed mapping) syncs the cage's
    mapping back to chunks so future fd reads see the latest bytes.
  - truncate-shrink on an mapped file zeros the cage's mapping bytes
    past the new EOF.

Cross-cage sharing comes from kernel fork-inheritance of MAP_ANON |
MAP_SHARED — parent and child see the same physical pages.  Unrelated
cages mapping the same file each get their own region, which is the
explicit tradeoff we took for now (postgres's typical access pattern
is fork from the postmaster, which works).

Leaves promote_to_mapped / ensure_mapped_backing / free_mapped_backing
and the RegMapped variants in place as dead code; will clean up after
the test run confirms the approach.
cage_exit fires from the SYS_EXIT handler, but by that point the
cage's address space may already be gone — a copy_data_between_cages
read against the cage's mapping uaddrs hits the runtime's range-
validation and prints a noisy '[3i|copy] range invalid' to stderr
even though the operation succeeds overall (we ignored the error).

The sync was best-effort anyway: chunks are stale during the mapped
period, and any consumer that wants the post-mmap bytes to persist
needs an explicit munmap (which we do sync, before the cage's vmmap
entry is torn down).  Drop the cage_exit sync and just clear the
tracking entries.
Composition: fs-routing-clamp routes /tmp to imfs-grate (no fs-view-
grate, so all cages share the same imfs).  Demonstrates the postgres
DSM pattern that single-fork shared mmap doesn't cover:

  - parent opens /tmp/dsm_segment, ftruncate to one page, mmap
    MAP_SHARED, writes 'parent_init' at offset 0
  - parent forks two children (N=3 cooperating cages total)
  - each child independently opens the same path and mmaps it again
    (does not reuse the inherited mapping — that's how postgres
    workers attach to a DSM segment by name)
  - each child stamps a child-specific marker at a distinct offset
  - parent waits for both and verifies its mapping shows all three
    markers — proves all three cages share the same imfs pages

Goal: catch regressions in shared mmap when more than 2 cooperating
cages touch the same imfs-backed page.
Path A handles single-fork shared mmap correctly via kernel-level
inheritance of MAP_ANON | MAP_SHARED: parent and forked child see
each other's writes through the inherited mapping at the parent's
uaddr.  The postgres-DSM pattern adds a wrinkle: each child reopens
the file and calls mmap() *itself* instead of reusing the inherited
address, expecting to attach to the existing segment.  Path A's
mmap forwards a fresh anonymous mmap on each call, so the child gets
its own region — no sharing, parent never sees the child's writes.

Two small changes recover the demo:

  - imfs::fork now also copies the parent's mmap_tracking entries
    over to the child (with the same uaddr).  That uaddr is valid in
    the child too, because Linux fork inherits the parent's vmmap.

  - imfs::mmap checks mmap_tracking before forwarding SYS_MMAP.  If
    the calling cage already has an inherited entry for the node,
    hand back the existing uaddr.  The cage's memcpy through that
    uaddr lands on the inherited shared kernel pages, which is what
    the demo (and postgres DSM) needs.

Cages outside the fork lineage still get a fresh anonymous region
(no sharing — same explicit tradeoff path A makes).  imfs_test.c
keeps passing 67/67.
Same behavior, less nested pattern-matching.
A/B probe to localize the test_mmap_basic regression seen after
7e46a35.  Two changes landed in that commit:

  1. imfs::fork copies mmap_tracking from parent to child
  2. imfs::mmap returns the inherited uaddr early if cage+node match

For test_mmap_basic the parent cage's mmap_tracking is empty (no
mmap ran before this test) so both changes should be inert — yet the
test segfaults.  This commit removes (2) only; if test_mmap_basic
now passes, (2) is the culprit; if it still fails, (1) is.  The DSM
demo will regress for now until we re-add the working piece.
Re-adds the imfs::mmap inherited-uaddr short-circuit and instruments
every mmap-related decision point with eprintln! so we can correlate
test output with what the grate is actually doing.

Traces (all go to stderr, prefixed with [TRACE imfs::...]):
  - imfs::open: cage/path/norm/flags -> fd
  - imfs::read / imfs::write: cage/fd/buflen
  - imfs::read_from_node / write_to_node: when find_active_mapping
    routes through an existing mapping (owner cage + uaddr)
  - imfs::mmap: ENTER args; fd->node lookup; short-circuit check
    (table_size + inherited match); SYS_MMAP forward + ret; mapped
    uaddr; seed copy_len + copy_data_between_cages ret; INSERT
  - imfs::munmap: cage/addr/len; tracked entry removed
  - imfs::fork: parent/child; mmap_tracking inheritance count + each
    copied entry
  - imfs::cage_exit: cage; each dropped entry; before/after size

Use stderr (eprintln!) so output flushes immediately on segfault —
previous "test_lseek PAS<segfault>" was a buffered-stdout artifact
that hid where the crash actually happens.

Diagnostic only — to be reverted once the cause is identified.
ROOT CAUSE: imfs::mmap stores mmap_tracking entries keyed by the
wasm uaddr returned from SYS_MMAP (e.g. 0xfffb7000), but by the
time the cage's munmap call reaches munmap_handler the runtime has
already translated the pointer arg from the cage's uaddr to the
host sysaddr (e.g. 0x724bbfdb7000).  The (cage, sysaddr) key never
matched any stored (cage, uaddr) entry, so munmap was a no-op for
the table.

Visible symptom: a subsequent imfs::mmap on a different file that
happened to be assigned the same reused node slot would trigger the
inherited-uaddr short-circuit, hand back the previous (now-unmapped)
uaddr, and the cage segfaulted on first memcpy through it.

This was confirmed by tracing — after test_mmap_basic:
  INSERT (cage=2,uaddr=0xfffb7000) -> (node=16,len=4096). table_size=1
  munmap cage=2 addr=0x724bbfdb7000 len=4096
  tracked entry removed: None   <-- LEAK
then in test_mmap_truncate_shrink_zeros:
  short-circuit check: mmap_tracking_size=1 inherited=Some("0xfffb7000")
  SHORT-CIRCUIT FIRE -> return uaddr=0xfffb7000
  Segmentation fault

FIX: in imfs::munmap, when the direct (cage, addr) lookup misses,
fall back to iterating cage's entries and matching by len.  Each
cage has at most one live mapping per node in our use cases, so
(cage, len) is unique enough.  Long-term: either disable the
runtime's pointer translation for this arg, or store host_addr
alongside uaddr at mmap time so direct lookup works.

Also adds an arg1cage trace in munmap_handler so we can see whether
the runtime tags the translated pointer with GRATE_MEMORY_FLAG.
Drops the eprintln traces added in 0598360 and the arg1cage trace
added in 48ed877.  The munmap fallback lookup that resolves the
sysaddr-vs-uaddr mismatch stays in place.

Net change over the previous passing path A baseline (6f856e8) is
three additions: fork-copy of mmap_tracking, the inherited-uaddr
short-circuit in imfs::mmap, and the (cage, len) fallback in
imfs::munmap.  imfs_test passes 67/67 and the DSM demo passes 9/9.
# Conflicts:
#	rust-grates/imfs-grate/src/imfs/mod.rs
#	rust-grates/imfs-grate/src/main.rs
#	rust-grates/imfs-grate/test/imfs_test.c
rennergade added 30 commits May 13, 2026 00:22
# Conflicts:
#	rust-grates/imfs-grate/src/handlers.rs
#	rust-grates/imfs-grate/src/imfs/node.rs
#	rust-grates/imfs-grate/test/imfs_test.c
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