# DF-5829 — `vm_object_page_clean()` partial-range clean is a silent no-op

## What

`vm_object_page_clean()` marks the pages it is about to clean with
`PG_CLEANCHK` **only in pass1**, and pass1 runs **only when the requested
range covers the entire object** (`wholescan`).  pass2 and
`vm_object_page_collect_flush()` both skip any page without `PG_CLEANCHK`,
and no in-tree caller passes `OBJPC_IGNORE_CLEANCHK`.  Therefore **every
partial-range clean flushes nothing and returns success**.

`msync(MS_SYNC)` maps 1:1 onto `vm_object_page_clean()`
(sys/vm/vm_map.c:3148) and has no other flush mechanism, so:

* on **UFS** (object sized exactly to the file): `msync()` of a sub-range,
  or of a whole mapping that is smaller than the file, silently does
  nothing;
* on **hammer2** (the default root filesystem — objects are sized beyond
  EOF by `nvnode_pager_setsize()`, sys/kern/vfs_vm.c:465, to cover the
  buffer straddling EOF): `wholescan` can never be true for any range the
  user can express on the file, so **every `msync(MS_SYNC)` of a hammer2
  file is a silent no-op** (proven: the only range that flushes is one that
  extends beyond EOF to the block-rounded `object->size` — arm F).

Observable: `vm.stats.vm.v_vnodeout` / `v_vnodepgsout` are incremented only
in `vnode_pager_putpages()` (sys/vm/vnode_pager.c:764), which is reached
only from the pass2 flush path. A real MS_SYNC flush must bump them.

## Build

    cc -O2 -Wall -o msync_poc msync_poc.c        (on the guest)

## Run (unprivileged)

    ./msync_poc 3 [dir]        # dir defaults to the hammer2 home dir;
                               # use a UFS dir (e.g. under /boot) for the
                               # UFS arms (needs root to place files there)

Arms (child dirties exactly one 4 KiB page through MAP_SHARED, then msyncs):

| arm | file / mapping | msync range | expected (vulnerable) |
|-----|----------------|-------------|------------------------|
| A | 8p file, mmap 8p, dirty p6 | `[5p,7p)` partial | delta 0 (no flush) |
| B | 8p file, mmap 8p, dirty p6 | `[0p,8p)` whole file | UFS: flush; H2: **0** |
| C | 8p file, mmap 4p, dirty p1 | `[0p,4p)` whole mapping | delta 0 (no flush) |
| D | 1p file, mmap 1p, dirty p0 | `[0p,1p)` whole file | UFS: flush; H2: **0** |
| E | 1p file, mmap 4p, dirty p0 | `[0p,4p)` beyond EOF | delta 0 |
| F | 1p file, mmap 16p, dirty p0 | `[0p,16p)` beyond EOF | **flushes on H2** |

Arm F flushing on hammer2 while B/D do not is the mechanistic proof that
the gate is exactly `start==0 && end_pindex==object->size-1` with
`object->size` block-rounded beyond EOF.

## Expected output (vulnerable kernel)

see `run.h2.log` (hammer2) and `run.3-ufs.log` (UFS): partial ranges A and
C (and on H2 also B and D) show `vnodeout delta = 0`, controls flush.

## Fix

`fix.diff` — always run the PG_CLEANCHK marking pass over the requested
range; keep the OBJ_WRITEABLE/OBJ_MIGHTBEDIRTY clearing gated on wholescan.
With the patched kernel arm A flushes (see VERDICT.md).
