diff --git a/.claude/rules/agent-orchestration.md b/.claude/rules/agent-orchestration.md
deleted file mode 100644
index 43547f00e..000000000
--- a/.claude/rules/agent-orchestration.md
+++ /dev/null
@@ -1,200 +0,0 @@
----
-name: agent-orchestration
-description: Bounded routing, ownership, approvals, and handoffs for RamShared agents.
-paths:
- - .claude/rules/**
- - tools/ci/check-agent-orchestration.*
----
-
-# Agent orchestration — RamShared
-
-
-
-This rule is the canonical contract for coordinating bounded work in this
-repository. It applies to the root agent and every worker in the same session.
-The root agent remains accountable for scope, integration, and the final
-report. A worker never expands the approved scope by inference.
-
-## Checker-visible representation
-
-The checker reads rendered CommonMark prose for authority and safety statements
-and the canonical `yaml` fences below for typed records. Markdown comments,
-raw HTML blocks, and indented code do not grant authority or satisfy a required
-statement. A fence with another info string is not a typed record.
-
-## Checker-visible safety invariants
-
-- Root Sol is read-only and must not edit, self-approve, commit, push, merge,
- or run host or destructive actions.
-- A worker must not spawn agents or workers.
-- Every approval is explicit, current, and scoped; a stale or inherited
- approval is invalid.
-- The two Sol gates require separate independent verdicts; one Sol verdict
- cannot satisfy both gates.
-
-## R0–R4 routing
-
-Use the lowest route that can safely handle the request. A route is a control
-boundary, not a model-quality label.
-
-| Route | Cost-first purpose | Default model/tier | Write authority |
-| --- | --- | --- | --- |
-| R0 | Read-only deterministic work. Root Sol is orchestration-only and read-only. | gpt-5.6-luna / low | None |
-| R1 | Small closed mutation. | gpt-5.6-luna / medium | Assigned files only |
-| R2 | Multi-file work with a known contract. | gpt-5.6-luna / high or max | Assigned files only |
-| R3 | Structural, security, concurrency, kernel, driver, or host work. | gpt-5.6-terra | Assigned files only |
-| R4 | Critical, release, or final audit work. | gpt-5.6-sol / gpt-5.6-terra | Only the explicitly approved action |
-
-Routing requirements:
-
-- R0 is the lowest-cost read-only deterministic route. Root Sol is
- orchestration-only and read-only; it does not edit a worker's files,
- self-approve a Sol gate, commit, push, merge, or perform host/destructive
- actions.
-- R1 handles a small closed mutation with an exact file allowlist and a local
- acceptance test. It is not a discovery route.
-- R2 handles multi-file work only when the contract, owner, acceptance tests,
- and rollback trigger are already known in the dispatch card.
-- R3 is required for structural, security, concurrency, kernel, driver, or
- host work and must be independent of the worker that wrote the slice.
-- R4 is reserved for critical work, release boundaries, and final audits.
- Protected or destructive actions still require a fresh, explicit user
- approval naming the action and target before dispatch.
-
-## Luna/Terra/Sol tier matrix
-
-| Model | Tier | Appropriate work |
-| --- | --- | --- |
-| gpt-5.6-luna | low | Read-only deterministic inspection and bounded evidence. |
-| gpt-5.6-luna | medium | Small closed mutation with a local acceptance test. |
-| gpt-5.6-luna | high | Multi-file work with a known contract. |
-| gpt-5.6-luna | max | Multi-file work with a known contract at the upper Luna budget. |
-| gpt-5.6-luna | ultra | Exceptional closed Luna task with critical explicit approval. |
-| gpt-5.6-terra | low | Structural or implementation work with bounded risk. |
-| gpt-5.6-terra | medium | Security, concurrency, or cross-file implementation work. |
-| gpt-5.6-terra | high | Kernel, driver, or host-bound implementation work. |
-| gpt-5.6-terra | xhigh | High-risk structural implementation and verification. |
-| gpt-5.6-terra | max | High-risk implementation with broad evidence requirements. |
-| gpt-5.6-sol | low | Read-only orchestration or independent review. |
-| gpt-5.6-sol | medium | Independent review with bounded evidence synthesis. |
-| gpt-5.6-sol | high | Protected escalation planning or final audit review. |
-| gpt-5.6-sol | xhigh | Critical release-boundary or final audit review. |
-| gpt-5.6-sol | max | Critical release and rollback review. |
-
-The model family is selected cost-first: Luna for deterministic and closed
-work, Terra for structural implementation, and Sol for orchestration and
-critical/final review. Sol root orchestration remains read-only at every tier.
-
-Tier selection does not transfer authority. A higher tier may review a lower
-tier's result, but it may not silently widen that result's scope.
-
-## Dispatch card
-
-Every worker dispatch is a complete, immutable card. The parent keeps the card
-and the worker receives only the relevant repository context plus this rule.
-
-```yaml
-schema: ramshared.dispatch.v1
-dispatch_id: current-turn-unique-id
-route: R3
-model: gpt-5.6-terra
-tier: medium
-objective: validate-one-bounded-checker-slice
-owner: worker-agent-id
-parent: root-agent-id
-scope:
- include: [tools/ci/check-agent-orchestration.mjs]
- exclude: [scripts/safety/cascade-up.sh]
-read_only: false
-approval: current-user-request
-inputs: [repository-facts]
-outputs: [ramshared.handoff.v1]
-tests: [node --test tools/ci/check-agent-orchestration.test.mjs]
-coverage: lines >= 80, branches >= 80, functions >= 80
-rollback_trigger: checker-refusal-is-observable
-```
-
-The card is refused when it has no single owner, an absolute or broad path,
-an ambiguous approval, an unbounded command, or no named test and rollback
-trigger. A worker may report that the card is blocked; it may not rewrite the
-card or dispatch another worker. A mutable card uses one permitted route/model/
-tier combination: R0 is Luna/low, R1 is Luna/medium, R2 is Luna/high or max,
-R3 is Terra, and R4 is Sol or Terra. A mutating card uses a current approval;
-`none` is valid only for an explicitly read-only card.
-
-## Ownership, fork, and context rules
-
-- The root agent owns the request, dispatch cards, integration, and final
- handoff. Each file and decision has exactly one active owner at a time.
-- Fork only for independent, bounded slices with disjoint file ownership.
- The parent retains integration ownership and must reconcile every handoff.
- Never fork merely to bypass a failing gate or to duplicate an owner.
-- Workers receive the minimum relevant context: the card, applicable rules,
- current file state, and explicit acceptance criteria. Do not assume hidden
- conversation state, stale memory, or another worker's untyped conclusions.
-- Workers do not spawn agents. Only the root orchestrator may dispatch a
- worker, and a worker must return control to its parent after its card is
- complete or blocked.
-- Preserve unrelated working-tree changes. Do not use broad staging, resets,
- generated rewrites, or edits outside the card.
-
-## Current approvals
-
-Approval is explicit, scoped, and current. Never inherit a stale approval from
-an earlier turn, another agent, a memory entry, or a similar historical
-campaign.
-
-| Action | Approval rule |
-| --- | --- |
-| Read-only inspection, local parsing, and bounded tests | Covered by the current request; no additional approval. |
-| Repository documentation/code edits, issues, commits, PR preparation, and a normal merge | Covered only when named by the current plan and exact scope. |
-| Remote, credential, host, reboot, live pressure, device/storage, destructive, release, or external publication action | Requires a separate fresh explicit approval naming that exact action and target. |
-
-## Mandatory typed handoff
-
-Every worker returns exactly one `ramshared.handoff.v1` record to its parent,
-even when blocked. The prose summary may follow it, but cannot replace it.
-
-```yaml
-schema: ramshared.handoff.v1
-dispatch_id: current-turn-unique-id
-route: R3
-model: gpt-5.6-terra
-tier: medium
-owner: worker-agent-id
-status: PARTIAL
-changed_files: [tools/ci/check-agent-orchestration.mjs]
-tests: [{command: node --test tools/ci/check-agent-orchestration.test.mjs, result: PASS}]
-metrics: {lines: 80, branches: 80, functions: 80}
-gates: [agent-orchestration-checker-PASS]
-residuals: [env-bound live proof is not claimed]
-next_action: none
-```
-
-The parent checks that the handoff dispatch identity, route, model, tier,
-owner, changed files, tests, metrics, gates, residuals, and next action match
-the card. Every changed file is repository-relative and inside the dispatch
-include scope. Every test includes an exact command and a `PASS`, `FAIL`, or
-`SKIP` result. Missing, malformed, unreconciled, or untyped handoffs are
-refused; a `PARTIAL` or `BLOCKED` handoff is not promoted by adjective or
-inference.
-
-## Two independent Sol gates
-
-Root Sol dispatches both gates and stays read-only. The gate reviewers are
-independent from the worker and from each other; one Sol result cannot satisfy
-both gates.
-
-- `SOL-GATE-PRE-COMMIT`: before any commit, an independent Sol performs a
- read-only review of the card, ownership, exact diff, tests, coverage,
- rollback trigger, typed handoff, and residuals. It returns a typed verdict;
- it does not edit, commit, or push.
-- `SOL-GATE-PRE-PR`: before a PR is proposed or opened, a second independent
- Sol performs a read-only full-branch review of the diff, synchronized docs,
- docs/governance/hygiene/link checks, test evidence, and unresolved scope.
- It returns a separate typed verdict; it does not edit, push, merge, or
- submit.
-
-Both gates must be `PASS` for the relevant boundary. A failed or missing gate
-stops the boundary and leaves the work `PARTIAL` or `NO-GO` with a residual;
-rerunning a gate after a material change creates a new verdict.
diff --git a/.claude/rules/governance.md b/.claude/rules/governance.md
index e8abb598e..ab0546336 100644
--- a/.claude/rules/governance.md
+++ b/.claude/rules/governance.md
@@ -10,6 +10,8 @@ paths:
# Governance rules — RamShared
+Production posture: source target v0.15.0; latest published stable v0.14.1.
+
These rules exist so that every PR (Patch/Pull Request) carries reviewable context and so that changes in agent rules live synchronized between `CLAUDE.md`, `AGENTS.md`, and `.claude/rules/*`.
## PR Template (canonical format)
@@ -83,8 +85,8 @@ systems or other repositories.
## Release, Packaging & Reliability Gap Parity
1. **Stable Release Alignment**:
- - Production posture is strictly stable (`v0.14.0`). No beta or prerelease flags remain on public releases.
- - Package build scripts (`scripts/package/build-deb-package.sh`, `build-rpm-package.sh`), documentation badges (`README.md`, `README.pt-BR.md`), and manifests (`docs/localization/manifest.json`) must stay synchronized with the active release tag.
+ - The current source/release candidate target is `v0.15.0`; the latest published stable release remains `v0.14.1` until the candidate is promoted. Do not label a target as published before its release exists.
+ - Package build scripts (`scripts/package/build-deb-package.sh`, `build-rpm-package.sh`), documentation badges (`README.md`, `README.pt-BR.md`), and manifests (`docs/localization/manifest.json`) must state the source target and published release accurately.
2. **Semantic Gap Register Governance**:
- `docs/reliability/GAP-REGISTER.md` must accurately reflect real CI and repository state.
- Never retain phantom blockers (e.g. "await external Guard repair") when CI gates for that capability are passing.
diff --git a/.github/workflows/ci-contract.yml b/.github/workflows/ci-contract.yml
index d5692ca73..b3a5424ea 100644
--- a/.github/workflows/ci-contract.yml
+++ b/.github/workflows/ci-contract.yml
@@ -73,6 +73,14 @@ jobs:
--test-coverage-functions=80
tools/ci/plan-rust-slice-coverage.test.mjs
+ - name: Rust coverage runner coverage
+ run: >-
+ node --test --experimental-test-coverage
+ --test-coverage-include=tools/ci/check-rust-slice-coverage.mjs
+ --test-coverage-lines=80 --test-coverage-branches=80
+ --test-coverage-functions=80
+ tools/ci/check-rust-slice-coverage.test.mjs
+
- name: Validate exact SPEC coverage map
run: node tools/ci/plan-rust-slice-coverage.mjs --all
@@ -158,6 +166,30 @@ jobs:
with:
fetch-depth: 0
+ - name: Install Mesa software Vulkan ICD
+ shell: bash
+ run: |
+ set -euo pipefail
+ sudo apt-get update
+ sudo apt-get install --yes --no-install-recommends libvulkan1 mesa-vulkan-drivers
+ icd_path="$(find /usr/share/vulkan/icd.d -maxdepth 1 -type f -name 'lvp_icd*.json' -print -quit)"
+ test -n "$icd_path"
+ printf 'VK_ICD_FILENAMES=%s\n' "$icd_path" >> "$GITHUB_ENV"
+
+ - name: Fetch immutable Rust slice baselines
+ run: |
+ set -euo pipefail
+ while IFS= read -r revision; do
+ [[ "$revision" =~ ^[0-9a-f]{40}$ ]]
+ if ! git cat-file -e "${revision}^{commit}" 2>/dev/null; then
+ git fetch --no-tags origin "$revision"
+ fi
+ git cat-file -e "${revision}^{commit}"
+ done < <(
+ jq -r '.entries[] | select(.kind == "rust-ignored-test-relocation") | .base_revision' \
+ docs/governance/rust-slice-coverage.json | sort -u
+ )
+
- name: Set up Rust 1.98.0 coverage toolchain
uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # action pinned
with:
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 4ffc90807..ad5c4f2d4 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -40,6 +40,40 @@ jobs:
- name: Tests (ignored cases require GPU/root)
run: cargo test --workspace -- --test-threads=1
+ guest-pressure-safety:
+ name: guest pressure safety guards
+ runs-on: ubuntu-latest
+ timeout-minutes: 10
+ permissions:
+ contents: read
+ steps:
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
+
+ - name: Shell syntax
+ run: |
+ set -euo pipefail
+ for script in \
+ scripts/safety/cascade-pressure-probe.sh \
+ scripts/safety/guest-pressure-runtime-guard.sh \
+ scripts/safety/test-cascade-pressure-probe-static.sh \
+ scripts/safety/test-guest-pressure-runtime-guard.sh \
+ scripts/safety/ramshared-guest-memory-admission.sh \
+ scripts/safety/test-ramshared-guest-memory-admission.sh \
+ scripts/safety/test-wsl2-freeze-campaign-artifact-static.sh \
+ scripts/safety/wsl2-freeze-campaign.sh \
+ scripts/safety/Test-Wsl2FreezeCampaignStatic.sh; do
+ bash -n "$script"
+ done
+
+ - name: Guest memory and pressure fixtures
+ run: |
+ set -euo pipefail
+ bash scripts/safety/test-guest-pressure-runtime-guard.sh
+ bash scripts/safety/test-cascade-pressure-probe-static.sh
+ bash scripts/safety/test-ramshared-guest-memory-admission.sh
+ bash scripts/safety/test-wsl2-freeze-campaign-artifact-static.sh
+ bash scripts/safety/Test-Wsl2FreezeCampaignStatic.sh
+
docs:
name: docs index + links
runs-on: ubuntu-latest
@@ -145,7 +179,7 @@ jobs:
ci-summary:
name: ci-summary
if: always()
- needs: [rust, docs]
+ needs: [rust, docs, guest-pressure-safety]
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
@@ -155,9 +189,10 @@ jobs:
env:
RUST_RESULT: ${{ needs.rust.result }}
DOCS_RESULT: ${{ needs.docs.result }}
+ GUEST_PRESSURE_RESULT: ${{ needs.guest-pressure-safety.result }}
run: |
set -euo pipefail
- for result in "$RUST_RESULT" "$DOCS_RESULT"; do
+ for result in "$RUST_RESULT" "$DOCS_RESULT" "$GUEST_PRESSURE_RESULT"; do
if [ "$result" != success ]; then
echo "CI_CORE_SUMMARY=FAIL"
exit 1
diff --git a/.github/workflows/release-packaging.yml b/.github/workflows/release-packaging.yml
index 470d08db5..146df05bf 100644
--- a/.github/workflows/release-packaging.yml
+++ b/.github/workflows/release-packaging.yml
@@ -7,9 +7,9 @@ on:
workflow_dispatch:
inputs:
version:
- description: 'Release version tag (e.g. v0.12.0)'
+ description: 'Release version tag (e.g. v0.15.0)'
required: false
- default: 'v0.12.0'
+ default: 'v0.15.0'
permissions:
contents: write
@@ -37,10 +37,10 @@ jobs:
run: cargo build --release --locked
- name: Build Debian/Ubuntu Package (.deb)
- run: ./scripts/package/build-deb-package.sh "${{ (github.ref_type == 'tag' && github.ref_name) || inputs.version || 'v0.12.0' }}"
+ run: ./scripts/package/build-deb-package.sh "${{ (github.ref_type == 'tag' && github.ref_name) || inputs.version || 'v0.15.0' }}"
- name: Build Fedora/RHEL Package (.rpm)
- run: ./scripts/package/build-rpm-package.sh "${{ (github.ref_type == 'tag' && github.ref_name) || inputs.version || 'v0.12.0' }}"
+ run: ./scripts/package/build-rpm-package.sh "${{ (github.ref_type == 'tag' && github.ref_name) || inputs.version || 'v0.15.0' }}"
- name: Package Arch Linux AUR Tarball
run: |
@@ -68,5 +68,5 @@ jobs:
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
- TARGET_TAG="${{ (github.ref_type == 'tag' && github.ref_name) || inputs.version || 'v0.12.0' }}"
+ TARGET_TAG="${{ (github.ref_type == 'tag' && github.ref_name) || inputs.version || 'v0.15.0' }}"
find artifacts/packages -maxdepth 1 -type f \( -name "*.deb" -o -name "*.rpm" -o -name "*.tar.gz" -o -name "SHA256SUMS.txt" \) -exec gh release upload "$TARGET_TAG" --clobber {} +
diff --git a/.github/workflows/security-scans.yml b/.github/workflows/security-scans.yml
index dc4ee77bf..d0f69a587 100644
--- a/.github/workflows/security-scans.yml
+++ b/.github/workflows/security-scans.yml
@@ -39,8 +39,8 @@ jobs:
- name: Audit vetted RustSec snapshot
env:
RUSTSEC_DB_URL: https://github.com/RustSec/advisory-db.git
- RUSTSEC_DB_COMMIT: f58ccfe51a5954186716998f01360d1079a8a3a5
- RUSTSEC_DB_COMMIT_UTC: "2026-09-17T07:37:14Z"
+ RUSTSEC_DB_COMMIT: ef03605143a913024f864d2edf476adad5720c93
+ RUSTSEC_DB_COMMIT_UTC: "2026-09-28T09:30:11Z"
RUSTSEC_DB_MAX_AGE_DAYS: "7"
run: |
set -euo pipefail
diff --git a/.release-please-manifest.json b/.release-please-manifest.json
index 7b5cb6458..f87262aa8 100644
--- a/.release-please-manifest.json
+++ b/.release-please-manifest.json
@@ -1,3 +1,3 @@
{
- ".": "0.14.1"
+ ".": "0.15.0"
}
diff --git a/AGENTS.md b/AGENTS.md
index f7c5d86bd..1b1b22a97 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -16,8 +16,6 @@ The source of truth for architecture and coding rules is:
- [`.claude/rules/coding.md`](.claude/rules/coding.md)
- [`.claude/rules/governance.md`](.claude/rules/governance.md)
- [`.claude/rules/benchmarks.md`](.claude/rules/benchmarks.md)
-- Agent orchestration and dispatch: [`.claude/rules/agent-orchestration.md`](.claude/rules/agent-orchestration.md).
-- Its rendered policy and canonical typed records are the machine-checked source.
### Before planning, editing, or opening a patch/PR
@@ -76,5 +74,6 @@ PR descriptions must follow `.github/pull_request_template.md` strictly: canonic
- No persisting secrets.
- No undocumented dependencies.
- **Reliability Gap Register & Release Parity**: Keep `docs/reliability/GAP-REGISTER.md`
- semantically synchronized with active CI status and releases (`v0.14.0`). Phantom
+ semantically synchronized with active CI status and the `v0.15.0` source target;
+ distinguish that target from the latest published release until promotion. Phantom
blockers (such as resolved Guard repairs) are strictly forbidden when CI gates pass.
diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index 7ff06a882..30e1ba010 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -7,13 +7,15 @@ RamShared models **idle GPU memory** as a clean, revocable cache for an SSD-auth
RamShared enforces deterministic fail-closed execution boundaries and strict identity bindings:
- **Write-Through Invariant:** Every acknowledged write is persisted to the authoritative SSD origin before cache mutation. VRAM eviction or reclamation affects performance, not data integrity.
- **Ordered Teardown:** Swapoff-first ordering guarantees that devices are never detached while active in the kernel swap table.
-- **Dynamic Headroom Protection:** GPU memory is dynamically bounded by WDDM headroom, automatically reserving `max(2 GiB, 20% total VRAM)` for 3D and graphics workloads.
+- **Surface-Specific Headroom Protection:** Broker/NBD preserves `max(1536 MiB, 20% of budget capacity)` from current free headroom, plus its `768 MiB` runtime buffer and canary. The isolated origin cache preserves `max(configured reserve, 20% of budget capacity)` from live headroom plus its `640 MiB` runtime buffer; StorPort retains its independent `max(configured reserve, 512 MiB, 10%)` rule.
- **Legacy Preallocation Sunset:** The legacy full-VRAM NBD source composition and `RAMSHARED_VRAM_PREALLOC_LEGACY` selector were removed from executable source and are no longer available or supported.
| Track | Status | Deployment Architecture |
| --- | --- | --- |
-| Linux / WSL2 cascade | Production Qualified (EVD-0040) | Multi-tier cascade via ublk/io_uring, page-locked DMA, and ZRAM |
-| Windows StorPort | Hardware Miniport Qualified | Isolated SCM broker/consumer services communicating over local named pipes |
+| Standard WSL2 cascade | Stable userspace path, live gates still tracked | NBD transport, ZRAM, revocable VRAM cache, and authoritative SSD origin |
+| Native Linux / compatible WSL2 custom kernel | Bounded transport qualification (EVD-0039) | `ublk`/`io_uring` plus page-locked DMA on the recorded hardware and workload |
+| CUDA host mapping | Qualified library surface (EVD-0040) | Zero-copy CUDA host mapping with `cuMemHostRegister` and `PinnedHostMapping` |
+| Windows StorPort | Experimental supervised-lab surface | Isolated SCM broker/consumer services; public distribution remains blocked |
---
@@ -46,32 +48,46 @@ origin failure.
(disk swap or sufficient free RAM). The controller verifies this before any lifecycle
transition.
-On WSL2, Windows WDDM/VidMm remains the memory authority. The physical target
-is the minimum of logical capacity, the sealed cache cap, and the measured
-budget after external use and `max(2 GiB, 20% total VRAM)` headroom.
+On WSL2, Windows WDDM/VidMm remains the memory authority. Standard WSL2 uses
+NBD as its baseline transport. `ublk`/`io_uring` is qualified on native Linux
+or WSL2 with a compatible custom kernel; it is not assumed on stock WSL2.
+
+The broker/NBD physical target preserves `max(1536 MiB, 20% of budget
+capacity)` from both total capacity and current available headroom, then keeps
+the separate `768 MiB` runtime buffer and canary available. Its direct broker
+path refreshes the driver budget before each allocation and intersects the
+matching WDDM budget when the selected adapter exposes an LUID. The isolated
+origin cache applies its configured reserve (default `max(1536 MiB, 20%)`)
+against current headroom and keeps a `640 MiB` runtime buffer. StorPort applies
+`max(configured reserve, 512 MiB, 10%)`. A capacity reserve limits admitted
+cache; the live-headroom check includes existing use so later allocations
+cannot spend the display reserve.
### Control-Plane Containment
RamShared manages workloads within a dedicated `ramshared-workloads.slice` budget.
-Unmanaged memory allocations outside this hierarchy are monitored and flagged as `UNMANAGED_PRESSURE`
-to protect system responsiveness. Control units occupy `ramshared-control.slice` with protected
-memory and elevated CPU/I/O weights.
+An unmanaged process with at least 512 MiB of RSS plus swap is reported as
+`UNMANAGED_MEMORY`. This records its footprint and ownership boundary; it does
+not claim that the system is under memory pressure. The monitor reports current
+pressure separately through PSI and `MemAvailable`. Control units occupy
+`ramshared-control.slice` with protected memory and elevated CPU/I/O weights.
The supervisor's policy closes admission in `GUARDED`,
shrinks cache and manages discardable scopes in `CRITICAL`,
and enforces bounded termination sequences in `EMERGENCY`.
-### Modular Architecture — 15 Workspace Crates
+### Modular Architecture — 16 Workspace Crates
The codebase is organized into 15 focused Rust crates across 6 architectural tiers:
| Layer | Crates | Role & Responsibility |
| :--- | :--- | :--- |
| **Layer 1: Frontend & CLI** | [`ramshared-cli`](crates/ramshared-cli/README.md) | Primary operator interface (`doctor`, `stress`, `monitor`, `top`, `cascade`, `diagnose`). |
-| **Layer 2: Daemons & Agents** | [`ramshared-wsl2d`](crates/ramshared-wsl2d/README.md) [`ramshared-agent`](crates/ramshared-agent/README.md) [`ramshared-winsvc`](crates/ramshared-winsvc/README.md) [`ramshared-winbroker`](crates/ramshared-winbroker/README.md) | In-guest block device daemon (`ublk`/NBD), local kernel swap agent, Windows StorPort worker service, and SCM broker daemon. |
+| **Layer 2: Daemons & Services** | [`ramshared-wsl2d`](crates/ramshared-wsl2d/README.md) [`ramshared-agent`](crates/ramshared-agent/README.md) [`ramshared-winsvc`](crates/ramshared-winsvc/README.md) [`ramshared-winbroker`](crates/ramshared-winbroker/README.md) | In-guest block device daemon (NBD baseline; conditional `ublk`), local host-observation service, Windows StorPort worker service, and SCM broker daemon. |
| **Layer 3: Broker & Policy** | [`ramshared-broker`](crates/ramshared-broker/README.md) [`ramshared-config`](crates/ramshared-config/README.md) [`ramshared-tier`](crates/ramshared-tier/README.md) | Logical lease arbitration, fail-closed configuration parsing, and 3-tier cascade state machine (N1/N2/N3 hysteresis). |
| **Layer 4: Memory & I/O** | [`ramshared-vram`](crates/ramshared-vram/README.md) [`ramshared-cuda`](crates/ramshared-cuda/README.md) [`ramshared-vulkan`](crates/ramshared-vulkan/README.md) [`ramshared-uring`](crates/ramshared-uring/README.md) | Hardware-agnostic VRAM allocator abstraction, NVIDIA CUDA DMA, cross-vendor Vulkan allocator (AMD/Intel), and Linux `io_uring` engine. |
| **Layer 5: Storage & Origin** | [`ramshared-block`](crates/ramshared-block/README.md) [`ramshared-integrity`](crates/ramshared-integrity/README.md) [`ramshared-dxg`](crates/ramshared-dxg/README.md) | Authoritative SSD origin persistence, SHA-256 block corruption prevention, and `/dev/dxg` WDDM memory budget query. |
+| **Layer 6: Host-Guest IPC** | [`ramshared-ipc`](crates/ramshared-ipc/README.md) | Shared vsock control plane protocol (binary framing, HMAC handshake, heartbeat/lease, VHDX lifecycle messages). |
| **Layer 6: Kernel Drivers** | `drivers/block/ramshared` `drivers/windows/ramshared` | Native upstream Linux kernel block driver and high-performance Windows StorPort virtual miniport driver (C). |
@@ -101,4 +117,3 @@ Logical lease arbitration is isolated into a dedicated least-privilege `RamShare
## Verification & Failure Mode Registry
All architectural transitions and failure edge cases are cataloged in the [Degradation Matrix](docs/reliability/DEGRADATION-MATRIX.md). Stress testing, benchmark qualifications, and operational validation execute against controlled, isolated test harnesses with watchdog limits to prevent host resource starvation.
-
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 6ee787152..2e2661e43 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,12 @@
# Changelog
+## [0.15.0](https://github.com/emersonbusson/ramshared/compare/v0.14.1...v0.15.0) (2026-09-27)
+
+
+### Features
+
+* **cli:** show build revision and host installation time in the dashboard
+
## [0.14.1](https://github.com/emersonbusson/ramshared/compare/v0.14.0...v0.14.1) (2026-09-18)
diff --git a/CLAUDE.md b/CLAUDE.md
index 03c2ac517..e4f1df433 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -5,9 +5,6 @@
[`.claude/rules/*.md`](.claude/rules/*.md) are the authoritative code rules. `AGENTS.md` mirrors these guidelines.
-- Agent orchestration and dispatch: [`.claude/rules/agent-orchestration.md`](.claude/rules/agent-orchestration.md).
-- Its rendered policy and canonical typed records are the machine-checked source.
-
**Documentation scope:** only this repository. Do not load or invent requirements from other products/monorepos when working here.
Before changing code:
diff --git a/Cargo.lock b/Cargo.lock
index 95301be92..631ee4907 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -936,7 +936,7 @@ dependencies = [
[[package]]
name = "ramshared-agent"
-version = "0.14.1" # x-release-please-version
+version = "0.15.0"
dependencies = [
"ramshared-broker",
"serde",
@@ -945,14 +945,15 @@ dependencies = [
[[package]]
name = "ramshared-block"
-version = "0.14.1" # x-release-please-version
+version = "0.15.0"
dependencies = [
"ramshared-vram",
+ "serde_json",
]
[[package]]
name = "ramshared-broker"
-version = "0.14.1" # x-release-please-version
+version = "0.15.0"
dependencies = [
"serde",
"serde_json",
@@ -960,11 +961,13 @@ dependencies = [
[[package]]
name = "ramshared-cli"
-version = "0.14.1" # x-release-please-version
+version = "0.15.0"
dependencies = [
"libc",
+ "ramshared-config",
"ramshared-cuda",
"ramshared-tier",
+ "ramshared-vram",
"ratatui",
"rustix 1.1.4",
"serde",
@@ -974,7 +977,7 @@ dependencies = [
[[package]]
name = "ramshared-config"
-version = "0.14.1" # x-release-please-version
+version = "0.15.0"
dependencies = [
"serde",
"serde_path_to_error",
@@ -983,7 +986,7 @@ dependencies = [
[[package]]
name = "ramshared-cuda"
-version = "0.14.1" # x-release-please-version
+version = "0.15.0"
dependencies = [
"cuda-async",
"cuda-core",
@@ -993,22 +996,34 @@ dependencies = [
[[package]]
name = "ramshared-dxg"
-version = "0.14.1" # x-release-please-version
+version = "0.15.0"
dependencies = [
"libc",
+ "ramshared-vram",
]
[[package]]
name = "ramshared-integrity"
-version = "0.14.1" # x-release-please-version
+version = "0.15.0"
+
+[[package]]
+name = "ramshared-ipc"
+version = "0.15.0"
+dependencies = [
+ "libc",
+ "serde",
+ "serde_json",
+ "sha2",
+ "windows-sys 0.61.2",
+]
[[package]]
name = "ramshared-tier"
-version = "0.14.1" # x-release-please-version
+version = "0.15.0"
[[package]]
name = "ramshared-uring"
-version = "0.14.1" # x-release-please-version
+version = "0.15.0"
dependencies = [
"io-uring",
"libc",
@@ -1016,11 +1031,15 @@ dependencies = [
[[package]]
name = "ramshared-vram"
-version = "0.14.1" # x-release-please-version
+version = "0.15.0"
+dependencies = [
+ "serde",
+ "serde_json",
+]
[[package]]
name = "ramshared-vulkan"
-version = "0.14.1" # x-release-please-version
+version = "0.15.0"
dependencies = [
"ash",
"ramshared-vram",
@@ -1028,7 +1047,7 @@ dependencies = [
[[package]]
name = "ramshared-winbroker"
-version = "0.14.1" # x-release-please-version
+version = "0.15.0"
dependencies = [
"ramshared-broker",
"serde",
@@ -1041,7 +1060,7 @@ dependencies = [
[[package]]
name = "ramshared-winsvc"
-version = "0.14.1" # x-release-please-version
+version = "0.15.0"
dependencies = [
"base64",
"ramshared-block",
@@ -1059,7 +1078,7 @@ dependencies = [
[[package]]
name = "ramshared-wsl2d"
-version = "0.14.1" # x-release-please-version
+version = "0.15.0"
dependencies = [
"ramshared-block",
"ramshared-broker",
diff --git a/Cargo.toml b/Cargo.toml
index 0d01b49e9..bb583fff9 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -16,10 +16,11 @@ members = [
"crates/ramshared-vulkan",
"crates/ramshared-winsvc",
"crates/ramshared-winbroker",
+ "crates/ramshared-ipc",
]
[workspace.package]
-version = "0.14.1" # x-release-please-version
+version = "0.15.0" # x-release-please-version
edition = "2024"
rust-version = "1.98.0"
license = "MIT"
diff --git a/README.md b/README.md
index a9812170a..a26f9a05d 100644
--- a/README.md
+++ b/README.md
@@ -9,7 +9,7 @@ The project is intended for people who want to study or operate GPU-backed memor

-
+
@@ -37,7 +37,11 @@ The project is intended for people who want to study or operate GPU-backed memor
## Current Status
-Latest published release: **[v0.14.0](https://github.com/emersonbusson/ramshared/releases/tag/v0.14.0)**. This checkout builds **0.14.0**, the current stable maintenance release.
+Release v0.15.0 is the source target built by this checkout. The latest published stable remains **[v0.14.1](https://github.com/emersonbusson/ramshared/releases/tag/v0.14.1)**; v0.15.0 has not been published yet.
+
+Standard WSL2 uses **NBD as its baseline transport**. `ublk`/`io_uring` is
+qualified on native Linux or WSL2 with a compatible custom kernel; it is not a
+universal baseline for stock WSL2 kernels.
| Surface | Status | What that means |
| --- | --- | --- |
@@ -45,7 +49,7 @@ Latest published release: **[v0.14.0](https://github.com/emersonbusson/ramshared
| GPU cache | **Stable on qualified hardware** | CUDA and Vulkan backends exist, while usable capacity and behaviour still depend on the driver, GPU, display workload, and current host pressure. |
| Disk origin and integrity | **Stable and tested** | The software has integrity and teardown checks; every deployment still needs its own before/after validation. |
| Windows StorPort driver | **Not publicly distributable yet** | The driver remains a supervised lab surface until a production-trusted signing and qualification path is complete. |
-| Custom kernel and ublk transport | **Deferred** | These are development and lab surfaces, not the default day-one WSL2 transport. |
+| Custom kernel and `ublk` transport | **Qualified on a bounded surface; product promotion deferred** | EVD-0039 covers native Linux and one compatible WSL2 custom-kernel surface. Standard WSL2 continues to use NBD while lifecycle qualification remains open. |
Historical measurements are retained in [`docs/BENCHMARKS.md`](docs/BENCHMARKS.md). Entries without a public evidence envelope are historical records, not current release baselines. Open limits and qualification work are tracked in [`docs/reliability/`](docs/reliability/).
@@ -57,7 +61,7 @@ The v0.13 qualification reached **19,777 MB** across Tier 0 (ZRAM), Tier 1 (GPU
## Run it safely
-RamShared is designed with strict safety defaults. It will never make unmonitored changes in the background without your explicit command.
+RamShared uses strict safety defaults and does not activate the cascade without an explicit operator command.
Build once with the commands above, then use `./target/release/ramshared check`. Do not activate a tier when the check reports a blocker. Starting and stopping memory offload always requires an explicit operator command (`sudo ./target/release/ramshared up` / `sudo ./target/release/ramshared down`).
@@ -95,13 +99,8 @@ Memory tiering uses on-demand, revocable chunks backed by a durable origin. It r
│
▼
┌─────────────────────────────────────────────────────────────┐
- │ Tier 1: RamShared GPU VRAM Direct DMA Cache │ (Priority 50 - 0.85 µs access)
- │ │
- │ ┌──────────────────────────┐ ┌───────────────────────┐ │
- │ │ GPU VRAM (Cache Tier) │ │ Hot Spillway / Direct │ │
- │ │ 4 GiB Active on GPU │──►│ 15.6x - 21.5x Speedup │ │
- │ │ (Up to 429.6 MB/s DMA) │ │ Zero Kernel Lockup │ │
- │ └──────────────────────────┘ └───────────────────────┘ │
+ │ Tier 1: RamShared logical device (Priority 50) │
+ │ clean, revocable VRAM cache + authoritative SSD origin │
└──────────────────────────────┬──────────────────────────────┘
│
▼
@@ -113,19 +112,27 @@ Memory tiering uses on-demand, revocable chunks backed by a durable origin. It r
How the tiers work together:
-- **Tier 0: ZRAM (CPU Tier, 1024 MiB):** Ultra-fast memory compression handled directly by the host CPU.
-- **Tier 1: GPU VRAM Cache (4 GiB Active on GPU):** Blazing-fast memory cache over PCIe for active pages, configured with 4,096 MB capacity while preserving host display safety.
-- **Tier 3: Host SSD Origin Store:** Safe, durable backing storage that absorbs overflow memory traffic so your system never crashes.
-- **Always Safe (Write-Through):** Every write acknowledged by RamShared is safely stored in the backing store. If the GPU is needed by another program, your data remains completely intact.
+- **Tier 0: ZRAM:** Compressed host memory is the first pressure cushion.
+- **Tier 1: RamShared logical device:** A clean, revocable VRAM cache can accelerate pages whose authoritative copy is held by the SSD origin.
+- **Tier 3: Host SSD and WSL swap:** Lower storage tiers absorb traffic when the cache cannot admit or retain a page.
+- **Write-through contract:** An acknowledged origin-cache write is persisted to the authoritative origin before the cache mutation. Operational failures remain possible and are tracked in the gap register.
+
+The reserve is deliberately surface-specific. Broker/NBD sizing retains
+`max(1536 MiB, 20% of physical VRAM)` as capacity reserve and separately keeps
+`768 MiB` of reported free VRAM as a runtime allocation buffer. The origin
+cache uses `max(2 GiB, 20%)`; Windows StorPort uses
+`max(configured reserve, 512 MiB, 10%)`. These values are not interchangeable:
+a capacity reserve bounds the cache target, while the runtime buffer protects
+new allocations against changing external GPU use.
### Automatic GPU Protection for Windows & Gaming
-When Windows, games, or 3D rendering workloads request GPU memory, RamShared steps aside immediately:
+When Windows, games, or 3D rendering workloads request GPU memory, RamShared's governor attempts to reduce cache pressure:
-1. Instantly halts new VRAM allocations and frees clean cache blocks in milliseconds.
-2. Continues memory I/O smoothly through the backing store without interrupting active apps.
-3. Automatically reserves at least `max(1.5 GiB, 20% of physical VRAM)` exclusively for Windows and display tasks (SSDV3 Principle 11), ensuring Desktop Window Manager (DWM) stability while granting a full 4 GiB slice on 6GB+ GPUs.
-4. Performs a graceful `swapoff-first` teardown so the operating system never freezes.
+1. Stops new cache admission when the measured budget crosses the configured guard.
+2. Drops clean chunks and routes cache misses through the authoritative origin.
+3. Applies the broker/NBD capacity reserve and separate runtime buffer described above.
+4. Uses ordered `swapoff-first` teardown; timeouts or uncertain state fail closed and remain visible to the operator.
### Evidence, without marketing shortcuts
@@ -161,8 +168,8 @@ ramshared top
### Operational Guardrails & Stability Rules
- **Always use `ramshared down` for graceful shutdown:** Never forcefully kill the background daemon (`ramsharedd`) while swap is active. An orderly unmount (`swapoff`) keeps Linux stable and prevents filesystem corruption.
-- **Dynamic memory allocation:** RamShared only claims GPU memory when needed by active swap traffic. If games, browsers, or AI apps request VRAM, RamShared yields it immediately.
-- **Desktop Window Manager protection:** At least 1.5 GB (or 20% of VRAM) is always preserved for Windows display rendering, ensuring your screen, mouse, and monitors never freeze.
+- **Dynamic memory allocation:** RamShared claims cache chunks on demand and releases clean chunks when measured pressure requires it; release latency depends on the active workload and driver.
+- **Desktop headroom:** Broker/NBD capacity is bounded by `max(1536 MiB, 20%)`, with a separate `768 MiB` runtime free buffer when live telemetry is available.
- **Strict storage safety:** Storage operations bind strictly to authoritative volume UUIDs, never ambiguous or transient drive letters.
- **Attended legacy handoff:** `migrate-cascade --from-legacy` is the only supported path from an unbound earlier cascade; it is not automatic recovery.
@@ -186,7 +193,7 @@ scripts, systemd service templates, documentation, and `SHA256SUMS` cryptographi
Build caches, credentials, and transient environment artifacts are excluded by policy. See
[`docs/packaging/INSTALLABLES.md`](docs/packaging/INSTALLABLES.md).
-Official Linux release distributions (including v0.14.0 and prior milestones) and
+Official Linux release distributions (including v0.14.1 and prior milestones) and
their detached checksums are qualified through the automated release promotion workflow.
## Windows StorPort Driver Architecture
diff --git a/README.pt-BR.md b/README.pt-BR.md
index a6ad2442a..728349836 100644
--- a/README.pt-BR.md
+++ b/README.pt-BR.md
@@ -12,7 +12,7 @@ O projeto é destinado a quem quer operar ou estudar camadas de memória acelera

-
+
@@ -40,7 +40,11 @@ O projeto é destinado a quem quer operar ou estudar camadas de memória acelera
## Status atual
-Última release publicada: **[v0.14.0](https://github.com/emersonbusson/ramshared/releases/tag/v0.14.0)**. Este checkout compila a versão **0.14.0**, a manutenção estável atual.
+Versão v0.15.0 é o alvo de código deste checkout. A versão estável mais recente publicada continua sendo **[v0.14.1](https://github.com/emersonbusson/ramshared/releases/tag/v0.14.1)**; a v0.15.0 ainda não foi publicada.
+
+O WSL2 padrão usa **NBD como transporte base**. `ublk`/`io_uring` é qualificado
+no Linux nativo ou no WSL2 com kernel customizado compatível; não é uma base
+universal para kernels WSL2 padrão.
| Superfície | Status | O que isso significa |
| --- | --- | --- |
@@ -48,7 +52,7 @@ O projeto é destinado a quem quer operar ou estudar camadas de memória acelera
| Cache de GPU | **Estável em hardware qualificado** | Os backends CUDA e Vulkan existem, mas a capacidade e o comportamento dependem do driver, GPU, desktop e pressão atual do host. |
| Origem em disco e integridade | **Estáveis e testadas** | Há verificações de integridade e desligamento; cada instalação ainda precisa validar seu próprio antes/depois. |
| Driver Windows StorPort | **Ainda não distribuível publicamente** | O driver permanece uma superfície de laboratório supervisionada até que exista assinatura confiável para produção e qualificação completa. |
-| Kernel customizado e transporte ublk | **Adiados** | São superfícies de desenvolvimento e laboratório, não o transporte WSL2 padrão do primeiro dia. |
+| Kernel customizado e transporte `ublk` | **Qualificados em superfície limitada; promoção de produto adiada** | EVD-0039 cobre Linux nativo e uma superfície WSL2 com kernel customizado compatível. O WSL2 padrão continua usando NBD enquanto a qualificação de ciclo de vida permanece aberta. |
As medições históricas estão em [`docs/BENCHMARKS.md`](docs/BENCHMARKS.md). Entradas sem envelope público de evidência são registros históricos, não baselines atuais de release. Limites e qualificações em aberto estão em [`docs/reliability/`](docs/reliability/).
@@ -60,7 +64,7 @@ A qualificação da v0.13 alcançou **19.777 MB** entre Tier 0 (ZRAM), Tier 1 (c
## Operação Segura e Guia de Início Rápido
-O RamShared foi projetado com regras rígidas de segurança. Ele nunca realiza alterações não monitoradas em segundo plano sem a sua ordem explícita.
+O RamShared usa padrões rígidos de segurança e não ativa a cascata sem comando explícito do operador.
Para instalar e verificar seu ambiente em menos de um minuto:
@@ -100,7 +104,7 @@ O perfil padrão define 4 GiB de capacidade lógica com um teto de cache físico
### Nota de Arquitetura: Alocação Dinâmica Apenas
-Toda a organização de memória opera através de blocos revogáveis sob demanda respaldados pelo SSD. A pré-alocação estática antiga foi removida para garantir que sua GPU nunca fique sem memória para jogos e tarefas visuais.
+Toda a organização de memória opera através de blocos revogáveis sob demanda respaldados pelo SSD. A pré-alocação estática antiga foi removida; a capacidade disponível ainda depende da GPU, do driver e da carga ativa.
## Cascata de memória
@@ -114,13 +118,8 @@ Toda a organização de memória opera através de blocos revogáveis sob demand
│
▼
┌─────────────────────────────────────────────────────────────┐
- │ Tier 1: RamShared Cache Direto na VRAM via DMA │ (Prioridade 50 - acesso em 0,85 µs)
- │ │
- │ ┌──────────────────────────┐ ┌───────────────────────┐ │
- │ │ VRAM da GPU (Cache Tier) │ │ Spillway Quente │ │
- │ │ 4 GiB Ativos na GPU │──►│ 15,6x - 21,5x Rápido │ │
- │ │ (Até 429,6 MB/s via DMA) │ │ Zero Fome no Host │ │
- │ └──────────────────────────┘ └───────────────────────┘ │
+ │ Tier 1: dispositivo lógico RamShared (Prioridade 50) │
+ │ cache VRAM limpo e revogável + origem SSD autoritativa │
└──────────────────────────────┬──────────────────────────────┘
│
▼
@@ -132,19 +131,27 @@ Toda a organização de memória opera através de blocos revogáveis sob demand
Como os níveis trabalham juntos:
-- **Tier 0: ZRAM (Nível CPU, 1024 MiB):** Compressão ultra-rápida de memória em nível de microssegundos feita diretamente pelo processador.
-- **Tier 1: Cache em VRAM da GPU (4 GiB Ativos na GPU):** Cache de altíssima velocidade via PCIe para as páginas ativas, configurado com capacidade total de 4.096 MB preservando a estabilidade do display.
-- **Tier 3: Origem no SSD do Host:** Armazenamento seguro e permanente no disco que absorve o overflow de memória para o sistema nunca travar.
-- **Sempre Seguro (Write-Through):** Toda escrita confirmada pelo RamShared é guardada com segurança no armazenamento durável. Se a GPU for solicitada por outro aplicativo, seus dados continuam 100% salvos.
+- **Tier 0: ZRAM:** A memória comprimida do host é a primeira proteção sob pressão.
+- **Tier 1: dispositivo lógico RamShared:** Um cache VRAM limpo e revogável pode acelerar páginas cuja cópia autoritativa está na origem SSD.
+- **Tier 3: SSD do host e swap do WSL:** Os níveis inferiores recebem tráfego quando o cache não consegue admitir ou reter uma página.
+- **Contrato write-through:** Uma escrita confirmada pelo cache de origem é persistida na origem autoritativa antes da mutação do cache. Falhas operacionais continuam possíveis e são registradas no registro de gaps.
+
+A reserva varia deliberadamente por superfície. O broker/NBD mantém
+`max(1536 MiB, 20% da VRAM física)` como reserva de capacidade e preserva,
+separadamente, `768 MiB` da VRAM livre reportada como buffer de runtime. O
+cache de origem usa `max(2 GiB, 20%)`; o StorPort usa
+`max(reserva configurada, 512 MiB, 10%)`. Os valores não são intercambiáveis:
+a reserva de capacidade limita o alvo do cache, enquanto o buffer de runtime
+protege novas alocações contra mudanças no uso externo da GPU.
### Proteção Automática da GPU para Jogos e Windows
-Quando o Windows, jogos ou aplicativos 3D solicitam memória de vídeo, o RamShared libera espaço imediatamente:
+Quando o Windows, jogos ou aplicativos 3D solicitam memória de vídeo, o governador do RamShared tenta reduzir a pressão do cache:
-1. Interrompe na hora novas alocações na VRAM e libera os blocos limpos de cache em milissegundos.
-2. Continua as operações de memória suavemente direto pelo armazenamento de origem sem interromper seus programas abertos.
-3. Reserva automaticamente pelo menos `max(1,5 GiB, 20% da VRAM física)` exclusivamente para o Windows e tarefas visuais (Princípio 11 do SSDV3), assegurando estabilidade ao Gerenciador de Janelas (DWM) enquanto libera 4 GiB completos em GPUs de 6GB+.
-4. Faz o desligamento ordenado (`swapoff-first`) para que o sistema operacional nunca congele.
+1. Interrompe novas admissões no cache quando o orçamento medido cruza o limite configurado.
+2. Descarta blocos limpos e atende falhas de cache pela origem autoritativa.
+3. Aplica a reserva de capacidade do broker/NBD e o buffer de runtime descritos acima.
+4. Usa desligamento ordenado (`swapoff-first`); timeout ou estado incerto falha de modo fechado e permanece visível ao operador.
### Evidência, sem atalho de marketing
@@ -180,8 +187,8 @@ ramshared top
### Diretrizes Operacionais e Regras de Estabilidade
- **Sempre use `ramshared down` para desligar:** Nunca encerre o daemon `ramsharedd` à força com o swap montado. O desmonte ordenado (`swapoff`) mantém o Linux estável e evita corrupção de sistema de arquivos.
-- **Alocação dinâmica, sem desperdício:** O RamShared só aloca memória de vídeo sob demanda. Se jogos, navegadores ou aplicativos 3D precisarem de VRAM, o RamShared devolve o espaço na hora.
-- **Proteção do Gerenciador de Janelas (DWM):** Pelo menos 1,5 GB (ou 20% da VRAM) fica sempre reservado para a interface do Windows, garantindo que suas telas, janelas e cursor continuem perfeitamente fluidos.
+- **Alocação dinâmica:** O RamShared aloca blocos de cache sob demanda e libera blocos limpos quando a pressão medida exige; a latência depende da carga e do driver.
+- **Margem para o desktop:** A capacidade do broker/NBD é limitada por `max(1536 MiB, 20%)`, com buffer livre de runtime separado de `768 MiB` quando há telemetria ao vivo.
- **Segurança total de armazenamento:** As operações em disco vinculam-se estritamente ao identificador único do volume (UUID), nunca a letras voláteis de unidade.
- **Transição legada assistida:** `migrate-cascade --from-legacy` é o único caminho suportado para sair de uma cascata anterior sem binding; não é recuperação automática.
@@ -205,7 +212,7 @@ segurança, modelos de serviços systemd, documentação e assinaturas criptogr
Caches de compilação, credenciais e artefatos de ambientes transitórios são estritamente excluídos. Consulte
[`docs/packaging/INSTALLABLES.md`](docs/packaging/INSTALLABLES.md).
-As versões oficiais para Linux (incluindo v0.14.0 e marcos anteriores) e
+As versões oficiais para Linux (incluindo v0.14.1 e marcos anteriores) e
seus checksums criptográficos são qualificados pelo fluxo automatizado de promoção de releases.
## Arquitetura do Driver Windows StorPort
diff --git a/ROADMAP.md b/ROADMAP.md
index 2d936c881..8e56e3afc 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -1,6 +1,10 @@
# Roadmap
-Current release posture: **v0.12.0 Qualified Production Release**. Fully qualified across 100% capacity saturation under live host memory pressure on physical hardware. The multi-tier memory cascade (ZRAM ➔ GPU VRAM ➔ SSD Origin ➔ WSL2 disk fallback) operates with zero panics, zero data loss, and sub-millisecond page-fault latency.
+Current release posture: source target **v0.15.0**; latest published stable **v0.14.1**.
+Standard WSL2 uses NBD as its baseline transport. `ublk`/`io_uring` is qualified
+on native Linux or WSL2 with a compatible custom kernel (EVD-0039); product lifecycle
+promotion on the custom-kernel path remains deferred. EVD-0040 covers only
+zero-copy CUDA host mapping.
Evidence lives in [validation.md](validation.md) and feature IMPL files.
@@ -12,7 +16,7 @@ Evidence lives in [validation.md](validation.md) and feature IMPL files.
- Upstream Linux Kernel Driver RFC v2 submitted to LKML and Microsoft WSL ([microsoft/WSL#41054](https://github.com/microsoft/WSL/issues/41054)).
- Consolidated Linux kernel drivers, multi-tier memory management, and fail-safe recovery into a unified production architecture.
-- Full Tier 3 cascade saturation stress qualification (EVD-0040): 9,160 MB active swap held across 40 continuous cycles under 99% RAM pressure with 100% SHA-256 byte-exact match and zero panics.
+- Historical Tier 3 cascade saturation evidence is retained in the benchmark and validation registries. It is not EVD-0040, which records zero-copy CUDA host mapping only.
- High-resolution vector diagrams (Inter & JetBrains Mono) with infinite resolution across displays.
- Interactive terminal TUI dashboard: `ramshared top`.
@@ -52,11 +56,11 @@ Format, pagefile residency, kernel-page drill, ordered teardown (DT-9), and isol
---
-## Next (v0.13.0)
+## Next (v0.16.0)
| Priority | Milestone Target | Focus |
| :--- | :--- | :--- |
-| Upstream Linux & WSL2 | LKML driver review & WSL merge (#41054) | Direct `ublk`/`io_uring` zero-copy default transport |
+| Upstream Linux & WSL2 | LKML driver review & WSL merge (#41054) | Complete lifecycle qualification without presenting `ublk`/`io_uring` as the stock WSL2 default |
| Multi-vendor Acceleration | Vulkan Memory Allocator (VMA) multi-vendor tier | AMD Radeon & Intel Arc hardware qualification |
---
diff --git a/crates/ramshared-block/Cargo.toml b/crates/ramshared-block/Cargo.toml
index 3c9e13657..18a8bc3a3 100644
--- a/crates/ramshared-block/Cargo.toml
+++ b/crates/ramshared-block/Cargo.toml
@@ -9,6 +9,7 @@ publish.workspace = true
[dependencies]
ramshared-vram = { path = "../ramshared-vram" }
+serde_json = "1"
[lints.clippy]
unwrap_used = "deny"
diff --git a/crates/ramshared-block/README.md b/crates/ramshared-block/README.md
index c7f79b5d9..3f7741691 100644
--- a/crates/ramshared-block/README.md
+++ b/crates/ramshared-block/README.md
@@ -8,11 +8,13 @@ Authoritative SSD storage origin, revocable VRAM block cache, and NBD protocol e
- **Authoritative SSD Origin:** Ensures all writes are persisted to an authoritative backing store before cache acknowledgement.
- **Revocable VRAM Cache:** Provides clean, dynamically demountable 128 MiB block chunks in GPU memory.
- **NBD Fixed-Newstyle Wire Protocol:** Safe parser and encoder for NBD protocol negotiation without root privileges.
-- **Inflight I/O Tracking:** Lock-free tracking of inflight requests to guarantee request idempotence and atomic teardown.
+- **Inflight Range Model:** A small, mutable range-conflict model for tests and
+ prospective callers. It is not lock-free, is not wired into the daemon I/O
+ path, and does not itself provide request idempotence or teardown safety.
## Workspace Dependencies
-- Internal crates: None (pure protocol and storage model).
+- Internal crates: `ramshared-vram` for the reusable VRAM-backed block models.
## Safety Invariants
diff --git a/crates/ramshared-block/src/gpu_cache_worker.rs b/crates/ramshared-block/src/gpu_cache_worker.rs
new file mode 100644
index 000000000..50f8cee74
--- /dev/null
+++ b/crates/ramshared-block/src/gpu_cache_worker.rs
@@ -0,0 +1,1128 @@
+//! Process-isolated GPU cache worker.
+//!
+//! Provides out-of-process VRAM allocation and cache chunk management
+//! communicating over an anonymous Unix domain socket pair.
+
+use std::collections::HashMap;
+use std::io::{Read, Write};
+use std::os::unix::net::UnixStream;
+use std::time::{Instant, SystemTime, UNIX_EPOCH};
+
+use ramshared_vram::{GpuBudgetSnapshot, GpuBudgetTelemetry, VramError, VramMemory, VramProvider};
+
+pub const FRAME_HEADER_LEN: usize = 32;
+pub const MAX_IPC_PAYLOAD_BYTES: usize = 16 * 1024 * 1024;
+
+pub const MSG_READ_REQ: u8 = 1;
+pub const MSG_READ_RESP: u8 = 2;
+pub const MSG_UPDATE: u8 = 3;
+pub const MSG_PROMOTE: u8 = 4;
+pub const MSG_DISABLE_REQ: u8 = 5;
+pub const MSG_DISABLE_RESP: u8 = 6;
+pub const MSG_HEARTBEAT_REQ: u8 = 7;
+pub const MSG_HEARTBEAT_RESP: u8 = 8;
+pub const MSG_HANDSHAKE_REQ: u8 = 9;
+pub const MSG_HANDSHAKE_RESP: u8 = 10;
+
+pub const STATUS_OK: u8 = 0;
+pub const STATUS_MISS: u8 = 1;
+pub const STATUS_ERROR: u8 = 2;
+pub const RUNTIME_FREE_BUFFER_BYTES: u64 = 640 * 1024 * 1024;
+const RUNTIME_RECOVERY_BUFFER_BYTES: u64 = 896 * 1024 * 1024;
+const MAX_GPU_BUDGET_PAYLOAD_BYTES: usize = 4096;
+
+fn unix_time_ms() -> u64 {
+ SystemTime::now()
+ .duration_since(UNIX_EPOCH)
+ .map(|duration| duration.as_millis().min(u64::MAX as u128) as u64)
+ .unwrap_or_default()
+}
+
+fn effective_target_from_budget(budget: &GpuBudgetSnapshot, config: GpuWorkerConfig) -> u64 {
+ if !budget.can_admit(0) {
+ return 0;
+ }
+ budget.safe_target_bytes(
+ config.target_bytes,
+ config.reserve_floor_bytes,
+ RUNTIME_FREE_BUFFER_BYTES,
+ )
+}
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub struct FrameHeader {
+ pub msg_type: u8,
+ pub status: u8,
+ pub correlation_id: u64,
+ pub offset: u64,
+ pub payload_len: u32,
+ pub aux: u32,
+}
+
+impl FrameHeader {
+ pub fn encode(&self) -> [u8; FRAME_HEADER_LEN] {
+ let mut buf = [0u8; FRAME_HEADER_LEN];
+ buf[0] = self.msg_type;
+ buf[1] = self.status;
+ buf[8..16].copy_from_slice(&self.correlation_id.to_le_bytes());
+ buf[16..24].copy_from_slice(&self.offset.to_le_bytes());
+ buf[24..28].copy_from_slice(&self.payload_len.to_le_bytes());
+ buf[28..32].copy_from_slice(&self.aux.to_le_bytes());
+ buf
+ }
+
+ pub fn decode(buf: &[u8; FRAME_HEADER_LEN]) -> Self {
+ let msg_type = buf[0];
+ let status = buf[1];
+ let correlation_id = u64::from_le_bytes([
+ buf[8], buf[9], buf[10], buf[11], buf[12], buf[13], buf[14], buf[15],
+ ]);
+ let offset = u64::from_le_bytes([
+ buf[16], buf[17], buf[18], buf[19], buf[20], buf[21], buf[22], buf[23],
+ ]);
+ let payload_len = u32::from_le_bytes([buf[24], buf[25], buf[26], buf[27]]);
+ let aux = u32::from_le_bytes([buf[28], buf[29], buf[30], buf[31]]);
+ Self {
+ msg_type,
+ status,
+ correlation_id,
+ offset,
+ payload_len,
+ aux,
+ }
+ }
+}
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub struct GpuWorkerConfig {
+ pub target_bytes: u64,
+ pub chunk_bytes: usize,
+ pub reserve_floor_bytes: u64,
+}
+
+impl Default for GpuWorkerConfig {
+ fn default() -> Self {
+ Self {
+ target_bytes: 4 * 1024 * 1024 * 1024,
+ chunk_bytes: 2 * 1024 * 1024,
+ reserve_floor_bytes: 1536 * 1024 * 1024,
+ }
+ }
+}
+
+struct CacheChunk<'p, P: VramProvider + 'p> {
+ mem: P::Mem<'p>,
+ last_accessed: Instant,
+ valid_ranges: Vec<(u64, u64)>,
+}
+
+impl CacheChunk<'_, P> {
+ fn contains(&self, start: u64, end: u64) -> bool {
+ self.valid_ranges
+ .iter()
+ .any(|&(valid_start, valid_end)| valid_start <= start && end <= valid_end)
+ }
+
+ fn mark_valid(&mut self, start: u64, end: u64) {
+ self.valid_ranges.push((start, end));
+ self.valid_ranges.sort_unstable_by_key(|range| range.0);
+ let mut merged: Vec<(u64, u64)> = Vec::with_capacity(self.valid_ranges.len());
+ for (start, end) in self.valid_ranges.drain(..) {
+ if let Some(last) = merged.last_mut()
+ && start <= last.1
+ {
+ last.1 = last.1.max(end);
+ continue;
+ }
+ merged.push((start, end));
+ }
+ self.valid_ranges = merged;
+ }
+}
+
+pub struct GpuCacheWorker<'p, P: VramProvider + 'p> {
+ provider: &'p P,
+ config: GpuWorkerConfig,
+ effective_target_bytes: u64,
+ chunks: HashMap>,
+ disabled: bool,
+ pressure_constrained: bool,
+}
+
+impl<'p, P: VramProvider + 'p> GpuCacheWorker<'p, P> {
+ pub fn new(provider: &'p P, config: GpuWorkerConfig) -> Self {
+ let effective_target = match provider.budget_snapshot() {
+ Ok(budget) => effective_target_from_budget(&budget, config),
+ // No GPU measurement available: report zero target so the client
+ // and telemetry correctly reflect that physical VRAM is absent
+ // (SPEC RF-4, GAP-6).
+ Err(_) => 0,
+ };
+
+ Self {
+ provider,
+ config,
+ effective_target_bytes: effective_target,
+ chunks: HashMap::new(),
+ disabled: false,
+ pressure_constrained: false,
+ }
+ }
+
+ pub fn target_bytes(&self) -> u64 {
+ self.effective_target_bytes
+ }
+
+ pub fn cached_bytes(&self) -> u64 {
+ (self.chunks.len() as u64).saturating_mul(self.config.chunk_bytes as u64)
+ }
+
+ pub fn active_chunks_count(&self) -> usize {
+ self.chunks.len()
+ }
+
+ pub fn is_disabled(&self) -> bool {
+ self.disabled
+ }
+
+ pub fn handle_read(&mut self, offset: u64, len: usize) -> Option> {
+ if self.disabled || self.config.chunk_bytes == 0 || len == 0 {
+ return None;
+ }
+ let chunk_bytes = self.config.chunk_bytes as u64;
+ let chunk_idx = offset / chunk_bytes;
+ let chunk_off = offset % chunk_bytes;
+ if chunk_off.saturating_add(len as u64) > chunk_bytes {
+ return None;
+ }
+ let chunk_base = chunk_idx.saturating_mul(chunk_bytes);
+ if let Some(chunk) = self.chunks.get_mut(&chunk_base) {
+ if !chunk.contains(chunk_off, chunk_off + len as u64) {
+ return None;
+ }
+ chunk.last_accessed = Instant::now();
+ let mut buf = vec![0u8; len];
+ if chunk.mem.read_at(chunk_off, &mut buf).is_ok() {
+ Some(buf)
+ } else {
+ None
+ }
+ } else {
+ None
+ }
+ }
+
+ pub fn handle_update(&mut self, offset: u64, data: &[u8]) {
+ if self.disabled || self.config.chunk_bytes == 0 || data.is_empty() {
+ return;
+ }
+ let chunk_bytes = self.config.chunk_bytes as u64;
+ let chunk_idx = offset / chunk_bytes;
+ let chunk_off = offset % chunk_bytes;
+ if chunk_off.saturating_add(data.len() as u64) > chunk_bytes {
+ return;
+ }
+ let chunk_base = chunk_idx.saturating_mul(chunk_bytes);
+ if let Some(chunk) = self.chunks.get_mut(&chunk_base) {
+ if chunk.mem.write_at(chunk_off, data).is_err() {
+ self.chunks.remove(&chunk_base);
+ return;
+ }
+ chunk.mark_valid(chunk_off, chunk_off + data.len() as u64);
+ chunk.last_accessed = Instant::now();
+ return;
+ }
+ self.allocate_and_write(chunk_base, chunk_off, data);
+ }
+
+ pub fn handle_promote(&mut self, offset: u64, data: &[u8]) {
+ self.handle_update(offset, data);
+ }
+
+ pub fn handle_disable(&mut self) {
+ self.disabled = true;
+ self.chunks.clear();
+ }
+
+ /// Give clean cache chunks back when other GPU users consume the free buffer.
+ /// The durable origin remains authoritative for every evicted range.
+ pub fn reclaim_under_host_pressure(&mut self) -> Result {
+ let mut released = 0u64;
+ loop {
+ let budget = self.provider.budget_snapshot()?;
+ let free = if budget.can_admit(0) {
+ budget.available_bytes()
+ } else {
+ 0
+ };
+ let required_free = budget
+ .required_free_bytes(self.config.reserve_floor_bytes, RUNTIME_FREE_BUFFER_BYTES);
+ let recovery_free = required_free.max(RUNTIME_RECOVERY_BUFFER_BYTES);
+ if free >= recovery_free {
+ self.pressure_constrained = false;
+ }
+ if free >= required_free {
+ break;
+ }
+ self.pressure_constrained = true;
+ if !self.evict_coldest_chunk() {
+ break;
+ }
+ released = released.saturating_add(self.config.chunk_bytes as u64);
+ }
+ Ok(released)
+ }
+
+ fn allocate_and_write(&mut self, chunk_base: u64, chunk_off: u64, data: &[u8]) {
+ if self.pressure_constrained {
+ return;
+ }
+ let chunk_bytes = self.config.chunk_bytes;
+ let needed = chunk_bytes as u64;
+
+ // Preserve the display reserve and runtime buffer from the live headroom
+ // on every allocation, including allocations after external GPU use changes.
+ let admissible = self.provider.budget_snapshot().is_ok_and(|budget| {
+ budget.can_admit(0)
+ && budget.available_bytes()
+ >= needed.saturating_add(budget.required_free_bytes(
+ self.config.reserve_floor_bytes,
+ RUNTIME_FREE_BUFFER_BYTES,
+ ))
+ });
+ if !admissible {
+ return;
+ }
+
+ while self.cached_bytes().saturating_add(needed) > self.effective_target_bytes {
+ if !self.evict_coldest_chunk() {
+ return;
+ }
+ }
+
+ if let Ok(mut mem) = self.provider.alloc(chunk_bytes)
+ && mem.write_at(chunk_off, data).is_ok()
+ {
+ self.chunks.insert(
+ chunk_base,
+ CacheChunk {
+ mem,
+ last_accessed: Instant::now(),
+ valid_ranges: vec![(chunk_off, chunk_off + data.len() as u64)],
+ },
+ );
+ }
+ }
+
+ fn evict_coldest_chunk(&mut self) -> bool {
+ let coldest = self
+ .chunks
+ .iter()
+ .min_by_key(|(_, chunk)| chunk.last_accessed)
+ .map(|(&base, _)| base);
+ if let Some(base) = coldest {
+ self.chunks.remove(&base);
+ true
+ } else {
+ false
+ }
+ }
+}
+
+pub fn run_gpu_worker_loop(
+ mut socket: UnixStream,
+ provider: P,
+ config: GpuWorkerConfig,
+) -> Result<(), String> {
+ let mut worker = GpuCacheWorker::new(&provider, config);
+ let mut hdr_buf = [0u8; FRAME_HEADER_LEN];
+
+ loop {
+ match socket.read_exact(&mut hdr_buf) {
+ Ok(()) => {}
+ Err(ref e) if e.kind() == std::io::ErrorKind::UnexpectedEof => {
+ break;
+ }
+ Err(e) => return Err(format!("worker read header error: {e}")),
+ }
+
+ let hdr = FrameHeader::decode(&hdr_buf);
+
+ let payload = if hdr.payload_len > 0 {
+ if hdr.payload_len as usize > MAX_IPC_PAYLOAD_BYTES {
+ return Err("worker payload len exceeds limit".to_string());
+ }
+ let mut buf = vec![0u8; hdr.payload_len as usize];
+ if let Err(e) = socket.read_exact(&mut buf) {
+ return Err(format!("worker read payload error: {e}"));
+ }
+ buf
+ } else {
+ Vec::new()
+ };
+
+ match hdr.msg_type {
+ MSG_HANDSHAKE_REQ => {
+ let resp = FrameHeader {
+ msg_type: MSG_HANDSHAKE_RESP,
+ status: STATUS_OK,
+ correlation_id: hdr.correlation_id,
+ offset: worker.target_bytes(),
+ payload_len: 0,
+ aux: (worker.cached_bytes() >> 10) as u32,
+ };
+ if let Err(e) = socket.write_all(&resp.encode()) {
+ return Err(format!("worker write handshake resp error: {e}"));
+ }
+ }
+ MSG_READ_REQ if hdr.aux as usize > MAX_IPC_PAYLOAD_BYTES => {
+ return Err("worker read length exceeds limit".to_string());
+ }
+ MSG_READ_REQ => match worker.handle_read(hdr.offset, hdr.aux as usize) {
+ Some(data) => {
+ let resp = FrameHeader {
+ msg_type: MSG_READ_RESP,
+ status: STATUS_OK,
+ correlation_id: hdr.correlation_id,
+ offset: hdr.offset,
+ payload_len: data.len() as u32,
+ aux: (worker.cached_bytes() >> 10) as u32,
+ };
+ if let Err(e) = socket.write_all(&resp.encode()) {
+ return Err(format!("worker write read resp error: {e}"));
+ }
+ if let Err(e) = socket.write_all(&data) {
+ return Err(format!("worker write read data error: {e}"));
+ }
+ }
+ None => {
+ let resp = FrameHeader {
+ msg_type: MSG_READ_RESP,
+ status: STATUS_MISS,
+ correlation_id: hdr.correlation_id,
+ offset: hdr.offset,
+ payload_len: 0,
+ aux: (worker.cached_bytes() >> 10) as u32,
+ };
+ if let Err(e) = socket.write_all(&resp.encode()) {
+ return Err(format!("worker write read resp error: {e}"));
+ }
+ }
+ },
+ MSG_UPDATE => {
+ worker.handle_update(hdr.offset, &payload);
+ }
+ MSG_PROMOTE => {
+ worker.handle_promote(hdr.offset, &payload);
+ }
+ MSG_DISABLE_REQ => {
+ worker.handle_disable();
+ let resp = FrameHeader {
+ msg_type: MSG_DISABLE_RESP,
+ status: STATUS_OK,
+ correlation_id: hdr.correlation_id,
+ offset: 0,
+ payload_len: 0,
+ aux: 0,
+ };
+ let _ = socket.write_all(&resp.encode());
+ break;
+ }
+ MSG_HEARTBEAT_REQ => {
+ if worker.reclaim_under_host_pressure().is_err() {
+ worker.handle_disable();
+ }
+ let budget_payload = if worker.is_disabled() {
+ Vec::new()
+ } else {
+ worker
+ .provider
+ .budget_snapshot()
+ .ok()
+ .map(|snapshot| {
+ GpuBudgetTelemetry::from_snapshot(&snapshot, unix_time_ms())
+ })
+ .and_then(|telemetry| serde_json::to_vec(&telemetry).ok())
+ .filter(|payload| payload.len() <= MAX_GPU_BUDGET_PAYLOAD_BYTES)
+ .unwrap_or_default()
+ };
+ let resp = FrameHeader {
+ msg_type: MSG_HEARTBEAT_RESP,
+ status: if worker.is_disabled() {
+ STATUS_ERROR
+ } else {
+ STATUS_OK
+ },
+ correlation_id: hdr.correlation_id,
+ offset: worker.target_bytes(),
+ payload_len: budget_payload.len() as u32,
+ aux: (worker.cached_bytes() >> 10) as u32,
+ };
+ if let Err(e) = socket.write_all(&resp.encode()) {
+ return Err(format!("worker write heartbeat error: {e}"));
+ }
+ if let Err(e) = socket.write_all(&budget_payload) {
+ return Err(format!(
+ "worker write heartbeat budget telemetry error: {e}"
+ ));
+ }
+ }
+ _ => {}
+ }
+ }
+
+ Ok(())
+}
+
+#[cfg(test)]
+mod tests {
+ #![allow(clippy::unwrap_used, clippy::expect_used)]
+
+ use super::*;
+ use crate::ipc_cache_client::IpcCacheClient;
+ use crate::isolated_origin::{BestEffortCache, CacheMutation, CacheRead};
+ use ramshared_vram::{GpuAdapterIdentity, GpuBudgetSnapshot, GpuBudgetSource, VramError};
+ use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
+ use std::sync::{Arc, Mutex};
+ use std::time::{Duration, Instant};
+
+ fn trusted_test_budget(free: u64, total: u64) -> GpuBudgetSnapshot {
+ GpuBudgetSnapshot {
+ adapter: Some(GpuAdapterIdentity {
+ backend: "test".into(),
+ key: "fake-adapter-0".into(),
+ luid: None,
+ }),
+ total_bytes: Some(total),
+ budget_bytes: total,
+ used_bytes: total.saturating_sub(free),
+ source: GpuBudgetSource::DriverReported,
+ sampled_at: Instant::now(),
+ }
+ }
+
+ #[test]
+ fn worker_budget_target_requires_external_adapter_bound_snapshot() {
+ const GIB: u64 = 1024 * 1024 * 1024;
+ let config = GpuWorkerConfig {
+ target_bytes: 4 * GIB,
+ chunk_bytes: 512 * 1024 * 1024,
+ reserve_floor_bytes: GIB,
+ };
+ let trusted = GpuBudgetSnapshot {
+ adapter: Some(GpuAdapterIdentity {
+ backend: "test".into(),
+ key: "stable-id".into(),
+ luid: None,
+ }),
+ total_bytes: Some(8 * GIB),
+ budget_bytes: 5 * GIB,
+ used_bytes: 0,
+ source: GpuBudgetSource::DriverReported,
+ sampled_at: Instant::now(),
+ };
+ assert_eq!(
+ effective_target_from_budget(&trusted, config),
+ 4 * GIB - RUNTIME_FREE_BUFFER_BYTES
+ );
+
+ let unknown = GpuBudgetSnapshot {
+ adapter: None,
+ source: GpuBudgetSource::ProviderLocalEstimate,
+ ..trusted
+ };
+ assert_eq!(effective_target_from_budget(&unknown, config), 0);
+ }
+
+ #[test]
+ fn worker_budget_target_never_exceeds_current_available_headroom() {
+ const GIB: u64 = 1024 * 1024 * 1024;
+ let config = GpuWorkerConfig {
+ target_bytes: 4 * GIB,
+ chunk_bytes: 512 * 1024 * 1024,
+ reserve_floor_bytes: GIB,
+ };
+ let low_headroom = GpuBudgetSnapshot {
+ adapter: Some(GpuAdapterIdentity {
+ backend: "test".into(),
+ key: "stable-id".into(),
+ luid: None,
+ }),
+ total_bytes: Some(8 * GIB),
+ budget_bytes: 5 * GIB,
+ used_bytes: 3 * GIB,
+ source: GpuBudgetSource::DriverReported,
+ sampled_at: Instant::now(),
+ };
+
+ assert_eq!(
+ effective_target_from_budget(&low_headroom, config),
+ 2 * GIB - GIB - RUNTIME_FREE_BUFFER_BYTES
+ );
+ }
+
+ #[test]
+ fn worker_budget_target_is_zero_until_runtime_buffer_is_available() {
+ const GIB: u64 = 1024 * 1024 * 1024;
+ let config = GpuWorkerConfig {
+ target_bytes: GIB,
+ chunk_bytes: 64 * 1024 * 1024,
+ reserve_floor_bytes: 0,
+ };
+ let insufficient = GpuBudgetSnapshot {
+ adapter: Some(GpuAdapterIdentity {
+ backend: "test".into(),
+ key: "stable-id".into(),
+ luid: None,
+ }),
+ total_bytes: Some(2 * GIB),
+ budget_bytes: 2 * GIB,
+ used_bytes: 2 * GIB - (RUNTIME_FREE_BUFFER_BYTES - 1),
+ source: GpuBudgetSource::DriverReported,
+ sampled_at: Instant::now(),
+ };
+
+ assert_eq!(effective_target_from_budget(&insufficient, config), 0);
+ }
+
+ struct FakeMem {
+ data: Arc>>,
+ len: usize,
+ live_allocations: Arc,
+ }
+
+ impl Drop for FakeMem {
+ fn drop(&mut self) {
+ self.live_allocations.fetch_sub(1, Ordering::SeqCst);
+ }
+ }
+
+ impl VramMemory for FakeMem {
+ fn len(&self) -> usize {
+ self.len
+ }
+
+ fn zero(&mut self) -> Result<(), VramError> {
+ let mut guard = self.data.lock().map_err(|_| VramError::Busy)?;
+ guard.fill(0);
+ Ok(())
+ }
+
+ fn read_at(&self, off: u64, dst: &mut [u8]) -> Result<(), VramError> {
+ let guard = self.data.lock().map_err(|_| VramError::Busy)?;
+ let start = off as usize;
+ let end = start + dst.len();
+ if end > guard.len() {
+ return Err(VramError::OutOfRange {
+ off,
+ len: dst.len() as u64,
+ size: guard.len() as u64,
+ });
+ }
+ dst.copy_from_slice(&guard[start..end]);
+ Ok(())
+ }
+
+ fn write_at(&mut self, off: u64, src: &[u8]) -> Result<(), VramError> {
+ let mut guard = self.data.lock().map_err(|_| VramError::Busy)?;
+ let start = off as usize;
+ let end = start + src.len();
+ if end > guard.len() {
+ return Err(VramError::OutOfRange {
+ off,
+ len: src.len() as u64,
+ size: guard.len() as u64,
+ });
+ }
+ guard[start..end].copy_from_slice(src);
+ Ok(())
+ }
+ }
+
+ struct FakeProvider {
+ total: u64,
+ free: u64,
+ live_allocations: Arc,
+ }
+
+ impl FakeProvider {
+ fn new(total: u64, free: u64) -> Self {
+ Self {
+ total,
+ free,
+ live_allocations: Arc::new(AtomicUsize::new(0)),
+ }
+ }
+ }
+
+ impl VramProvider for FakeProvider {
+ type Mem<'p>
+ = FakeMem
+ where
+ Self: 'p;
+
+ fn alloc(&self, bytes: usize) -> Result, VramError> {
+ self.live_allocations.fetch_add(1, Ordering::SeqCst);
+ Ok(FakeMem {
+ data: Arc::new(Mutex::new(vec![0u8; bytes])),
+ len: bytes,
+ live_allocations: Arc::clone(&self.live_allocations),
+ })
+ }
+
+ fn mem_info(&self) -> Result<(u64, u64), VramError> {
+ Ok((self.free, self.total))
+ }
+
+ fn budget_snapshot(&self) -> Result {
+ Ok(trusted_test_budget(self.free, self.total))
+ }
+ }
+
+ struct PressureProvider {
+ total: u64,
+ external: Arc,
+ live_allocations: Arc,
+ chunk_bytes: u64,
+ }
+
+ impl VramProvider for PressureProvider {
+ type Mem<'p>
+ = FakeMem
+ where
+ Self: 'p;
+
+ fn alloc(&self, bytes: usize) -> Result, VramError> {
+ self.live_allocations.fetch_add(1, Ordering::SeqCst);
+ Ok(FakeMem {
+ data: Arc::new(Mutex::new(vec![0u8; bytes])),
+ len: bytes,
+ live_allocations: Arc::clone(&self.live_allocations),
+ })
+ }
+
+ fn mem_info(&self) -> Result<(u64, u64), VramError> {
+ let cache_bytes = (self.live_allocations.load(Ordering::SeqCst) as u64)
+ .saturating_mul(self.chunk_bytes);
+ let free = self
+ .total
+ .saturating_sub(self.external.load(Ordering::SeqCst))
+ .saturating_sub(cache_bytes);
+ Ok((free, self.total))
+ }
+
+ fn budget_snapshot(&self) -> Result {
+ let (free, total) = self.mem_info()?;
+ Ok(trusted_test_budget(free, total))
+ }
+ }
+
+ #[test]
+ fn heartbeat_pressure_reclaims_cold_cache_and_keeps_origin_fallback() {
+ let chunk_bytes = 2 * 1024 * 1024;
+ let total = 2 * 1024 * 1024 * 1024;
+ let external = Arc::new(AtomicU64::new(0));
+ let live_allocations = Arc::new(AtomicUsize::new(0));
+ let provider = PressureProvider {
+ total,
+ external: Arc::clone(&external),
+ live_allocations: Arc::clone(&live_allocations),
+ chunk_bytes,
+ };
+ let mut worker = GpuCacheWorker::new(
+ &provider,
+ GpuWorkerConfig {
+ target_bytes: 4 * chunk_bytes,
+ chunk_bytes: chunk_bytes as usize,
+ reserve_floor_bytes: 128 * 1024 * 1024,
+ },
+ );
+ worker.handle_update(0, &[1]);
+ std::thread::sleep(Duration::from_millis(1));
+ worker.handle_update(chunk_bytes, &[2]);
+ assert_eq!(worker.cached_bytes(), 2 * chunk_bytes);
+
+ external.store(
+ total - 2 * chunk_bytes - (RUNTIME_FREE_BUFFER_BYTES - chunk_bytes),
+ Ordering::SeqCst,
+ );
+ assert_eq!(
+ worker.reclaim_under_host_pressure().unwrap(),
+ 2 * chunk_bytes
+ );
+ assert_eq!(worker.cached_bytes(), 0);
+ assert_eq!(worker.handle_read(0, 1), None);
+ assert_eq!(worker.handle_read(chunk_bytes, 1), None);
+ assert!(provider.mem_info().unwrap().0 >= RUNTIME_FREE_BUFFER_BYTES);
+ worker.handle_update(2 * chunk_bytes, &[3]);
+ assert_eq!(worker.cached_bytes(), 0);
+ external.store(0, Ordering::SeqCst);
+ assert_eq!(worker.reclaim_under_host_pressure().unwrap(), 0);
+ worker.handle_update(2 * chunk_bytes, &[3]);
+ assert_eq!(worker.cached_bytes(), chunk_bytes);
+ }
+
+ #[test]
+ fn heartbeat_reports_physical_release_after_external_gpu_pressure() {
+ let chunk_bytes = 2 * 1024 * 1024;
+ let total = 2 * 1024 * 1024 * 1024;
+ let external = Arc::new(AtomicU64::new(0));
+ let live_allocations = Arc::new(AtomicUsize::new(0));
+ let provider = PressureProvider {
+ total,
+ external: Arc::clone(&external),
+ live_allocations: Arc::clone(&live_allocations),
+ chunk_bytes,
+ };
+ let (client_socket, worker_socket) = UnixStream::pair().expect("socketpair failed");
+ let worker_thread = std::thread::spawn(move || {
+ run_gpu_worker_loop(
+ worker_socket,
+ provider,
+ GpuWorkerConfig {
+ target_bytes: 4 * chunk_bytes,
+ chunk_bytes: chunk_bytes as usize,
+ reserve_floor_bytes: 128 * 1024 * 1024,
+ },
+ )
+ .expect("worker loop failed");
+ });
+ let mut client =
+ IpcCacheClient::new(client_socket, Duration::from_secs(1), 4 * chunk_bytes);
+ client.perform_handshake().expect("handshake failed");
+ assert_eq!(client.update(0, &[1]), CacheMutation::Accepted);
+ assert_eq!(client.update(chunk_bytes, &[2]), CacheMutation::Accepted);
+ assert_eq!(
+ client.refresh_cached_bytes().expect("first heartbeat"),
+ 2 * chunk_bytes
+ );
+
+ external.store(
+ total - 2 * chunk_bytes - (RUNTIME_FREE_BUFFER_BYTES - chunk_bytes),
+ Ordering::SeqCst,
+ );
+ assert_eq!(
+ client.refresh_cached_bytes().expect("pressure heartbeat"),
+ 0
+ );
+ assert_eq!(client.read(0, &mut [0]), CacheRead::Miss);
+ assert_eq!(
+ client.update(2 * chunk_bytes, &[3]),
+ CacheMutation::Accepted
+ );
+ assert_eq!(client.refresh_cached_bytes().expect("parked heartbeat"), 0);
+ external.store(0, Ordering::SeqCst);
+ assert_eq!(
+ client.refresh_cached_bytes().expect("recovery heartbeat"),
+ 0
+ );
+ assert_eq!(
+ client.update(2 * chunk_bytes, &[3]),
+ CacheMutation::Accepted
+ );
+ assert_eq!(
+ client.refresh_cached_bytes().expect("refill heartbeat"),
+ chunk_bytes
+ );
+ drop(client);
+ worker_thread.join().expect("worker thread joined");
+ assert_eq!(live_allocations.load(Ordering::SeqCst), 0);
+ }
+
+ #[test]
+ fn worker_handshake_and_read_hit_cycle() {
+ let (client_sock, worker_sock) = UnixStream::pair().expect("socketpair failed");
+ let provider = FakeProvider::new(8 * 1024 * 1024 * 1024, 6 * 1024 * 1024 * 1024);
+ let config = GpuWorkerConfig {
+ target_bytes: 64 * 1024 * 1024,
+ chunk_bytes: 2 * 1024 * 1024,
+ reserve_floor_bytes: 1536 * 1024 * 1024,
+ };
+
+ let worker_thread = std::thread::spawn(move || {
+ run_gpu_worker_loop(worker_sock, provider, config).expect("worker loop failed");
+ });
+
+ let mut client =
+ IpcCacheClient::new(client_sock, Duration::from_millis(100), 64 * 1024 * 1024);
+ client.perform_handshake().expect("handshake failed");
+ assert_eq!(client.target_bytes(), 64 * 1024 * 1024);
+
+ let test_payload = vec![0x42; 4096];
+ let outcome = client.update(0, &test_payload);
+ assert_eq!(outcome, CacheMutation::Accepted);
+
+ // Give worker brief moment to process non-blocking update
+ std::thread::sleep(Duration::from_millis(10));
+
+ let mut read_buf = vec![0u8; 4096];
+ let read_outcome = client.read(0, &mut read_buf);
+ assert_eq!(read_outcome, CacheRead::Hit);
+ assert_eq!(read_buf, test_payload);
+
+ // Read unwritten offset in another chunk
+ let mut unwritten = vec![0u8; 4096];
+ let miss_outcome = client.read(4 * 1024 * 1024, &mut unwritten);
+ assert_eq!(miss_outcome, CacheRead::Miss);
+
+ let disable_outcome = client.disable();
+ assert_eq!(disable_outcome, CacheMutation::Accepted);
+ worker_thread.join().expect("join worker thread");
+ }
+
+ #[test]
+ fn worker_never_serves_unwritten_bytes_from_an_allocated_chunk() {
+ let provider = FakeProvider::new(4 * 1024 * 1024 * 1024, 3 * 1024 * 1024 * 1024);
+ let mut worker = GpuCacheWorker::new(
+ &provider,
+ GpuWorkerConfig {
+ target_bytes: 2 * 1024 * 1024,
+ chunk_bytes: 2 * 1024 * 1024,
+ reserve_floor_bytes: 1536 * 1024 * 1024,
+ },
+ );
+ worker.handle_update(0, &[1, 2, 3, 4]);
+ assert_eq!(worker.handle_read(0, 4), Some(vec![1, 2, 3, 4]));
+ assert_eq!(worker.handle_read(4096, 4), None);
+ assert_eq!(worker.handle_read(2, 4), None);
+ worker.handle_update(4, &[5, 6, 7, 8]);
+ assert_eq!(worker.handle_read(0, 8), Some(vec![1, 2, 3, 4, 5, 6, 7, 8]));
+ }
+
+ #[test]
+ fn worker_reports_allocated_vram_after_bounded_heartbeat() {
+ let (client_sock, worker_sock) = UnixStream::pair().expect("socketpair failed");
+ let provider = FakeProvider::new(4 * 1024 * 1024 * 1024, 3 * 1024 * 1024 * 1024);
+ let worker_thread = std::thread::spawn(move || {
+ run_gpu_worker_loop(
+ worker_sock,
+ provider,
+ GpuWorkerConfig {
+ target_bytes: 2 * 1024 * 1024,
+ chunk_bytes: 2 * 1024 * 1024,
+ reserve_floor_bytes: 1536 * 1024 * 1024,
+ },
+ )
+ .expect("worker loop failed");
+ });
+ let mut client =
+ IpcCacheClient::new(client_sock, Duration::from_millis(100), 2 * 1024 * 1024);
+ client.perform_handshake().expect("handshake failed");
+ assert_eq!(client.update(0, &[1, 2, 3, 4]), CacheMutation::Accepted);
+ assert_eq!(
+ client.refresh_cached_bytes().expect("heartbeat failed"),
+ 2 * 1024 * 1024
+ );
+ assert_eq!(client.cached_bytes(), 2 * 1024 * 1024);
+ assert_eq!(client.disable(), CacheMutation::Accepted);
+ worker_thread.join().expect("join worker thread");
+ }
+
+ #[test]
+ fn oversized_mutation_disables_cache_before_worker_frame_is_sent() {
+ let (client_sock, worker_sock) = UnixStream::pair().expect("socketpair failed");
+ let provider = FakeProvider::new(4 * 1024 * 1024 * 1024, 3 * 1024 * 1024 * 1024);
+ let worker_thread = std::thread::spawn(move || {
+ run_gpu_worker_loop(
+ worker_sock,
+ provider,
+ GpuWorkerConfig {
+ target_bytes: 2 * 1024 * 1024,
+ chunk_bytes: 2 * 1024 * 1024,
+ reserve_floor_bytes: 1536 * 1024 * 1024,
+ },
+ )
+ .expect("worker loop failed");
+ });
+ let mut client =
+ IpcCacheClient::new(client_sock, Duration::from_millis(100), 2 * 1024 * 1024);
+ client.perform_handshake().expect("handshake failed");
+ let payload = vec![0x5a; 512 * 1024];
+ assert_eq!(client.update(0, &payload), CacheMutation::Failed);
+ assert_eq!(client.state(), crate::origin_cache::CacheState::Unavailable);
+ worker_thread.join().expect("join worker thread");
+ }
+
+ #[test]
+ fn worker_respects_headroom_floor() {
+ let total_vram = 8 * 1024 * 1024 * 1024u64; // 8 GiB
+ let free_vram = 7 * 1024 * 1024 * 1024u64; // 7 GiB
+ let provider = FakeProvider::new(total_vram, free_vram);
+
+ // Current use leaves 7 GiB; a 2 GiB display reserve and 640 MiB runtime
+ // headroom must remain available after any cache allocation.
+ let config = GpuWorkerConfig {
+ target_bytes: 10 * 1024 * 1024 * 1024, // Request 10 GiB (more than available)
+ chunk_bytes: 2 * 1024 * 1024,
+ reserve_floor_bytes: 2 * 1024 * 1024 * 1024,
+ };
+
+ let worker = GpuCacheWorker::new(&provider, config);
+ assert_eq!(
+ worker.target_bytes(),
+ 7 * 1024 * 1024 * 1024 - 2 * 1024 * 1024 * 1024 - RUNTIME_FREE_BUFFER_BYTES
+ );
+ }
+
+ #[test]
+ fn worker_refuses_allocation_when_live_gpu_free_buffer_is_low() {
+ let provider = FakeProvider::new(6 * 1024 * 1024 * 1024, 256 * 1024 * 1024);
+ let allocations = Arc::clone(&provider.live_allocations);
+ let mut worker = GpuCacheWorker::new(
+ &provider,
+ GpuWorkerConfig {
+ target_bytes: 2 * 1024 * 1024,
+ chunk_bytes: 2 * 1024 * 1024,
+ reserve_floor_bytes: 1536 * 1024 * 1024,
+ },
+ );
+ worker.handle_update(0, &[1, 2, 3, 4]);
+ assert_eq!(allocations.load(Ordering::SeqCst), 0);
+ assert_eq!(worker.cached_bytes(), 0);
+ }
+
+ #[test]
+ fn worker_disable_frees_allocations() {
+ let provider = FakeProvider::new(4 * 1024 * 1024 * 1024, 3 * 1024 * 1024 * 1024);
+ let live_allocs = Arc::clone(&provider.live_allocations);
+ let config = GpuWorkerConfig {
+ target_bytes: 16 * 1024 * 1024,
+ chunk_bytes: 2 * 1024 * 1024,
+ reserve_floor_bytes: 1536 * 1024 * 1024,
+ };
+
+ let mut worker = GpuCacheWorker::new(&provider, config);
+ worker.handle_update(0, &[1, 2, 3, 4]);
+ worker.handle_update(2 * 1024 * 1024, &[5, 6, 7, 8]);
+
+ assert_eq!(worker.active_chunks_count(), 2);
+ assert_eq!(worker.cached_bytes(), 4 * 1024 * 1024);
+ assert_eq!(live_allocs.load(Ordering::SeqCst), 2);
+
+ worker.handle_disable();
+
+ assert_eq!(worker.active_chunks_count(), 0);
+ assert_eq!(worker.cached_bytes(), 0);
+ assert!(worker.is_disabled());
+ assert_eq!(live_allocs.load(Ordering::SeqCst), 0);
+ }
+
+ #[test]
+ fn worker_evicts_coldest_chunk_on_pressure() {
+ let provider = FakeProvider::new(4 * 1024 * 1024 * 1024, 3 * 1024 * 1024 * 1024);
+ let config = GpuWorkerConfig {
+ target_bytes: 4 * 1024 * 1024, // Space for only 2 chunks of 2 MiB
+ chunk_bytes: 2 * 1024 * 1024,
+ reserve_floor_bytes: 1536 * 1024 * 1024,
+ };
+
+ let mut worker = GpuCacheWorker::new(&provider, config);
+
+ // Fill 2 chunks
+ worker.handle_update(0, &[10, 20]);
+ std::thread::sleep(Duration::from_millis(5));
+ worker.handle_promote(2 * 1024 * 1024, &[30, 40]);
+ assert_eq!(worker.active_chunks_count(), 2);
+
+ // Add 3rd chunk - chunk 0 should be evicted as coldest
+ std::thread::sleep(Duration::from_millis(5));
+ worker.handle_update(4 * 1024 * 1024, &[50, 60]);
+ assert_eq!(worker.active_chunks_count(), 2);
+
+ // Chunk 0 is evicted -> miss
+ assert_eq!(worker.handle_read(0, 2), None);
+ // Chunk 1 and 2 remain -> hit
+ assert_eq!(worker.handle_read(2 * 1024 * 1024, 2), Some(vec![30, 40]));
+ assert_eq!(worker.handle_read(4 * 1024 * 1024, 2), Some(vec![50, 60]));
+
+ // Boundary crossing read returns None
+ assert_eq!(worker.handle_read(2 * 1024 * 1024 - 1, 4), None);
+ }
+
+ #[test]
+ fn worker_handles_promote_and_heartbeat_loop() {
+ let (client_sock, worker_sock) = UnixStream::pair().expect("socketpair failed");
+ let provider = FakeProvider::new(8 * 1024 * 1024 * 1024, 6 * 1024 * 1024 * 1024);
+ let config = GpuWorkerConfig {
+ target_bytes: 64 * 1024 * 1024,
+ chunk_bytes: 2 * 1024 * 1024,
+ reserve_floor_bytes: 1536 * 1024 * 1024,
+ };
+
+ let worker_thread = std::thread::spawn(move || {
+ run_gpu_worker_loop(worker_sock, provider, config).expect("worker loop failed");
+ });
+
+ let mut client =
+ IpcCacheClient::new(client_sock, Duration::from_millis(100), 64 * 1024 * 1024);
+ client.perform_handshake().expect("handshake failed");
+
+ // Promote frame
+ let promote_data = vec![0xEE; 1024];
+ let promote_outcome = client.promote(0, &promote_data);
+ assert_eq!(promote_outcome, CacheMutation::Accepted);
+
+ std::thread::sleep(Duration::from_millis(10));
+
+ let mut read_buf = vec![0u8; 1024];
+ let read_outcome = client.read(0, &mut read_buf);
+ assert_eq!(read_outcome, CacheRead::Hit);
+ assert_eq!(read_buf, promote_data);
+
+ client
+ .refresh_cached_bytes()
+ .expect("heartbeat reports cached bytes");
+ let budget = client
+ .gpu_budget_telemetry()
+ .expect("heartbeat publishes adapter budget");
+ assert_eq!(
+ budget.adapter.as_ref().map(|id| id.backend.as_str()),
+ Some("test")
+ );
+ assert_eq!(budget.source, GpuBudgetSource::DriverReported);
+ assert_eq!(
+ budget.trusted_available_at(unix_time_ms(), 5_000),
+ Some(6 * 1024 * 1024 * 1024)
+ );
+
+ let disable_outcome = client.disable();
+ assert_eq!(disable_outcome, CacheMutation::Accepted);
+ worker_thread.join().expect("join worker thread");
+ }
+
+ /// Kahneman #17 — teardown must be idempotent and bounded.
+ /// SPEC: `worker_teardown_is_idempotent_and_bounded`
+ #[test]
+ fn worker_teardown_is_idempotent_and_bounded() {
+ let provider = FakeProvider::new(4 * 1024 * 1024 * 1024, 3 * 1024 * 1024 * 1024);
+ let live_allocs = Arc::clone(&provider.live_allocations);
+ let config = GpuWorkerConfig {
+ target_bytes: 16 * 1024 * 1024,
+ chunk_bytes: 2 * 1024 * 1024,
+ reserve_floor_bytes: 1536 * 1024 * 1024,
+ };
+
+ let mut worker = GpuCacheWorker::new(&provider, config);
+ worker.handle_update(0, &[1, 2, 3]);
+ worker.handle_update(2 * 1024 * 1024, &[4, 5, 6]);
+ assert_eq!(live_allocs.load(Ordering::SeqCst), 2);
+
+ // First teardown: frees all allocations
+ let start = Instant::now();
+ worker.handle_disable();
+ assert!(
+ start.elapsed() < Duration::from_secs(5),
+ "teardown must be bounded"
+ );
+ assert_eq!(live_allocs.load(Ordering::SeqCst), 0);
+
+ // Second teardown: idempotent — no panic, no double-free
+ worker.handle_disable();
+ assert_eq!(live_allocs.load(Ordering::SeqCst), 0);
+ assert!(worker.is_disabled());
+
+ // Third teardown on empty state: still idempotent
+ worker.handle_disable();
+ assert_eq!(worker.active_chunks_count(), 0);
+ }
+}
diff --git a/crates/ramshared-block/src/handshake.rs b/crates/ramshared-block/src/handshake.rs
index 993253987..7c9e14091 100644
--- a/crates/ramshared-block/src/handshake.rs
+++ b/crates/ramshared-block/src/handshake.rs
@@ -410,4 +410,37 @@ mod tests {
assert_eq!(idx, 0);
assert_eq!(u64::from_be_bytes(out[18..26].try_into().unwrap()), 4096);
}
+
+ #[test]
+ fn unsupported_option_replies_and_keeps_negotiating() {
+ let mut input = stream_opts(0, &[(999, vec![]), (NBD_OPT_ABORT, vec![])]);
+ let mut output = Vec::new();
+ let result = server_handshake(&mut input, &mut output, &one(4096), 1);
+ assert!(matches!(result, Err(HandshakeError::Aborted)));
+ assert!(has_rep(&output, NBD_REP_ERR_UNSUP));
+ }
+
+ #[test]
+ fn truncated_go_payload_is_invalid() {
+ let mut input = client_stream(NBD_FLAG_C_NO_ZEROES, NBD_OPT_GO, &[0, 0, 0]);
+ let mut output = Vec::new();
+ let result = server_handshake(&mut input, &mut output, &one(4096), 1);
+ assert!(matches!(result, Err(HandshakeError::InvalidFormat)));
+ }
+
+ #[test]
+ fn go_payload_missing_info_count_is_invalid() {
+ let mut input = client_stream(NBD_FLAG_C_NO_ZEROES, NBD_OPT_GO, &[0, 0, 0, 1, b'a']);
+ let mut output = Vec::new();
+ let result = server_handshake(&mut input, &mut output, &one(4096), 1);
+ assert!(matches!(result, Err(HandshakeError::InvalidFormat)));
+ }
+
+ #[test]
+ fn go_payload_name_length_exceeding_frame_is_invalid() {
+ let mut input = client_stream(NBD_FLAG_C_NO_ZEROES, NBD_OPT_GO, &[0xff, 0xff, 0xff, 0xff]);
+ let mut output = Vec::new();
+ let result = server_handshake(&mut input, &mut output, &one(4096), 1);
+ assert!(matches!(result, Err(HandshakeError::InvalidFormat)));
+ }
}
diff --git a/crates/ramshared-block/src/ipc_cache_client.rs b/crates/ramshared-block/src/ipc_cache_client.rs
new file mode 100644
index 000000000..5eef325cb
--- /dev/null
+++ b/crates/ramshared-block/src/ipc_cache_client.rs
@@ -0,0 +1,607 @@
+//! IPC cache client communicating with the isolated GPU cache worker.
+
+use std::io::{self, ErrorKind, Read, Write};
+use std::os::unix::net::UnixStream;
+use std::time::{Duration, Instant};
+
+use crate::gpu_cache_worker::{
+ FRAME_HEADER_LEN, FrameHeader, MAX_IPC_PAYLOAD_BYTES, MSG_DISABLE_REQ, MSG_DISABLE_RESP,
+ MSG_HANDSHAKE_REQ, MSG_HANDSHAKE_RESP, MSG_HEARTBEAT_REQ, MSG_HEARTBEAT_RESP, MSG_PROMOTE,
+ MSG_READ_REQ, MSG_READ_RESP, MSG_UPDATE, STATUS_MISS, STATUS_OK,
+};
+use crate::isolated_origin::{BestEffortCache, CacheMutation, CacheRead};
+use crate::origin_cache::CacheState;
+use ramshared_vram::GpuBudgetTelemetry;
+
+pub const DEFAULT_READ_TIMEOUT: Duration = Duration::from_millis(50);
+/// Handshake allows extra time for the worker to initialize CUDA/Vulkan
+/// contexts before the first frame is served (SPEC: DT-2, NFR-1).
+pub const HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(5);
+/// Disable/teardown uses a longer timeout to accommodate GPU context cleanup.
+/// SPEC: DT-5 (5s bounded supervisor teardown).
+pub const DISABLE_TIMEOUT: Duration = Duration::from_secs(5);
+const MAX_GPU_BUDGET_PAYLOAD_BYTES: u32 = 4096;
+const MAX_MUTATION_FRAME_DATA_BYTES: usize = 64 * 1024;
+
+fn deadline_after(timeout: Duration) -> io::Result {
+ Instant::now()
+ .checked_add(timeout)
+ .ok_or_else(|| io::Error::new(ErrorKind::InvalidInput, "IPC deadline overflow"))
+}
+
+fn remaining(deadline: Instant) -> io::Result {
+ let duration = deadline.saturating_duration_since(Instant::now());
+ if duration.is_zero() {
+ Err(io::Error::new(ErrorKind::TimedOut, "IPC deadline expired"))
+ } else {
+ Ok(duration)
+ }
+}
+
+fn read_exact_until(
+ socket: &mut UnixStream,
+ mut buffer: &mut [u8],
+ deadline: Instant,
+) -> io::Result<()> {
+ while !buffer.is_empty() {
+ socket.set_read_timeout(Some(remaining(deadline)?))?;
+ match socket.read(buffer) {
+ Ok(0) => {
+ return Err(io::Error::new(
+ ErrorKind::UnexpectedEof,
+ "IPC stream closed",
+ ));
+ }
+ Ok(read) => buffer = &mut buffer[read..],
+ Err(error) if error.kind() == ErrorKind::Interrupted => continue,
+ Err(error) => return Err(error),
+ }
+ }
+ Ok(())
+}
+
+fn write_all_until(
+ socket: &mut UnixStream,
+ mut buffer: &[u8],
+ deadline: Instant,
+) -> io::Result<()> {
+ while !buffer.is_empty() {
+ socket.set_write_timeout(Some(remaining(deadline)?))?;
+ match socket.write(buffer) {
+ Ok(0) => {
+ return Err(io::Error::new(
+ ErrorKind::WriteZero,
+ "IPC stream made no progress",
+ ));
+ }
+ Ok(written) => buffer = &buffer[written..],
+ Err(error) if error.kind() == ErrorKind::Interrupted => continue,
+ Err(error) => return Err(error),
+ }
+ }
+ Ok(())
+}
+
+fn try_write_frame(socket: &mut UnixStream, frame: &[u8]) -> io::Result<()> {
+ socket.set_nonblocking(true)?;
+ let write_result = match socket.write(frame) {
+ Ok(written) if written == frame.len() => Ok(()),
+ Ok(written) => Err(io::Error::new(
+ ErrorKind::WriteZero,
+ format!("IPC mutation frame was only partially queued ({written} bytes)"),
+ )),
+ Err(error) => Err(error),
+ };
+ let restore_result = socket.set_nonblocking(false);
+ write_result.and(restore_result)
+}
+
+pub struct IpcCacheClient {
+ socket: UnixStream,
+ read_timeout: Duration,
+ timeouts_configured: bool,
+ state: CacheState,
+ cached_bytes: u64,
+ target_bytes: u64,
+ gpu_budget: Option,
+ seq: u64,
+}
+
+impl IpcCacheClient {
+ pub fn new(socket: UnixStream, read_timeout: Duration, target_bytes: u64) -> Self {
+ let timeouts_configured = !read_timeout.is_zero()
+ && socket.set_read_timeout(Some(read_timeout)).is_ok()
+ && socket.set_write_timeout(Some(read_timeout)).is_ok();
+ if !timeouts_configured {
+ eprintln!("[ramsharedd] isolated GPU cache unavailable: IPC timeout setup failed");
+ }
+ Self {
+ socket,
+ read_timeout,
+ timeouts_configured,
+ state: if timeouts_configured {
+ CacheState::Active
+ } else {
+ CacheState::Unavailable
+ },
+ cached_bytes: 0,
+ target_bytes,
+ gpu_budget: None,
+ seq: 0,
+ }
+ }
+
+ pub fn perform_handshake(&mut self) -> Result<(), String> {
+ if !self.timeouts_configured || self.state != CacheState::Active {
+ return Err("IPC timeout configuration is unavailable".to_string());
+ }
+ self.seq = self.seq.saturating_add(1);
+ let req = FrameHeader {
+ msg_type: MSG_HANDSHAKE_REQ,
+ status: STATUS_OK,
+ correlation_id: self.seq,
+ offset: 0,
+ payload_len: 0,
+ aux: 0,
+ };
+ let deadline = match deadline_after(HANDSHAKE_TIMEOUT) {
+ Ok(deadline) => deadline,
+ Err(error) => {
+ self.fail("handshake deadline setup failed");
+ return Err(format!("handshake deadline setup failed: {error}"));
+ }
+ };
+ if let Err(error) = write_all_until(&mut self.socket, &req.encode(), deadline) {
+ self.fail("handshake write failed");
+ return Err(format!("handshake write error: {error}"));
+ }
+
+ let mut buf = [0u8; FRAME_HEADER_LEN];
+ if let Err(error) = read_exact_until(&mut self.socket, &mut buf, deadline) {
+ self.fail("handshake read failed");
+ return Err(format!("handshake read error: {error}"));
+ }
+ if let Err(error) = self
+ .socket
+ .set_read_timeout(Some(self.read_timeout))
+ .and_then(|()| self.socket.set_write_timeout(Some(self.read_timeout)))
+ {
+ self.fail("steady-state read timeout restore failed");
+ return Err(format!("handshake timeout restore failed: {error}"));
+ }
+
+ let resp = FrameHeader::decode(&buf);
+ if resp.msg_type != MSG_HANDSHAKE_RESP || resp.correlation_id != self.seq {
+ self.fail("handshake response mismatched");
+ return Err("invalid handshake response".to_string());
+ }
+ if resp.offset > 0 {
+ self.target_bytes = resp.offset;
+ }
+ self.cached_bytes = (resp.aux as u64) << 10;
+ // target_bytes == 0 means the worker has no VRAM provider (GAP-6):
+ // report Unavailable so telemetry and cascade gates see the truth.
+ if self.target_bytes == 0 {
+ self.state = CacheState::Unavailable;
+ } else {
+ self.state = CacheState::Active;
+ }
+ Ok(())
+ }
+
+ fn fail(&mut self, reason: &'static str) {
+ eprintln!("[ramsharedd] isolated GPU cache unavailable: {reason}");
+ self.state = CacheState::Unavailable;
+ self.cached_bytes = 0;
+ self.gpu_budget = None;
+ let _ = self.socket.shutdown(std::net::Shutdown::Both);
+ }
+
+ pub fn refresh_cached_bytes(&mut self) -> Result {
+ if self.state != CacheState::Active {
+ return Err("GPU cache worker is unavailable");
+ }
+ let deadline = match deadline_after(self.read_timeout) {
+ Ok(deadline) => deadline,
+ Err(_) => {
+ self.fail("heartbeat deadline setup failed");
+ return Err("GPU cache worker heartbeat deadline could not be configured");
+ }
+ };
+ self.seq = self.seq.saturating_add(1);
+ let req = FrameHeader {
+ msg_type: MSG_HEARTBEAT_REQ,
+ status: STATUS_OK,
+ correlation_id: self.seq,
+ offset: 0,
+ payload_len: 0,
+ aux: 0,
+ };
+ let mut buf = [0u8; FRAME_HEADER_LEN];
+ if write_all_until(&mut self.socket, &req.encode(), deadline).is_err()
+ || read_exact_until(&mut self.socket, &mut buf, deadline).is_err()
+ {
+ self.fail("heartbeat I/O failed");
+ return Err("GPU cache worker heartbeat timed out");
+ }
+ let resp = FrameHeader::decode(&buf);
+ if resp.msg_type != MSG_HEARTBEAT_RESP
+ || resp.correlation_id != self.seq
+ || resp.status != STATUS_OK
+ || resp.offset != self.target_bytes
+ {
+ self.fail("heartbeat response mismatched");
+ return Err("GPU cache worker heartbeat mismatched");
+ }
+ self.cached_bytes = (resp.aux as u64) << 10;
+ if resp.payload_len == 0 || resp.payload_len > MAX_GPU_BUDGET_PAYLOAD_BYTES {
+ self.gpu_budget = None;
+ if resp.payload_len > MAX_GPU_BUDGET_PAYLOAD_BYTES {
+ self.fail("heartbeat GPU telemetry exceeded its size limit");
+ return Err("GPU cache worker heartbeat telemetry exceeded its limit");
+ }
+ } else {
+ let mut payload = vec![0; resp.payload_len as usize];
+ if read_exact_until(&mut self.socket, &mut payload, deadline).is_err() {
+ self.fail("heartbeat GPU telemetry was truncated");
+ return Err("GPU cache worker heartbeat telemetry was truncated");
+ }
+ self.gpu_budget = serde_json::from_slice::(&payload)
+ .ok()
+ .filter(|telemetry| telemetry.schema_version == 1);
+ }
+ Ok(self.cached_bytes)
+ }
+
+ pub fn gpu_budget_telemetry(&self) -> Option<&GpuBudgetTelemetry> {
+ self.gpu_budget.as_ref()
+ }
+
+ fn send_mutation_frame(&mut self, header: FrameHeader, payload: &[u8]) -> CacheMutation {
+ if self.state != CacheState::Active {
+ return CacheMutation::Skipped;
+ }
+ if payload.len() > MAX_MUTATION_FRAME_DATA_BYTES {
+ self.fail("cache mutation exceeds nonblocking frame limit");
+ return CacheMutation::Failed;
+ }
+ // Assemble the complete frame before the single nonblocking send.
+ let mut frame = Vec::with_capacity(FRAME_HEADER_LEN + payload.len());
+ frame.extend_from_slice(&header.encode());
+ frame.extend_from_slice(payload);
+
+ // Mutations are optional. If the complete frame cannot be queued in a
+ // nonblocking attempt, fail closed and discard the stream.
+ if try_write_frame(&mut self.socket, &frame).is_err() {
+ self.fail("nonblocking mutation frame write failed");
+ return CacheMutation::Failed;
+ }
+
+ // Accepted bytes are queued, not evidence of GPU allocation.
+ self.cached_bytes = 0;
+ self.gpu_budget = None;
+ CacheMutation::Accepted
+ }
+}
+
+impl BestEffortCache for IpcCacheClient {
+ fn read(&mut self, offset: u64, destination: &mut [u8]) -> CacheRead {
+ if self.state != CacheState::Active {
+ return CacheRead::Miss;
+ }
+ if destination.len() > MAX_IPC_PAYLOAD_BYTES {
+ return CacheRead::Miss;
+ }
+ self.seq = self.seq.saturating_add(1);
+ let req = FrameHeader {
+ msg_type: MSG_READ_REQ,
+ status: STATUS_OK,
+ correlation_id: self.seq,
+ offset,
+ payload_len: 0,
+ aux: destination.len() as u32,
+ };
+
+ let deadline = match deadline_after(self.read_timeout) {
+ Ok(deadline) => deadline,
+ Err(_) => {
+ self.fail("read deadline setup failed");
+ return CacheRead::Failed;
+ }
+ };
+ if write_all_until(&mut self.socket, &req.encode(), deadline).is_err() {
+ self.fail("read request write failed");
+ return CacheRead::Failed;
+ }
+
+ let mut hdr_buf = [0u8; FRAME_HEADER_LEN];
+ if read_exact_until(&mut self.socket, &mut hdr_buf, deadline).is_err() {
+ self.fail("read response timed out");
+ return CacheRead::Failed;
+ }
+
+ let resp = FrameHeader::decode(&hdr_buf);
+ if resp.msg_type != MSG_READ_RESP || resp.correlation_id != self.seq {
+ self.fail("read response identity mismatched");
+ return CacheRead::Failed;
+ }
+ // Sync authoritative cached_bytes from the worker (aux carries KiB).
+ self.cached_bytes = (resp.aux as u64) << 10;
+
+ match resp.status {
+ STATUS_OK => {
+ if resp.payload_len as usize != destination.len() {
+ self.fail("read response payload length mismatched");
+ return CacheRead::Failed;
+ }
+ if read_exact_until(&mut self.socket, destination, deadline).is_err() {
+ self.fail("read response payload timed out");
+ return CacheRead::Failed;
+ }
+ CacheRead::Hit
+ }
+ STATUS_MISS => CacheRead::Miss,
+ _ => {
+ self.fail("read response status failed");
+ CacheRead::Failed
+ }
+ }
+ }
+
+ fn update(&mut self, offset: u64, data: &[u8]) -> CacheMutation {
+ self.seq = self.seq.saturating_add(1);
+ let req = FrameHeader {
+ msg_type: MSG_UPDATE,
+ status: STATUS_OK,
+ correlation_id: self.seq,
+ offset,
+ payload_len: data.len() as u32,
+ aux: 0,
+ };
+ self.send_mutation_frame(req, data)
+ }
+
+ fn promote(&mut self, offset: u64, data: &[u8]) -> CacheMutation {
+ self.seq = self.seq.saturating_add(1);
+ let req = FrameHeader {
+ msg_type: MSG_PROMOTE,
+ status: STATUS_OK,
+ correlation_id: self.seq,
+ offset,
+ payload_len: data.len() as u32,
+ aux: 0,
+ };
+ self.send_mutation_frame(req, data)
+ }
+
+ fn disable(&mut self) -> CacheMutation {
+ if matches!(self.state, CacheState::Off | CacheState::Unavailable) {
+ return CacheMutation::Skipped;
+ }
+ self.seq = self.seq.saturating_add(1);
+ let req = FrameHeader {
+ msg_type: MSG_DISABLE_REQ,
+ status: STATUS_OK,
+ correlation_id: self.seq,
+ offset: 0,
+ payload_len: 0,
+ aux: 0,
+ };
+
+ let deadline = match deadline_after(DISABLE_TIMEOUT) {
+ Ok(deadline) => deadline,
+ Err(_) => {
+ self.state = CacheState::Stuck;
+ return CacheMutation::Failed;
+ }
+ };
+ if write_all_until(&mut self.socket, &req.encode(), deadline).is_err() {
+ self.state = CacheState::Stuck;
+ return CacheMutation::Failed;
+ }
+
+ let mut hdr_buf = [0u8; FRAME_HEADER_LEN];
+ if read_exact_until(&mut self.socket, &mut hdr_buf, deadline).is_err() {
+ self.state = CacheState::Stuck;
+ return CacheMutation::Failed;
+ }
+
+ let resp = FrameHeader::decode(&hdr_buf);
+ if resp.msg_type == MSG_DISABLE_RESP && resp.status == STATUS_OK {
+ self.state = CacheState::Off;
+ self.cached_bytes = 0;
+ CacheMutation::Accepted
+ } else {
+ self.state = CacheState::Stuck;
+ CacheMutation::Failed
+ }
+ }
+
+ fn state(&self) -> CacheState {
+ self.state
+ }
+
+ fn cached_bytes(&self) -> u64 {
+ if self.state == CacheState::Active {
+ self.cached_bytes
+ } else {
+ 0
+ }
+ }
+
+ fn refresh_cached_bytes(&mut self) -> Result {
+ IpcCacheClient::refresh_cached_bytes(self)
+ }
+
+ fn target_bytes(&self) -> u64 {
+ self.target_bytes
+ }
+
+ fn gpu_budget_telemetry(&self) -> Option<&GpuBudgetTelemetry> {
+ IpcCacheClient::gpu_budget_telemetry(self)
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ #![allow(clippy::unwrap_used, clippy::expect_used)]
+
+ use super::*;
+ use std::time::Instant;
+
+ #[test]
+ fn read_timeout_falls_back_cleanly() {
+ let (client_sock, _hung_worker) = UnixStream::pair().expect("socketpair failed");
+ // Use a short read timeout for the test to avoid slowing down CI
+ let mut client = IpcCacheClient::new(client_sock, Duration::from_millis(20), 1024 * 1024);
+ assert_eq!(client.state(), CacheState::Active);
+
+ let mut buf = [0u8; 128];
+ let outcome = client.read(0, &mut buf);
+
+ // Hung worker causes timeout -> marks unavailable and returns Failed
+ assert_eq!(outcome, CacheRead::Failed);
+ assert_eq!(client.state(), CacheState::Unavailable);
+
+ // Subsequent reads immediately return Miss without waiting
+ let start = Instant::now();
+ let second_outcome = client.read(0, &mut buf);
+ assert_eq!(second_outcome, CacheRead::Miss);
+ assert!(start.elapsed() < Duration::from_millis(5));
+ }
+
+ #[test]
+ fn trickled_response_cannot_extend_the_absolute_read_deadline() {
+ let (client_sock, mut worker_sock) = UnixStream::pair().expect("socketpair failed");
+ let timeout = Duration::from_millis(30);
+ let worker = std::thread::spawn(move || {
+ let mut request = [0u8; FRAME_HEADER_LEN];
+ worker_sock.read_exact(&mut request).unwrap();
+ let mut written = 0;
+ for _ in 0..FRAME_HEADER_LEN {
+ if worker_sock.write_all(&[0]).is_err() {
+ break;
+ }
+ written += 1;
+ std::thread::sleep(Duration::from_millis(8));
+ }
+ written
+ });
+ let mut client = IpcCacheClient::new(client_sock, timeout, 1024 * 1024);
+
+ let start = Instant::now();
+ let outcome = client.read(0, &mut [0u8; 16]);
+ let elapsed = start.elapsed();
+
+ assert_eq!(outcome, CacheRead::Failed);
+ assert_eq!(client.state(), CacheState::Unavailable);
+ assert!(
+ elapsed < Duration::from_millis(180),
+ "30ms cache read exceeded absolute deadline by too much: {elapsed:?}"
+ );
+ assert!(worker.join().unwrap() < FRAME_HEADER_LEN);
+ }
+
+ #[test]
+ fn socket_disconnect_marks_unavailable() {
+ let (client_sock, worker_sock) = UnixStream::pair().expect("socketpair failed");
+ let mut client = IpcCacheClient::new(client_sock, Duration::from_millis(50), 1024 * 1024);
+ assert_eq!(client.state(), CacheState::Active);
+
+ // Abrupt worker crash closes socket
+ drop(worker_sock);
+
+ let mut buf = [0u8; 128];
+ let outcome = client.read(0, &mut buf);
+ assert_eq!(outcome, CacheRead::Failed);
+ assert_eq!(client.state(), CacheState::Unavailable);
+ assert_eq!(client.cached_bytes(), 0);
+
+ // Mutations on disconnected client return Skipped
+ let mut_outcome = client.update(0, &[1, 2, 3]);
+ assert_eq!(mut_outcome, CacheMutation::Skipped);
+ }
+
+ #[test]
+ fn small_update_and_promote_complete_within_the_deadline() {
+ let (client_sock, _worker_sock) = UnixStream::pair().expect("socketpair failed");
+ let mut client = IpcCacheClient::new(client_sock, Duration::from_millis(50), 1024 * 1024);
+
+ let start = Instant::now();
+ let payload = vec![0xAB; 4096];
+ let update_outcome = client.update(0, &payload);
+ let promote_outcome = client.promote(4096, &payload);
+
+ assert!(start.elapsed() < Duration::from_millis(50));
+ assert_eq!(update_outcome, CacheMutation::Accepted);
+ assert_eq!(promote_outcome, CacheMutation::Accepted);
+ assert_eq!(
+ client.cached_bytes(),
+ 0,
+ "queued bytes are not physical allocations"
+ );
+ }
+
+ #[test]
+ fn saturated_mutation_socket_does_not_block_origin_thread() {
+ let (client_sock, _worker_sock) = UnixStream::pair().expect("socketpair failed");
+ let mut client = IpcCacheClient::new(client_sock, Duration::from_millis(250), 1024 * 1024);
+ let payload = vec![0xCD; 1024 * 1024];
+
+ let start = Instant::now();
+ let outcome = client.update(0, &payload);
+ let elapsed = start.elapsed();
+
+ assert_eq!(outcome, CacheMutation::Failed);
+ assert_eq!(client.state(), CacheState::Unavailable);
+ assert!(
+ elapsed < Duration::from_millis(100),
+ "a blocked mutation consumed the origin thread for {elapsed:?}"
+ );
+ }
+
+ #[test]
+ fn oversize_mutation_disables_cache_without_touching_ipc() {
+ let (client_sock, mut worker_sock) = UnixStream::pair().expect("socketpair failed");
+ worker_sock.set_nonblocking(true).unwrap();
+ let mut client = IpcCacheClient::new(client_sock, Duration::from_millis(50), 1024 * 1024);
+ let payload = vec![0xEE; MAX_MUTATION_FRAME_DATA_BYTES + 1];
+
+ assert_eq!(client.update(0, &payload), CacheMutation::Failed);
+ assert_eq!(client.state(), CacheState::Unavailable);
+ let mut byte = [0u8; 1];
+ match worker_sock.read(&mut byte) {
+ Ok(0) => {}
+ Err(error) if error.kind() == ErrorKind::WouldBlock => {}
+ other => panic!("oversize mutation unexpectedly reached IPC: {other:?}"),
+ }
+ }
+
+ #[test]
+ fn oversize_read_is_a_cache_miss_without_waiting_for_ipc() {
+ let (client_sock, mut worker_sock) = UnixStream::pair().expect("socketpair failed");
+ worker_sock.set_nonblocking(true).unwrap();
+ let mut client = IpcCacheClient::new(client_sock, Duration::from_millis(50), 1024 * 1024);
+ let mut destination = vec![0u8; 16 * 1024 * 1024 + 1];
+
+ let start = Instant::now();
+ assert_eq!(client.read(0, &mut destination), CacheRead::Miss);
+ assert!(start.elapsed() < Duration::from_millis(10));
+ assert_eq!(client.state(), CacheState::Active);
+ let mut byte = [0u8; 1];
+ assert_eq!(
+ worker_sock.read(&mut byte).unwrap_err().kind(),
+ ErrorKind::WouldBlock
+ );
+ }
+
+ #[test]
+ fn invalid_timeout_configuration_disables_cache_before_io() {
+ let (client_sock, _worker_sock) = UnixStream::pair().expect("socketpair failed");
+ let mut client = IpcCacheClient::new(client_sock, Duration::ZERO, 1024 * 1024);
+
+ assert_eq!(client.state(), CacheState::Unavailable);
+ assert!(client.perform_handshake().is_err());
+ assert_eq!(client.read(0, &mut [0u8; 16]), CacheRead::Miss);
+ }
+}
diff --git a/crates/ramshared-block/src/isolated_origin.rs b/crates/ramshared-block/src/isolated_origin.rs
index 482d1607d..e55d0171d 100644
--- a/crates/ramshared-block/src/isolated_origin.rs
+++ b/crates/ramshared-block/src/isolated_origin.rs
@@ -9,6 +9,7 @@ use std::time::Duration;
use crate::origin_cache::{CacheState, CacheTelemetry, OriginState, OriginStorage};
use crate::{BlockBackend, IoError, WriteOptions};
+use ramshared_vram::GpuBudgetTelemetry;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum CacheRead {
@@ -67,9 +68,17 @@ pub trait BestEffortCache {
0
}
+ fn refresh_cached_bytes(&mut self) -> Result {
+ Ok(self.cached_bytes())
+ }
+
fn target_bytes(&self) -> u64 {
0
}
+
+ fn gpu_budget_telemetry(&self) -> Option<&GpuBudgetTelemetry> {
+ None
+ }
}
/// Fail-closed cache used until a separately supervised GPU worker is wired.
@@ -261,10 +270,18 @@ impl AuthoritativeOriginBackend {
self.cache.cached_bytes()
}
+ pub fn refresh_cached_bytes(&mut self) -> Result {
+ self.cache.refresh_cached_bytes()
+ }
+
pub fn target_bytes(&self) -> u64 {
self.cache.target_bytes()
}
+ pub fn gpu_budget_telemetry(&self) -> Option<&GpuBudgetTelemetry> {
+ self.cache.gpu_budget_telemetry()
+ }
+
pub fn telemetry(&self) -> CacheTelemetry {
self.telemetry
}
diff --git a/crates/ramshared-block/src/lib.rs b/crates/ramshared-block/src/lib.rs
index c0cc39ad0..ea2bf1c6d 100644
--- a/crates/ramshared-block/src/lib.rs
+++ b/crates/ramshared-block/src/lib.rs
@@ -4,14 +4,18 @@
//! Also hosts [`VramBackend`] (windows-swap-driver ITEM-2 / DT-6).
//!
//! Core **testable without root**: parse/encode of the NBD wire, the trait
-//! [`BlockBackend`] and the map of inflight blocks ([`Inflight`], §8.1). The wiring of
+//! [`BlockBackend`] and an unwired inflight range model ([`Inflight`], §8.1). The wiring of
//! `/dev/nbdX` (ioctl `NBD_SET_SOCK`/`NBD_DO_IT`) is a separate module (requires
//! root + device) — this lib is only the protocol and logic.
#![forbid(unsafe_code)]
pub mod elastic_cache;
+#[cfg(unix)]
+pub mod gpu_cache_worker;
pub mod handshake;
pub mod inflight;
+#[cfg(unix)]
+pub mod ipc_cache_client;
pub mod isolated_origin;
pub mod origin_cache;
pub mod protocol;
@@ -22,8 +26,15 @@ pub mod vram_backend;
pub use elastic_cache::{
ELASTIC_CHUNK_BYTES, ElasticCacheConfig, ElasticExtentTable, ElasticVramCache,
};
+#[cfg(unix)]
+pub use gpu_cache_worker::{
+ FRAME_HEADER_LEN, FrameHeader, GpuCacheWorker, GpuWorkerConfig, RUNTIME_FREE_BUFFER_BYTES,
+ run_gpu_worker_loop,
+};
pub use handshake::{HandshakeError, server_handshake};
pub use inflight::Inflight;
+#[cfg(unix)]
+pub use ipc_cache_client::{DEFAULT_READ_TIMEOUT, IpcCacheClient};
pub use isolated_origin::{
AuthoritativeOriginBackend, BestEffortCache, BoundedCacheClient, CacheMutation, CacheRead,
DisabledCache, IsolatedCacheControl, IsolatedCacheRequest, IsolatedCacheWorker,
diff --git a/crates/ramshared-block/src/sparse_vram.rs b/crates/ramshared-block/src/sparse_vram.rs
index c998955d5..4e09265ca 100644
--- a/crates/ramshared-block/src/sparse_vram.rs
+++ b/crates/ramshared-block/src/sparse_vram.rs
@@ -317,6 +317,12 @@ impl<'p, P: VramProvider + 'p> SparseVramBackend<'p, P> {
}
}
+fn physical_range_fits(physical_len: usize, relative: usize, transfer_len: usize) -> bool {
+ relative
+ .checked_add(transfer_len)
+ .is_some_and(|end| end <= physical_len)
+}
+
impl<'p, P: VramProvider + 'p> BlockBackend for SparseVramBackend<'p, P> {
fn size_bytes(&self) -> u64 {
self.capacity
@@ -356,6 +362,12 @@ impl<'p, P: VramProvider + 'p> BlockBackend for SparseVramBackend<'p, P> {
)));
};
if let Some(m) = &chunk.mem {
+ if !physical_range_fits(m.len(), rel, n) {
+ return Err(IoError(format!(
+ "sparse physical read oob rel={rel} len={n} phys_len={}",
+ m.len()
+ )));
+ }
m.read_at(rel as u64, &mut buf[done..done + n])
.map_err(|e: VramError| IoError(e.to_string()))?;
} else {
@@ -401,6 +413,12 @@ impl<'p, P: VramProvider + 'p> BlockBackend for SparseVramBackend<'p, P> {
.mem
.as_mut()
.ok_or_else(|| IoError("sparse: mem missing after ensure".into()))?;
+ if !physical_range_fits(m.len(), rel, n) {
+ return Err(IoError(format!(
+ "sparse physical write oob rel={rel} len={n} phys_len={}",
+ m.len()
+ )));
+ }
m.write_at(rel as u64, &data[done..done + n])
.map_err(|e: VramError| IoError(e.to_string()))?;
@@ -542,6 +560,27 @@ mod tests {
}
}
+ #[test]
+ fn zero_block_size_is_rejected_without_panic() {
+ let provider = FakeProvider::new();
+ assert!(SparseVramBackend::new(&provider, 4096, 4096, 0).is_err());
+ }
+
+ #[test]
+ fn physical_bounds_refuse_provider_io() {
+ let provider = FakeProvider::new();
+ let mut backend = SparseVramBackend::new(&provider, 1024 * 1024, 256 * 1024, 4096).unwrap();
+ backend.ensure_live(0).unwrap();
+ backend.chunks[0].mem.as_mut().unwrap().0.truncate(4096);
+
+ let write_error = backend.write_at(0, &[1u8; 8192]).unwrap_err();
+ assert!(write_error.0.contains("sparse physical write oob"));
+
+ let mut read_buffer = [0u8; 8192];
+ let read_error = backend.read_at(0, &mut read_buffer).unwrap_err();
+ assert!(read_error.0.contains("sparse physical read oob"));
+ }
+
#[test]
fn page_table_bounds_guard_enforces_limit() {
let p = FakeProvider::new();
diff --git a/crates/ramshared-cli/Cargo.toml b/crates/ramshared-cli/Cargo.toml
index 274b2a676..5131b326a 100644
--- a/crates/ramshared-cli/Cargo.toml
+++ b/crates/ramshared-cli/Cargo.toml
@@ -13,7 +13,9 @@ path = "src/main.rs"
[dependencies]
ramshared-tier = { path = "../ramshared-tier" }
+ramshared-config = { path = "../ramshared-config" }
ramshared-cuda = { path = "../ramshared-cuda" }
+ramshared-vram = { path = "../ramshared-vram" }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
sha2 = "0.11"
diff --git a/crates/ramshared-cli/build.rs b/crates/ramshared-cli/build.rs
new file mode 100644
index 000000000..9943f8dea
--- /dev/null
+++ b/crates/ramshared-cli/build.rs
@@ -0,0 +1,132 @@
+use std::collections::BTreeSet;
+use std::env;
+use std::path::Path;
+use std::process::Command;
+
+fn main() {
+ let Some(manifest_dir) = env::var_os("CARGO_MANIFEST_DIR") else {
+ println!("cargo:rustc-env=RAMSHARED_BUILD_GIT_SHA=");
+ println!("cargo:rustc-env=RAMSHARED_BUILD_TREE_STATE=unavailable");
+ return;
+ };
+ let manifest_dir = Path::new(&manifest_dir);
+ let workspace_root = manifest_dir.join("../..");
+ for path in [
+ workspace_root.join("Cargo.toml"),
+ workspace_root.join("Cargo.lock"),
+ ] {
+ println!("cargo:rerun-if-changed={}", path.display());
+ }
+ watch_worktree_paths(&workspace_root);
+ watch_git_metadata(&workspace_root);
+
+ let (commit, tree_state) = read_source_identity(manifest_dir);
+ println!(
+ "cargo:rustc-env=RAMSHARED_BUILD_GIT_SHA={}",
+ commit.unwrap_or_default()
+ );
+ println!("cargo:rustc-env=RAMSHARED_BUILD_TREE_STATE={tree_state}");
+}
+
+fn watch_worktree_paths(workspace_root: &Path) {
+ let output = Command::new("git")
+ .arg("-C")
+ .arg(workspace_root)
+ .args([
+ "ls-files",
+ "--cached",
+ "--others",
+ "--exclude-standard",
+ "-z",
+ ])
+ .output();
+ let Ok(output) = output else {
+ return;
+ };
+ if !output.status.success() {
+ return;
+ }
+
+ let mut directories = BTreeSet::new();
+ for entry in output
+ .stdout
+ .split(|byte| *byte == 0)
+ .filter(|entry| !entry.is_empty())
+ {
+ let Ok(relative) = std::str::from_utf8(entry) else {
+ continue;
+ };
+ let path = workspace_root.join(relative);
+ println!("cargo:rerun-if-changed={}", path.display());
+ let mut parent = path.parent();
+ while let Some(directory) = parent {
+ if !directory.starts_with(workspace_root) {
+ break;
+ }
+ directories.insert(directory.to_path_buf());
+ parent = directory.parent();
+ }
+ }
+ for directory in directories {
+ println!("cargo:rerun-if-changed={}", directory.display());
+ }
+}
+
+fn watch_git_metadata(workspace_root: &Path) {
+ for path in ["HEAD", "index", "packed-refs"] {
+ watch_git_path(workspace_root, path);
+ }
+ if let Some(reference) = git_output_with_args(workspace_root, &["symbolic-ref", "-q", "HEAD"]) {
+ watch_git_path(workspace_root, &reference);
+ }
+}
+
+fn watch_git_path(workspace_root: &Path, path: &str) {
+ let Some(relative) = git_output_with_args(workspace_root, &["rev-parse", "--git-path", path])
+ else {
+ return;
+ };
+ let path = Path::new(&relative);
+ let path = if path.is_absolute() {
+ path.to_path_buf()
+ } else {
+ workspace_root.join(path)
+ };
+ println!("cargo:rerun-if-changed={}", path.display());
+}
+
+fn read_source_identity(manifest_dir: &Path) -> (Option, &'static str) {
+ let commit = git_output_with_args(manifest_dir, &["rev-parse", "--verify", "HEAD^{commit}"])
+ .filter(|value| is_full_commit(value));
+ let Some(commit) = commit else {
+ return (None, "unavailable");
+ };
+
+ let Some(status) = git_output_with_args(
+ manifest_dir,
+ &["status", "--porcelain=v1", "--untracked-files=all"],
+ ) else {
+ return (Some(commit), "unavailable");
+ };
+ let tree_state = if status.is_empty() { "clean" } else { "dirty" };
+ (Some(commit), tree_state)
+}
+
+fn git_output_with_args(manifest_dir: &Path, args: &[&str]) -> Option {
+ let output = Command::new("git")
+ .arg("-C")
+ .arg(manifest_dir)
+ .args(args)
+ .output()
+ .ok()?;
+ if !output.status.success() {
+ return None;
+ }
+ String::from_utf8(output.stdout)
+ .ok()
+ .map(|value| value.trim().to_string())
+}
+
+fn is_full_commit(value: &str) -> bool {
+ value.len() == 40 && value.bytes().all(|byte| byte.is_ascii_hexdigit())
+}
diff --git a/crates/ramshared-cli/src/bounded_process.rs b/crates/ramshared-cli/src/bounded_process.rs
index 695c04221..015fe46bd 100644
--- a/crates/ramshared-cli/src/bounded_process.rs
+++ b/crates/ramshared-cli/src/bounded_process.rs
@@ -82,10 +82,6 @@ impl ProcessSpawnError {
}
}
- pub(crate) fn is_not_found(&self) -> bool {
- matches!(self, ProcessSpawnError::BinaryNotFound { .. })
- }
-
fn fatal(detail: impl Into) -> Self {
ProcessSpawnError::FatalContainment {
detail: detail.into(),
diff --git a/crates/ramshared-cli/src/cascade/cascade_io.rs b/crates/ramshared-cli/src/cascade/cascade_io.rs
index af1fbb906..4744c52fb 100644
--- a/crates/ramshared-cli/src/cascade/cascade_io.rs
+++ b/crates/ramshared-cli/src/cascade/cascade_io.rs
@@ -22,6 +22,8 @@ use std::thread::sleep;
use std::time::{Duration, Instant};
const SHORT_COMMAND_TIMEOUT: Duration = Duration::from_secs(5);
+const SWAPOFF_TIMEOUT: Duration = Duration::from_secs(120);
+const ORIGIN_DAEMON_READINESS_TIMEOUT: Duration = Duration::from_secs(15);
const COMMAND_OUTPUT_LIMIT: usize = 64 * 1024;
const LIFECYCLE_BINDING_SCHEMA: u32 = 1;
const LIFECYCLE_BINDING_MAX_BYTES: u64 = 64 * 1024;
@@ -103,6 +105,15 @@ fn run_command_bounded(command: &str, args: &[&str]) -> Result Result;
+
+ fn run_bounded(
+ &self,
+ command: &str,
+ args: &[&str],
+ _timeout: Duration,
+ ) -> Result {
+ self.run(command, args)
+ }
}
struct SystemCommandRunner;
@@ -111,6 +122,15 @@ impl CommandRunner for SystemCommandRunner {
fn run(&self, command: &str, args: &[&str]) -> Result {
run_command_bounded(command, args)
}
+
+ fn run_bounded(
+ &self,
+ command: &str,
+ args: &[&str],
+ timeout: Duration,
+ ) -> Result {
+ run_command_bounded_for(command, args, timeout)
+ }
}
#[derive(Clone, Debug)]
@@ -1436,7 +1456,10 @@ fn cache_status_has_current_daemon_identity_at(
return false;
};
cache_status_matches_current_daemon(&status, &expected, now_unix_ms)
- && status.get("ok").and_then(serde_json::Value::as_bool) == Some(true)
+ // Cache health can degrade while the origin remains authoritative.
+ // Teardown still needs the exact live daemon identity so swapoff can
+ // complete before stopping that daemon.
+ && status.get("ok").and_then(serde_json::Value::as_bool).is_some()
&& status
.get("origin_state")
.and_then(serde_json::Value::as_str)
@@ -1474,6 +1497,15 @@ fn cache_status_has_current_daemon_identity(paths: &RuntimePaths, pid: u32) -> b
})
}
+fn cache_status_is_healthy(paths: &RuntimePaths, pid: u32) -> bool {
+ cache_status_has_current_daemon_identity(paths, pid)
+ && fs::read_to_string(&paths.cache_status_file)
+ .ok()
+ .and_then(|text| serde_json::from_str::(&text).ok())
+ .and_then(|status| status.get("ok").and_then(serde_json::Value::as_bool))
+ == Some(true)
+}
+
fn verified_daemon_pid(paths: &RuntimePaths) -> Option {
let pid = fs::read_to_string(&paths.pid_file)
.ok()?
@@ -1949,8 +1981,18 @@ fn build_daemon_command(
ORIGIN_CONFIG_FILE,
])
.env("RAMSHARED_VRAM_CACHE_CAP_MIB", cache_cap_mib.to_string())
- .stdout(Stdio::null())
- .stderr(Stdio::null());
+ .stdout(Stdio::null());
+ // Capture daemon stderr for debugging (Bug 5: was Stdio::null()).
+ let log_path = std::path::Path::new("/run/ramsharedd.log");
+ if let Ok(log_file) = std::fs::OpenOptions::new()
+ .create(true)
+ .append(true)
+ .open(log_path)
+ {
+ command.stderr(log_file);
+ } else {
+ command.stderr(Stdio::null());
+ }
bounded_process::configure_process_group(&mut command);
command
}
@@ -1965,6 +2007,10 @@ fn spawn_daemon_with_deadline(
readiness_timeout: Duration,
) -> Result {
fs::create_dir_all(&paths.runtime_dir).map_err(|error| CascadeError::Io(error.to_string()))?;
+ {
+ use std::os::unix::fs::PermissionsExt;
+ let _ = fs::set_permissions(&paths.runtime_dir, fs::Permissions::from_mode(0o755));
+ }
remove_runtime_file(&paths.socket);
remove_runtime_file(&paths.cache_status_file);
remove_runtime_file(&paths.supervisor_status_file);
@@ -1989,7 +2035,7 @@ fn spawn_daemon_with_deadline(
return Err(CascadeError::Io(error.to_string()));
}
let deadline = Instant::now() + readiness_timeout;
- while (!paths.socket.exists() || !cache_status_has_current_daemon_identity(paths, child.id()))
+ while (!paths.socket.exists() || !cache_status_is_healthy(paths, child.id()))
&& Instant::now() < deadline
{
sleep(Duration::from_millis(50));
@@ -2003,7 +2049,7 @@ fn spawn_daemon_with_deadline(
"daemon did not start (socket missing)".into(),
));
}
- if !cache_status_has_current_daemon_identity(paths, child.id()) {
+ if !cache_status_is_healthy(paths, child.id()) {
// No NBD attach exists yet. A daemon without an exact current identity
// cannot safely consume control-plane zero-cache requests.
terminate_spawned_child(&mut child)?;
@@ -2478,12 +2524,128 @@ pub fn up_with_args(args: &[String]) -> Result<(), CascadeError> {
up_with_config(parse_up_args_from(args, default_daemon())?)
}
+fn validate_windows_origin_path(path: &str) -> Result<(), CascadeError> {
+ let bytes = path.as_bytes();
+ if bytes.len() < 4
+ || !bytes[0].is_ascii_alphabetic()
+ || bytes[1] != b':'
+ || (bytes[2] != b'\\' && bytes[2] != b'/')
+ {
+ return Err(CascadeError::Precondition(
+ "origin VHDX path must be an absolute Windows drive path (e.g. C:\\...)".into(),
+ ));
+ }
+ if bytes[3..].iter().any(|byte| {
+ !(byte.is_ascii_alphanumeric() || matches!(byte, b'\\' | b'/' | b'.' | b'_' | b'-' | b' '))
+ }) {
+ return Err(CascadeError::Precondition(
+ "origin VHDX path contains forbidden shell characters".into(),
+ ));
+ }
+ Ok(())
+}
+
+fn ensure_origin_attached(
+ runner: &R,
+ origin_path: &str,
+ expected_partuuid: &str,
+ manifest_path: &Path,
+ expected_manifest_sha256: &str,
+) -> Result<(), CascadeError> {
+ #[cfg(test)]
+ {
+ if origin_path == "/dev/disk/by-partuuid/11111111-2222-4333-8444-555555555555" {
+ return Ok(());
+ }
+ }
+ if Path::new(origin_path).exists() {
+ return Ok(());
+ }
+ let manifest = fs::read(manifest_path).map_err(|error| {
+ CascadeError::Precondition(format!(
+ "sealed host origin manifest is unavailable: {error}"
+ ))
+ })?;
+ if manifest.len() > 64 * 1024 || !canonical_sha256(expected_manifest_sha256) {
+ return Err(CascadeError::Precondition(
+ "sealed host origin manifest size or hash is invalid".into(),
+ ));
+ }
+ let actual_hash: String = Sha256::digest(&manifest)
+ .iter()
+ .map(|byte| format!("{byte:02x}"))
+ .collect();
+ if !actual_hash.eq_ignore_ascii_case(expected_manifest_sha256) {
+ return Err(CascadeError::Precondition(
+ "sealed host origin manifest hash does not match origin configuration".into(),
+ ));
+ }
+ let json_bytes = manifest
+ .strip_prefix(&[0xEF, 0xBB, 0xBF][..])
+ .unwrap_or(&manifest);
+ let value: serde_json::Value = serde_json::from_slice(json_bytes).map_err(|error| {
+ CascadeError::Precondition(format!("sealed host origin manifest is invalid: {error}"))
+ })?;
+ let origin_vhdx = value
+ .get("origin_vhdx")
+ .and_then(serde_json::Value::as_str)
+ .ok_or_else(|| {
+ CascadeError::Precondition("sealed host origin VHDX path is missing".into())
+ })?;
+ if value
+ .get("partuuid")
+ .and_then(serde_json::Value::as_str)
+ .is_none_or(|partuuid| !partuuid.eq_ignore_ascii_case(expected_partuuid))
+ {
+ return Err(CascadeError::Precondition(
+ "sealed host origin manifest PARTUUID differs from origin configuration".into(),
+ ));
+ }
+ validate_windows_origin_path(origin_vhdx)?;
+
+ eprintln!("[up] origin VHDX detached; attempting bounded host attach via wsl.exe...");
+ let wsl_path = if std::path::Path::new("/mnt/c/Windows/System32/wsl.exe").exists() {
+ "/mnt/c/Windows/System32/wsl.exe"
+ } else {
+ "wsl.exe"
+ };
+ runner.run_bounded(
+ wsl_path,
+ &["--mount", "--vhd", origin_vhdx, "--bare"],
+ Duration::from_secs(10),
+ )?;
+
+ #[cfg(not(test))]
+ {
+ for _ in 0..20 {
+ if Path::new(origin_path).exists() {
+ break;
+ }
+ std::thread::sleep(Duration::from_millis(250));
+ }
+ }
+
+ if !Path::new(origin_path).exists() {
+ return Err(CascadeError::Precondition(format!(
+ "origin device {origin_path} (PARTUUID {expected_partuuid}) did not appear after host attach"
+ )));
+ }
+ Ok(())
+}
+
fn setup_new_cascade(
runner: &R,
paths: &RuntimePaths,
args: &UpArgs,
prios: &TierPriorities,
) -> Result {
+ ensure_origin_attached(
+ runner,
+ &args.origin_path,
+ &args.origin_partuuid,
+ Path::new("/mnt/c/ProgramData/RamShared/ramshared-origin-manifest.json"),
+ &args.host_manifest_sha256,
+ )?;
let partuuid = origin_partuuid(&args.origin_path)?;
if !partuuid.eq_ignore_ascii_case(&args.origin_partuuid) {
return Err(CascadeError::Precondition(
@@ -2491,6 +2653,10 @@ fn setup_new_cascade(
));
}
fs::create_dir_all(&paths.runtime_dir).map_err(|error| CascadeError::Io(error.to_string()))?;
+ {
+ use std::os::unix::fs::PermissionsExt;
+ let _ = fs::set_permissions(&paths.runtime_dir, fs::Permissions::from_mode(0o755));
+ }
arm_forensics_at(paths);
// zram tier (HOT). --zram 0 skips.
@@ -2513,7 +2679,7 @@ fn setup_new_cascade(
&args.swap_dev,
&args.origin_path,
paths,
- Duration::from_secs(6),
+ ORIGIN_DAEMON_READINESS_TIMEOUT,
)?;
connect_nbd_with(
runner,
@@ -3379,7 +3545,9 @@ impl NbdLifecycleExecutor for RuntimeNbdLifecycleExecutor<'_,
&self.binding.devices,
)?;
let pinned = bind_device_for_effect(device)?;
- let result = self.runner.run("swapoff", &["--", pinned.path()]);
+ let result = self
+ .runner
+ .run_bounded("swapoff", &["--", pinned.path()], SWAPOFF_TIMEOUT);
match result {
Ok(_) => {
prove_exact_swap_absent(device)?;
@@ -3969,6 +4137,24 @@ mod tests {
.unwrap_or_else(|error| panic!("write cache identity status: {error}"));
}
+ #[test]
+ fn degraded_cache_keeps_exact_daemon_identity_for_swapoff_first_teardown() {
+ let fixture = TestDir::new();
+ let paths = RuntimePaths::under(&fixture.path);
+ fs::create_dir_all(&paths.runtime_dir).expect("create runtime directory");
+ let pid = std::process::id();
+ let instance_id = daemon_instance_id_from_pid(pid).expect("current process identity");
+ fs::write(
+ &paths.cache_status_file,
+ format!(
+ r#"{{"schema_version":1,"daemon_instance_id":"{instance_id}","written_at_unix_ms":{},"ok":false,"origin_state":"READY","cache_state":"UNAVAILABLE","logical_capacity_kib":4194304,"vram_cached_kib":0,"gpu_headroom_kib":null,"ssd_origin_written_kib":1,"cache_fallback_reads":1,"cache_invalidations":0,"cache_releases":0,"cache_target_kib":4194304}}"#,
+ unix_time_ms().expect("current time")
+ ),
+ )
+ .expect("write degraded status");
+ assert!(cache_status_has_current_daemon_identity(&paths, pid));
+ }
+
fn seal_runtime_lifecycle(
paths: &RuntimePaths,
daemon_pid: u32,
@@ -4097,6 +4283,7 @@ mod tests {
struct ScriptedRunner {
responses: RefCell)>>,
calls: RefCell>,
+ bounded_calls: RefCell>,
}
impl ScriptedRunner {
@@ -4104,6 +4291,7 @@ mod tests {
Self {
responses: RefCell::new(responses.into()),
calls: RefCell::new(Vec::new()),
+ bounded_calls: RefCell::new(Vec::new()),
}
}
@@ -4125,6 +4313,18 @@ mod tests {
assert_eq!(expected, label, "test command order");
response
}
+
+ fn run_bounded(
+ &self,
+ command: &str,
+ args: &[&str],
+ timeout: Duration,
+ ) -> Result {
+ self.bounded_calls
+ .borrow_mut()
+ .push((command_label(command, args), timeout));
+ self.run(command, args)
+ }
}
struct ParentSeams;
@@ -6560,6 +6760,16 @@ mod tests {
"nbd-client -d /dev/nbd0",
]
);
+ assert_eq!(
+ runner.bounded_calls.borrow().as_slice(),
+ &[
+ ("swapoff -- /dev/nbd0".to_string(), Duration::from_secs(120)),
+ (
+ "swapoff -- /dev/zram0".to_string(),
+ Duration::from_secs(120)
+ ),
+ ]
+ );
assert!(!paths.swap_dev_file.exists());
assert!(!paths.zram_dev_file.exists());
assert!(!paths.forensics_markers[0].exists());
@@ -7241,4 +7451,165 @@ mod tests {
assert!(up_with_args(&["--vram-mb".to_string(), "invalid".to_string()]).is_err());
assert!(up_with_args(&["--zram-mb".to_string(), "-5".to_string()]).is_err());
}
+
+ fn write_test_host_manifest(dir: &TestDir) -> (PathBuf, String) {
+ let manifest = dir.path.join("origin-manifest.json");
+ let contents = br#"{"origin_vhdx":"C:\\ProgramData\\RamShared\\ramshared-origin.vhdx","partuuid":"11111111-2222-4333-8444-555555555555"}"#;
+ fs::write(&manifest, contents).expect("write manifest");
+ let hash = Sha256::digest(contents)
+ .iter()
+ .map(|byte| format!("{byte:02x}"))
+ .collect();
+ (manifest, hash)
+ }
+
+ #[test]
+ fn ensure_origin_attached_is_noop_when_device_present() {
+ let dir = TestDir::new();
+ let present_device = dir.path.join("present-device");
+ fs::write(&present_device, b"block").expect("write present device");
+
+ struct NoOpRunner(RefCell>);
+ impl CommandRunner for NoOpRunner {
+ fn run(&self, command: &str, args: &[&str]) -> Result {
+ self.0.borrow_mut().push(command_label(command, args));
+ Ok(String::new())
+ }
+ }
+ let runner = NoOpRunner(RefCell::new(Vec::new()));
+ let res = ensure_origin_attached(
+ &runner,
+ present_device.to_str().expect("valid utf-8 path"),
+ "11111111-2222-4333-8444-555555555555",
+ Path::new("/nonexistent/manifest.json"),
+ "",
+ );
+ assert!(res.is_ok());
+ assert!(runner.0.borrow().is_empty());
+ }
+
+ #[test]
+ fn ensure_origin_attached_issues_bounded_mount_when_absent() {
+ let dir = TestDir::new();
+ let absent_device = dir.path.join("absent-device");
+ let (manifest, manifest_hash) = write_test_host_manifest(&dir);
+
+ struct MountRunner {
+ target: PathBuf,
+ calls: RefCell>,
+ }
+ impl CommandRunner for MountRunner {
+ fn run(&self, command: &str, args: &[&str]) -> Result {
+ self.calls.borrow_mut().push(command_label(command, args));
+ // Simulate host mount exposing the target device
+ fs::write(&self.target, b"mounted").expect("write target device");
+ Ok(String::new())
+ }
+ }
+ let runner = MountRunner {
+ target: absent_device.clone(),
+ calls: RefCell::new(Vec::new()),
+ };
+ let res = ensure_origin_attached(
+ &runner,
+ absent_device.to_str().expect("valid utf-8 path"),
+ "11111111-2222-4333-8444-555555555555",
+ &manifest,
+ &manifest_hash,
+ );
+ assert!(res.is_ok());
+ let calls = runner.calls.borrow().clone();
+ assert_eq!(calls.len(), 1);
+ assert!(
+ calls[0].starts_with("wsl.exe --mount")
+ || calls[0].starts_with("/mnt/c/Windows/System32/wsl.exe --mount")
+ );
+ assert!(!calls[0].contains("cmd.exe"));
+ }
+
+ #[test]
+ fn ensure_origin_attached_refuses_unsealed_manifest_before_host_call() {
+ let dir = TestDir::new();
+ let device = dir.path.join("absent-device");
+ let manifest = dir.path.join("origin-manifest.json");
+ fs::write(&manifest, br#"{"origin_vhdx":"C:\\Other\\disk.vhdx"}"#).expect("write manifest");
+ struct NoHostCall;
+ impl CommandRunner for NoHostCall {
+ fn run(&self, _: &str, _: &[&str]) -> Result {
+ panic!("unsealed manifest must not invoke the host");
+ }
+ }
+ let error = ensure_origin_attached(
+ &NoHostCall,
+ device.to_str().expect("utf-8 path"),
+ "11111111-2222-4333-8444-555555555555",
+ &manifest,
+ &"0".repeat(64),
+ )
+ .expect_err("hash mismatch must refuse attachment");
+ assert!(error.to_string().contains("manifest"));
+ }
+
+ #[test]
+ fn ensure_origin_attached_fails_closed_on_timeout_or_mismatch() {
+ let dir = TestDir::new();
+ let absent_device = dir.path.join("absent-device");
+ let (manifest, manifest_hash) = write_test_host_manifest(&dir);
+
+ struct FailingRunner(RefCell>);
+ impl CommandRunner for FailingRunner {
+ fn run(&self, command: &str, args: &[&str]) -> Result {
+ self.0.borrow_mut().push(command_label(command, args));
+ // Deliberately do NOT create the target device to simulate timeout/failure
+ Ok(String::new())
+ }
+ }
+ let runner = FailingRunner(RefCell::new(Vec::new()));
+ let res = ensure_origin_attached(
+ &runner,
+ absent_device.to_str().expect("valid utf-8 path"),
+ "11111111-2222-4333-8444-555555555555",
+ &manifest,
+ &manifest_hash,
+ );
+ assert!(res.is_err());
+ assert!(
+ res.expect_err("expected error on timeout")
+ .to_string()
+ .contains("did not appear")
+ );
+
+ // Validation of dangerous windows path characters
+ assert!(validate_windows_origin_path("C:\\safe\\origin.vhdx").is_ok());
+ assert!(validate_windows_origin_path("invalid-drive-path").is_err());
+ assert!(validate_windows_origin_path("C:\\path;rm -rf").is_err());
+ assert!(validate_windows_origin_path("C:\\path&echo").is_err());
+ assert!(validate_windows_origin_path("C:\\path%TEMP%\\origin.vhdx").is_err());
+ assert!(validate_windows_origin_path("C:\\path^echo\\origin.vhdx").is_err());
+ assert!(validate_windows_origin_path("C:\\path\norigin.vhdx").is_err());
+ }
+
+ #[test]
+ fn ensure_origin_attached_propagates_host_mount_failure() {
+ let dir = TestDir::new();
+ let device = dir.path.join("appeared-despite-error");
+ let (manifest, manifest_hash) = write_test_host_manifest(&dir);
+
+ struct FailedMount(PathBuf);
+ impl CommandRunner for FailedMount {
+ fn run(&self, _command: &str, _args: &[&str]) -> Result {
+ fs::write(&self.0, b"unexpected-device").expect("write fixture");
+ Err(CascadeError::Precondition("host mount failed".into()))
+ }
+ }
+ let error = ensure_origin_attached(
+ &FailedMount(device.clone()),
+ device.to_str().expect("utf-8 path"),
+ "11111111-2222-4333-8444-555555555555",
+ &manifest,
+ &manifest_hash,
+ )
+ .expect_err("host mount failure must not be ignored");
+ assert!(error.to_string().contains("host mount failed"));
+ }
}
diff --git a/crates/ramshared-cli/src/cascade/lifecycle.rs b/crates/ramshared-cli/src/cascade/lifecycle.rs
index 9680d461f..07c12e4b7 100644
--- a/crates/ramshared-cli/src/cascade/lifecycle.rs
+++ b/crates/ramshared-cli/src/cascade/lifecycle.rs
@@ -4,6 +4,8 @@
use std::env;
+use ramshared_vram::GpuBudgetTelemetry;
+
use super::{is_nbd_device_path, is_ublk_device_path, is_zram_device_path};
/// Default active-use threshold (KiB). Residual nbd under this still counts as Armed.
@@ -186,6 +188,7 @@ pub struct CascadeSnapshot {
pub logical_capacity_kib: Option,
pub vram_cached_kib: Option,
pub gpu_headroom_kib: Option,
+ pub gpu_budget: Option,
pub ssd_origin_written_kib: Option,
pub fallback_swap_used_kib: Option,
pub measurement_errors: Vec,
@@ -267,17 +270,30 @@ pub fn derive_lifecycle(s: &CascadeSnapshot) -> LifecycleView {
if !s.order_ok {
reasons.push("priority_order_bad".into());
}
- let hot_vram_no_daemon = s.vram.present && !s.daemon_alive && s.vram.used_kib >= thr;
+ let daemon_identity_unreadable = s.vram.present
+ && s.measurement_errors
+ .iter()
+ .any(|error| error == "daemon_identity_unreadable");
+ if daemon_identity_unreadable {
+ reasons.push("daemon_identity_unreadable".into());
+ }
+ let hot_vram_no_daemon =
+ s.vram.present && !s.daemon_alive && !daemon_identity_unreadable && s.vram.used_kib >= thr;
if hot_vram_no_daemon {
reasons.push("daemon_dead_hot_vram".into());
}
- let vram_present_no_daemon = s.vram.present && !s.daemon_alive && s.vram.used_kib < thr;
+ let vram_present_no_daemon =
+ s.vram.present && !s.daemon_alive && !daemon_identity_unreadable && s.vram.used_kib < thr;
// Half-state: vram swapon without daemon even if used low (degraded safety).
if vram_present_no_daemon {
reasons.push("vram_tier_without_daemon".into());
}
- let degraded = s.ghost || !s.order_ok || hot_vram_no_daemon || vram_present_no_daemon;
+ let degraded = s.ghost
+ || !s.order_ok
+ || daemon_identity_unreadable
+ || hot_vram_no_daemon
+ || vram_present_no_daemon;
if degraded {
return LifecycleView {
phase: CascadePhase::Degraded,
@@ -285,6 +301,8 @@ pub fn derive_lifecycle(s: &CascadeSnapshot) -> LifecycleView {
"ghost"
} else if !s.order_ok {
"priority_order_bad"
+ } else if daemon_identity_unreadable {
+ "daemon_identity_unreadable"
} else if hot_vram_no_daemon {
"daemon_dead_hot_vram"
} else {
@@ -533,6 +551,11 @@ pub fn render_status_json(view: &LifecycleView, snap: &CascadeSnapshot, ts: &str
.collect::>()
.join(",")
);
+ let gpu_budget_json = snap
+ .gpu_budget
+ .as_ref()
+ .and_then(|budget| serde_json::to_string(budget).ok())
+ .unwrap_or_else(|| "null".to_string());
format!(
"{{\"schema_version\":4,\"phase\":{phase},\"phase_reason\":{reason},\
\"protection_state\":{protection},\"protection_reason\":{protection_reason},\
@@ -549,6 +572,7 @@ pub fn render_status_json(view: &LifecycleView, snap: &CascadeSnapshot, ts: &str
\"daemon\":{{\"alive\":{alive},\"pid\":{pid}}},\
\"demote\":{{\"total\":{dt},\"last_reason\":{dr},\"in_progress\":{di}}},\
\"thresholds_kib\":{{\"active\":{thr}}},\
+\"gpu_budget\":{gpu_budget},\
\"ts\":{ts}}}",
phase = json_escape(view.phase.as_str()),
reason = json_escape(view.phase_reason),
@@ -569,6 +593,7 @@ pub fn render_status_json(view: &LifecycleView, snap: &CascadeSnapshot, ts: &str
logical_capacity_kib = number_or_null(snap.logical_capacity_kib),
vram_cached_kib = number_or_null(snap.vram_cached_kib),
gpu_headroom_kib = number_or_null(snap.gpu_headroom_kib),
+ gpu_budget = gpu_budget_json,
ssd_origin_written_kib = number_or_null(snap.ssd_origin_written_kib),
fallback_swap_used_kib = number_or_null(snap.fallback_swap_used_kib),
activation_active = if activation_active { "true" } else { "false" },
@@ -633,6 +658,7 @@ mod tests {
logical_capacity_kib: Some(2_097_148),
vram_cached_kib: Some(0),
gpu_headroom_kib: Some(2_097_152),
+ gpu_budget: None,
ssd_origin_written_kib: Some(0),
fallback_swap_used_kib: Some(0),
measurement_errors: Vec::new(),
@@ -660,6 +686,7 @@ mod tests {
logical_capacity_kib: None,
vram_cached_kib: None,
gpu_headroom_kib: None,
+ gpu_budget: None,
ssd_origin_written_kib: None,
fallback_swap_used_kib: Some(5_000),
measurement_errors: Vec::new(),
@@ -770,6 +797,26 @@ mod tests {
assert_eq!(v.phase_reason, "daemon_dead_hot_vram");
}
+ #[test]
+ fn unreadable_daemon_identity_does_not_claim_daemon_death() {
+ let mut s = base();
+ s.daemon_alive = false;
+ s.daemon_pid = None;
+ s.vram.used_kib = 50_000;
+ s.measurement_errors
+ .push("daemon_identity_unreadable".to_string());
+ let view = derive_lifecycle(&s);
+ assert_eq!(view.phase, CascadePhase::Degraded);
+ assert_eq!(view.phase_reason, "daemon_identity_unreadable");
+ assert!(
+ !view
+ .reasons
+ .iter()
+ .any(|reason| reason == "daemon_dead_hot_vram")
+ );
+ assert_eq!(overall_state(&view, &s), OverallState::Blocked);
+ }
+
#[test]
fn phase_demoting_only_when_flag() {
let mut s = base();
@@ -897,6 +944,34 @@ mod tests {
assert!(json.contains("\"ok\":false"));
}
+ #[test]
+ fn status_json_publishes_adapter_bound_gpu_budget() {
+ let mut snapshot = base();
+ snapshot.gpu_budget = Some(GpuBudgetTelemetry {
+ schema_version: 1,
+ adapter: Some(ramshared_vram::GpuAdapterIdentity {
+ backend: "vulkan".into(),
+ key: "uuid:fixture".into(),
+ luid: Some("aabbccdd:00001122".into()),
+ }),
+ total_bytes: Some(8_000),
+ budget_bytes: 6_000,
+ used_bytes: 2_000,
+ available_bytes: 4_000,
+ source: ramshared_vram::GpuBudgetSource::DriverReported,
+ sampled_at_unix_ms: 1_000,
+ });
+ let json = render_status_json(
+ &derive_lifecycle(&snapshot),
+ &snapshot,
+ "2026-08-20T00:00:00Z",
+ );
+ let parsed: serde_json::Value = serde_json::from_str(&json).expect("valid status JSON");
+ assert_eq!(parsed["gpu_budget"]["adapter"]["backend"], "vulkan");
+ assert_eq!(parsed["gpu_budget"]["adapter"]["luid"], "aabbccdd:00001122");
+ assert_eq!(parsed["gpu_budget"]["available_bytes"], 4_000);
+ }
+
#[test]
fn using_vram_never_masks_critical_pressure() {
let mut snapshot = base();
diff --git a/crates/ramshared-cli/src/cascade/mod.rs b/crates/ramshared-cli/src/cascade/mod.rs
index c2936de77..3b0f02adc 100644
--- a/crates/ramshared-cli/src/cascade/mod.rs
+++ b/crates/ramshared-cli/src/cascade/mod.rs
@@ -12,6 +12,7 @@
//! Mounts tiers by `swapon` priority and unmounts in reverse order.
use ramshared_tier::TierPriorities;
+use ramshared_vram::GpuBudgetTelemetry;
use std::fmt;
use std::fs;
use std::path::Path;
@@ -1318,20 +1319,38 @@ fn supervisor_status_matches_current_daemon(
})
}
+#[cfg(test)]
fn control_plane_status_is_current(
cache_status: &serde_json::Value,
supervisor_status: &serde_json::Value,
daemon_instance_id: &str,
now_unix_ms: u64,
) -> bool {
- cache_status_shape_is_valid(cache_status)
- && supervisor_status_shape_is_valid(supervisor_status)
- && cache_status_matches_current_daemon(cache_status, daemon_instance_id, now_unix_ms)
- && supervisor_status_matches_current_daemon(
- supervisor_status,
- daemon_instance_id,
- now_unix_ms,
- )
+ let (cache_current, supervisor_current) = control_plane_status_freshness(
+ Some(cache_status),
+ Some(supervisor_status),
+ daemon_instance_id,
+ now_unix_ms,
+ );
+ cache_current && supervisor_current
+}
+
+fn control_plane_status_freshness(
+ cache_status: Option<&serde_json::Value>,
+ supervisor_status: Option<&serde_json::Value>,
+ daemon_instance_id: &str,
+ now_unix_ms: u64,
+) -> (bool, bool) {
+ let cache_current = cache_status.is_some_and(|status| {
+ cache_status_shape_is_valid(status)
+ && cache_status_matches_current_daemon(status, daemon_instance_id, now_unix_ms)
+ });
+ let supervisor_current = supervisor_status.is_some_and(|status| {
+ supervisor_status_shape_is_valid(status)
+ && supervisor_status_matches_current_daemon(status, daemon_instance_id, now_unix_ms)
+ });
+
+ (cache_current, supervisor_current)
}
fn guardian_state_from_files(
@@ -1339,11 +1358,18 @@ fn guardian_state_from_files(
health: &Path,
max_age: Duration,
) -> (GuardianState, Option) {
- match fs::read_to_string(safe_mode) {
- Ok(text) if serde_json::from_str::(&text).is_ok() => {
- return (GuardianState::SafeMode, None);
+ // Presence of the marker is the gate (Kahneman #9). Validate content only
+ // when readable; an unreadable marker is still a marker (fail-safe #16).
+ match fs::symlink_metadata(safe_mode) {
+ Ok(_) => {
+ return match fs::read_to_string(safe_mode) {
+ Ok(text) if serde_json::from_str::(&text).is_ok() => {
+ (GuardianState::SafeMode, None)
+ }
+ Ok(_) => (GuardianState::Blocked, Some("safe_mode_invalid".into())),
+ Err(_) => (GuardianState::SafeMode, None),
+ };
}
- Ok(_) => return (GuardianState::Blocked, Some("safe_mode_invalid".into())),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
Err(_) => return (GuardianState::Blocked, Some("safe_mode_unreadable".into())),
}
@@ -1398,6 +1424,16 @@ use lifecycle::{
};
/// Build lifecycle snapshot from live swaps + daemon (read-only).
+fn trusted_gpu_budget_from_status(
+ status: &serde_json::Value,
+ now_unix_ms: Option,
+) -> Option {
+ let budget =
+ serde_json::from_value::(status.get("gpu_budget")?.clone()).ok()?;
+ let now = now_unix_ms?;
+ budget.trusted_available_at(now, 5_000).map(|_| budget)
+}
+
pub fn build_cascade_snapshot(entries: &[SwapEntry]) -> CascadeSnapshot {
let pairs: Vec<(&str, u64, u64, i32)> = entries
.iter()
@@ -1407,6 +1443,10 @@ pub fn build_cascade_snapshot(entries: &[SwapEntry]) -> CascadeSnapshot {
let (zram, vram, disk, order_ok) = lifecycle::tiers_from_swap_names(&pairs);
let ghosts = ghost_vram_swaps(entries);
let (daemon_alive, daemon_pid) = daemon_alive_pid();
+ let daemon_identity_unreadable = matches!(
+ fs::read_to_string(PID_FILE),
+ Err(ref error) if error.kind() == std::io::ErrorKind::PermissionDenied
+ );
let product_active = daemon_alive || vram.present;
let cache_status = fs::read_to_string(CACHE_STATUS_FILE)
.ok()
@@ -1415,23 +1455,21 @@ pub fn build_cascade_snapshot(entries: &[SwapEntry]) -> CascadeSnapshot {
.ok()
.and_then(|text| serde_json::from_str::(&text).ok());
let daemon_instance_id = daemon_pid.and_then(daemon_instance_id_from_pid);
- let control_plane_current = daemon_instance_id
+ let (cache_status_current, supervisor_status_current) = daemon_instance_id
.as_deref()
.zip(unix_time_ms())
- .zip(cache_status.as_ref())
- .zip(supervisor_status.as_ref())
- .is_some_and(
- |(((daemon_instance_id, now_unix_ms), cache_status), supervisor_status)| {
- control_plane_status_is_current(
- cache_status,
- supervisor_status,
- daemon_instance_id,
- now_unix_ms,
- )
- },
- );
- let cache_status = control_plane_current.then_some(cache_status).flatten();
- let supervisor_status = control_plane_current.then_some(supervisor_status).flatten();
+ .map_or((false, false), |(daemon_instance_id, now_unix_ms)| {
+ control_plane_status_freshness(
+ cache_status.as_ref(),
+ supervisor_status.as_ref(),
+ daemon_instance_id,
+ now_unix_ms,
+ )
+ });
+ let cache_status = cache_status_current.then_some(cache_status).flatten();
+ let supervisor_status = supervisor_status_current
+ .then_some(supervisor_status)
+ .flatten();
let status_text = |key: &str| {
cache_status
.as_ref()
@@ -1444,6 +1482,17 @@ pub fn build_cascade_snapshot(entries: &[SwapEntry]) -> CascadeSnapshot {
.and_then(|value| value.get(key))
.and_then(serde_json::Value::as_u64)
};
+ let gpu_budget_reported = cache_status
+ .as_ref()
+ .is_some_and(|value| value.get("gpu_budget").is_some());
+ let gpu_budget = cache_status
+ .as_ref()
+ .and_then(|value| trusted_gpu_budget_from_status(value, unix_time_ms()));
+ let gpu_headroom_kib = gpu_budget.as_ref().and_then(|budget| {
+ unix_time_ms()
+ .and_then(|now| budget.trusted_available_at(now, 5_000))
+ .map(|available| available >> 10)
+ });
let cache_status_ok = cache_status
.as_ref()
.and_then(|value| value.get("ok"))
@@ -1484,10 +1533,16 @@ pub fn build_cascade_snapshot(entries: &[SwapEntry]) -> CascadeSnapshot {
Duration::from_secs(15),
);
let mut measurement_errors = Vec::new();
- if product_active && !control_plane_current {
+ if vram.present && daemon_identity_unreadable {
+ measurement_errors.push("daemon_identity_unreadable".to_string());
+ }
+ if product_active && !cache_status_current {
measurement_errors.push("cache_status_not_current".to_string());
}
- if product_active && !control_plane_current {
+ if product_active && gpu_budget_reported && gpu_budget.is_none() {
+ measurement_errors.push("gpu_budget_telemetry_invalid_or_stale".to_string());
+ }
+ if product_active && !supervisor_status_current {
measurement_errors.push("supervisor_status_not_current".to_string());
}
if let Some(error) = guardian_error {
@@ -1512,13 +1567,44 @@ pub fn build_cascade_snapshot(entries: &[SwapEntry]) -> CascadeSnapshot {
guardian_state,
logical_capacity_kib: capacity_field_u64("logical_capacity_kib"),
vram_cached_kib: status_number("vram_cached_kib"),
- gpu_headroom_kib: status_number("gpu_headroom_kib"),
+ gpu_headroom_kib,
+ gpu_budget,
ssd_origin_written_kib: status_number("ssd_origin_written_kib"),
fallback_swap_used_kib: Some(fallback_swap_used_kib),
measurement_errors,
}
}
+fn stress_readiness_from_snapshot(snapshot: &CascadeSnapshot) -> Result<(), String> {
+ let lifecycle = derive_lifecycle(snapshot);
+ if !lifecycle.ok
+ || snapshot.ghost
+ || !snapshot.order_ok
+ || !snapshot.zram.present
+ || !snapshot.vram.present
+ || !snapshot.disk.present
+ || !snapshot.daemon_alive
+ || !snapshot.capacity_guaranteed
+ || snapshot.control_state != ControlState::Healthy
+ || snapshot.origin_state != OriginState::Ready
+ || snapshot.cache_state != CacheState::Active
+ || snapshot.guardian_state != GuardianState::Healthy
+ || !snapshot.measurement_errors.is_empty()
+ {
+ return Err(format!(
+ "cascade stress requires healthy zram/NBD/disk, daemon, physical cache, supervisor, and host guardian (reasons: {:?}; measurement errors: {:?})",
+ lifecycle.reasons, snapshot.measurement_errors
+ ));
+ }
+ Ok(())
+}
+
+/// Read-only admission and continuation check for pressure workloads.
+pub fn stress_readiness() -> Result<(), String> {
+ let entries = read_swaps().map_err(|error| error.to_string())?;
+ stress_readiness_from_snapshot(&build_cascade_snapshot(&entries))
+}
+
fn capacity_field(key: &str) -> Option {
let text = fs::read_to_string(CAPACITY_STATUS_FILE).ok()?;
text.lines().find_map(|line| {
@@ -1740,6 +1826,36 @@ mod tests {
#![allow(clippy::unwrap_used, clippy::expect_used)]
use super::*;
+ #[test]
+ fn gpu_budget_status_requires_fresh_driver_bound_telemetry() {
+ let status = serde_json::json!({
+ "gpu_budget": {
+ "schema_version": 1,
+ "adapter": {
+ "backend": "vulkan",
+ "key": "uuid:fixture",
+ "luid": "aabbccdd:00001122"
+ },
+ "total_bytes": 8000,
+ "budget_bytes": 6000,
+ "used_bytes": 2000,
+ "available_bytes": 4000,
+ "source": "driver_reported",
+ "sampled_at_unix_ms": 1000
+ }
+ });
+ assert!(trusted_gpu_budget_from_status(&status, Some(5000)).is_some());
+ assert!(trusted_gpu_budget_from_status(&status, Some(6001)).is_none());
+ assert!(trusted_gpu_budget_from_status(&status, Some(999)).is_none());
+
+ let mut local_only = status.clone();
+ local_only["gpu_budget"]["source"] = serde_json::json!("provider_local_estimate");
+ assert!(trusted_gpu_budget_from_status(&local_only, Some(5000)).is_none());
+
+ let malformed = serde_json::json!({"gpu_budget": {"available_bytes": 4000}});
+ assert!(trusted_gpu_budget_from_status(&malformed, Some(5000)).is_none());
+ }
+
fn parse_proc_swaps(text: &str) -> Vec {
super::parse_proc_swaps(text).expect("strict /proc/swaps fixture")
}
@@ -1771,6 +1887,60 @@ mod tests {
})
}
+ #[test]
+ fn stress_readiness_refuses_missing_control_plane_and_cache() {
+ let tier = TierSample {
+ present: true,
+ prio: Some(200),
+ size_kib: 1024,
+ used_kib: 0,
+ };
+ let mut snapshot = CascadeSnapshot {
+ zram: tier.clone(),
+ vram: TierSample {
+ prio: Some(100),
+ ..tier.clone()
+ },
+ disk: TierSample {
+ prio: Some(-2),
+ ..tier
+ },
+ ghost: false,
+ order_ok: true,
+ daemon_alive: true,
+ daemon_pid: Some(1),
+ capacity_guaranteed: true,
+ disk_baseline_kib: Some(0),
+ demote: DemoteSnapshot::default(),
+ active_kib: 1024,
+ control_state: ControlState::Healthy,
+ origin_state: OriginState::Ready,
+ cache_state: CacheState::Active,
+ guardian_state: GuardianState::Healthy,
+ logical_capacity_kib: Some(1024),
+ vram_cached_kib: Some(256),
+ gpu_headroom_kib: Some(768),
+ gpu_budget: None,
+ ssd_origin_written_kib: Some(0),
+ fallback_swap_used_kib: Some(0),
+ measurement_errors: Vec::new(),
+ };
+ assert!(stress_readiness_from_snapshot(&snapshot).is_ok());
+ snapshot.control_state = ControlState::Guarded;
+ assert!(stress_readiness_from_snapshot(&snapshot).is_err());
+ snapshot.control_state = ControlState::Healthy;
+ snapshot.cache_state = CacheState::Unavailable;
+ assert!(stress_readiness_from_snapshot(&snapshot).is_err());
+ snapshot.cache_state = CacheState::Active;
+ snapshot.guardian_state = GuardianState::Blocked;
+ assert!(stress_readiness_from_snapshot(&snapshot).is_err());
+ snapshot.guardian_state = GuardianState::Healthy;
+ snapshot
+ .measurement_errors
+ .push("cache_status_not_current".into());
+ assert!(stress_readiness_from_snapshot(&snapshot).is_err());
+ }
+
#[test]
fn canonicalize_swap_path_table() {
assert_eq!(canonicalize_swap_path("/nbd0"), "/dev/nbd0");
@@ -2203,6 +2373,56 @@ Filename Type Size Used Priority
fs::remove_dir_all(root).unwrap();
}
+ // Kahneman #9/#16: the hard question is "is the safe-mode marker present?",
+ // not "can this uid read its bytes?". Presence is the gate; content is a
+ // secondary validation. An unreadable marker must still report SafeMode.
+ #[test]
+ fn safe_mode_marker_presence_is_the_gate_even_when_content_unreadable() {
+ use std::os::unix::fs::PermissionsExt;
+ let root = std::env::temp_dir().join(format!(
+ "ramshared-guardian-unreadable-{}",
+ std::process::id()
+ ));
+ let _ = fs::remove_dir_all(&root);
+ fs::create_dir_all(&root).unwrap();
+ let safe = root.join("safe.json");
+ let health = root.join("health.json");
+ fs::write(
+ &health,
+ r#"{"schema_version":1,"distro":"Ubuntu-24.04","state":"HEALTHY"}"#,
+ )
+ .unwrap();
+
+ fs::write(&safe, "not-json").unwrap();
+ assert_eq!(
+ guardian_state_from_files(&safe, &health, Duration::from_secs(15)),
+ (GuardianState::Blocked, Some("safe_mode_invalid".into()))
+ );
+
+ // Deliberately invalid content: only presence-first semantics can yield
+ // SafeMode when the read is denied.
+ let mut perms = fs::metadata(&safe).unwrap().permissions();
+ perms.set_mode(0o000);
+ fs::set_permissions(&safe, perms).unwrap();
+ let denied = fs::read_to_string(&safe).is_err();
+ let observed = guardian_state_from_files(&safe, &health, Duration::from_secs(15));
+ let mut perms = fs::metadata(&safe).unwrap().permissions();
+ perms.set_mode(0o644);
+ fs::set_permissions(&safe, perms).unwrap();
+
+ if denied {
+ assert_eq!(observed, (GuardianState::SafeMode, None));
+ } else {
+ // CAP_DAC_OVERRIDE can still read mode 0000 and must reject the
+ // invalid content rather than invent SafeMode from bytes it saw.
+ assert_eq!(
+ observed,
+ (GuardianState::Blocked, Some("safe_mode_invalid".into()))
+ );
+ }
+ fs::remove_dir_all(root).unwrap();
+ }
+
#[test]
fn guardian_health_accepts_a_windows_utf8_bom_and_rejects_malformed_json() {
let root = std::env::temp_dir().join(format!(
@@ -2291,6 +2511,30 @@ Filename Type Size Used Priority
}
}
+ #[test]
+ fn cache_and_supervisor_freshness_are_reported_independently() {
+ let fresh_cache = serde_json::json!({
+ "schema_version": 1,
+ "daemon_instance_id": "daemon-1",
+ "written_at_unix_ms": 1_000,
+ "ok": true,
+ "origin_state": "READY",
+ "cache_state": "ACTIVE",
+ });
+ let fresh_supervisor = valid_supervisor_status_v3();
+
+ assert_eq!(
+ control_plane_status_freshness(Some(&fresh_cache), None, "daemon-1", 1_001,),
+ (true, false),
+ "fresh cache telemetry must remain visible when supervisor telemetry is absent",
+ );
+ assert_eq!(
+ control_plane_status_freshness(None, Some(&fresh_supervisor), "daemon-1", 1_001,),
+ (false, true),
+ "fresh supervisor telemetry must remain visible when cache telemetry is absent",
+ );
+ }
+
#[test]
fn supervisor_status_v3_with_ordered_action_results_is_current() {
let supervisor = valid_supervisor_status_v3();
diff --git a/crates/ramshared-cli/src/main.rs b/crates/ramshared-cli/src/main.rs
index 0721ec709..3df8c15eb 100644
--- a/crates/ramshared-cli/src/main.rs
+++ b/crates/ramshared-cli/src/main.rs
@@ -18,11 +18,13 @@ mod bounded_process;
mod cascade;
mod diagnose;
mod monitor;
+mod resource_config;
mod stress;
mod supervisor;
mod workload;
use monitor::MonitorOptions;
+use resource_config::ConfigMode;
const PROBE_COMMAND_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(2);
const KERNEL_CONFIG_OUTPUT_LIMIT: usize = 4 * 1024 * 1024;
@@ -199,6 +201,7 @@ impl CheckReport {
#[derive(Clone, Debug, Eq, PartialEq)]
enum CliCommand {
Version,
+ BuildInfo,
Run { args: Vec },
Session { args: Vec },
Supervise { args: Vec },
@@ -210,6 +213,7 @@ enum CliCommand {
Down,
Status { json: bool },
Monitor { options: MonitorOptions },
+ Config { mode: ConfigMode },
Diagnose { args: Vec },
Stress { args: Vec },
Help,
@@ -248,12 +252,82 @@ fn parse_json_option(command: &'static str, options: &[String]) -> Result Result {
+ match options {
+ [] => Ok(ConfigMode::Interactive),
+ [command] if command == "show" => Ok(ConfigMode::Show { json: false }),
+ [command, format] if command == "show" && format == "--json" => {
+ Ok(ConfigMode::Show { json: true })
+ }
+ [command, ..] if command == "plan" => parse_config_plan(&options[1..]),
+ [command, ..] if command == "draft" => parse_config_draft(&options[1..]),
+ _ => Err(CliParseError::InvalidOption {
+ command: "config",
+ options: options.to_vec(),
+ }),
+ }
+}
+
+fn parse_config_draft(options: &[String]) -> Result {
+ match options {
+ [flag, path] if flag == "--output" && !path.is_empty() && !path.starts_with("--") => {
+ Ok(ConfigMode::Draft {
+ output_path: path.clone(),
+ })
+ }
+ _ => Err(CliParseError::InvalidOption {
+ command: "config",
+ options: options.to_vec(),
+ }),
+ }
+}
+
+fn parse_config_plan(options: &[String]) -> Result {
+ let mut json = false;
+ let mut profile_path = None;
+ let mut index = 0;
+ while index < options.len() {
+ match options[index].as_str() {
+ "--json" if !json => json = true,
+ "--profile" if profile_path.is_none() && index + 1 < options.len() => {
+ index += 1;
+ let path = options[index].as_str();
+ if path.is_empty() || path.starts_with("--") {
+ return Err(CliParseError::InvalidOption {
+ command: "config",
+ options: options.to_vec(),
+ });
+ }
+ profile_path = Some(path.to_string());
+ }
+ _ => {
+ return Err(CliParseError::InvalidOption {
+ command: "config",
+ options: options.to_vec(),
+ });
+ }
+ }
+ index += 1;
+ }
+ Ok(ConfigMode::Plan { json, profile_path })
+}
+
fn parse_cli_command(args: &[String]) -> Result {
let Some((command, options)) = args.split_first() else {
return Ok(CliCommand::Help);
};
match command.as_str() {
+ "--build-info" => {
+ if options.is_empty() {
+ Ok(CliCommand::BuildInfo)
+ } else {
+ Err(CliParseError::InvalidOption {
+ command: "--build-info",
+ options: options.to_vec(),
+ })
+ }
+ }
"version" | "-V" | "--version" => {
if options.is_empty() {
Ok(CliCommand::Version)
@@ -296,6 +370,9 @@ fn parse_cli_command(args: &[String]) -> Result {
"doctor" => Ok(CliCommand::Doctor {
json: parse_json_option("doctor", options)?,
}),
+ "config" => Ok(CliCommand::Config {
+ mode: parse_config_mode(options)?,
+ }),
"up" => Ok(CliCommand::Up {
args: options.to_vec(),
}),
@@ -353,6 +430,12 @@ trait CliActionRunner {
) -> ExitCode;
fn down(&mut self, stdout: &mut dyn Write, stderr: &mut dyn Write) -> ExitCode;
fn status(&mut self, json: bool, stdout: &mut dyn Write, stderr: &mut dyn Write) -> ExitCode;
+ fn config(
+ &mut self,
+ mode: ConfigMode,
+ stdout: &mut dyn Write,
+ stderr: &mut dyn Write,
+ ) -> ExitCode;
fn monitor(
&mut self,
options: &MonitorOptions,
@@ -433,6 +516,12 @@ impl CliActionRunner for SystemCliActions {
}
fn up(&mut self, args: &[String], _stdout: &mut dyn Write, stderr: &mut dyn Write) -> ExitCode {
+ if should_auto_wrap_systemd_scope(
+ &|k| std::env::var(k),
+ Path::new("/run/systemd/system").exists(),
+ ) {
+ return dispatch_systemd_scope(args, stderr);
+ }
to_exit(cascade::up_with_args(args), stderr)
}
@@ -452,6 +541,15 @@ impl CliActionRunner for SystemCliActions {
to_exit(cascade::status(json), stderr)
}
+ fn config(
+ &mut self,
+ mode: ConfigMode,
+ stdout: &mut dyn Write,
+ stderr: &mut dyn Write,
+ ) -> ExitCode {
+ resource_config::run(mode, stdout, stderr)
+ }
+
fn monitor(
&mut self,
options: &MonitorOptions,
@@ -560,11 +658,15 @@ fn run_from_args(
Ok(CliCommand::Version) => {
let _ = writeln!(
stdout,
- "ramshared {} (Author: Emerson Busson - https://www.linkedin.com/in/emersonbusson)",
- env!("CARGO_PKG_VERSION")
+ "{}\n(Author: Emerson Busson - https://www.linkedin.com/in/emersonbusson)",
+ monitor::version_status_lines()
);
ExitCode::SUCCESS
}
+ Ok(CliCommand::BuildInfo) => {
+ let _ = writeln!(stdout, "{}", monitor::build_info_lines());
+ ExitCode::SUCCESS
+ }
Ok(CliCommand::Run { args }) => actions.run_workload(&args, stdout, stderr),
Ok(CliCommand::Session { args }) => actions.session(&args, stdout, stderr),
Ok(CliCommand::Supervise { args }) => actions.supervise(&args, stdout, stderr),
@@ -575,6 +677,7 @@ fn run_from_args(
Ok(CliCommand::MigrateLegacyCascade) => actions.migrate_legacy_cascade(stdout, stderr),
Ok(CliCommand::Down) => actions.down(stdout, stderr),
Ok(CliCommand::Status { json }) => actions.status(json, stdout, stderr),
+ Ok(CliCommand::Config { mode }) => actions.config(mode, stdout, stderr),
Ok(CliCommand::Monitor { options }) => actions.monitor(&options, stdout, stderr),
Ok(CliCommand::Diagnose { args }) => actions.diagnose(&args, stdout, stderr),
Ok(CliCommand::Stress { args }) => actions.stress(&args, stdout, stderr),
@@ -600,6 +703,58 @@ fn to_exit(r: Result<(), E>, stderr: &mut dyn Write) -> ExitCod
}
}
+fn should_auto_wrap_systemd_scope(env_lookup: &F, systemd_running: bool) -> bool
+where
+ F: Fn(&str) -> Result,
+{
+ if !systemd_running {
+ return false;
+ }
+ if env_lookup("RAMSHARED_NO_AUTO_SCOPE").is_ok() {
+ return false;
+ }
+ if env_lookup("_RAMSHARED_SCOPED").is_ok() {
+ return false;
+ }
+ env_lookup("INVOCATION_ID").is_err()
+}
+
+fn dispatch_systemd_scope(args: &[String], stderr: &mut dyn Write) -> ExitCode {
+ let current_exe = match std::env::current_exe() {
+ Ok(path) => path,
+ Err(error) => {
+ let _ = writeln!(
+ stderr,
+ "failed to resolve current binary path for systemd scope: {error}"
+ );
+ return ExitCode::from(1);
+ }
+ };
+ let mut cmd = Command::new("systemd-run");
+ cmd.arg("--scope")
+ .arg("-q")
+ .arg("--")
+ .arg(current_exe)
+ .arg("up");
+ for arg in args {
+ cmd.arg(arg);
+ }
+ cmd.env("_RAMSHARED_SCOPED", "1");
+ match cmd.status() {
+ Ok(status) => {
+ if let Some(code) = status.code() {
+ ExitCode::from(code as u8)
+ } else {
+ ExitCode::from(1)
+ }
+ }
+ Err(error) => {
+ let _ = writeln!(stderr, "failed to spawn systemd-run --scope: {error}");
+ ExitCode::from(1)
+ }
+ }
+}
+
fn print_usage(stderr: &mut dyn Write) {
let _ = writeln!(stderr, "usage:");
let _ = writeln!(stderr, " ramshared --version");
@@ -615,6 +770,10 @@ fn print_usage(stderr: &mut dyn Write) {
let _ = writeln!(stderr, " ramshared recover --status|--resume");
let _ = writeln!(stderr, " ramshared check [--json]");
let _ = writeln!(stderr, " ramshared doctor [--json]");
+ let _ = writeln!(
+ stderr,
+ " ramshared config [show [--json] | plan [--json] [--profile PATH] | draft --output PATH]"
+ );
let _ = writeln!(stderr, " ramshared diagnose --events PATH [--json]");
let _ = writeln!(
stderr,
@@ -643,7 +802,7 @@ fn print_usage(stderr: &mut dyn Write) {
);
let _ = writeln!(
stderr,
- " ramshared stress [--start %] [--target %] [--step %] [--interval-ms N] [--hold-sec N] [--min-ram-mb N] [--json]"
+ " ramshared stress [--tier3-only --tier3-target-pct %] [--full-three-tier] [--tier1-target-pct %] [--tier2-target-pct %] [--tier3-target-pct %] [--physical-cache-target-mib N] [--json]"
);
let _ = writeln!(
stderr,
@@ -668,7 +827,7 @@ fn run_check() -> CheckReport {
let cuda = probe_cuda();
let backends = probe_backends(&kernel);
- let mut blockers = Vec::new();
+ let mut blockers = active_swap_activation_blockers(&swaps);
let mut warnings = Vec::new();
if wsl.status == Status::Fail {
@@ -858,6 +1017,23 @@ fn parse_swaps(text: &str) -> Vec {
.collect()
}
+fn active_swap_activation_blockers(swaps: &[SwapEntry]) -> Vec {
+ swaps
+ .iter()
+ .filter(|swap| {
+ cascade::is_nbd_device_path(&swap.filename)
+ || cascade::is_ublk_device_path(&swap.filename)
+ || cascade::is_zram_device_path(&swap.filename)
+ })
+ .map(|swap| {
+ format!(
+ "managed-style swap is already active at {} (used_kib={}); refuse a new activation and inspect `ramshared status`",
+ swap.filename, swap.used_kib
+ )
+ })
+ .collect()
+}
+
fn probe_backends(kernel: &KernelFeatures) -> BackendProbe {
let (ublk_control_present, ublk_control_openable) =
probe_ublk_control(Path::new("/dev/ublk-control"));
@@ -1663,6 +1839,16 @@ mod tests {
self.result()
}
+ fn config(
+ &mut self,
+ mode: ConfigMode,
+ _stdout: &mut dyn std::io::Write,
+ _stderr: &mut dyn std::io::Write,
+ ) -> ExitCode {
+ self.calls.push(CliCommand::Config { mode });
+ self.result()
+ }
+
fn monitor(
&mut self,
options: &MonitorOptions,
@@ -1807,13 +1993,75 @@ mod tests {
);
assert_eq!(exit, ExitCode::SUCCESS);
assert!(actions.calls.is_empty());
+ let output = String::from_utf8(stdout).expect("version output is UTF-8");
+ let mut lines = output.lines();
+ let build_line = lines.next().expect("version/build identity line");
+ assert!(
+ build_line.starts_with(&format!("RamShared CLI v{} · ", env!("CARGO_PKG_VERSION")))
+ );
+ assert!(!build_line.contains("git "));
+ let build_info = monitor::build_info_lines();
+ if let Some(commit) = build_info
+ .lines()
+ .find_map(|line| line.strip_prefix("source_commit="))
+ .filter(|commit| commit.len() == 40)
+ {
+ assert!(build_line.contains(&commit[..8]));
+ assert!(!build_line.contains(commit));
+ }
+ assert!(
+ lines
+ .next()
+ .is_some_and(|line| line.starts_with("Running: "))
+ );
+ assert!(
+ lines
+ .next()
+ .is_some_and(|line| line.starts_with("Installed direct /usr/local:"))
+ );
+ assert!(
+ lines
+ .next()
+ .is_some_and(|line| line.starts_with("Installed active /opt/ramshared/current:"))
+ );
assert_eq!(
- String::from_utf8(stdout).expect("version output is UTF-8"),
- format!(
- "ramshared {} (Author: Emerson Busson - https://www.linkedin.com/in/emersonbusson)\n",
- env!("CARGO_PKG_VERSION")
- )
+ lines.next(),
+ Some("(Author: Emerson Busson - https://www.linkedin.com/in/emersonbusson)")
);
+ assert!(lines.next().is_none());
+ assert!(stderr.is_empty());
+ }
+
+ #[test]
+ fn build_info_keeps_full_commit_for_audit_tools() {
+ let mut actions = RecordingCliActions::default();
+ let mut stdout = Vec::new();
+ let mut stderr = Vec::new();
+ let exit = run_from_args(
+ &cli_args(&["--build-info"]),
+ &mut actions,
+ &mut stdout,
+ &mut stderr,
+ );
+ assert_eq!(exit, ExitCode::SUCCESS);
+ assert!(actions.calls.is_empty());
+ let output = String::from_utf8(stdout).expect("build info is UTF-8");
+ let fields = output
+ .lines()
+ .filter_map(|line| line.split_once('='))
+ .collect::>();
+ assert_eq!(
+ fields.get("version").copied(),
+ Some(env!("CARGO_PKG_VERSION"))
+ );
+ assert!(fields.get("source_commit").copied().is_some_and(|commit| {
+ commit == "unavailable"
+ || (commit.len() == 40 && commit.bytes().all(|byte| byte.is_ascii_hexdigit()))
+ }));
+ assert!(matches!(
+ fields.get("source_tree_state").copied(),
+ Some("clean" | "dirty" | "unavailable")
+ ));
assert!(stderr.is_empty());
}
@@ -1836,6 +2084,157 @@ mod tests {
);
}
+ #[test]
+ fn config_command_accepts_interactive_show_and_read_only_plan_modes() {
+ assert_eq!(
+ parse_cli_command(&cli_args(&["config"])).expect("interactive config parses"),
+ CliCommand::Config {
+ mode: ConfigMode::Interactive,
+ }
+ );
+ assert_eq!(
+ parse_cli_command(&cli_args(&["config", "show"])).expect("show parses"),
+ CliCommand::Config {
+ mode: ConfigMode::Show { json: false },
+ }
+ );
+ assert_eq!(
+ parse_cli_command(&cli_args(&["config", "show", "--json"])).expect("json show parses"),
+ CliCommand::Config {
+ mode: ConfigMode::Show { json: true },
+ }
+ );
+ assert_eq!(
+ parse_cli_command(&cli_args(&["config", "plan"])).expect("plan parses"),
+ CliCommand::Config {
+ mode: ConfigMode::Plan {
+ json: false,
+ profile_path: None,
+ },
+ }
+ );
+ assert_eq!(
+ parse_cli_command(&cli_args(&[
+ "config",
+ "plan",
+ "--json",
+ "--profile",
+ "/tmp/draft.toml",
+ ]))
+ .expect("profile-backed json plan parses"),
+ CliCommand::Config {
+ mode: ConfigMode::Plan {
+ json: true,
+ profile_path: Some("/tmp/draft.toml".into()),
+ },
+ }
+ );
+ assert!(parse_cli_command(&cli_args(&["config", "apply"])).is_err());
+ assert!(parse_cli_command(&cli_args(&["config", "show", "--write"])).is_err());
+ assert!(parse_cli_command(&cli_args(&["config", "plan", "--profile"])).is_err());
+ assert!(parse_cli_command(&cli_args(&["config", "plan", "--profile", "--json"])).is_err());
+ assert_eq!(
+ parse_cli_command(&cli_args(&["config", "plan", "--profile", " draft.toml "]))
+ .expect("profile path whitespace is preserved"),
+ CliCommand::Config {
+ mode: ConfigMode::Plan {
+ json: false,
+ profile_path: Some(" draft.toml ".into()),
+ },
+ }
+ );
+ assert!(
+ parse_cli_command(&cli_args(&[
+ "config",
+ "plan",
+ "--profile",
+ "a",
+ "--profile",
+ "b"
+ ]))
+ .is_err()
+ );
+ }
+
+ #[test]
+ fn config_command_accepts_draft_mode_and_requires_output_path() {
+ assert_eq!(
+ parse_cli_command(&cli_args(&[
+ "config",
+ "draft",
+ "--output",
+ "/tmp/ramshared-draft.toml",
+ ]))
+ .expect("draft mode parses"),
+ CliCommand::Config {
+ mode: ConfigMode::Draft {
+ output_path: "/tmp/ramshared-draft.toml".into(),
+ },
+ }
+ );
+ assert!(parse_cli_command(&cli_args(&["config", "draft"])).is_err());
+ assert!(parse_cli_command(&cli_args(&["config", "draft", "--output"])).is_err());
+ assert!(parse_cli_command(&cli_args(&["config", "draft", "--output", "--json"])).is_err());
+ assert!(
+ parse_cli_command(&cli_args(&[
+ "config",
+ "draft",
+ "--output",
+ "/tmp/a.toml",
+ "--output",
+ "/tmp/b.toml",
+ ]))
+ .is_err()
+ );
+ }
+
+ #[test]
+ fn config_show_dispatches_to_read_only_action() {
+ let mut actions = RecordingCliActions::default();
+ let mut stdout = Vec::new();
+ let mut stderr = Vec::new();
+
+ let exit = run_from_args(
+ &cli_args(&["config", "show", "--json"]),
+ &mut actions,
+ &mut stdout,
+ &mut stderr,
+ );
+
+ assert_eq!(exit, ExitCode::SUCCESS);
+ assert_eq!(
+ actions.calls,
+ vec![CliCommand::Config {
+ mode: ConfigMode::Show { json: true },
+ }]
+ );
+ }
+
+ #[test]
+ fn config_plan_dispatches_to_read_only_action() {
+ let mut actions = RecordingCliActions::default();
+ let mut stdout = Vec::new();
+ let mut stderr = Vec::new();
+
+ let exit = run_from_args(
+ &cli_args(&["config", "plan", "--json"]),
+ &mut actions,
+ &mut stdout,
+ &mut stderr,
+ );
+
+ assert_eq!(exit, ExitCode::SUCCESS);
+ assert_eq!(
+ actions.calls,
+ vec![CliCommand::Config {
+ mode: ConfigMode::Plan {
+ json: true,
+ profile_path: None,
+ },
+ }]
+ );
+ }
+
#[test]
fn monitor_parses_machine_stream_outputs_without_mutation_flags() {
let command = parse_cli_command(&cli_args(&[
@@ -1981,6 +2380,78 @@ Filename\t\t\t\tType\t\tSize\t\tUsed\t\tPriority\n\
assert_eq!(swaps[0].priority, -2);
}
+ #[test]
+ fn check_blocks_existing_managed_swap_even_when_backend_is_available() {
+ let disk = SwapEntry {
+ filename: "/dev/sdb".to_string(),
+ kind: "partition".to_string(),
+ size_kib: 4_194_304,
+ used_kib: 0,
+ priority: -2,
+ };
+ assert!(active_swap_activation_blockers(&[disk]).is_empty());
+
+ for (device, used_kib) in [
+ ("/nbd0", 346_316),
+ ("/dev/nbd0", 0),
+ ("/dev/ublkb0", 0),
+ ("/zram1", 0),
+ ] {
+ let swaps = [SwapEntry {
+ filename: device.to_string(),
+ kind: "partition".to_string(),
+ size_kib: 3_801_084,
+ used_kib,
+ priority: 50,
+ }];
+ let blockers = active_swap_activation_blockers(&swaps);
+ assert_eq!(blockers.len(), 1, "{device} must block a new activation");
+ assert!(blockers[0].contains(device));
+ }
+ }
+
+ #[test]
+ fn up_auto_envelops_in_systemd_scope_when_invocation_id_missing() {
+ let env_empty = |_key: &str| Err(std::env::VarError::NotPresent);
+ assert!(should_auto_wrap_systemd_scope(&env_empty, true));
+
+ // When systemd is not running, do not attempt systemd-run
+ assert!(!should_auto_wrap_systemd_scope(&env_empty, false));
+
+ // When RAMSHARED_NO_AUTO_SCOPE is set, do not auto-wrap
+ let env_no_scope = |key: &str| {
+ if key == "RAMSHARED_NO_AUTO_SCOPE" {
+ Ok("1".to_string())
+ } else {
+ Err(std::env::VarError::NotPresent)
+ }
+ };
+ assert!(!should_auto_wrap_systemd_scope(&env_no_scope, true));
+
+ // When recursion guard _RAMSHARED_SCOPED is set, do not re-wrap
+ let env_scoped = |key: &str| {
+ if key == "_RAMSHARED_SCOPED" {
+ Ok("1".to_string())
+ } else {
+ Err(std::env::VarError::NotPresent)
+ }
+ };
+ assert!(!should_auto_wrap_systemd_scope(&env_scoped, true));
+ }
+
+ #[test]
+ fn up_executes_inline_when_invocation_id_present() {
+ let env_with_invocation = |key: &str| {
+ if key == "INVOCATION_ID" {
+ Ok("0123456789abcdef0123456789abcdef".to_string())
+ } else {
+ Err(std::env::VarError::NotPresent)
+ }
+ };
+ assert!(!should_auto_wrap_systemd_scope(&env_with_invocation, true));
+ assert!(!should_auto_wrap_systemd_scope(&env_with_invocation, false));
+ }
+
#[test]
fn parses_kernel_config_values() {
let text = "\
@@ -2260,6 +2731,9 @@ CONFIG_BLK_DEV_NBD=m\n\
&["check", "--json"][..],
&["doctor"][..],
&["doctor", "--json"][..],
+ &["config"][..],
+ &["config", "show"][..],
+ &["config", "show", "--json"][..],
&["up", "--vram", "1024"][..],
&["migrate-cascade", "--from-legacy"][..],
&["down"][..],
diff --git a/crates/ramshared-cli/src/monitor.rs b/crates/ramshared-cli/src/monitor.rs
index be6f121c6..5f5c4e09e 100644
--- a/crates/ramshared-cli/src/monitor.rs
+++ b/crates/ramshared-cli/src/monitor.rs
@@ -1,11 +1,12 @@
//! Read-only RamShared observability stream and terminal dashboard.
-use std::collections::{BTreeMap, VecDeque};
+use std::collections::{BTreeMap, HashMap, VecDeque};
use std::fmt;
-use std::fs::{self, OpenOptions};
-use std::io::Write;
+use std::fs::{self, File, OpenOptions};
+use std::io::{Read, Write};
use std::path::{Path, PathBuf};
use std::process::Command;
+use std::sync::OnceLock;
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use ratatui::crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers};
@@ -16,15 +17,27 @@ use ratatui::widgets::{Block, Borders, Paragraph, Sparkline, Wrap};
use ratatui::{DefaultTerminal, Frame};
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
+use sha2::{Digest, Sha256};
-use crate::{bounded_process, cascade, workload};
+use crate::{cascade, workload};
+use ramshared_vram::{GpuBudgetSource, GpuBudgetTelemetry};
const DEFAULT_INTERVAL_MS: u64 = 1_000;
const DEFAULT_HISTORY_SECONDS: u64 = 300;
const MIN_INTERVAL_MS: u64 = 250;
const MAX_HISTORY_SECONDS: u64 = 3_600;
-const GPU_QUERY_TIMEOUT: Duration = Duration::from_millis(2_500);
const DEFAULT_MAX_LOG_BYTES: u64 = 50 * 1024 * 1024;
+const GPU_BUDGET_MAX_AGE_MS: u64 = 5_000;
+const MIB_BYTES: u64 = 1024 * 1024;
+const BENCHMARK_EVIDENCE_SCHEMA_V1: &str = "ramshared-evidence/v1";
+const BENCHMARK_MIN_SAMPLE_COUNT: usize = 3;
+const BUILD_GIT_SHA: &str = env!("RAMSHARED_BUILD_GIT_SHA");
+const BUILD_TREE_STATE: &str = env!("RAMSHARED_BUILD_TREE_STATE");
+const INSTALLED_PROVENANCE_V1: &str = "ramshared-installed-release-provenance/v1";
+const INSTALLED_PROVENANCE_V2: &str = "ramshared-installed-release-provenance/v2";
+#[cfg(test)]
+const DIRECT_INSTALL_METADATA_V1: &str = "ramshared-direct-install-metadata/v1";
+const DIRECT_INSTALL_METADATA_V2: &str = "ramshared-direct-install-metadata/v2";
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct MonitorOptions {
@@ -51,6 +64,10 @@ impl Default for MonitorOptions {
}
}
+#[cfg(test)]
+#[path = "monitor_pressure_tests.rs"]
+mod pressure_classification_tests;
+
impl MonitorOptions {
pub fn parse(args: &[String]) -> Result {
let mut options = Self::default();
@@ -121,11 +138,100 @@ impl fmt::Display for MonitorError {
}
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
+#[serde(default)]
pub struct MemoryObservation {
+ pub required_counters_available: bool,
pub total_kib: u64,
pub available_kib: u64,
pub swap_total_kib: u64,
pub swap_free_kib: u64,
+ pub anon_pages_kib: Option,
+ pub shmem_kib: Option,
+ pub slab_kib: Option,
+ pub s_unreclaim_kib: Option,
+ pub dirty_kib: Option,
+ pub writeback_kib: Option,
+}
+
+#[derive(Clone, Debug, Default, Deserialize, Serialize)]
+pub struct HyperVBalloonObservation {
+ pub debugfs_status: String,
+ pub nr_balloon_pages: Option,
+ pub host_version: Option,
+ pub capabilities: Option,
+ pub state: Option,
+ pub page_size: Option,
+ pub pages_added: Option,
+ pub pages_onlined: Option,
+ pub pages_ballooned: Option,
+ pub total_pages_committed: Option,
+ pub max_dynamic_page_count: Option,
+}
+
+#[derive(Clone, Debug, Default, Deserialize, Serialize)]
+pub struct ProcessMemoryTotals {
+ pub visible_processes: u64,
+ pub rss_kib: u64,
+ pub swap_kib: u64,
+}
+
+#[derive(Clone, Debug, Default, Deserialize, Serialize)]
+pub struct CgroupMemoryObservation {
+ pub status: String,
+ pub current_bytes: Option,
+ pub events: Option,
+ pub subgroup_current_bytes: Option,
+ pub subgroups_with_memory: u64,
+ pub root_direct_processes: Option,
+}
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
+#[serde(rename_all = "snake_case")]
+pub enum MemoryScope {
+ LinuxHost,
+ Wsl,
+ Wsl2,
+}
+
+impl MemoryScope {
+ fn ram_label(self) -> &'static str {
+ match self {
+ Self::LinuxHost => "Host RAM",
+ Self::Wsl => "WSL Guest RAM",
+ Self::Wsl2 => "WSL2 Guest RAM",
+ }
+ }
+
+ fn panel_title(self) -> &'static str {
+ match self {
+ Self::LinuxHost => "Host RAM & Swap",
+ Self::Wsl => "WSL Guest RAM & Swap",
+ Self::Wsl2 => "WSL2 Guest RAM & Swap",
+ }
+ }
+
+ fn history_title(self) -> &'static str {
+ match self {
+ Self::LinuxHost => "Host RAM History",
+ Self::Wsl => "WSL Guest RAM History",
+ Self::Wsl2 => "WSL2 Guest RAM History",
+ }
+ }
+}
+
+fn detect_memory_scope(osrelease: &str, wsl_interop_available: bool) -> MemoryScope {
+ let normalized = osrelease.to_ascii_lowercase();
+ if normalized.contains("microsoft-standard-wsl2")
+ || (normalized.contains("microsoft") && normalized.contains("wsl2"))
+ {
+ MemoryScope::Wsl2
+ } else if (normalized.contains("microsoft") && normalized.contains("wsl"))
+ || wsl_interop_available
+ {
+ MemoryScope::Wsl
+ } else {
+ MemoryScope::LinuxHost
+ }
}
#[derive(Clone, Copy, Debug, Default, Deserialize, Serialize)]
@@ -147,6 +253,8 @@ pub struct TierIoStats {
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
pub struct ControlPlaneObservation {
+ #[serde(default)]
+ pub memory_psi_available: bool,
pub memory_psi_some_avg10: f64,
pub memory_psi_some_avg60: f64,
pub memory_psi_some_avg300: f64,
@@ -179,8 +287,12 @@ pub struct ControlPlaneObservation {
pub docker_memory_current_bytes: u64,
pub managed_reservations: u64,
pub managed_reserved_bytes: u64,
- pub unmanaged_pressure_state: String,
- pub unmanaged_pressure_kib: u64,
+ /// The v4 JSON key is retained for compatibility; the value describes
+ /// unmanaged process memory use, not measured system pressure.
+ #[serde(rename = "unmanaged_pressure_state")]
+ pub unmanaged_memory_state: String,
+ #[serde(rename = "unmanaged_pressure_kib")]
+ pub unmanaged_memory_kib: u64,
pub unmanaged_processes: u64,
pub reclaim_speed_gbs: f64,
pub reclaim_duration_ms: f64,
@@ -213,8 +325,10 @@ pub struct ProcessObservation {
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct GpuObservation {
- pub name: String,
- pub total_mib: u64,
+ pub adapter: ramshared_vram::GpuAdapterIdentity,
+ pub source: GpuBudgetSource,
+ pub total_mib: Option,
+ pub budget_mib: u64,
pub used_mib: u64,
pub free_mib: u64,
}
@@ -225,7 +339,11 @@ pub struct Observation {
pub status: BTreeMap,
pub epoch_ms: u64,
pub sample_age_ms: u64,
+ pub memory_scope: MemoryScope,
pub mem: MemoryObservation,
+ pub cgroup_memory: CgroupMemoryObservation,
+ pub hyperv_balloon: HyperVBalloonObservation,
+ pub process_totals: ProcessMemoryTotals,
pub control_plane: ControlPlaneObservation,
pub gpu: Option,
pub top_processes: Vec,
@@ -247,58 +365,157 @@ impl Observation {
}
fn read_benchmark_qualification(path: &Path) -> (f64, f64, f64, f64, String) {
- if let Ok(content) = fs::read_to_string(path)
- && let Ok(json) = serde_json::from_str::(&content)
+ let awaiting = || (0.0, 0.0, 0.0, 0.0, "AWAITING_QUALIFICATION".to_string());
+ let Ok(content) = fs::read_to_string(path) else {
+ return awaiting();
+ };
+ let Ok(json) = serde_json::from_str::(&content) else {
+ return awaiting();
+ };
+ if !is_promotable_benchmark_evidence(&json) {
+ return awaiting();
+ }
+
+ let Some(speed) = benchmark_metric_summary(&json, "reclaim_speed_gbs", "GB/s", "median") else {
+ return awaiting();
+ };
+ let Some(duration) = benchmark_metric_summary(&json, "reclaim_duration_ms", "ms", "median")
+ else {
+ return awaiting();
+ };
+ let Some(p50) = benchmark_metric_summary(&json, "p50_cycle_latency_ms", "ms", "median") else {
+ return awaiting();
+ };
+ let Some(p99) =
+ benchmark_metric_summary(&json, "p99_cycle_latency_ms", "ms", "p99_nearest_rank")
+ else {
+ return awaiting();
+ };
+
+ (speed, duration, p50, p99, "PASS".to_string())
+}
+
+fn is_promotable_benchmark_evidence(json: &Value) -> bool {
+ let source = &json["source"];
+ let workload = &json["workload"];
+ let comparison = &json["comparison"];
+ let lifecycle = &json["lifecycle"];
+ let decision = &json["decision"];
+ let artifacts = json["artifacts"].as_array();
+ let refusals = lifecycle["refusals"].as_array();
+
+ json["schema_version"].as_str() == Some(BENCHMARK_EVIDENCE_SCHEMA_V1)
+ && json["run_id"]
+ .as_str()
+ .is_some_and(|run_id| !run_id.is_empty())
+ && source["commit"]
+ .as_str()
+ .is_some_and(|commit| !commit.is_empty())
+ && source["dirty"].as_bool() == Some(false)
+ && source["dirty_entry_count"].as_u64() == Some(0)
+ && json["candidate"]
+ .as_object()
+ .is_some_and(|candidate| !candidate.is_empty())
+ && workload["runs"].as_u64().is_some_and(|runs| runs >= 3)
+ && comparison["qualified"].as_bool() == Some(true)
+ && lifecycle["binary_match"].as_bool() == Some(true)
+ && lifecycle["legitimate"]["verdict"].as_str() == Some("PASS")
+ && refusals.is_some_and(|entries| {
+ !entries.is_empty()
+ && entries
+ .iter()
+ .all(|entry| entry["verdict"].as_str() == Some("PASS"))
+ })
+ && lifecycle["cleanup"]["complete"].as_bool() == Some(true)
+ && lifecycle["residue"].as_u64() == Some(0)
+ && artifacts.is_some_and(|entries| !entries.is_empty())
+ && decision["verdict"].as_str() == Some("PASS")
+ && decision["promotable"].as_bool() == Some(true)
+}
+
+fn benchmark_metric_summary(
+ json: &Value,
+ metric_name: &str,
+ expected_unit: &str,
+ summary_key: &str,
+) -> Option {
+ let metric = json.get("metrics")?.get(metric_name)?;
+ if metric.get("unit")?.as_str()? != expected_unit {
+ return None;
+ }
+ let samples = metric.get("samples")?.as_array()?;
+ if samples.len() < BENCHMARK_MIN_SAMPLE_COUNT
+ || metric.get("n")?.as_u64()? != u64::try_from(samples.len()).ok()?
{
- let speed = json
- .get("reclaim_speed_gbs")
- .and_then(Value::as_f64)
- .unwrap_or(0.0);
- let duration = json
- .get("reclaim_duration_ms")
- .and_then(Value::as_f64)
- .unwrap_or(0.0);
- let p50 = json
- .get("p50_cycle_latency_ms")
- .and_then(Value::as_f64)
- .unwrap_or(0.0);
- let p99 = json
- .get("p99_cycle_latency_ms")
- .and_then(Value::as_f64)
- .unwrap_or(0.0);
- let status = json
- .get("status")
- .and_then(Value::as_str)
- .unwrap_or("UNKNOWN")
- .to_string();
- (speed, duration, p50, p99, status)
- } else {
- (0.0, 0.0, 0.0, 0.0, "AWAITING_QUALIFICATION".to_string())
+ return None;
+ }
+
+ let mut values = samples
+ .iter()
+ .map(Value::as_f64)
+ .collect::