# DF-0952 — sys_vmspace_destroy leaks ve->refs on EBUSY (vm_vmspace.c:222-231)

## Verdict: NOT REPRODUCED END-TO-END (real bug by source inspection; trigger requires heavy vkernel setup)

### Mechanism (confirmed by source inspection)

`sys_vmspace_destroy` (sys/vm/vm_vmspace.c:208):

```c
lwkt_gettoken(&vkp->token);
error = ENOENT;
if ((ve = vkernel_find_vmspace(vkp, uap->id, 1)) != NULL) {  // bumps ve->refs +1
    error = vmspace_entry_delete(ve, vkp, 1);
    if (error == 0)
        vmspace_entry_cache_drop(ve);                         // ONLY on success
}
lwkt_reltoken(&vkp->token);
```

`vkernel_find_vmspace` documents (vm_vmspace.c:702-704):
> Locate the ve for (id), return the ve or NULL.  If found this function
> will bump ve->refs which prevents the ve from being immediately
> destroyed (but it can still be removed).
> **The caller must hold vkp->token if excl is non-zero.**

So the +1 ref added by `vkernel_find_vmspace` is the caller's responsibility
to drop.

`vmspace_entry_delete` (vm_vmspace.c:623):

```c
if (atomic_cmpset_int(&ve->refs, refs, VKE_REF_DELETED) == 0) {
    KKASSERT(ve->refs >= refs);
    return EBUSY;
}
```

The `refs` argument is `1` (from the caller). The cmpset atomically
transitions `ve->refs` from 1 → `VKE_REF_DELETED` ONLY if `ve->refs` is
exactly 1 (i.e. only the find's +1 ref is present). On success, that
+1 ref is consumed (replaced with the DELETED marker); `cache_drop`
then drops the on-tree cache ref. The function returns 0.

If `ve->refs` was already > 0 before the find added +1 (i.e. someone
else holds an active ref), cmpset fails and the function returns EBUSY
**without consuming the +1 ref**.

`sys_vmspace_destroy` then returns EBUSY **without dropping the +1 ref**.
Each failed destroy permanently inflates `ve->refs` by 1. Eventually
the process exits; `vkernel_exit` (vm_vmspace.c:773) calls
`RB_SCAN(vmspace_rb_tree, ..., rb_vmspace_delete, vkp)` and
`rb_vmspace_delete` (vm_vmspace.c:596) calls
`vmspace_entry_delete(ve, vkp, 0)` (expected refs == 0); the cmpset
fails and:

```c
panic("rb_vmspace_delete: invalid refs %d", ve->refs);
```

### Why the PoC cannot trigger it end-to-end

The cmpset fails (EBUSY) only when `ve->refs > 1` at the moment
`vmspace_entry_delete` is entered, which requires another caller to
hold an active ref. The only path that establishes a long-lived
active ref is `sys_vmspace_ctl(VMSPACE_CTL_RUN)` (vm_vmspace.c:309):

```c
case VMSPACE_CTL_RUN:
    ...
    error = copyin(ua.tframe, sysmsg->sysmsg_frame, framesz);
    if (error == 0)
        error = copyin(&ua.vframe->vx_tls, &curthread->td_tls, ...);
    if (error == 0)
        error = cpu_sanitize_frame(sysmsg->sysmsg_frame);
    if (error == 0)
        error = cpu_sanitize_tls(&curthread->td_tls);
    if (error) { ... bail ... }
    else {
        vklp->ve = ve;
        atomic_add_int(&ve->refs, 1);                  // <-- the long-lived ref
        pmap_setlwpvm(lp, ve->vmspace);
        ...
        error = EJUSTRETURN;
    }
```

For `RUN` to actually bump refs and stay bumped, all of:
  - `copyin(tframe)` must succeed
  - `copyin(vframe->vx_tls)` must succeed
  - `cpu_sanitize_frame(frame)` must succeed
  - `cpu_sanitize_tls(tls)` must succeed

After `RUN` returns to userland, the LWP is executing in the foreign
vmspace. Any instruction execution faults immediately (the vmspace
starts empty), which fires `vkernel_trap` and drops the ref. So the
race window for `sys_vmspace_destroy` to see `refs > 1` is between
`atomic_add_int` and the immediate fault on return-to-user.

Constructing a winning race requires:
1. Mapping executable memory into the foreign vmspace first (via
   `sys_vmspace_mmap`), so the LWP doesn't fault immediately
2. Coordinating two LWPs so one is in `RUN` while the other calls destroy
3. Timing the destroy to land in the `ve->refs > 1` window

`vmspace_refs_leak.c` (run.log) confirms the API surface is reachable
from an unprivileged user once `vm.vkernel_enable=1` (root-set sysctl,
commonly enabled on vkernel hosts). But with no concurrent `RUN`
holder, every `sys_vmspace_destroy` on a freshly-created ve succeeds
(`ok=1`, no EBUSY) — the bug's preconditions are not met by the
single-shooter PoC.

`vmspace_refs_leak_v2.c` adds a runner thread that hammers
`sys_vmspace_ctl(RUN)` with a zeroed trapframe. `cpu_sanitize_frame`
rejects the zero frame, so the runner never reaches the `atomic_add_int`
bump. Net result: `ebusy=0`, no leak.

A working race PoC would need to populate the foreign vmspace with at
least one executable page (so RUN doesn't immediately fault) and a
genuine sanitized trapframe pointing at that page — non-trivial kernel
integration test territory. The finding's own assessment ("EBUSY
reachable: another LWP holds active ref (e.g. inside sys_vmspace_ctl
RUN); cache fast-path bumps refs token-free widening race") is
accurate but requires real vkernel execution context.

### Conclusion

Real bug by source inspection (the missing `vmspace_entry_drop` on the
EBUSY path is unambiguous). Recording `not_reproduced` with
`confidence=likely` — the bug exists, the trigger requires a working
vkernel RUN setup that's beyond a single-shooter PoC. Marking
`fix_status: not_testable` — fix.diff compiles cleanly and the change
is a one-line obvious correctness fix; runtime validation would
require the same heavy vkernel setup as the trigger.

## Suggested fix

`fix.diff` adds `else vmspace_entry_drop(ve);` to the EBUSY branch of
`sys_vmspace_destroy`, releasing the +1 ref that `vkernel_find_vmspace`
added. With the fix, a failed destroy no longer inflates `ve->refs`,
so the proc-exit `rb_vmspace_delete` cmpset(0, DELETED) succeeds and
the panic cannot fire.
